搭建一套.net下能落地的飞书考勤系统

4 阅读16分钟

去年给公司做 HR 系统选型,最终选择了飞书考勤。但用了两个月后发现——原生功能再强,也架不住企业那些奇奇怪怪的业务规则。

比如:我们公司的请假审批要过三级(直属领导→部门负责人→HR),但飞书考勤的审批流只支持两级。还有,我们的薪资系统需要实时同步考勤数据做工资计算,但飞书没有开放这种级别的 API 集成。

最后只能自己开发一个中间层,把飞书考勤和内部系统打通。这篇笔记就是这段时间踩坑总结下来的。

如果你也在做类似的事情,这篇文章能帮你避开几个坑。

系统架构设计

整体架构

在动手写代码前,先想清楚系统怎么搭。我们的架构是这样的:

flowchart TB
    subgraph "内部系统"
        A[HR 审批系统]
        B[薪资系统]
        C[考勤管理系统]
    end

    subgraph "中间层"
        D[Mud.Feishu SDK]
        E[业务服务层]
        F[数据同步服务]
    end

    subgraph "飞书"
        G[飞书开放平台 API]
        H[飞书考勤系统]
    end

    A --> E
    B --> F
    C --> D

    D --> G
    E --> D
    F --> D

    G --> H
    H --> G

    style D fill:#e1f5ff
    style H fill:#fff4e1

为什么要加中间层?

  1. 解耦:内部系统和飞书解耦,飞书 API 变了不用改核心业务代码
  2. 数据转换:两边数据结构不一样,中间层负责转换
  3. 统一认证:令牌管理、重试、限流这些脏活交给 SDK
  4. 灵活扩展:以后要对接其他系统(比如钉钉),加一层适配就行

数据流转

sequenceDiagram
    participant 员工
    participant 内部系统
    participant 中间层
    participant 飞书API
    participant 飞书考勤

    员工->>内部系统: 发起请假申请
    内部系统->>中间层: 写入飞书考勤
    中间层->>飞书API: CreateUserApprovalAsync
    飞书API->>飞书考勤: 保存审批信息
    飞书考勤-->>飞书API: 返回结果
    飞书API-->>中间层: 返回审批ID
    中间层-->>内部系统: 保存 OutId 映射关系
    内部系统-->>员工: 显示提交成功

    Note over 飞书考勤,内部系统: 审批流程
    飞书考勤->>飞书API: 审批状态变更
    飞书API->>中间层: Webhook 事件推送
    中间层->>内部系统: 同步审批状态
    内部系统->>内部系统: 更新内部审批状态
    内部系统-->>员工: 通知审批结果

    Note over 内部系统,飞书考勤: 薪资计算
    HR系统->>中间层: 查询考勤统计
    中间层->>飞书API: QueryUserStatsDataAsync
    飞书API->>飞书考勤: 查询统计数据
    飞书考勤-->>飞书API: 返回统计结果
    飞书API-->>中间层: 返回数据
    中间层->>中间层: 数据转换和计算
    中间层-->>HR系统: 返回考勤数据
    HR系统->>HR系统: 计算工资

快速上手

飞书开放平台配置

先说重点——权限别漏了。第一次开发时我漏配了 attendance:approval 权限,搞了一下午才发现是权限问题。

创建自建应用步骤:

  1. 登录飞书开放平台(open.feishu.cn/)
  2. 进入"开发者后台",点击"创建企业自建应用"
  3. 填写应用名称、描述
  4. 选择应用类型为"企业自建应用"

获取凭证:

创建应用后,在应用详情页的"凭证与基础信息"中获取:

  • App ID:应用唯一标识
  • App Secret:应用密钥(记得保密)

必配权限清单:

权限点描述必要性
attendance:approval考勤审批相关权限必需
attendance:leave考勤休假相关权限必需
attendance:stats考勤统计相关权限必需
attendance:remedy考勤补卡相关权限必需
approval:instance审批实例相关权限必需
attendance:shift考勤班次相关权限可选
attendance:group考勤组相关权限可选

配置事件订阅(可选):

如果需要实时接收审批状态变更等事件,需要配置事件订阅:

  1. 在"事件订阅"中配置请求 URL(接收事件的回调地址)
  2. 选择需要订阅的事件,如 approval_instance_change
  3. 配置加密密钥和验证令牌

事件订阅类型对比:

方式优点缺点适用场景
Webhook简单、飞书主动推送需要公网 IP实时性要求高
WebSocket长连接、实时性强需要处理断线重连需要即时响应
定时轮询实现简单有延迟、浪费资源实时性要求不高

项目搭建

创建项目:

# 创建项目
dotnet new webapi -n AttendanceSystem
cd AttendanceSystem

# 安装 SDK
dotnet add package Mud.Feishu

# 如果需要 Redis 缓存
dotnet add package Mud.Feishu.Redis

配置文件:

// appsettings.json
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "Feishu": {
    "Apps": [
      {
        "AppKey": "default",
        "AppId": "cli_xxxxxxxxxxxxxxxx",
        "AppSecret": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
        "BaseUrl": "https://open.feishu.cn",
        "IsDefault": true,
        "TimeOut": 30,
        "RetryCount": 3
      }
    ]
  }
}

多应用配置示例:

{
  "Feishu": {
    "Apps": [
      {
        "AppKey": "default",
        "AppId": "cli_xxx",
        "AppSecret": "dsk_xxx",
        "IsDefault": true
      },
      {
        "AppKey": "hr-app",
        "AppId": "cli_yyy",
        "AppSecret": "dsk_yyy"
      }
    ]
  }
}

服务注册

// Program.cs
using Mud.Feishu;

var builder = WebApplication.CreateBuilder(args);

// 方式1:一行代码注册所有飞书服务(懒人模式)
builder.Services.AddFeishuServices(builder.Configuration);

// 方式2:使用构造者模式,按需注册(推荐)
builder.Services.CreateFeishuServicesBuilder(builder.Configuration)
    .AddOrganizationApi()   // 组织架构
    .AddMessageApi()        // 消息服务
    .AddApprovalApi()       // 审批流程(包含考勤审批)
    .AddTaskApi()           // 任务管理
    .AddCalendarApi()       // 日程管理
    .Build();

// 方式3:代码配置
builder.Services.CreateFeishuServicesBuilder(options =>
{
    options.Apps = new List<FeishuAppConfig>
    {
        new FeishuAppConfig
        {
            AppKey = "default",
            AppId = "cli_xxx",
            AppSecret = "dsk_xxx",
            BaseUrl = "https://open.feishu.cn",
            TimeOut = 30,
            RetryCount = 3,
            TokenRefreshThreshold = 300
        }
    };
})
    .AddOrganizationApi()
    .AddApprovalApi()
    .Build();

// 注册自己的业务服务
builder.Services.AddScoped<IApprovalService, ApprovalService>();
builder.Services.AddScoped<ILeaveService, LeaveService>();
builder.Services.AddScoped<IRemedyService, RemedyService>();
builder.Services.AddScoped<IStatsService, StatsService>();

var app = builder.Build();

// 配置中间件...
app.Run();

服务注册方式对比:

方式优点缺点适用场景
AddFeishuServices()简单,一行搞定注册了所有服务快速开发、测试环境
CreateFeishuServicesBuilder()按需注册,更灵活需要指定模块生产环境、性能优化
代码配置完全可控配置写死在代码里复杂配置需求

核心功能一:审批管理

业务场景

飞书考勤支持四种审批类型:

类型代码值说明常见字段
请假leave员工因个人原因需要请假leave_type(请假类型)
加班overtime员工因工作需要加班overtime_type(加班类型)
外出out员工因工作需要外出-
出差business员工因工作需要出差destination(目的地)

企业典型场景:

  1. 内向外写:员工在内部系统发起审批 → 内部系统审批通过 → 写入飞书考勤
  2. 外向内写:员工在飞书发起审批 → 飞书审批完成 → 同步回内部系统
  3. 双向同步:两边都可以发起,通过 OutId 关联,确保数据一致

查询审批数据

完整示例:

public class ApprovalService : IApprovalService
{
    private readonly IFeishuTenantV1AttendanceApprovals _approvalsClient;
    private readonly ILogger<ApprovalService> _logger;
    private readonly IFeishuAppManager _appManager;

