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

去年给公司做 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 长连接、实时性强 需要处理断线重连 需要即时响应
定时轮询 实现简单 有延迟、浪费资源 实时性要求不高

项目搭建

创建项目:

bash 复制代码
# 创建项目
dotnet new webapi -n AttendanceSystem
cd AttendanceSystem

# 安装 SDK
dotnet add package Mud.Feishu

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

配置文件:

json 复制代码
// 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
      }
    ]
  }
}

多应用配置示例:

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

服务注册

csharp 复制代码
// 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 关联,确保数据一致

查询审批数据

完整示例:

csharp 复制代码
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;
    }
}

写入审批数据

完整示例:

csharp 复制代码
/// <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 找到内部记录进行更新
csharp 复制代码
// 保存 OutId 映射
await SaveApprovalMappingAsync(internalId, feishuApprovalId, outId);

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

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

更新审批状态

完整示例:

csharp 复制代码
/// <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);
}

审批状态枚举:

csharp 复制代码
public enum ApprovalStatus
{
    Approved = 1,    // 通过
    Rejected = 2,    // 不通过
    Revoked = 3       // 撤销
}

事件订阅处理

Webhook 示例:

csharp 复制代码
// 如果使用 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 示例:

csharp 复制代码
// 如果使用 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. 使用事件订阅,不要定时轮询

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

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

2. 做好幂等处理

csharp 复制代码
// 同一个审批可能收到多次事件,需要做幂等
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. 数据一致性保障

csharp 复制代码
// 本地系统和飞书系统要设计好同步机制
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. 错误处理和重试

csharp 复制代码
// 使用 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 手动调整(比如补偿假期)

查询假期类型

csharp 复制代码
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>();
    }
}

查询发放记录

完整示例:

csharp 复制代码
/// <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;
}

更新发放记录

完整示例:

csharp 复制代码
/// <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. 跨年处理

csharp 复制代码
// 年假是否跨年取决于企业政策
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. 休假类型计算规则

csharp 复制代码
// 不同休假类型有不同的计算规则
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. 批量处理:需要批量审批补卡申请

创建补卡审批

完整示例:

csharp 复制代码
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()
        };
    }
}

查询可补卡时间

csharp 复制代码
/// <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);
}

查询补卡记录

完整示例:

csharp 复制代码
/// <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)
    };
}

补卡审批流程

完整流程:

csharp 复制代码
/// <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, "补卡申请已通过");
    }
}

补卡规则配置

csharp 复制代码
/// <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:考勤结果(正常/迟到/早退/缺勤/请假)

查询统计表头

csharp 复制代码
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;
    }
}

更新统计视图

csharp 复制代码
/// <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>()
    };
}

查询统计数据

完整示例:

csharp 复制代码
/// <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;
}

统计数据缓存

csharp 复制代码
/// <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次/分钟 批量拉取后本地缓存

解决方案:

csharp 复制代码
// 方案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。第一次同步数据时,发现时间都对不上。

时间流程:

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

解决方案:

csharp 复制代码
// 统一时区处理工具类
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. 统一使用工具类:避免散落在各处的时区转换逻辑不一致

数据安全

问题:

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

解决方案:

csharp 复制代码
// 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. 开启详细日志

csharp 复制代码
// 开启 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. 详细记录请求参数和响应结果

csharp 复制代码
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。


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

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

相关推荐
lolo大魔王5 小时前
Go语言的异常处理
开发语言·后端·golang
IT_陈寒7 小时前
Python多进程共享变量那个坑,我差点没爬出来
前端·人工智能·后端
码事漫谈8 小时前
2026软考高级·系统架构设计师备考指南
后端
AI茶水间管理员9 小时前
如何让LLM稳定输出 JSON 格式结果?
前端·人工智能·后端
其实是白羊9 小时前
我用 Vibe Coding 搓了一个 IDEA 插件,复制URI 再也不用手动拼了
后端·intellij idea
用户8356290780519 小时前
Python 操作 Word 文档节与页面设置
后端·python
酒後少女的夢10 小时前
设计模式教程
后端·架构
凌览10 小时前
别再手搓 Skill 了,用这个工具 5 分钟搞定
前端·后端
游乐码10 小时前
c#lambad表达式
开发语言·c#