如何用.NET开发一套实用的飞书考勤系统?

摘要:去年给公司做 HR 系统选型,最终选择了飞书考勤。但用了两个月后发现——原生功能再强,也架不住企业那些奇奇怪怪的业务规则。 比如:我们公司的请假审批要过三级(直属领导→部门负责人→HR),但飞书考勤的审批流只支持两级。还有,我们的薪资系统需
去年给公司做 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 为什么要加中间层? 解耦:内部系统和飞书解耦,飞书 API 变了不用改核心业务代码 数据转换:两边数据结构不一样,中间层负责转换 统一认证:令牌管理、重试、限流这些脏活交给 SDK 灵活扩展:以后要对接其他系统(比如钉钉),加一层适配就行 数据流转 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 权限,搞了一下午才发现是权限问题。 创建自建应用步骤: 登录飞书开放平台(https://open.feishu.cn/) 进入"开发者后台",点击"创建企业自建应用" 填写应用名称、描述 选择应用类型为"企业自建应用" 获取凭证: 创建应用后,在应用详情页的"凭证与基础信息"中获取: App ID:应用唯一标识 App Secret:应用密钥(记得保密) 必配权限清单: 权限点 描述 必要性 attendance:approval 考勤审批相关权限 必需 attendance:leave 考勤休假相关权限 必需 attendance:stats 考勤统计相关权限 必需 attendance:remedy 考勤补卡相关权限 必需 approval:instance 审批实例相关权限 必需 attendance:shift 考勤班次相关权限 可选 attendance:group 考勤组相关权限 可选 配置事件订阅(可选): 如果需要实时接收审批状态变更等事件,需要配置事件订阅: 在"事件订阅"中配置请求 URL(接收事件的回调地址) 选择需要订阅的事件,如 approval_instance_change 配置加密密钥和验证令牌 事件订阅类型对比: 方式 优点 缺点 适用场景 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(目的地) 企业典型场景: 内向外写:员工在内部系统发起审批 → 内部系统审批通过 → 写入飞书考勤 外向内写:员工在飞书发起审批 → 飞书审批完成 → 同步回内部系统 双向同步:两边都可以发起,通过 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 是外部系统的唯一标识,非常重要: 关联查询:可以通过 OutId 找到内部系统的审批记录 防止重复:同一笔审批多次写入时,可以通过 OutId 判断是否已存在 状态同步:飞书审批状态变更时,通过 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("达到最大重试次数,创建审批失败"); } 核心功能二:休假管理 业务场景 休假管理主要涉及: 假期类型管理:年假、病假、事假、调休等 假期发放记录:每年年初发放年假、入职时发放年假等 假期余额查询:员工查看还有多少天假期可用 假期余额调整: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); } } 核心功能三:补卡管理 业务场景 补卡管理的典型场景: 员工忘记打卡:早上忘记打上班卡,需要补卡 设备故障:打卡机故障导致无法打卡 外出办公:外出办公无法打卡 批量处理:需要批量审批补卡申请 创建补卡审批 完整示例: 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)) }; 最佳实践: 后端统一用 UTC 存储:数据库时间字段存储 UTC 时间 与飞书交互时显式转换:调用飞书 API 前转换为上海时间 前端展示时转回用户本地时区:用户看到的是自己的本地时间 统一使用工具类:避免散落在各处的时区转换逻辑不一致 数据安全 问题: 员工数据比较敏感,需要做好安全防护。 解决方案: // 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。 如果你也在做类似的项目,希望这篇笔记能帮你少踩几个坑。 有问题欢迎交流,让我进步!