    public ApprovalService(
        IFeishuTenantV1AttendanceApprovals approvalsClient,
        ILogger<ApprovalService> logger,
        IFeishuAppManager appManager)
    {
        _approvalsClient = approvalsClient;
        _logger = logger;
        _appManager = appManager;
    }

    /// <summary>
    /// 查询单个员工的审批数据
    /// </summary>
    public async Task<QueryAttendanceApprovalsResult> GetUserApprovalsAsync(
        string userId,
        DateTime startTime,
        DateTime endTime,
        string approvalType = null)
    {
        var request = new QueryAttendanceApprovalsRequest
        {
            UserId = userId,
            StartTime = startTime.ToString("yyyy-MM-dd HH:mm:ss"),
            EndTime = endTime.ToString("yyyy-MM-dd HH:mm:ss"),
            Type = approvalType, // leave、overtime、out、business
            Limit = 100,
            Offset = 0
        };

        _logger.LogInformation("查询员工 {UserId} 的审批数据", userId);

        var result = await _approvalsClient.QueryUserApprovalAsync(request);

        if (result?.Code == 0 && result.Data != null)
        {
            _logger.LogInformation("成功获取审批数据,共 {Count} 条",
                result.Data.Items?.Count ?? 0);
            return result.Data;
        }

        _logger.LogError("获取审批数据失败:{Message}", result?.Message ?? "未知错误");
        return null;
    }

    /// <summary>
    /// 批量查询多个员工的审批数据(带并发控制)
    /// </summary>
    public async Task<Dictionary<string, List<ApprovalItem>>> GetBatchUserApprovalsAsync(
        List<string> userIds,
        DateTime startTime,
        DateTime endTime,
        int maxConcurrency = 5)
    {
        var results = new Dictionary<string, List<ApprovalItem>>();
        var semaphore = new SemaphoreSlim(maxConcurrency);

        var tasks = userIds.Select(async userId =>
        {
            await semaphore.WaitAsync();
            try
            {
                var approvalData = await GetUserApprovalsAsync(userId, startTime, endTime);
                if (approvalData?.Items != null)
                {
                    lock (results)
                    {
                        results[userId] = approvalData.Items.ToList();
                    }
                }
            }
            finally
            {
                semaphore.Release();
            }
        });

        await Task.WhenAll(tasks);
        return results;
    }

    /// <summary>
    /// 分页查询所有审批数据
    /// </summary>
    public async Task<List<ApprovalItem>> GetAllApprovalsAsync(
        string userId,
        DateTime startTime,
        DateTime endTime,
        string approvalType = null)
    {
        var allItems = new List<ApprovalItem>();
        int offset = 0;
        int limit = 100;
        bool hasMore = true;

        while (hasMore)
        {
            var request = new QueryAttendanceApprovalsRequest
            {
                UserId = userId,
                StartTime = startTime.ToString("yyyy-MM-dd HH:mm:ss"),
                EndTime = endTime.ToString("yyyy-MM-dd HH:mm:ss"),
                Type = approvalType,
                Limit = limit,
                Offset = offset
            };

            var result = await _approvalsClient.QueryUserApprovalAsync(request);

            if (result?.Code == 0 && result.Data?.Items != null)
            {
                allItems.AddRange(result.Data.Items);
                hasMore = result.Data.Items.Count >= limit;
                offset += limit;
            }
            else
            {
                hasMore = false;
            }

            // 避免触发限流
            if (hasMore)
            {
                await Task.Delay(100);
            }
        }

        return allItems;
    }
}

写入审批数据

完整示例:

/// <summary>
/// 创建审批数据,将内部系统的审批结果写入飞书考勤
/// </summary>
public async Task<CreateUserApprovalResult> CreateUserApprovalAsync(
    InternalApprovalRequest internalRequest)
{
    // 转换内部审批请求为飞书审批请求
    var request = MapToFeishuRequest(internalRequest);

    _logger.LogInformation("创建审批数据,员工ID:{UserId},类型:{Type}",
        request.UserId, request.Type);

    var result = await _approvalsClient.CreateUserApprovalAsync(request);

    if (result?.Code == 0 && result.Data != null)
    {
        _logger.LogInformation("成功创建审批数据,审批ID:{ApprovalId}",
            result.Data.ApprovalId);

        // 保存 OutId 映射关系,方便后续查询和更新
        await SaveApprovalMappingAsync(
            internalRequest.InternalId,
            result.Data.ApprovalId,
            result.Data.OutId);

        return result.Data;
    }

    _logger.LogError("创建审批数据失败:{Message}", result?.Message ?? "未知错误");
    throw new FeishuApiException($"创建审批数据失败:{result?.Message}");
}

/// <summary>
/// 构建请假审批请求
/// </summary>
public CreateUserApprovalRequest BuildLeaveRequest(
    string userId,
    string leaveType,
    DateTime startTime,
    DateTime endTime,
    double duration,
    string reason,
    string internalId = null)
{
    return new CreateUserApprovalRequest
    {
        UserId = userId,
        Type = "leave", // 请假类型
        StartTime = startTime.ToString("yyyy-MM-dd HH:mm:ss"),
        EndTime = endTime.ToString("yyyy-MM-dd HH:mm:ss"),
        Duration = duration,
        LeaveType = leaveType,
        Reason = reason,
        OutId = internalId ?? Guid.NewGuid().ToString() // 外部系统唯一标识
    };
}

/// <summary>
/// 构建加班审批请求
/// </summary>
public CreateUserApprovalRequest BuildOvertimeRequest(
    string userId,
    string overtimeType,
    DateTime startTime,
    DateTime endTime,
    double duration,
    string reason,
    string internalId = null)
{
    return new CreateUserApprovalRequest
    {
        UserId = userId,
        Type = "overtime",
        StartTime = startTime.ToString("yyyy-MM-dd HH:mm:ss"),
        EndTime = endTime.ToString("yyyy-MM-dd HH:mm:ss"),
        Duration = duration,
        OvertimeType = overtimeType,
        Reason = reason,
        OutId = internalId ?? Guid.NewGuid().ToString()
    };
}

/// <summary>
/// 内部审批请求映射到飞书审批请求
/// </summary>
private CreateUserApprovalRequest MapToFeishuRequest(InternalApprovalRequest internal)
{
    return internal.ApprovalType switch
    {
        "leave" => BuildLeaveRequest(
            internal.UserId,
            internal.LeaveType,
            internal.StartTime,
            internal.EndTime,
            internal.Duration,
            internal.Reason,
            internal.InternalId
        ),
        "overtime" => BuildOvertimeRequest(
            internal.UserId,
            internal.OvertimeType,
            internal.StartTime,
            internal.EndTime,
            internal.Duration,
            internal.Reason,
            internal.InternalId
        ),
        "out" => BuildOutRequest(
            internal.UserId,
            internal.StartTime,
            internal.EndTime,
            internal.Reason,
            internal.InternalId
        ),
        "business" => BuildBusinessRequest(
            internal.UserId,
            internal.StartTime,
            internal.EndTime,
            internal.Destination,
            internal.Reason,
            internal.InternalId
        ),
        _ => throw new NotSupportedException($"不支持的审批类型:{internal.ApprovalType}")
    };
}

OutId 的作用:

OutId 是外部系统的唯一标识,非常重要:

  1. 关联查询:可以通过 OutId 找到内部系统的审批记录
  2. 防止重复:同一笔审批多次写入时,可以通过 OutId 判断是否已存在
  3. 状态同步:飞书审批状态变更时,通过 OutId 找到内部记录进行更新
// 保存 OutId 映射
await SaveApprovalMappingAsync(internalId, feishuApprovalId, outId);

// 根据 OutId 查询内部审批
var internalApproval = await GetInternalApprovalByOutId(outId);

// 根据 OutId 更新内部审批状态
await UpdateInternalApprovalStatusAsync(outId, newStatus);

更新审批状态

完整示例:

/// <summary>
/// 更新审批状态
/// </summary>
public async Task<UpdateAttendanceApprovalInfoResult> UpdateApprovalStatusAsync(
    string approvalId,
    ApprovalStatus status,
    string outId = null)
{
    var request = new UpdateApprovalInfosRequest
    {
        ApprovalInfos = new List<ApprovalInfo>
        {
            new ApprovalInfo
            {
                ApprovalId = approvalId,
                Status = (int)status, // 1=通过,2=不通过,3=撤销
                OutId = outId
            }
        }
    };

    _logger.LogInformation("更新审批状态,审批ID:{ApprovalId},状态:{Status}",
        approvalId, status);

    var result = await _approvalsClient.ProcessApprovalInfoAsync(request);

    if (result?.Code == 0 && result.Data != null)
    {
        _logger.LogInformation("成功更新审批状态");
        return result.Data;
    }

    _logger.LogError("更新审批状态失败:{Message}", result?.Message ?? "未知错误");
    throw new FeishuApiException($"更新审批状态失败:{result?.Message}");
}

/// <summary>
/// 批量更新审批状态
/// </summary>
public async Task<UpdateAttendanceApprovalInfoResult> BatchUpdateApprovalStatusAsync(
    List<ApprovalUpdateRequest> updates)
{
    var approvalInfos = updates.Select(u => new ApprovalInfo
    {
        ApprovalId = u.ApprovalId,
        Status = (int)u.Status,
        OutId = u.OutId
    }).ToList();

    var request = new UpdateApprovalInfosRequest
    {
        ApprovalInfos = approvalInfos
    };

    _logger.LogInformation("批量更新审批状态,共 {Count} 条", updates.Count);

    var result = await _approvalsClient.ProcessApprovalInfoAsync(request);

    if (result?.Code == 0 && result.Data != null)
    {
        _logger.LogInformation("成功批量更新审批状态");
        return result.Data;
    }

    _logger.LogError("批量更新审批状态失败:{Message}", result?.Message ?? "未知错误");
    throw new FeishuApiException($"批量更新审批状态失败:{result?.Message}");
}

/// <summary>
/// 根据内部审批ID更新飞书审批状态
/// </summary>
public async Task UpdateApprovalByInternalIdAsync(
    string internalId,
    ApprovalStatus status)
{
    // 先根据内部ID查找飞书审批信息
    var mapping = await GetApprovalMappingAsync(internalId);
    if (mapping == null)
    {
        _logger.LogWarning("未找到内部审批 {InternalId} 对应的飞书审批", internalId);
        return;
    }

    // 更新飞书审批状态
    await UpdateApprovalStatusAsync(mapping.ApprovalId, status, mapping.OutId);

    // 更新映射记录
    await UpdateApprovalMappingStatusAsync(internalId, status);
}

审批状态枚举:

public enum ApprovalStatus
{
    Approved = 1,    // 通过
    Rejected = 2,    // 不通过
    Revoked = 3       // 撤销
}

事件订阅处理

Webhook 示例:

// 如果使用 Webhook,需要在控制器中处理回调
[HttpPost("api/webhook/feishu")]
[Route("api/webhook/feishu")]
public async Task<IActionResult> HandleFeishuWebhook([FromBody] WebhookEvent webhookEvent)
{
    try
    {
        // 验证签名
        if (!ValidateWebhookSignature(webhookEvent))
        {
            _logger.LogWarning("Webhook 签名验证失败");
            return Unauthorized();
        }

        // 解密事件数据(如果需要)
        var eventData = DecryptEventData(webhookEvent);

        // 根据事件类型分发处理
        await _eventDispatcher.DispatchAsync(eventData);

        return Ok(new { code = 0, msg = "success" });
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "处理 Webhook 事件失败");
        return StatusCode(500, new { code = -1, msg = "internal error" });
    }
}

WebSocket 示例:

// 如果使用 WebSocket, Mud.Feishu 提供了完整的支持
// 注册 WebSocket 服务
builder.Services.AddFeishuWebSocketBuilder()
    .ConfigureFrom(builder.Configuration)
    .UseMultiHandler()
    .AddHandler<ApprovalInstanceChangeEventHandler>()
    .AddHandler<ApprovalApprovedEventHandler>()
    .AddHandler<ApprovalRejectedEventHandler>()
    .Build();

// 审批实例变更事件处理器
public class ApprovalInstanceChangeEventHandler : IFeishuEventHandler
{
    private readonly IApprovalService _approvalService;
    private readonly ILogger<ApprovalInstanceChangeEventHandler> _logger;

    public ApprovalInstanceChangeEventHandler(
        IApprovalService approvalService,
        ILogger<ApprovalInstanceChangeEventHandler> logger)
    {
        _approvalService = approvalService;
        _logger = logger;
    }

    public string SupportedEventType => FeishuEventTypes.ApprovalInstanceV1;

    public async Task HandleAsync(EventData eventData, CancellationToken cancellationToken = default)
    {
        _logger.LogInformation("收到审批实例变更事件:{EventId}", eventData.EventId);

        try
        {
            // 解析事件数据
            var approvalEvent = JsonSerializer.Deserialize<ApprovalInstanceEvent>(
                eventData.Event?.ToString() ?? "{}");

            if (approvalEvent?.ApprovalId == null)
            {
                _logger.LogWarning("审批ID为空,跳过处理");
                return;
            }

            // 根据审批ID获取详情
            var approvalDetail = await _approvalService.GetApprovalDetailAsync(
                approvalEvent.ApprovalId);

            if (approvalDetail?.OutId == null)
            {
                _logger.LogWarning("OutId为空,无法同步到内部系统");
                return;
            }

            // 同步到内部系统
            await _approvalService.SyncApprovalToInternalAsync(
                approvalDetail.OutId,
                approvalDetail.Status);

            _logger.LogInformation("成功同步审批到内部系统");
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "处理审批实例变更事件失败");
            throw;
        }
    }
}

实战建议

1. 使用事件订阅,不要定时轮询

// ❌ 错误:定时轮询
while (true)
{
    var approvals = await GetPendingApprovalsAsync();
    foreach (var approval in approvals)
    {
        await SyncApprovalStatusAsync(approval);
    }
    await Task.Delay(60000); // 每分钟轮询一次
}

// ✅ 正确:使用事件订阅
// Webhook 或 WebSocket 自动推送,实时处理

2. 做好幂等处理

// 同一个审批可能收到多次事件,需要做幂等
public async Task HandleApprovalEventAsync(EventData eventData)
{
    // 检查事件是否已处理
    if (await IsEventProcessedAsync(eventData.EventId))
    {
        _logger.LogInformation("事件 {EventId} 已处理,跳过", eventData.EventId);
        return;
    }

    // 处理业务逻辑
    await ProcessApprovalAsync(eventData);

    // 标记事件已处理
    await MarkEventProcessedAsync(eventData.EventId);
}

3. 数据一致性保障

// 本地系统和飞书系统要设计好同步机制
public async Task SyncApprovalAsync(string internalId)
{
    // 获取本地审批状态
    var localApproval = await GetLocalApprovalAsync(internalId);

    // 获取飞书审批状态
    var feishuApproval = await GetFeishuApprovalAsync(localApproval.OutId);

    // 比较状态,不一致则同步
    if (localApproval.Status != feishuApproval.Status)
    {
        await UpdateLocalApprovalStatusAsync(internalId, feishuApproval.Status);
    }
}

4. 错误处理和重试

// 使用 Mud.Feishu 内置的重试机制,或者自己实现
public async Task<CreateUserApprovalResult> CreateUserApprovalWithRetryAsync(
    CreateUserApprovalRequest request,
    int maxRetries = 3)
{
    int retryCount = 0;
    while (retryCount < maxRetries)
    {
        try
        {
            return await _approvalsClient.CreateUserApprovalAsync(request);
        }
        catch (FeishuApiException ex) when (ex.ErrorCode == 429) // 限流
        {
            retryCount++;
            _logger.LogWarning("触发限流,{RetryCount}/{MaxRetries},等待后重试",
                retryCount, maxRetries);
            await Task.Delay(1000 * retryCount); // 指数退避
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "创建审批失败");
            throw;
        }
    }

    throw new FeishuApiException("达到最大重试次数,创建审批失败");
}

核心功能二:休假管理

业务场景

休假管理主要涉及:

  1. 假期类型管理:年假、病假、事假、调休等
  2. 假期发放记录:每年年初发放年假、入职时发放年假等
  3. 假期余额查询:员工查看还有多少天假期可用
  4. 假期余额调整:HR 手动调整(比如补偿假期)

查询假期类型

public class LeaveService : ILeaveService
{
    private readonly IFeishuV1AttendanceLeave_Tenant _leaveClient;
    private readonly IFeishuTenantV1AttendanceGroups _groupsClient;
    private readonly ILogger<LeaveService> _logger;

    public async Task<List<LeaveType>> GetLeaveTypesAsync()
    {
        // 通过考勤组查询假期类型配置
        var groupsResult = await _groupsClient.GetGroupAsync(new GetGroupRequest
        {
            GroupId = "default"
        });

        if (groupsResult?.Code == 0 && groupsResult.Data != null)
        {
            return groupsResult.Data.LeaveTypes ?? new List<LeaveType>();
        }

        return new List<LeaveType>();
    }
}

查询发放记录

完整示例:

/// <summary>
/// 查询员工的假期发放记录
/// </summary>
public async Task<LeaveBalance> GetLeaveBalanceAsync(
    string userId,
    string leaveId)
{
    var now = DateTime.Now;
    var request = new LeaveEmployExpireRecordsRequest
    {
        StartTime = new DateTime(now.Year, 1, 1).ToString("yyyy-MM-dd"),
        EndTime = new DateTime(now.Year, 12, 31).ToString("yyyy-MM-dd"),
        UserIds = new List<string> { userId },
        Limit = 100,
        Offset = 0
    };

    var result = await _leaveClient.GetLeaveEmployExpireRecordAsync(request, leaveId);

    if (result?.Code == 0 && result.Data?.Items != null)
    {
        // 计算可用天数
        var totalGranted = result.Data.Items.Sum(x => x.Quota);
        var totalUsed = result.Data.Items.Sum(x => x.Used);
        var available = totalGranted - totalUsed;

        return new LeaveBalance
        {
            UserId = userId,
            LeaveId = leaveId,
            TotalGranted = totalGranted,
            TotalUsed = totalUsed,
            Available = available,
            Records = result.Data.Items.ToList()
        };
    }

    return new LeaveBalance
    {
        UserId = userId,
        LeaveId = leaveId,
        TotalGranted = 0,
        TotalUsed = 0,
        Available = 0,
        Records = new List<LeaveEmployExpireRecord>()
    };
}

/// <summary>
/// 查询即将过期的假期
/// </summary>
public async Task<List<LeaveEmployExpireRecord>> GetExpiringLeavesAsync(
    string userId,
    int daysBeforeExpire = 30)
{
    var now = DateTime.Now;
    var expireDate = now.AddDays(daysBeforeExpire);

    var request = new LeaveEmployExpireRecordsRequest
    {
        StartTime = now.ToString("yyyy-MM-dd"),
        EndTime = expireDate.ToString("yyyy-MM-dd"),
        UserIds = new List<string> { userId },
        Limit = 100,
        Offset = 0
    };

    var allRecords = new List<LeaveEmployExpireRecord>();
    // 遍历所有假期类型
    var leaveTypes = await GetLeaveTypesAsync();

    foreach (var leaveType in leaveTypes)
    {
        var result = await _leaveClient.GetLeaveEmployExpireRecordAsync(
            request, leaveType.LeaveId);

        if (result?.Code == 0 && result.Data?.Items != null)
        {
            allRecords.AddRange(result.Data.Items);
        }
    }

    return allRecords;
}

更新发放记录

完整示例:

/// <summary>
/// 手动调整员工假期余额
/// </summary>
public async Task<LeaveAccrualRecordResult> AdjustLeaveBalanceAsync(
    string userId,
    string leaveId,
    double adjustmentAmount,
    string reason,
    string operatorId)
{
    // 先获取当前发放记录
    var currentRecords = await GetCurrentLeaveRecordsAsync(userId, leaveId);

    if (currentRecords.Count == 0)
    {
        // 如果没有发放记录,创建新的
        var createRequest = new LeaveAccrualRecordRequest
        {
            UserId = userId,
            LeaveId = leaveId,
            Quota = adjustmentAmount,
            ExpireDate = DateTime.Now.AddYears(1).ToString("yyyy-MM-dd"),
            Remark = $"手动调整:{reason},操作人:{operatorId}"
        };

        return await _leaveClient.CreateLeaveAccrualRecordAsync(createRequest, leaveId);
    }
    else
    {
        // 更新现有记录
        var latestRecord = currentRecords.OrderByDescending(x => x.CreateTime).First();
        var newQuota = latestRecord.Quota + adjustmentAmount;

        if (newQuota < 0)
        {
            throw new InvalidOperationException("调整后的假期余额不能为负数");
        }

        var updateRequest = new LeaveAccrualRecordRequest
        {
            UserId = userId,
            LeaveId = leaveId,
            RecordId = latestRecord.RecordId,
            Quota = newQuota,
            Remark = $"手动调整:{reason},操作人:{operatorId},原始余额:{latestRecord.Quota},调整:{adjustmentAmount},新余额:{newQuota}"
        };

        return await _leaveClient.ModifyLeaveAccrualRecordAsync(updateRequest, leaveId);
    }
}

/// <summary>
/// 年初批量发放年假
/// </summary>
public async Task BatchGrantAnnualLeaveAsync(
    List<string> userIds,
    string leaveId,
    int annualDays,
    string operatorId)
{
    var successCount = 0;
    var failCount = 0;
    var errors = new List<string>();

    foreach (var userId in userIds)
    {
        try
        {
            await AdjustLeaveBalanceAsync(
                userId,
                leaveId,
                annualDays,
                $"年初发放{annualDays}天年假",
                operatorId);

            successCount++;
            _logger.LogInformation("成功为用户 {UserId} 发放年假", userId);
        }
        catch (Exception ex)
        {
            failCount++;
            errors.Add($"用户 {UserId} 发放失败:{ex.Message}");
            _logger.LogError(ex, "为用户 {UserId} 发放年假失败", userId);
        }

        // 避免触发限流
        await Task.Delay(200);
    }

    // 记录操作日志
    await LogBatchOperationAsync(
        "批量发放年假",
        $"成功:{successCount},失败:{failCount}",
        errors);
}

休假计算注意事项

1. 跨年处理

// 年假是否跨年取决于企业政策
public async Task<List<LeaveBalance>> GetLeaveBalanceWithYearAsync(
    string userId,
    string leaveId)
{
    var now = DateTime.Now;
    var results = new List<LeaveBalance>();

    // 当前年度
    var currentYearBalance = await GetLeaveBalanceAsync(
        userId,
        leaveId,
        now.Year);

    // 上一年度(如果政策允许跨年)
    var lastYearBalance = await GetLeaveBalanceAsync(
        userId,
        leaveId,
        now.Year - 1);

    results.Add(currentYearBalance);
    results.Add(lastYearBalance);

    return results;
}

2. 休假类型计算规则

// 不同休假类型有不同的计算规则
public class LeaveCalculator
{
    /// <summary>
    /// 计算请假天数
    /// </summary>
    public double CalculateLeaveDays(
        DateTime startTime,
        DateTime endTime,
        string leaveType)
    {
        return leaveType switch
        {
            "annual" => CalculateAnnualLeaveDays(startTime, endTime),
            "sick" => CalculateSickLeaveDays(startTime, endTime),
            "personal" => CalculatePersonalLeaveDays(startTime, endTime),
            "maternity" => CalculateMaternityLeaveDays(startTime, endTime),
            _ => CalculateDefaultLeaveDays(startTime, endTime)
        };
    }

    /// <summary>
    /// 年假计算:只计算工作日
    /// </summary>
    private double CalculateAnnualLeaveDays(DateTime startTime, DateTime endTime)
    {
        var workDays = 0;
        var current = startTime.Date;

        while (current <= endTime.Date)
        {
            if (IsWorkDay(current))
            {
                workDays++;
            }
            current = current.AddDays(1);
        }

        // 按小时计算
        return workDays * 8;
    }

    /// <summary>
    /// 病假计算:包括节假日
    /// </summary>
    private double CalculateSickLeaveDays(DateTime startTime, DateTime endTime)
    {
        var totalHours = (endTime - startTime).TotalHours;
        return totalHours;
    }

    private bool IsWorkDay(DateTime date)
    {
        // 判断是否为工作日
        return date.DayOfWeek != DayOfWeek.Saturday &&
               date.DayOfWeek != DayOfWeek.Sunday &&
               !IsHoliday(date);
    }
}

核心功能三:补卡管理

业务场景

补卡管理的典型场景:

  1. 员工忘记打卡:早上忘记打上班卡,需要补卡
  2. 设备故障:打卡机故障导致无法打卡
  3. 外出办公:外出办公无法打卡
  4. 批量处理:需要批量审批补卡申请

创建补卡审批

完整示例:

public class RemedyService : IRemedyService
{
    private readonly IFeishuTenantV1AttendanceRemedys _remedyClient;
    private readonly IFeishuTenantV1AttendanceApprovals _approvalsClient;
    private readonly ILogger<RemedyService> _logger;

    public RemedyService(
        IFeishuTenantV1AttendanceRemedys remedyClient,
        IFeishuTenantV1AttendanceApprovals approvalsClient,
        ILogger<RemedyService> logger)
    {
        _remedyClient = remedyClient;
        _approvalsClient = approvalsClient;
        _logger = logger;
    }

    /// <summary>
    /// 创建补卡审批
    /// </summary>
    public async Task<AttendanceRemedysResult> CreateRemedyAsync(
        RemedyRequest internalRequest)
    {
        // 先查询员工当天可以补的打卡时间
        var allowedTimes = await GetAllowedRemedyTimesAsync(
            internalRequest.UserId,
            internalRequest.Date,
            internalRequest.Type);

        if (allowedTimes?.AllowedTimes == null || allowedTimes.AllowedTimes.Count == 0)
        {
            throw new InvalidOperationException("当天无可补卡时间");
        }

        // 验证补卡时间是否在允许范围内
        var remedyTime = DateTime.Parse(internalRequest.Time);
        var timeRange = allowedTimes.AllowedTimes.FirstOrDefault();

        if (timeRange != null &&
            (remedyTime < timeRange.EarliestTime || remedyTime > timeRange.LatestTime))
        {
            throw new InvalidOperationException(
                $"补卡时间不在允许范围内:{timeRange.EarliestTime:HH:mm:ss} - {timeRange.LatestTime:HH:mm:ss}");
        }

        // 构建补卡请求
        var request = new AttendanceRemedysRequest
        {
            UserId = internalRequest.UserId,
            Date = internalRequest.Date.ToString("yyyy-MM-dd"),
            Time = internalRequest.Time,
            Type = internalRequest.Type, // 1=上班,2=下班
            Reason = internalRequest.Reason,
            OutId = internalRequest.InternalId ?? Guid.NewGuid().ToString()
        };

        _logger.LogInformation("创建补卡审批,员工ID:{UserId},日期:{Date},时间:{Time}",
            request.UserId, request.Date, request.Time);

        var result = await _remedyClient.CreateUserTaskRemedyAsync(request);

        if (result?.Code == 0 && result.Data != null)
        {
            _logger.LogInformation("成功创建补卡审批,任务ID:{TaskId}", result.Data.TaskId);
            return result.Data;
        }

        _logger.LogError("创建补卡审批失败:{Message}", result?.Message ?? "未知错误");
        throw new FeishuApiException($"创建补卡审批失败:{result?.Message}");
    }

    /// <summary>
    /// 构建补卡请求
    /// </summary>
    public AttendanceRemedysRequest BuildRemedyRequest(
        string userId,
        DateTime date,
        DateTime time,
        int type,
        string reason,
        string outId = null)
    {
        return new AttendanceRemedysRequest
        {
            UserId = userId,
            Date = date.ToString("yyyy-MM-dd"),
            Time = time.ToString("HH:mm:ss"),
            Type = type, // 1=上班,2=下班
            Reason = reason,
            OutId = outId ?? Guid.NewGuid().ToString()
        };
    }
}

查询可补卡时间

/// <summary>
/// 查询用户某天可以补的第几次上/下班卡的时间
/// </summary>
public async Task<QueryUserAllowedRemedysResult> GetAllowedRemedyTimesAsync(
    string userId,
    DateTime date,
    int type)
{
    var request = new AllowedRemedysRequest
    {
        UserId = userId,
        Date = date.ToString("yyyy-MM-dd"),
        Type = type // 1=上班,2=下班
    };

    _logger.LogInformation("查询用户 {UserId} 在 {Date} 的可补卡时间,类型:{Type}",
        userId, date, type);

    var result = await _remedyClient.QueryUserAllowedRemedysUserTaskRemedyAsync(request);

    if (result?.Code == 0 && result.Data != null)
    {
        _logger.LogInformation("成功查询可补卡时间,共 {Count} 个时间段",
            result.Data.AllowedTimes?.Count ?? 0);
        return result.Data;
    }

    _logger.LogError("查询可补卡时间失败:{Message}", result?.Message ?? "未知错误");
    return null;
}

/// <summary>
/// 验证补卡时间是否有效
/// </summary>
public async Task<bool> ValidateRemedyTimeAsync(
    string userId,
    DateTime date,
    DateTime time,
    int type)
{
    var allowedTimes = await GetAllowedRemedyTimesAsync(userId, date, type);

    if (allowedTimes?.AllowedTimes == null || allowedTimes.AllowedTimes.Count == 0)
    {
        return false;
    }

    var timeOfDay = time.TimeOfDay;
    return allowedTimes.AllowedTimes.Any(t =>
        timeOfDay >= t.EarliestTime.TimeOfDay &&
        timeOfDay <= t.LatestTime.TimeOfDay);
}

查询补卡记录

完整示例:

/// <summary>
/// 获取用户的补卡记录
/// </summary>
public async Task<QueryUserRemedysResult> GetRemedyRecordsAsync(
    string userId,
    DateTime startDate,
    DateTime endDate,
    int? status = null,
    int limit = 100,
    int offset = 0)
{
    var request = new QueryUserRemedysRequest
    {
        UserId = userId,
        StartDate = startDate.ToString("yyyy-MM-dd"),
        EndDate = endDate.ToString("yyyy-MM-dd"),
        Status = status, // 1=审批中,2=通过,3=拒绝
        Limit = limit,
        Offset = offset
    };

    _logger.LogInformation("获取用户 {UserId} 的补卡记录,时间范围:{StartDate} 至 {EndDate}",
        userId, startDate, endDate);

    var result = await _remedyClient.QueryUserTaskRemedyAsync(request);

    if (result?.Code == 0 && result.Data != null)
    {
        _logger.LogInformation("成功获取补卡记录,共 {Count} 条", result.Data.Items?.Count ?? 0);
        return result.Data;
    }

    _logger.LogError("获取补卡记录失败:{Message}", result?.Message ?? "未知错误");
    return null;
}

/// <summary>
/// 批量获取多个员工的补卡记录
/// </summary>
public async Task<Dictionary<string, List<RemedyRecord>>> GetBatchRemedyRecordsAsync(
    List<string> userIds,
    DateTime startDate,
    DateTime endDate)
{
    var results = new Dictionary<string, List<RemedyRecord>>();

    foreach (var userId in userIds)
    {
        var remedyData = await GetRemedyRecordsAsync(userId, startDate, endDate);

        if (remedyData?.Items != null)
        {
            results[userId] = remedyData.Items.Select(x => new RemedyRecord
            {
                TaskId = x.TaskId,
                UserId = x.UserId,
                Date = x.Date,
                Time = x.Time,
                Type = x.Type,
                Status = x.Status,
                Reason = x.Reason,
                OutId = x.OutId
            }).ToList();
        }

        // 避免触发限流
        await Task.Delay(100);
    }

    return results;
}

/// <summary>
/// 获取员工的补卡统计
/// </summary>
public async Task<RemedyStatistics> GetRemedyStatisticsAsync(
    string userId,
    DateTime startDate,
    DateTime endDate)
{
    var allRecords = await GetRemedyRecordsAsync(userId, startDate, endDate);

    if (allRecords?.Items == null)
    {
        return new RemedyStatistics();
    }

    return new RemedyStatistics
    {
        TotalCount = allRecords.Items.Count,
        ApprovedCount = allRecords.Items.Count(x => x.Status == 2),
        RejectedCount = allRecords.Items.Count(x => x.Status == 3),
        PendingCount = allRecords.Items.Count(x => x.Status == 1),
        CheckInCount = allRecords.Items.Count(x => x.Type == 1),
        CheckOutCount = allRecords.Items.Count(x => x.Type == 2)
    };
}

补卡审批流程

完整流程:

/// <summary>
/// 补卡审批完整流程
/// </summary>
public async Task ProcessRemedyApprovalAsync(string internalRemedyId)
{
    // 1. 获取内部补卡申请
    var internalRemedy = await GetInternalRemedyAsync(internalRemedyId);

    if (internalRemedy == null)
    {
        throw new NotFoundException($"未找到补卡申请:{internalRemedyId}");
    }

    // 2. 创建飞书补卡审批
    var feishuRemedy = await CreateRemedyAsync(new RemedyRequest
    {
        UserId = internalRemedy.UserId,
        Date = internalRemedy.Date,
        Time = internalRemedy.Time,
        Type = internalRemedy.Type,
        Reason = internalRemedy.Reason,
        InternalId = internalRemedyId
    });

    // 3. 保存映射关系
    await SaveRemedyMappingAsync(internalRemedyId, feishuRemedy.TaskId, feishuRemedy.OutId);

    // 4. 等待飞书审批结果(通过事件订阅)
    // 事件处理器会监听审批状态变更并更新内部记录
}

/// <summary>
/// 审批通过后的处理
/// </summary>
public async Task HandleRemedyApprovedAsync(string taskId)
{
    // 获取补卡记录
    var remedyRecord = await GetRemedyRecordAsync(taskId);

    // 获取映射的内部记录
    var internalRemedy = await GetInternalRemedyByOutIdAsync(remedyRecord.OutId);

    if (internalRemedy != null)
    {
        // 更新内部审批状态
        await UpdateInternalRemedyStatusAsync(internalRemedy.Id, RemedyStatus.Approved);

        // 发送通知
        await SendNotificationAsync(internalRemedy.UserId, "补卡申请已通过");
    }
}

补卡规则配置

/// <summary>
/// 补卡规则配置
/// </summary>
public class RemedyRuleService
{
    /// <summary>
    /// 检查补卡申请是否符合规则
    /// </summary>
    public async Task<RemedyRuleCheckResult> CheckRemedyRuleAsync(
        string userId,
        DateTime date,
        int type)
    {
        var rules = await GetRemedyRulesAsync(userId);

        // 检查补卡次数限制
        var currentMonthRemedyCount = await GetMonthRemedyCountAsync(userId, date);
        if (currentMonthRemedyCount >= rules.MaxMonthlyRemedyCount)
        {
            return new RemedyRuleCheckResult
            {
                IsAllowed = false,
                Reason = $"本月补卡次数已达上限({rules.MaxMonthlyRemedyCount}次)"
            };
        }

        // 检查补卡时间限制
        var isWithinAllowedTime = await IsWithinAllowedTimeAsync(userId, date, type);
        if (!isWithinAllowedTime)
        {
            return new RemedyRuleCheckResult
            {
                IsAllowed = false,
                Reason = "补卡时间不在允许范围内"
            };
        }

        // 检查是否需要审批
        if (rules.RequireApproval)
        {
            return new RemedyRuleCheckResult
            {
                IsAllowed = true,
                RequireApproval = true
            };
        }

        return new RemedyRuleCheckResult
        {
            IsAllowed = true,
            RequireApproval = false
        };
    }
}

核心功能四:考勤统计

统计字段说明

飞书考勤统计支持丰富的字段,按类别分为:

基本信息:

  • user_id:员工 ID
  • user_name:员工姓名
  • department_id:部门 ID
  • department_name:部门名称

考勤组信息:

  • group_id:考勤组 ID
  • group_name:考勤组名称

出勤统计:

  • actual_work_hours:实际工作时长(小时)
  • normal_working_hours:正常工作时长(小时)
  • work_days:工作天数
  • work_days_ratio:工作日出勤率

异常统计:

  • late_count:迟到次数
  • late_minutes:迟到分钟数
  • early_count:早退次数
  • early_minutes:早退分钟数
  • absent_count:缺勤次数
  • absent_days:缺勤天数

请假统计:

  • leave_hours:请假时长(小时)
  • leave_count:请假次数
  • leave_days:请假天数

加班统计:

  • overtime_hours:加班时长(小时)
  • overtime_count:加班次数

打卡时间:

  • checkin_time:上班打卡时间
  • checkout_time:下班打卡时间
  • work_location:打卡地点

考勤结果:

  • attendance_result:考勤结果(正常/迟到/早退/缺勤/请假)

查询统计表头

public class StatsService : IStatsService
{
    private readonly IFeishuTenantV1AttendanceStats _statsClient;
    private readonly ILogger<StatsService> _logger;

    public StatsService(
        IFeishuTenantV1AttendanceStats statsClient,
        ILogger<StatsService> logger)
    {
        _statsClient = statsClient;
        _logger = logger;
    }

    /// <summary>
    /// 查询可用的统计字段
    /// </summary>
    public async Task<Dictionary<int, List<StatsField>>> GetAllStatsFieldsAsync()
    {
        var results = new Dictionary<int, List<StatsField>>();

        // 查询日度统计字段
        var dailyResult = await GetStatsFieldsAsync(1);
        results[1] = dailyResult?.Fields?.ToList() ?? new List<StatsField>();

        // 查询月度统计字段
        var monthlyResult = await GetStatsFieldsAsync(2);
        results[2] = monthlyResult?.Fields?.ToList() ?? new List<StatsField>();

        return results;
    }

    /// <summary>
    /// 查询统计字段
    /// </summary>
    public async Task<QueryStatsFieldsResult> GetStatsFieldsAsync(int statsType)
    {
        var request = new QueryStatsFieldsRequest
        {
            StatsType = statsType // 1=日度,2=月度
        };

        _logger.LogInformation("查询考勤统计支持的统计表头,统计类型:{StatsType}", statsType);

        var result = await _statsClient.QueryUserStatsFieldAsync(request);

        if (result?.Code == 0 && result.Data != null)
        {
            _logger.LogInformation("成功查询统计表头,共 {Count} 个字段",
                result.Data.Fields?.Count ?? 0);
            return result.Data;
        }

        _logger.LogError("查询统计表头失败:{Message}", result?.Message ?? "未知错误");
        return null;
    }
}

更新统计视图

/// <summary>
    /// 更新统计报表表头设置
    /// </summary>
public async Task<UserStatsViewsResult> UpdateStatsViewAsync(
    string viewId,
    UserStatsViewsRequest request)
{
    _logger.LogInformation("更新统计报表表头设置,视图ID:{ViewId}", viewId);

    var result = await _statsClient.UpdateUserStatsViewAsync(request, viewId);

    if (result?.Code == 0 && result.Data != null)
    {
        _logger.LogInformation("成功更新统计报表表头设置");
        return result.Data;
    }

    _logger.LogError("更新统计报表表头设置失败:{Message}", result?.Message ?? "未知错误");
    return null;
}

/// <summary>
/// 创建自定义统计视图
/// </summary>
public async Task<UserStatsViewsResult> CreateCustomStatsViewAsync(
    string viewName,
    int statsType,
    List<string> fieldIds)
{
    // 先查询现有视图
    var queryRequest = new QueryStatsViewsRequest
    {
        StatsType = statsType,
        PageSize = 100,
        PageToken = ""
    };

    var queryResult = await _statsClient.QueryUserStatsViewAsync(queryRequest);

    // 检查是否已存在同名视图
    var existingView = queryResult?.Data?.Items?
        .FirstOrDefault(v => v.ViewName == viewName);

    if (existingView != null)
    {
        // 更新现有视图
        return await UpdateStatsViewAsync(existingView.UserStatsViewId,
            new UserStatsViewsRequest
            {
                ViewName = viewName,
                StatsType = statsType,
                FieldIds = fieldIds
            });
    }
    else
    {
        // 创建新视图(通过更新默认视图实现)
        // 飞书 API 不直接支持创建视图,需要修改默认视图
        _logger.LogWarning("飞书 API 不支持直接创建视图,请手动在飞书后台创建");
        return null;
    }
}

/// <summary>
/// 构建统计报表表头设置请求
/// </summary>
public UserStatsViewsRequest BuildStatsViewRequest(
    string viewName,
    int statsType,
    List<string> fieldIds)
{
    return new UserStatsViewsRequest
    {
        ViewName = viewName,
        StatsType = statsType, // 1=日度,2=月度
        FieldIds = fieldIds
    };
}

/// <summary>
/// 获取常用字段配置
/// </summary>
public List<string> GetCommonStatsFields(StatsScenario scenario)
{
    return scenario switch
    {
        StatsScenario.AttendanceOverview => new List<string>
        {
            "user_id", "user_name", "department_id", "department_name",
            "actual_work_hours", "normal_working_hours", "attendance_result"
        },
        StatsScenario.AbnormalAnalysis => new List<string>
        {
            "user_id", "user_name", "late_count", "late_minutes",
            "early_count", "early_minutes", "absent_count"
        },
        StatsScenario.LeaveAnalysis => new List<string>
        {
            "user_id", "user_name", "leave_hours", "leave_count",
            "leave_days"
        },
        StatsScenario.OvertimeAnalysis => new List<string>
        {
            "user_id", "user_name", "overtime_hours", "overtime_count"
        },
        _ => new List<string>()
    };
}

查询统计数据

完整示例:

/// <summary>
/// 查询统计数据
/// </summary>
public async Task<QueryStatsDatasResult> GetStatsDataAsync(
    QueryStatsDatasRequest request)
{
    _logger.LogInformation(
        "查询统计数据,统计类型:{StatsType},时间范围:{StartDate} 至 {EndDate}",
        request.StatsType, request.StartDate, request.EndDate);

    var result = await _statsClient.QueryUserStatsDataAsync(request);

    if (result?.Code == 0 && result.Data != null)
    {
        _logger.LogInformation("成功查询统计数据,共 {Count} 条",
            result.Data.Items?.Count ?? 0);
        return result.Data;
    }

    _logger.LogError("查询统计数据失败:{Message}", result?.Message ?? "未知错误");
    return null;
}

/// <summary>
/// 构建统计数据请求
/// </summary>
public QueryStatsDatasRequest BuildStatsDataRequest(
    int statsType,
    string startDate,
    string endDate,
    List<string> userIds = null,
    List<string> groupIds = null,
    string viewId = null,
    int limit = 100,
    int offset = 0)
{
    return new QueryStatsDatasRequest
    {
        StatsType = statsType, // 1=日度,2=月度
        StartDate = startDate,
        EndDate = endDate,
        UserIds = userIds,
        GroupIds = groupIds,
        ViewId = viewId,
        Limit = limit,
        Offset = offset
    };
}

/// <summary>
/// 分页查询所有统计数据
/// </summary>
public async Task<List<StatsDataItem>> GetAllStatsDataAsync(
    int statsType,
    string startDate,
    string endDate,
    List<string> userIds = null,
    List<string> groupIds = null,
    string viewId = null)
{
    var allItems = new List<StatsDataItem>();
    int offset = 0;
    int limit = 100;
    bool hasMore = true;

    while (hasMore)
    {
        var request = BuildStatsDataRequest(
            statsType, startDate, endDate, userIds, groupIds, viewId, limit, offset);

        var result = await GetStatsDataAsync(request);

        if (result?.Items != null && result.Items.Count > 0)
        {
            allItems.AddRange(result.Items);
            hasMore = result.Items.Count >= limit;
            offset += limit;
        }
        else
        {
            hasMore = false;
        }

        if (hasMore)
        {
            await Task.Delay(200); // 避免触发限流
        }
    }

    return allItems;
}

/// <summary>
/// 获取员工月度考勤统计
/// </summary>
public async Task<MonthlyAttendanceStats> GetMonthlyAttendanceStatsAsync(
    string userId,
    int year,
    int month)
{
    var startDate = $"{year}-{month:D2}-01";
    var endDate = $"{year}-{month:D2}-{DateTime.DaysInMonth(year, month):D2}";

    var request = BuildStatsDataRequest(
        statsType: 2, // 月度统计
        startDate: startDate,
        endDate: endDate,
        userIds: new List<string> { userId },
        limit: 1
    );

    var result = await GetStatsDataAsync(request);

    if (result?.Items != null && result.Items.Count > 0)
    {
        var item = result.Items[0];
        return new MonthlyAttendanceStats
        {
            UserId = userId,
            Year = year,
            Month = month,
            WorkDays = item.WorkDays,
            ActualWorkHours = item.ActualWorkHours,
            LateCount = item.LateCount,
            LateMinutes = item.LateMinutes,
            EarlyCount = item.EarlyCount,
            EarlyMinutes = item.EarlyMinutes,
            LeaveHours = item.LeaveHours,
            OvertimeHours = item.OvertimeHours,
            AttendanceResult = item.AttendanceResult
        };
    }

    return new MonthlyAttendanceStats
    {
        UserId = userId,
        Year = year,
        Month = month,
        WorkDays = 0,
        ActualWorkHours = 0
    };
}

/// <summary>
/// 获取部门考勤统计
/// </summary>
public async Task<DepartmentAttendanceStats> GetDepartmentAttendanceStatsAsync(
    string departmentId,
    int year,
    int month)
{
    // 先获取部门下所有员工
    var userIds = await GetDepartmentUserIdsAsync(departmentId);

    if (userIds.Count == 0)
    {
        return new DepartmentAttendanceStats();
    }

    // 分批查询员工考勤数据
    var allStats = new List<MonthlyAttendanceStats>();
    var batchSize = 50;

    for (int i = 0; i < userIds.Count; i += batchSize)
    {
        var batchUsers = userIds.Skip(i).Take(batchSize).ToList();
        var stats = await GetMonthlyAttendanceStatsAsync(batchUsers, year, month);
        allStats.AddRange(stats);

        await Task.Delay(500); // 避免触发限流
    }

    // 汇总部门统计
    return new DepartmentAttendanceStats
    {
        DepartmentId = departmentId,
        Year = year,
        Month = month,
        TotalUsers = userIds.Count,
        TotalWorkDays = allStats.Sum(x => x.WorkDays),
        TotalActualWorkHours = allStats.Sum(x => x.ActualWorkHours),
        TotalLateCount = allStats.Sum(x => x.LateCount),
        TotalOvertimeHours = allStats.Sum(x => x.OvertimeHours),
        TotalLeaveHours = allStats.Sum(x => x.LeaveHours),
        AttendanceRate = CalculateAttendanceRate(allStats)
    };
}

private double CalculateAttendanceRate(List<MonthlyAttendanceStats> stats)
{
    if (stats.Count == 0) return 0;

    var totalWorkDays = stats.Sum(x => x.WorkDays);
    var totalNormalDays = stats.Count * 21; // 假设每月21个工作日

    return totalNormalDays > 0 ? (totalWorkDays / totalNormalDays) * 100 : 0;
}

统计数据缓存

/// <summary>
/// 带缓存的统计数据查询
/// </summary>
public async Task<QueryStatsDatasResult> GetStatsDataWithCacheAsync(
    QueryStatsDatasRequest request,
    TimeSpan cacheDuration)
{
    var cacheKey = $"stats:{request.StatsType}:{request.StartDate}:{request.EndDate}:" +
                    $"{string.Join(",", request.UserIds ?? new List<string>())}";

    // 尝试从缓存获取
    var cachedData = await _cache.GetAsync<QueryStatsDatasResult>(cacheKey);
    if (cachedData != null)
    {
        _logger.LogInformation("从缓存获取统计数据:{CacheKey}", cacheKey);
        return cachedData;
    }

    // 从飞书 API 获取
    var result = await GetStatsDataAsync(request);

    // 存入缓存
    if (result != null)
    {
        await _cache.SetAsync(cacheKey, result, cacheDuration);
    }

    return result;
}

踩坑实录

限流问题

问题:

飞书 API 有调用频率限制,超了就返回 429。一开始没注意,批量同步员工数据时直接触发限流。

限制参考:

API 类型限制建议
审批相关60次/分钟控制并发数,加延迟
休假相关60次/分钟批量操作时串行处理
统计相关30次/分钟尽量少查,使用缓存
补卡相关60次/分钟避免频繁调用
组织架构50次/分钟批量拉取后本地缓存

解决方案:

// 方案1:使用 SemaphoreSlim 控制并发
private readonly SemaphoreSlim _rateLimiter = new SemaphoreSlim(10); // 最多10个并发

public async Task BatchSyncUsersAsync(List<string> userIds)
{
    var tasks = userIds.Select(async userId =>
    {
        await _rateLimiter.WaitAsync();
        try
        {
            await SyncUserAsync(userId);
        }
        finally
        {
            _rateLimiter.Release();
        }
    });

    await Task.WhenAll(tasks);
}

// 方案2:使用 Polly 的限流策略
builder.Services.AddHttpClient<IFeishuHttpClient>()
    .AddTransientHttpErrorPolicy(p => p
        .OrResult(msg => msg.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
        .WaitAndRetryAsync(3, retryAttempt =>
            TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)), // 指数退避
            onRetry: (outcome, timespan, retryCount, context) =>
            {
                _logger.LogWarning(
                    "触发限流,等待 {WaitTime} 秒后重试,第 {RetryCount} 次",
                    timespan.TotalSeconds, retryCount);
            }
        )
    );

// 方案3:简单延迟
await Task.Delay(1000); // 每次调用后延迟1秒

时区坑

问题:

服务器是 UTC 时间,飞书用的是 Asia/Shanghai。第一次同步数据时,发现时间都对不上。

时间流程:

用户输入(本地时间)
    ↓
转换为 UTC 时间(存储到数据库)
    ↓
与飞书 API 交互时
    ↓
转换为 Asia/Shanghai 时间
    ↓
调用飞书 API
    ↓
飞书 API 返回数据(Asia/Shanghai)
    ↓
转换为 UTC 时间(存储到数据库)
    ↓
用户本地时区显示

解决方案:

// 统一时区处理工具类
public static class TimeZoneHelper
{
    private static readonly TimeZoneInfo ShanghaiTimeZone =
        TimeZoneInfo.FindSystemTimeZoneById("Asia/Shanghai");

    /// <summary>
    /// UTC 转上海时间
    /// </summary>
    public static DateTime UtcToShanghai(DateTime utcTime)
    {
        return TimeZoneInfo.ConvertTimeFromUtc(utcTime, ShanghaiTimeZone);
    }

    /// <summary>
    /// 上海时间转 UTC
    /// </summary>
    public static DateTime ShanghaiToUtc(DateTime shanghaiTime)
    {
        return TimeZoneInfo.ConvertTimeToUtc(shanghaiTime, ShanghaiTimeZone);
    }

    /// <summary>
    /// 本地时间转 UTC
    /// </summary>
    public static DateTime LocalToUtc(DateTime localTime)
    {
        return localTime.Kind == DateTimeKind.Utc
            ? localTime
            : localTime.ToUniversalTime();
    }

    /// <summary>
    /// 格式化为飞书 API 需要的时间格式
    /// </summary>
    public static string FormatForFeishu(DateTime dateTime)
    {
        var utcTime = LocalToUtc(dateTime);
        return UtcToShanghai(utcTime).ToString("yyyy-MM-dd HH:mm:ss");
    }
}

// 使用示例
var now = DateTime.Now;
var feishuTime = TimeZoneHelper.FormatForFeishu(now);
var request = new QueryAttendanceApprovalsRequest
{
    StartTime = feishuTime,
    EndTime = TimeZoneHelper.FormatForFeishu(now.AddDays(7))
};

最佳实践:

  1. 后端统一用 UTC 存储:数据库时间字段存储 UTC 时间
  2. 与飞书交互时显式转换:调用飞书 API 前转换为上海时间
  3. 前端展示时转回用户本地时区:用户看到的是自己的本地时间
  4. 统一使用工具类:避免散落在各处的时区转换逻辑不一致

数据安全

问题:

员工数据比较敏感,需要做好安全防护。

解决方案:

// 1. 数据库加密存储
public class EncryptionService
{
    private readonly IConfiguration _configuration;

    public EncryptionService(IConfiguration configuration)
    {
        _configuration = configuration;
    }

    public string Encrypt(string plainText)
    {
        var key = _configuration["Encryption:Key"];
        var iv = _configuration["Encryption:IV"];
        // 使用 AES 加密
        // ...
    }

    public string Decrypt(string cipherText)
    {
        var key = _configuration["Encryption:Key"];
        var iv = _configuration["Encryption:IV"];
        // 使用 AES 解密
        // ...
    }
}

// 2. 敏感信息脱敏
public class DataMaskingService
{
    public string MaskIdCard(string idCard)
    {
        if (string.IsNullOrEmpty(idCard) || idCard.Length < 4)
            return idCard;
        return idCard.Substring(0, 3) + "********" + idCard.Substring(idCard.Length - 4);
    }

    public string MaskPhone(string phone)
    {
        if (string.IsNullOrEmpty(phone) || phone.Length < 7)
            return phone;
        return phone.Substring(0, 3) + "****" + phone.Substring(phone.Length - 4);
    }
}

// 3. 接口访问权限控制
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class AttendanceController : ControllerBase
{
    [HttpGet("{userId}")]
    public async Task<IActionResult> GetUserAttendance(string userId)
    {
        // 只能查看自己的数据(管理员除外)
        var currentUserId = User.FindFirst("sub")?.Value;
        var isAdmin = User.IsInRole("Admin");

        if (!isAdmin && currentUserId != userId)
        {
            return Forbid();
        }

        // ...
    }
}

// 4. 操作日志记录
public class AuditLogService
{
    public async Task LogOperationAsync(AuditLog log)
    {
        // 记录操作人、操作时间、操作类型、操作内容
        await _auditLogRepository.AddAsync(log);
    }
}

安全检查清单:

  • 敏感字段加密存储(身份证、手机号等)
  • HTTP 传输使用 HTTPS
  • 接口访问权限控制(基于角色的访问控制 RBAC)
  • 操作日志记录(记录谁在什么时候做了什么)
  • 定期安全审计
  • 防止 SQL 注入、XSS 等常见攻击

调试技巧

1. 使用飞书开放平台的"调试工具"

先在飞书开放平台的调试工具中测试 API,确认参数和响应格式正确后再写代码。

2. 开启详细日志

// 开启 Mud.Feishu 的 DebugLog
builder.Services.CreateFeishuServicesBuilder(options =>
{
    options.AppId = builder.Configuration["Feishu:AppId"];
    options.AppSecret = builder.Configuration["Feishu:AppSecret"];
    options.EnableDebugLog = true; // 开启调试日志
})
    .AddApprovalApi()
    .Build();

// 日志配置
{
  "Logging": {
    "LogLevel": {
      "Mud.Feishu": "Debug",  // 开启 SDK 调试日志
      "Default": "Information"
    }
  }
}

3. 详细记录请求参数和响应结果

public async Task<FeishuApiResult<T>> CallFeishuApiAsync<T>(string apiName, object request)
{
    var requestId = Guid.NewGuid().ToString();

    _logger.LogInformation("[{RequestId}] 调用飞书 API:{ApiName}", requestId, apiName);
    _logger.LogDebug("[{RequestId}] 请求参数:{Request}", requestId,
        JsonSerializer.Serialize(request));

    try
    {
        var result = await _feishuApi.CallAsync<T>(request);

        _logger.LogInformation("[{RequestId}] API 调用成功,Code:{Code}", requestId, result?.Code);
        _logger.LogDebug("[{RequestId}] 响应结果:{Response}", requestId,
            JsonSerializer.Serialize(result));

        return result;
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "[{RequestId}] API 调用失败", requestId);
        throw;
    }
}

项目地址

代码都在这:GitHub Gitee

有 Demo 可以参考,有问题可以提 Issue。


如果你也在做类似的项目,希望这篇笔记能帮你少踩几个坑。

有问题欢迎交流,让我进步!