找回密码
 立即注册
首页 业界区 业界 搭建一套.net下能落地的飞书考勤系统

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

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

创建项目:
  1. # 创建项目
  2. dotnet new webapi -n AttendanceSystem
  3. cd AttendanceSystem
  4. # 安装 SDK
  5. dotnet add package Mud.Feishu
  6. # 如果需要 Redis 缓存
  7. dotnet add package Mud.Feishu.Redis
复制代码
配置文件:
  1. // appsettings.json
  2. {
  3.   "Logging": {
  4.     "LogLevel": {
  5.       "Default": "Information",
  6.       "Microsoft.AspNetCore": "Warning"
  7.     }
  8.   },
  9.   "AllowedHosts": "*",
  10.   "Feishu": {
  11.     "Apps": [
  12.       {
  13.         "AppKey": "default",
  14.         "AppId": "cli_xxxxxxxxxxxxxxxx",
  15.         "AppSecret": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  16.         "BaseUrl": "https://open.feishu.cn",
  17.         "IsDefault": true,
  18.         "TimeOut": 30,
  19.         "RetryCount": 3
  20.       }
  21.     ]
  22.   }
  23. }
复制代码
多应用配置示例:
  1. {
  2.   "Feishu": {
  3.     "Apps": [
  4.       {
  5.         "AppKey": "default",
  6.         "AppId": "cli_xxx",
  7.         "AppSecret": "dsk_xxx",
  8.         "IsDefault": true
  9.       },
  10.       {
  11.         "AppKey": "hr-app",
  12.         "AppId": "cli_yyy",
  13.         "AppSecret": "dsk_yyy"
  14.       }
  15.     ]
  16.   }
  17. }
复制代码
服务注册
  1. // Program.cs
  2. using Mud.Feishu;
  3. var builder = WebApplication.CreateBuilder(args);
  4. // 方式1:一行代码注册所有飞书服务(懒人模式)
  5. builder.Services.AddFeishuServices(builder.Configuration);
  6. // 方式2:使用构造者模式,按需注册(推荐)
  7. builder.Services.CreateFeishuServicesBuilder(builder.Configuration)
  8.     .AddOrganizationApi()   // 组织架构
  9.     .AddMessageApi()        // 消息服务
  10.     .AddApprovalApi()       // 审批流程(包含考勤审批)
  11.     .AddTaskApi()           // 任务管理
  12.     .AddCalendarApi()       // 日程管理
  13.     .Build();
  14. // 方式3:代码配置
  15. builder.Services.CreateFeishuServicesBuilder(options =>
  16. {
  17.     options.Apps = new List<FeishuAppConfig>
  18.     {
  19.         new FeishuAppConfig
  20.         {
  21.             AppKey = "default",
  22.             AppId = "cli_xxx",
  23.             AppSecret = "dsk_xxx",
  24.             BaseUrl = "https://open.feishu.cn",
  25.             TimeOut = 30,
  26.             RetryCount = 3,
  27.             TokenRefreshThreshold = 300
  28.         }
  29.     };
  30. })
  31.     .AddOrganizationApi()
  32.     .AddApprovalApi()
  33.     .Build();
  34. // 注册自己的业务服务
  35. builder.Services.AddScoped<IApprovalService, ApprovalService>();
  36. builder.Services.AddScoped<ILeaveService, LeaveService>();
  37. builder.Services.AddScoped<IRemedyService, RemedyService>();
  38. builder.Services.AddScoped<IStatsService, StatsService>();
  39. var app = builder.Build();
  40. // 配置中间件...
  41. app.Run();
复制代码
服务注册方式对比:
方式优点缺点适用场景AddFeishuServices()简单,一行搞定注册了所有服务快速开发、测试环境CreateFeishuServicesBuilder()按需注册,更灵活需要指定模块生产环境、性能优化代码配置完全可控配置写死在代码里复杂配置需求核心功能一:审批管理

业务场景

飞书考勤支持四种审批类型:
类型代码值说明常见字段请假leave员工因个人原因需要请假leave_type(请假类型)加班overtime员工因工作需要加班overtime_type(加班类型)外出out员工因工作需要外出-出差business员工因工作需要出差destination(目的地)企业典型场景:

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

完整示例:
  1. public class ApprovalService : IApprovalService
  2. {
  3.     private readonly IFeishuTenantV1AttendanceApprovals _approvalsClient;
  4.     private readonly ILogger _logger;
  5.     private readonly IFeishuAppManager _appManager;
  6.     public ApprovalService(
  7.         IFeishuTenantV1AttendanceApprovals approvalsClient,
  8.         ILogger logger,
  9.         IFeishuAppManager appManager)
  10.     {
  11.         _approvalsClient = approvalsClient;
  12.         _logger = logger;
  13.         _appManager = appManager;
  14.     }
  15.     /// <summary>
  16.     /// 查询单个员工的审批数据
  17.     /// </summary>
  18.     public async Task<QueryAttendanceApprovalsResult> GetUserApprovalsAsync(
  19.         string userId,
  20.         DateTime startTime,
  21.         DateTime endTime,
  22.         string approvalType = null)
  23.     {
  24.         var request = new QueryAttendanceApprovalsRequest
  25.         {
  26.             UserId = userId,
  27.             StartTime = startTime.ToString("yyyy-MM-dd HH:mm:ss"),
  28.             EndTime = endTime.ToString("yyyy-MM-dd HH:mm:ss"),
  29.             Type = approvalType, // leave、overtime、out、business
  30.             Limit = 100,
  31.             Offset = 0
  32.         };
  33.         _logger.LogInformation("查询员工 {UserId} 的审批数据", userId);
  34.         var result = await _approvalsClient.QueryUserApprovalAsync(request);
  35.         if (result?.Code == 0 && result.Data != null)
  36.         {
  37.             _logger.LogInformation("成功获取审批数据,共 {Count} 条",
  38.                 result.Data.Items?.Count ?? 0);
  39.             return result.Data;
  40.         }
  41.         _logger.LogError("获取审批数据失败:{Message}", result?.Message ?? "未知错误");
  42.         return null;
  43.     }
  44.     /// <summary>
  45.     /// 批量查询多个员工的审批数据(带并发控制)
  46.     /// </summary>
  47.     public async Task<Dictionary<string, List>> GetBatchUserApprovalsAsync(
  48.         List<string> userIds,
  49.         DateTime startTime,
  50.         DateTime endTime,
  51.         int maxConcurrency = 5)
  52.     {
  53.         var results = new Dictionary<string, List>();
  54.         var semaphore = new SemaphoreSlim(maxConcurrency);
  55.         var tasks = userIds.Select(async userId =>
  56.         {
  57.             await semaphore.WaitAsync();
  58.             try
  59.             {
  60.                 var approvalData = await GetUserApprovalsAsync(userId, startTime, endTime);
  61.                 if (approvalData?.Items != null)
  62.                 {
  63.                     lock (results)
  64.                     {
  65.                         results[userId] = approvalData.Items.ToList();
  66.                     }
  67.                 }
  68.             }
  69.             finally
  70.             {
  71.                 semaphore.Release();
  72.             }
  73.         });
  74.         await Task.WhenAll(tasks);
  75.         return results;
  76.     }
  77.     /// <summary>
  78.     /// 分页查询所有审批数据
  79.     /// </summary>
  80.     public async Task<List> GetAllApprovalsAsync(
  81.         string userId,
  82.         DateTime startTime,
  83.         DateTime endTime,
  84.         string approvalType = null)
  85.     {
  86.         var allItems = new List();
  87.         int offset = 0;
  88.         int limit = 100;
  89.         bool hasMore = true;
  90.         while (hasMore)
  91.         {
  92.             var request = new QueryAttendanceApprovalsRequest
  93.             {
  94.                 UserId = userId,
  95.                 StartTime = startTime.ToString("yyyy-MM-dd HH:mm:ss"),
  96.                 EndTime = endTime.ToString("yyyy-MM-dd HH:mm:ss"),
  97.                 Type = approvalType,
  98.                 Limit = limit,
  99.                 Offset = offset
  100.             };
  101.             var result = await _approvalsClient.QueryUserApprovalAsync(request);
  102.             if (result?.Code == 0 && result.Data?.Items != null)
  103.             {
  104.                 allItems.AddRange(result.Data.Items);
  105.                 hasMore = result.Data.Items.Count >= limit;
  106.                 offset += limit;
  107.             }
  108.             else
  109.             {
  110.                 hasMore = false;
  111.             }
  112.             // 避免触发限流
  113.             if (hasMore)
  114.             {
  115.                 await Task.Delay(100);
  116.             }
  117.         }
  118.         return allItems;
  119.     }
  120. }
复制代码
写入审批数据

完整示例:
  1. /// <summary>
  2. /// 创建审批数据,将内部系统的审批结果写入飞书考勤
  3. /// </summary>
  4. public async Task<CreateUserApprovalResult> CreateUserApprovalAsync(
  5.     InternalApprovalRequest internalRequest)
  6. {
  7.     // 转换内部审批请求为飞书审批请求
  8.     var request = MapToFeishuRequest(internalRequest);
  9.     _logger.LogInformation("创建审批数据,员工ID:{UserId},类型:{Type}",
  10.         request.UserId, request.Type);
  11.     var result = await _approvalsClient.CreateUserApprovalAsync(request);
  12.     if (result?.Code == 0 && result.Data != null)
  13.     {
  14.         _logger.LogInformation("成功创建审批数据,审批ID:{ApprovalId}",
  15.             result.Data.ApprovalId);
  16.         // 保存 OutId 映射关系,方便后续查询和更新
  17.         await SaveApprovalMappingAsync(
  18.             internalRequest.InternalId,
  19.             result.Data.ApprovalId,
  20.             result.Data.OutId);
  21.         return result.Data;
  22.     }
  23.     _logger.LogError("创建审批数据失败:{Message}", result?.Message ?? "未知错误");
  24.     throw new FeishuApiException($"创建审批数据失败:{result?.Message}");
  25. }
  26. /// <summary>
  27. /// 构建请假审批请求
  28. /// </summary>
  29. public CreateUserApprovalRequest BuildLeaveRequest(
  30.     string userId,
  31.     string leaveType,
  32.     DateTime startTime,
  33.     DateTime endTime,
  34.     double duration,
  35.     string reason,
  36.     string internalId = null)
  37. {
  38.     return new CreateUserApprovalRequest
  39.     {
  40.         UserId = userId,
  41.         Type = "leave", // 请假类型
  42.         StartTime = startTime.ToString("yyyy-MM-dd HH:mm:ss"),
  43.         EndTime = endTime.ToString("yyyy-MM-dd HH:mm:ss"),
  44.         Duration = duration,
  45.         LeaveType = leaveType,
  46.         Reason = reason,
  47.         OutId = internalId ?? Guid.NewGuid().ToString() // 外部系统唯一标识
  48.     };
  49. }
  50. /// <summary>
  51. /// 构建加班审批请求
  52. /// </summary>
  53. public CreateUserApprovalRequest BuildOvertimeRequest(
  54.     string userId,
  55.     string overtimeType,
  56.     DateTime startTime,
  57.     DateTime endTime,
  58.     double duration,
  59.     string reason,
  60.     string internalId = null)
  61. {
  62.     return new CreateUserApprovalRequest
  63.     {
  64.         UserId = userId,
  65.         Type = "overtime",
  66.         StartTime = startTime.ToString("yyyy-MM-dd HH:mm:ss"),
  67.         EndTime = endTime.ToString("yyyy-MM-dd HH:mm:ss"),
  68.         Duration = duration,
  69.         OvertimeType = overtimeType,
  70.         Reason = reason,
  71.         OutId = internalId ?? Guid.NewGuid().ToString()
  72.     };
  73. }
  74. /// <summary>
  75. /// 内部审批请求映射到飞书审批请求
  76. /// </summary>
  77. private CreateUserApprovalRequest MapToFeishuRequest(InternalApprovalRequest internal)
  78. {
  79.     return internal.ApprovalType switch
  80.     {
  81.         "leave" => BuildLeaveRequest(
  82.             internal.UserId,
  83.             internal.LeaveType,
  84.             internal.StartTime,
  85.             internal.EndTime,
  86.             internal.Duration,
  87.             internal.Reason,
  88.             internal.InternalId
  89.         ),
  90.         "overtime" => BuildOvertimeRequest(
  91.             internal.UserId,
  92.             internal.OvertimeType,
  93.             internal.StartTime,
  94.             internal.EndTime,
  95.             internal.Duration,
  96.             internal.Reason,
  97.             internal.InternalId
  98.         ),
  99.         "out" => BuildOutRequest(
  100.             internal.UserId,
  101.             internal.StartTime,
  102.             internal.EndTime,
  103.             internal.Reason,
  104.             internal.InternalId
  105.         ),
  106.         "business" => BuildBusinessRequest(
  107.             internal.UserId,
  108.             internal.StartTime,
  109.             internal.EndTime,
  110.             internal.Destination,
  111.             internal.Reason,
  112.             internal.InternalId
  113.         ),
  114.         _ => throw new NotSupportedException($"不支持的审批类型:{internal.ApprovalType}")
  115.     };
  116. }
复制代码
OutId 的作用:
OutId 是外部系统的唯一标识,非常重要:

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

完整示例:
  1. /// <summary>
  2. /// 更新审批状态
  3. /// </summary>
  4. public async Task<UpdateAttendanceApprovalInfoResult> UpdateApprovalStatusAsync(
  5.     string approvalId,
  6.     ApprovalStatus status,
  7.     string outId = null)
  8. {
  9.     var request = new UpdateApprovalInfosRequest
  10.     {
  11.         ApprovalInfos = new List
  12.         {
  13.             new ApprovalInfo
  14.             {
  15.                 ApprovalId = approvalId,
  16.                 Status = (int)status, // 1=通过,2=不通过,3=撤销
  17.                 OutId = outId
  18.             }
  19.         }
  20.     };
  21.     _logger.LogInformation("更新审批状态,审批ID:{ApprovalId},状态:{Status}",
  22.         approvalId, status);
  23.     var result = await _approvalsClient.ProcessApprovalInfoAsync(request);
  24.     if (result?.Code == 0 && result.Data != null)
  25.     {
  26.         _logger.LogInformation("成功更新审批状态");
  27.         return result.Data;
  28.     }
  29.     _logger.LogError("更新审批状态失败:{Message}", result?.Message ?? "未知错误");
  30.     throw new FeishuApiException($"更新审批状态失败:{result?.Message}");
  31. }
  32. /// <summary>
  33. /// 批量更新审批状态
  34. /// </summary>
  35. public async Task<UpdateAttendanceApprovalInfoResult> BatchUpdateApprovalStatusAsync(
  36.     List updates)
  37. {
  38.     var approvalInfos = updates.Select(u => new ApprovalInfo
  39.     {
  40.         ApprovalId = u.ApprovalId,
  41.         Status = (int)u.Status,
  42.         OutId = u.OutId
  43.     }).ToList();
  44.     var request = new UpdateApprovalInfosRequest
  45.     {
  46.         ApprovalInfos = approvalInfos
  47.     };
  48.     _logger.LogInformation("批量更新审批状态,共 {Count} 条", updates.Count);
  49.     var result = await _approvalsClient.ProcessApprovalInfoAsync(request);
  50.     if (result?.Code == 0 && result.Data != null)
  51.     {
  52.         _logger.LogInformation("成功批量更新审批状态");
  53.         return result.Data;
  54.     }
  55.     _logger.LogError("批量更新审批状态失败:{Message}", result?.Message ?? "未知错误");
  56.     throw new FeishuApiException($"批量更新审批状态失败:{result?.Message}");
  57. }
  58. /// <summary>
  59. /// 根据内部审批ID更新飞书审批状态
  60. /// </summary>
  61. public async Task UpdateApprovalByInternalIdAsync(
  62.     string internalId,
  63.     ApprovalStatus status)
  64. {
  65.     // 先根据内部ID查找飞书审批信息
  66.     var mapping = await GetApprovalMappingAsync(internalId);
  67.     if (mapping == null)
  68.     {
  69.         _logger.LogWarning("未找到内部审批 {InternalId} 对应的飞书审批", internalId);
  70.         return;
  71.     }
  72.     // 更新飞书审批状态
  73.     await UpdateApprovalStatusAsync(mapping.ApprovalId, status, mapping.OutId);
  74.     // 更新映射记录
  75.     await UpdateApprovalMappingStatusAsync(internalId, status);
  76. }
复制代码
审批状态枚举:
  1. public enum ApprovalStatus
  2. {
  3.     Approved = 1,    // 通过
  4.     Rejected = 2,    // 不通过
  5.     Revoked = 3       // 撤销
  6. }
复制代码
事件订阅处理

Webhook 示例:
  1. // 如果使用 Webhook,需要在控制器中处理回调
  2. [HttpPost("api/webhook/feishu")]
  3. [Route("api/webhook/feishu")]
  4. public async Task<IActionResult> HandleFeishuWebhook([FromBody] WebhookEvent webhookEvent)
  5. {
  6.     try
  7.     {
  8.         // 验证签名
  9.         if (!ValidateWebhookSignature(webhookEvent))
  10.         {
  11.             _logger.LogWarning("Webhook 签名验证失败");
  12.             return Unauthorized();
  13.         }
  14.         // 解密事件数据(如果需要)
  15.         var eventData = DecryptEventData(webhookEvent);
  16.         // 根据事件类型分发处理
  17.         await _eventDispatcher.DispatchAsync(eventData);
  18.         return Ok(new { code = 0, msg = "success" });
  19.     }
  20.     catch (Exception ex)
  21.     {
  22.         _logger.LogError(ex, "处理 Webhook 事件失败");
  23.         return StatusCode(500, new { code = -1, msg = "internal error" });
  24.     }
  25. }
复制代码
WebSocket 示例:
  1. // 如果使用 WebSocket, Mud.Feishu 提供了完整的支持
  2. // 注册 WebSocket 服务
  3. builder.Services.AddFeishuWebSocketBuilder()
  4.     .ConfigureFrom(builder.Configuration)
  5.     .UseMultiHandler()
  6.     .AddHandler()
  7.     .AddHandler()
  8.     .AddHandler()
  9.     .Build();
  10. // 审批实例变更事件处理器
  11. public class ApprovalInstanceChangeEventHandler : IFeishuEventHandler
  12. {
  13.     private readonly IApprovalService _approvalService;
  14.     private readonly ILogger _logger;
  15.     public ApprovalInstanceChangeEventHandler(
  16.         IApprovalService approvalService,
  17.         ILogger logger)
  18.     {
  19.         _approvalService = approvalService;
  20.         _logger = logger;
  21.     }
  22.     public string SupportedEventType => FeishuEventTypes.ApprovalInstanceV1;
  23.     public async Task HandleAsync(EventData eventData, CancellationToken cancellationToken = default)
  24.     {
  25.         _logger.LogInformation("收到审批实例变更事件:{EventId}", eventData.EventId);
  26.         try
  27.         {
  28.             // 解析事件数据
  29.             var approvalEvent = JsonSerializer.Deserialize(
  30.                 eventData.Event?.ToString() ?? "{}");
  31.             if (approvalEvent?.ApprovalId == null)
  32.             {
  33.                 _logger.LogWarning("审批ID为空,跳过处理");
  34.                 return;
  35.             }
  36.             // 根据审批ID获取详情
  37.             var approvalDetail = await _approvalService.GetApprovalDetailAsync(
  38.                 approvalEvent.ApprovalId);
  39.             if (approvalDetail?.OutId == null)
  40.             {
  41.                 _logger.LogWarning("OutId为空,无法同步到内部系统");
  42.                 return;
  43.             }
  44.             // 同步到内部系统
  45.             await _approvalService.SyncApprovalToInternalAsync(
  46.                 approvalDetail.OutId,
  47.                 approvalDetail.Status);
  48.             _logger.LogInformation("成功同步审批到内部系统");
  49.         }
  50.         catch (Exception ex)
  51.         {
  52.             _logger.LogError(ex, "处理审批实例变更事件失败");
  53.             throw;
  54.         }
  55.     }
  56. }
复制代码
实战建议

1. 使用事件订阅,不要定时轮询
  1. // ❌ 错误:定时轮询
  2. while (true)
  3. {
  4.     var approvals = await GetPendingApprovalsAsync();
  5.     foreach (var approval in approvals)
  6.     {
  7.         await SyncApprovalStatusAsync(approval);
  8.     }
  9.     await Task.Delay(60000); // 每分钟轮询一次
  10. }
  11. // ✅ 正确:使用事件订阅
  12. // Webhook 或 WebSocket 自动推送,实时处理
复制代码
2. 做好幂等处理
  1. // 同一个审批可能收到多次事件,需要做幂等
  2. public async Task HandleApprovalEventAsync(EventData eventData)
  3. {
  4.     // 检查事件是否已处理
  5.     if (await IsEventProcessedAsync(eventData.EventId))
  6.     {
  7.         _logger.LogInformation("事件 {EventId} 已处理,跳过", eventData.EventId);
  8.         return;
  9.     }
  10.     // 处理业务逻辑
  11.     await ProcessApprovalAsync(eventData);
  12.     // 标记事件已处理
  13.     await MarkEventProcessedAsync(eventData.EventId);
  14. }
复制代码
3. 数据一致性保障
  1. // 本地系统和飞书系统要设计好同步机制
  2. public async Task SyncApprovalAsync(string internalId)
  3. {
  4.     // 获取本地审批状态
  5.     var localApproval = await GetLocalApprovalAsync(internalId);
  6.     // 获取飞书审批状态
  7.     var feishuApproval = await GetFeishuApprovalAsync(localApproval.OutId);
  8.     // 比较状态,不一致则同步
  9.     if (localApproval.Status != feishuApproval.Status)
  10.     {
  11.         await UpdateLocalApprovalStatusAsync(internalId, feishuApproval.Status);
  12.     }
  13. }
复制代码
4. 错误处理和重试
  1. // 使用 Mud.Feishu 内置的重试机制,或者自己实现
  2. public async Task<CreateUserApprovalResult> CreateUserApprovalWithRetryAsync(
  3.     CreateUserApprovalRequest request,
  4.     int maxRetries = 3)
  5. {
  6.     int retryCount = 0;
  7.     while (retryCount < maxRetries)
  8.     {
  9.         try
  10.         {
  11.             return await _approvalsClient.CreateUserApprovalAsync(request);
  12.         }
  13.         catch (FeishuApiException ex) when (ex.ErrorCode == 429) // 限流
  14.         {
  15.             retryCount++;
  16.             _logger.LogWarning("触发限流,{RetryCount}/{MaxRetries},等待后重试",
  17.                 retryCount, maxRetries);
  18.             await Task.Delay(1000 * retryCount); // 指数退避
  19.         }
  20.         catch (Exception ex)
  21.         {
  22.             _logger.LogError(ex, "创建审批失败");
  23.             throw;
  24.         }
  25.     }
  26.     throw new FeishuApiException("达到最大重试次数,创建审批失败");
  27. }
复制代码
核心功能二:休假管理

业务场景

休假管理主要涉及:

  • 假期类型管理:年假、病假、事假、调休等
  • 假期发放记录:每年年初发放年假、入职时发放年假等
  • 假期余额查询:员工查看还有多少天假期可用
  • 假期余额调整:HR 手动调整(比如补偿假期)
查询假期类型
  1. public class LeaveService : ILeaveService
  2. {
  3.     private readonly IFeishuV1AttendanceLeave_Tenant _leaveClient;
  4.     private readonly IFeishuTenantV1AttendanceGroups _groupsClient;
  5.     private readonly ILogger<LeaveService> _logger;
  6.     public async Task<List<LeaveType>> GetLeaveTypesAsync()
  7.     {
  8.         // 通过考勤组查询假期类型配置
  9.         var groupsResult = await _groupsClient.GetGroupAsync(new GetGroupRequest
  10.         {
  11.             GroupId = "default"
  12.         });
  13.         if (groupsResult?.Code == 0 && groupsResult.Data != null)
  14.         {
  15.             return groupsResult.Data.LeaveTypes ?? new List<LeaveType>();
  16.         }
  17.         return new List<LeaveType>();
  18.     }
  19. }
复制代码
查询发放记录

完整示例:
  1. /// <summary>
  2. /// 查询员工的假期发放记录
  3. /// </summary>
  4. public async Task<LeaveBalance> GetLeaveBalanceAsync(
  5.     string userId,
  6.     string leaveId)
  7. {
  8.     var now = DateTime.Now;
  9.     var request = new LeaveEmployExpireRecordsRequest
  10.     {
  11.         StartTime = new DateTime(now.Year, 1, 1).ToString("yyyy-MM-dd"),
  12.         EndTime = new DateTime(now.Year, 12, 31).ToString("yyyy-MM-dd"),
  13.         UserIds = new List<string> { userId },
  14.         Limit = 100,
  15.         Offset = 0
  16.     };
  17.     var result = await _leaveClient.GetLeaveEmployExpireRecordAsync(request, leaveId);
  18.     if (result?.Code == 0 && result.Data?.Items != null)
  19.     {
  20.         // 计算可用天数
  21.         var totalGranted = result.Data.Items.Sum(x => x.Quota);
  22.         var totalUsed = result.Data.Items.Sum(x => x.Used);
  23.         var available = totalGranted - totalUsed;
  24.         return new LeaveBalance
  25.         {
  26.             UserId = userId,
  27.             LeaveId = leaveId,
  28.             TotalGranted = totalGranted,
  29.             TotalUsed = totalUsed,
  30.             Available = available,
  31.             Records = result.Data.Items.ToList()
  32.         };
  33.     }
  34.     return new LeaveBalance
  35.     {
  36.         UserId = userId,
  37.         LeaveId = leaveId,
  38.         TotalGranted = 0,
  39.         TotalUsed = 0,
  40.         Available = 0,
  41.         Records = new List<LeaveEmployExpireRecord>()
  42.     };
  43. }
  44. /// <summary>
  45. /// 查询即将过期的假期
  46. /// </summary>
  47. public async Task<List<LeaveEmployExpireRecord>> GetExpiringLeavesAsync(
  48.     string userId,
  49.     int daysBeforeExpire = 30)
  50. {
  51.     var now = DateTime.Now;
  52.     var expireDate = now.AddDays(daysBeforeExpire);
  53.     var request = new LeaveEmployExpireRecordsRequest
  54.     {
  55.         StartTime = now.ToString("yyyy-MM-dd"),
  56.         EndTime = expireDate.ToString("yyyy-MM-dd"),
  57.         UserIds = new List<string> { userId },
  58.         Limit = 100,
  59.         Offset = 0
  60.     };
  61.     var allRecords = new List<LeaveEmployExpireRecord>();
  62.     // 遍历所有假期类型
  63.     var leaveTypes = await GetLeaveTypesAsync();
  64.     foreach (var leaveType in leaveTypes)
  65.     {
  66.         var result = await _leaveClient.GetLeaveEmployExpireRecordAsync(
  67.             request, leaveType.LeaveId);
  68.         if (result?.Code == 0 && result.Data?.Items != null)
  69.         {
  70.             allRecords.AddRange(result.Data.Items);
  71.         }
  72.     }
  73.     return allRecords;
  74. }
复制代码
更新发放记录

完整示例:
  1. /// <summary>
  2. /// 手动调整员工假期余额
  3. /// </summary>
  4. public async Task<LeaveAccrualRecordResult> AdjustLeaveBalanceAsync(
  5.     string userId,
  6.     string leaveId,
  7.     double adjustmentAmount,
  8.     string reason,
  9.     string operatorId)
  10. {
  11.     // 先获取当前发放记录
  12.     var currentRecords = await GetCurrentLeaveRecordsAsync(userId, leaveId);
  13.     if (currentRecords.Count == 0)
  14.     {
  15.         // 如果没有发放记录,创建新的
  16.         var createRequest = new LeaveAccrualRecordRequest
  17.         {
  18.             UserId = userId,
  19.             LeaveId = leaveId,
  20.             Quota = adjustmentAmount,
  21.             ExpireDate = DateTime.Now.AddYears(1).ToString("yyyy-MM-dd"),
  22.             Remark = $"手动调整:{reason},操作人:{operatorId}"
  23.         };
  24.         return await _leaveClient.CreateLeaveAccrualRecordAsync(createRequest, leaveId);
  25.     }
  26.     else
  27.     {
  28.         // 更新现有记录
  29.         var latestRecord = currentRecords.OrderByDescending(x => x.CreateTime).First();
  30.         var newQuota = latestRecord.Quota + adjustmentAmount;
  31.         if (newQuota < 0)
  32.         {
  33.             throw new InvalidOperationException("调整后的假期余额不能为负数");
  34.         }
  35.         var updateRequest = new LeaveAccrualRecordRequest
  36.         {
  37.             UserId = userId,
  38.             LeaveId = leaveId,
  39.             RecordId = latestRecord.RecordId,
  40.             Quota = newQuota,
  41.             Remark = $"手动调整:{reason},操作人:{operatorId},原始余额:{latestRecord.Quota},调整:{adjustmentAmount},新余额:{newQuota}"
  42.         };
  43.         return await _leaveClient.ModifyLeaveAccrualRecordAsync(updateRequest, leaveId);
  44.     }
  45. }
  46. /// <summary>
  47. /// 年初批量发放年假
  48. /// </summary>
  49. public async Task BatchGrantAnnualLeaveAsync(
  50.     List<string> userIds,
  51.     string leaveId,
  52.     int annualDays,
  53.     string operatorId)
  54. {
  55.     var successCount = 0;
  56.     var failCount = 0;
  57.     var errors = new List<string>();
  58.     foreach (var userId in userIds)
  59.     {
  60.         try
  61.         {
  62.             await AdjustLeaveBalanceAsync(
  63.                 userId,
  64.                 leaveId,
  65.                 annualDays,
  66.                 $"年初发放{annualDays}天年假",
  67.                 operatorId);
  68.             successCount++;
  69.             _logger.LogInformation("成功为用户 {UserId} 发放年假", userId);
  70.         }
  71.         catch (Exception ex)
  72.         {
  73.             failCount++;
  74.             errors.Add($"用户 {UserId} 发放失败:{ex.Message}");
  75.             _logger.LogError(ex, "为用户 {UserId} 发放年假失败", userId);
  76.         }
  77.         // 避免触发限流
  78.         await Task.Delay(200);
  79.     }
  80.     // 记录操作日志
  81.     await LogBatchOperationAsync(
  82.         "批量发放年假",
  83.         $"成功:{successCount},失败:{failCount}",
  84.         errors);
  85. }
复制代码
休假计算注意事项

1. 跨年处理
  1. // 年假是否跨年取决于企业政策
  2. public async Task<List<LeaveBalance>> GetLeaveBalanceWithYearAsync(
  3.     string userId,
  4.     string leaveId)
  5. {
  6.     var now = DateTime.Now;
  7.     var results = new List<LeaveBalance>();
  8.     // 当前年度
  9.     var currentYearBalance = await GetLeaveBalanceAsync(
  10.         userId,
  11.         leaveId,
  12.         now.Year);
  13.     // 上一年度(如果政策允许跨年)
  14.     var lastYearBalance = await GetLeaveBalanceAsync(
  15.         userId,
  16.         leaveId,
  17.         now.Year - 1);
  18.     results.Add(currentYearBalance);
  19.     results.Add(lastYearBalance);
  20.     return results;
  21. }
复制代码
2. 休假类型计算规则
  1. // 不同休假类型有不同的计算规则
  2. public class LeaveCalculator
  3. {
  4.     /// <summary>
  5.     /// 计算请假天数
  6.     /// </summary>
  7.     public double CalculateLeaveDays(
  8.         DateTime startTime,
  9.         DateTime endTime,
  10.         string leaveType)
  11.     {
  12.         return leaveType switch
  13.         {
  14.             "annual" => CalculateAnnualLeaveDays(startTime, endTime),
  15.             "sick" => CalculateSickLeaveDays(startTime, endTime),
  16.             "personal" => CalculatePersonalLeaveDays(startTime, endTime),
  17.             "maternity" => CalculateMaternityLeaveDays(startTime, endTime),
  18.             _ => CalculateDefaultLeaveDays(startTime, endTime)
  19.         };
  20.     }
  21.     /// <summary>
  22.     /// 年假计算:只计算工作日
  23.     /// </summary>
  24.     private double CalculateAnnualLeaveDays(DateTime startTime, DateTime endTime)
  25.     {
  26.         var workDays = 0;
  27.         var current = startTime.Date;
  28.         while (current <= endTime.Date)
  29.         {
  30.             if (IsWorkDay(current))
  31.             {
  32.                 workDays++;
  33.             }
  34.             current = current.AddDays(1);
  35.         }
  36.         // 按小时计算
  37.         return workDays * 8;
  38.     }
  39.     /// <summary>
  40.     /// 病假计算:包括节假日
  41.     /// </summary>
  42.     private double CalculateSickLeaveDays(DateTime startTime, DateTime endTime)
  43.     {
  44.         var totalHours = (endTime - startTime).TotalHours;
  45.         return totalHours;
  46.     }
  47.     private bool IsWorkDay(DateTime date)
  48.     {
  49.         // 判断是否为工作日
  50.         return date.DayOfWeek != DayOfWeek.Saturday &&
  51.                date.DayOfWeek != DayOfWeek.Sunday &&
  52.                !IsHoliday(date);
  53.     }
  54. }
复制代码
查询可补卡时间
  1. public class RemedyService : IRemedyService
  2. {
  3.     private readonly IFeishuTenantV1AttendanceRemedys _remedyClient;
  4.     private readonly IFeishuTenantV1AttendanceApprovals _approvalsClient;
  5.     private readonly ILogger<RemedyService> _logger;
  6.     public RemedyService(
  7.         IFeishuTenantV1AttendanceRemedys remedyClient,
  8.         IFeishuTenantV1AttendanceApprovals approvalsClient,
  9.         ILogger<RemedyService> logger)
  10.     {
  11.         _remedyClient = remedyClient;
  12.         _approvalsClient = approvalsClient;
  13.         _logger = logger;
  14.     }
  15.     /// <summary>
  16.     /// 创建补卡审批
  17.     /// </summary>
  18.     public async Task CreateRemedyAsync(
  19.         RemedyRequest internalRequest)
  20.     {
  21.         // 先查询员工当天可以补的打卡时间
  22.         var allowedTimes = await GetAllowedRemedyTimesAsync(
  23.             internalRequest.UserId,
  24.             internalRequest.Date,
  25.             internalRequest.Type);
  26.         if (allowedTimes?.AllowedTimes == null || allowedTimes.AllowedTimes.Count == 0)
  27.         {
  28.             throw new InvalidOperationException("当天无可补卡时间");
  29.         }
  30.         // 验证补卡时间是否在允许范围内
  31.         var remedyTime = DateTime.Parse(internalRequest.Time);
  32.         var timeRange = allowedTimes.AllowedTimes.FirstOrDefault();
  33.         if (timeRange != null &&
  34.             (remedyTime < timeRange.EarliestTime || remedyTime > timeRange.LatestTime))
  35.         {
  36.             throw new InvalidOperationException(
  37.                 $"补卡时间不在允许范围内:{timeRange.EarliestTime:HH:mm:ss} - {timeRange.LatestTime:HH:mm:ss}");
  38.         }
  39.         // 构建补卡请求
  40.         var request = new AttendanceRemedysRequest
  41.         {
  42.             UserId = internalRequest.UserId,
  43.             Date = internalRequest.Date.ToString("yyyy-MM-dd"),
  44.             Time = internalRequest.Time,
  45.             Type = internalRequest.Type, // 1=上班,2=下班
  46.             Reason = internalRequest.Reason,
  47.             OutId = internalRequest.InternalId ?? Guid.NewGuid().ToString()
  48.         };
  49.         _logger.LogInformation("创建补卡审批,员工ID:{UserId},日期:{Date},时间:{Time}",
  50.             request.UserId, request.Date, request.Time);
  51.         var result = await _remedyClient.CreateUserTaskRemedyAsync(request);
  52.         if (result?.Code == 0 && result.Data != null)
  53.         {
  54.             _logger.LogInformation("成功创建补卡审批,任务ID:{TaskId}", result.Data.TaskId);
  55.             return result.Data;
  56.         }
  57.         _logger.LogError("创建补卡审批失败:{Message}", result?.Message ?? "未知错误");
  58.         throw new FeishuApiException($"创建补卡审批失败:{result?.Message}");
  59.     }
  60.     /// <summary>
  61.     /// 构建补卡请求
  62.     /// </summary>
  63.     public AttendanceRemedysRequest BuildRemedyRequest(
  64.         string userId,
  65.         DateTime date,
  66.         DateTime time,
  67.         int type,
  68.         string reason,
  69.         string outId = null)
  70.     {
  71.         return new AttendanceRemedysRequest
  72.         {
  73.             UserId = userId,
  74.             Date = date.ToString("yyyy-MM-dd"),
  75.             Time = time.ToString("HH:mm:ss"),
  76.             Type = type, // 1=上班,2=下班
  77.             Reason = reason,
  78.             OutId = outId ?? Guid.NewGuid().ToString()
  79.         };
  80.     }
  81. }
复制代码
补卡审批流程

完整流程:
  1. /// <summary>
  2. /// 查询用户某天可以补的第几次上/下班卡的时间
  3. /// </summary>
  4. public async Task<QueryUserAllowedRemedysResult> GetAllowedRemedyTimesAsync(
  5.     string userId,
  6.     DateTime date,
  7.     int type)
  8. {
  9.     var request = new AllowedRemedysRequest
  10.     {
  11.         UserId = userId,
  12.         Date = date.ToString("yyyy-MM-dd"),
  13.         Type = type // 1=上班,2=下班
  14.     };
  15.     _logger.LogInformation("查询用户 {UserId} 在 {Date} 的可补卡时间,类型:{Type}",
  16.         userId, date, type);
  17.     var result = await _remedyClient.QueryUserAllowedRemedysUserTaskRemedyAsync(request);
  18.     if (result?.Code == 0 && result.Data != null)
  19.     {
  20.         _logger.LogInformation("成功查询可补卡时间,共 {Count} 个时间段",
  21.             result.Data.AllowedTimes?.Count ?? 0);
  22.         return result.Data;
  23.     }
  24.     _logger.LogError("查询可补卡时间失败:{Message}", result?.Message ?? "未知错误");
  25.     return null;
  26. }
  27. /// <summary>
  28. /// 验证补卡时间是否有效
  29. /// </summary>
  30. public async Task<bool> ValidateRemedyTimeAsync(
  31.     string userId,
  32.     DateTime date,
  33.     DateTime time,
  34.     int type)
  35. {
  36.     var allowedTimes = await GetAllowedRemedyTimesAsync(userId, date, type);
  37.     if (allowedTimes?.AllowedTimes == null || allowedTimes.AllowedTimes.Count == 0)
  38.     {
  39.         return false;
  40.     }
  41.     var timeOfDay = time.TimeOfDay;
  42.     return allowedTimes.AllowedTimes.Any(t =>
  43.         timeOfDay >= t.EarliestTime.TimeOfDay &&
  44.         timeOfDay <= t.LatestTime.TimeOfDay);
  45. }
复制代码
补卡规则配置
  1. /// <summary>
  2. /// 获取用户的补卡记录
  3. /// </summary>
  4. public async Task<QueryUserRemedysResult> GetRemedyRecordsAsync(
  5.     string userId,
  6.     DateTime startDate,
  7.     DateTime endDate,
  8.     int? status = null,
  9.     int limit = 100,
  10.     int offset = 0)
  11. {
  12.     var request = new QueryUserRemedysRequest
  13.     {
  14.         UserId = userId,
  15.         StartDate = startDate.ToString("yyyy-MM-dd"),
  16.         EndDate = endDate.ToString("yyyy-MM-dd"),
  17.         Status = status, // 1=审批中,2=通过,3=拒绝
  18.         Limit = limit,
  19.         Offset = offset
  20.     };
  21.     _logger.LogInformation("获取用户 {UserId} 的补卡记录,时间范围:{StartDate} 至 {EndDate}",
  22.         userId, startDate, endDate);
  23.     var result = await _remedyClient.QueryUserTaskRemedyAsync(request);
  24.     if (result?.Code == 0 && result.Data != null)
  25.     {
  26.         _logger.LogInformation("成功获取补卡记录,共 {Count} 条", result.Data.Items?.Count ?? 0);
  27.         return result.Data;
  28.     }
  29.     _logger.LogError("获取补卡记录失败:{Message}", result?.Message ?? "未知错误");
  30.     return null;
  31. }
  32. /// <summary>
  33. /// 批量获取多个员工的补卡记录
  34. /// </summary>
  35. public async Task<Dictionary<string, List<RemedyRecord>>> GetBatchRemedyRecordsAsync(
  36.     List<string> userIds,
  37.     DateTime startDate,
  38.     DateTime endDate)
  39. {
  40.     var results = new Dictionary<string, List<RemedyRecord>>();
  41.     foreach (var userId in userIds)
  42.     {
  43.         var remedyData = await GetRemedyRecordsAsync(userId, startDate, endDate);
  44.         if (remedyData?.Items != null)
  45.         {
  46.             results[userId] = remedyData.Items.Select(x => new RemedyRecord
  47.             {
  48.                 TaskId = x.TaskId,
  49.                 UserId = x.UserId,
  50.                 Date = x.Date,
  51.                 Time = x.Time,
  52.                 Type = x.Type,
  53.                 Status = x.Status,
  54.                 Reason = x.Reason,
  55.                 OutId = x.OutId
  56.             }).ToList();
  57.         }
  58.         // 避免触发限流
  59.         await Task.Delay(100);
  60.     }
  61.     return results;
  62. }
  63. /// <summary>
  64. /// 获取员工的补卡统计
  65. /// </summary>
  66. public async Task<RemedyStatistics> GetRemedyStatisticsAsync(
  67.     string userId,
  68.     DateTime startDate,
  69.     DateTime endDate)
  70. {
  71.     var allRecords = await GetRemedyRecordsAsync(userId, startDate, endDate);
  72.     if (allRecords?.Items == null)
  73.     {
  74.         return new RemedyStatistics();
  75.     }
  76.     return new RemedyStatistics
  77.     {
  78.         TotalCount = allRecords.Items.Count,
  79.         ApprovedCount = allRecords.Items.Count(x => x.Status == 2),
  80.         RejectedCount = allRecords.Items.Count(x => x.Status == 3),
  81.         PendingCount = allRecords.Items.Count(x => x.Status == 1),
  82.         CheckInCount = allRecords.Items.Count(x => x.Type == 1),
  83.         CheckOutCount = allRecords.Items.Count(x => x.Type == 2)
  84.     };
  85. }
复制代码
核心功能四:考勤统计

统计字段说明

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

  • 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:考勤结果(正常/迟到/早退/缺勤/请假)
查询统计表头
  1. /// <summary>
  2. /// 补卡审批完整流程
  3. /// </summary>
  4. public async Task ProcessRemedyApprovalAsync(string internalRemedyId)
  5. {
  6.     // 1. 获取内部补卡申请
  7.     var internalRemedy = await GetInternalRemedyAsync(internalRemedyId);
  8.     if (internalRemedy == null)
  9.     {
  10.         throw new NotFoundException($"未找到补卡申请:{internalRemedyId}");
  11.     }
  12.     // 2. 创建飞书补卡审批
  13.     var feishuRemedy = await CreateRemedyAsync(new RemedyRequest
  14.     {
  15.         UserId = internalRemedy.UserId,
  16.         Date = internalRemedy.Date,
  17.         Time = internalRemedy.Time,
  18.         Type = internalRemedy.Type,
  19.         Reason = internalRemedy.Reason,
  20.         InternalId = internalRemedyId
  21.     });
  22.     // 3. 保存映射关系
  23.     await SaveRemedyMappingAsync(internalRemedyId, feishuRemedy.TaskId, feishuRemedy.OutId);
  24.     // 4. 等待飞书审批结果(通过事件订阅)
  25.     // 事件处理器会监听审批状态变更并更新内部记录
  26. }
  27. /// <summary>
  28. /// 审批通过后的处理
  29. /// </summary>
  30. public async Task HandleRemedyApprovedAsync(string taskId)
  31. {
  32.     // 获取补卡记录
  33.     var remedyRecord = await GetRemedyRecordAsync(taskId);
  34.     // 获取映射的内部记录
  35.     var internalRemedy = await GetInternalRemedyByOutIdAsync(remedyRecord.OutId);
  36.     if (internalRemedy != null)
  37.     {
  38.         // 更新内部审批状态
  39.         await UpdateInternalRemedyStatusAsync(internalRemedy.Id, RemedyStatus.Approved);
  40.         // 发送通知
  41.         await SendNotificationAsync(internalRemedy.UserId, "补卡申请已通过");
  42.     }
  43. }
复制代码
更新统计视图
  1. /// <summary>
  2. /// 补卡规则配置
  3. /// </summary>
  4. public class RemedyRuleService
  5. {
  6.     /// <summary>
  7.     /// 检查补卡申请是否符合规则
  8.     /// </summary>
  9.     public async Task<RemedyRuleCheckResult> CheckRemedyRuleAsync(
  10.         string userId,
  11.         DateTime date,
  12.         int type)
  13.     {
  14.         var rules = await GetRemedyRulesAsync(userId);
  15.         // 检查补卡次数限制
  16.         var currentMonthRemedyCount = await GetMonthRemedyCountAsync(userId, date);
  17.         if (currentMonthRemedyCount >= rules.MaxMonthlyRemedyCount)
  18.         {
  19.             return new RemedyRuleCheckResult
  20.             {
  21.                 IsAllowed = false,
  22.                 Reason = $"本月补卡次数已达上限({rules.MaxMonthlyRemedyCount}次)"
  23.             };
  24.         }
  25.         // 检查补卡时间限制
  26.         var isWithinAllowedTime = await IsWithinAllowedTimeAsync(userId, date, type);
  27.         if (!isWithinAllowedTime)
  28.         {
  29.             return new RemedyRuleCheckResult
  30.             {
  31.                 IsAllowed = false,
  32.                 Reason = "补卡时间不在允许范围内"
  33.             };
  34.         }
  35.         // 检查是否需要审批
  36.         if (rules.RequireApproval)
  37.         {
  38.             return new RemedyRuleCheckResult
  39.             {
  40.                 IsAllowed = true,
  41.                 RequireApproval = true
  42.             };
  43.         }
  44.         return new RemedyRuleCheckResult
  45.         {
  46.             IsAllowed = true,
  47.             RequireApproval = false
  48.         };
  49.     }
  50. }
复制代码
查询统计数据

完整示例:
  1. public class StatsService : IStatsService
  2. {
  3.     private readonly IFeishuTenantV1AttendanceStats _statsClient;
  4.     private readonly ILogger<StatsService> _logger;
  5.     public StatsService(
  6.         IFeishuTenantV1AttendanceStats statsClient,
  7.         ILogger<StatsService> logger)
  8.     {
  9.         _statsClient = statsClient;
  10.         _logger = logger;
  11.     }
  12.     /// <summary>
  13.     /// 查询可用的统计字段
  14.     /// </summary>
  15.     public async Task<Dictionary<int, List<StatsField>>> GetAllStatsFieldsAsync()
  16.     {
  17.         var results = new Dictionary<int, List<StatsField>>();
  18.         // 查询日度统计字段
  19.         var dailyResult = await GetStatsFieldsAsync(1);
  20.         results[1] = dailyResult?.Fields?.ToList() ?? new List<StatsField>();
  21.         // 查询月度统计字段
  22.         var monthlyResult = await GetStatsFieldsAsync(2);
  23.         results[2] = monthlyResult?.Fields?.ToList() ?? new List<StatsField>();
  24.         return results;
  25.     }
  26.     /// <summary>
  27.     /// 查询统计字段
  28.     /// </summary>
  29.     public async Task<QueryStatsFieldsResult> GetStatsFieldsAsync(int statsType)
  30.     {
  31.         var request = new QueryStatsFieldsRequest
  32.         {
  33.             StatsType = statsType // 1=日度,2=月度
  34.         };
  35.         _logger.LogInformation("查询考勤统计支持的统计表头,统计类型:{StatsType}", statsType);
  36.         var result = await _statsClient.QueryUserStatsFieldAsync(request);
  37.         if (result?.Code == 0 && result.Data != null)
  38.         {
  39.             _logger.LogInformation("成功查询统计表头,共 {Count} 个字段",
  40.                 result.Data.Fields?.Count ?? 0);
  41.             return result.Data;
  42.         }
  43.         _logger.LogError("查询统计表头失败:{Message}", result?.Message ?? "未知错误");
  44.         return null;
  45.     }
  46. }
复制代码
统计数据缓存
  1. /// <summary>
  2.     /// 更新统计报表表头设置
  3.     /// </summary>
  4. public async Task<UserStatsViewsResult> UpdateStatsViewAsync(
  5.     string viewId,
  6.     UserStatsViewsRequest request)
  7. {
  8.     _logger.LogInformation("更新统计报表表头设置,视图ID:{ViewId}", viewId);
  9.     var result = await _statsClient.UpdateUserStatsViewAsync(request, viewId);
  10.     if (result?.Code == 0 && result.Data != null)
  11.     {
  12.         _logger.LogInformation("成功更新统计报表表头设置");
  13.         return result.Data;
  14.     }
  15.     _logger.LogError("更新统计报表表头设置失败:{Message}", result?.Message ?? "未知错误");
  16.     return null;
  17. }
  18. /// <summary>
  19. /// 创建自定义统计视图
  20. /// </summary>
  21. public async Task<UserStatsViewsResult> CreateCustomStatsViewAsync(
  22.     string viewName,
  23.     int statsType,
  24.     List<string> fieldIds)
  25. {
  26.     // 先查询现有视图
  27.     var queryRequest = new QueryStatsViewsRequest
  28.     {
  29.         StatsType = statsType,
  30.         PageSize = 100,
  31.         PageToken = ""
  32.     };
  33.     var queryResult = await _statsClient.QueryUserStatsViewAsync(queryRequest);
  34.     // 检查是否已存在同名视图
  35.     var existingView = queryResult?.Data?.Items?
  36.         .FirstOrDefault(v => v.ViewName == viewName);
  37.     if (existingView != null)
  38.     {
  39.         // 更新现有视图
  40.         return await UpdateStatsViewAsync(existingView.UserStatsViewId,
  41.             new UserStatsViewsRequest
  42.             {
  43.                 ViewName = viewName,
  44.                 StatsType = statsType,
  45.                 FieldIds = fieldIds
  46.             });
  47.     }
  48.     else
  49.     {
  50.         // 创建新视图(通过更新默认视图实现)
  51.         // 飞书 API 不直接支持创建视图,需要修改默认视图
  52.         _logger.LogWarning("飞书 API 不支持直接创建视图,请手动在飞书后台创建");
  53.         return null;
  54.     }
  55. }
  56. /// <summary>
  57. /// 构建统计报表表头设置请求
  58. /// </summary>
  59. public UserStatsViewsRequest BuildStatsViewRequest(
  60.     string viewName,
  61.     int statsType,
  62.     List<string> fieldIds)
  63. {
  64.     return new UserStatsViewsRequest
  65.     {
  66.         ViewName = viewName,
  67.         StatsType = statsType, // 1=日度,2=月度
  68.         FieldIds = fieldIds
  69.     };
  70. }
  71. /// <summary>
  72. /// 获取常用字段配置
  73. /// </summary>
  74. public List<string> GetCommonStatsFields(StatsScenario scenario)
  75. {
  76.     return scenario switch
  77.     {
  78.         StatsScenario.AttendanceOverview => new List<string>
  79.         {
  80.             "user_id", "user_name", "department_id", "department_name",
  81.             "actual_work_hours", "normal_working_hours", "attendance_result"
  82.         },
  83.         StatsScenario.AbnormalAnalysis => new List<string>
  84.         {
  85.             "user_id", "user_name", "late_count", "late_minutes",
  86.             "early_count", "early_minutes", "absent_count"
  87.         },
  88.         StatsScenario.LeaveAnalysis => new List<string>
  89.         {
  90.             "user_id", "user_name", "leave_hours", "leave_count",
  91.             "leave_days"
  92.         },
  93.         StatsScenario.OvertimeAnalysis => new List<string>
  94.         {
  95.             "user_id", "user_name", "overtime_hours", "overtime_count"
  96.         },
  97.         _ => new List<string>()
  98.     };
  99. }
复制代码
踩坑实录

限流问题

问题:
飞书 API 有调用频率限制,超了就返回 429。一开始没注意,批量同步员工数据时直接触发限流。
限制参考:
API 类型限制建议审批相关60次/分钟控制并发数,加延迟休假相关60次/分钟批量操作时串行处理统计相关30次/分钟尽量少查,使用缓存补卡相关60次/分钟避免频繁调用组织架构50次/分钟批量拉取后本地缓存解决方案:
  1. /// <summary>
  2. /// 查询统计数据
  3. /// </summary>
  4. public async Task<QueryStatsDatasResult> GetStatsDataAsync(
  5.     QueryStatsDatasRequest request)
  6. {
  7.     _logger.LogInformation(
  8.         "查询统计数据,统计类型:{StatsType},时间范围:{StartDate} 至 {EndDate}",
  9.         request.StatsType, request.StartDate, request.EndDate);
  10.     var result = await _statsClient.QueryUserStatsDataAsync(request);
  11.     if (result?.Code == 0 && result.Data != null)
  12.     {
  13.         _logger.LogInformation("成功查询统计数据,共 {Count} 条",
  14.             result.Data.Items?.Count ?? 0);
  15.         return result.Data;
  16.     }
  17.     _logger.LogError("查询统计数据失败:{Message}", result?.Message ?? "未知错误");
  18.     return null;
  19. }
  20. /// <summary>
  21. /// 构建统计数据请求
  22. /// </summary>
  23. public QueryStatsDatasRequest BuildStatsDataRequest(
  24.     int statsType,
  25.     string startDate,
  26.     string endDate,
  27.     List<string> userIds = null,
  28.     List<string> groupIds = null,
  29.     string viewId = null,
  30.     int limit = 100,
  31.     int offset = 0)
  32. {
  33.     return new QueryStatsDatasRequest
  34.     {
  35.         StatsType = statsType, // 1=日度,2=月度
  36.         StartDate = startDate,
  37.         EndDate = endDate,
  38.         UserIds = userIds,
  39.         GroupIds = groupIds,
  40.         ViewId = viewId,
  41.         Limit = limit,
  42.         Offset = offset
  43.     };
  44. }
  45. /// <summary>
  46. /// 分页查询所有统计数据
  47. /// </summary>
  48. public async Task<List<StatsDataItem>> GetAllStatsDataAsync(
  49.     int statsType,
  50.     string startDate,
  51.     string endDate,
  52.     List<string> userIds = null,
  53.     List<string> groupIds = null,
  54.     string viewId = null)
  55. {
  56.     var allItems = new List<StatsDataItem>();
  57.     int offset = 0;
  58.     int limit = 100;
  59.     bool hasMore = true;
  60.     while (hasMore)
  61.     {
  62.         var request = BuildStatsDataRequest(
  63.             statsType, startDate, endDate, userIds, groupIds, viewId, limit, offset);
  64.         var result = await GetStatsDataAsync(request);
  65.         if (result?.Items != null && result.Items.Count > 0)
  66.         {
  67.             allItems.AddRange(result.Items);
  68.             hasMore = result.Items.Count >= limit;
  69.             offset += limit;
  70.         }
  71.         else
  72.         {
  73.             hasMore = false;
  74.         }
  75.         if (hasMore)
  76.         {
  77.             await Task.Delay(200); // 避免触发限流
  78.         }
  79.     }
  80.     return allItems;
  81. }
  82. /// <summary>
  83. /// 获取员工月度考勤统计
  84. /// </summary>
  85. public async Task<MonthlyAttendanceStats> GetMonthlyAttendanceStatsAsync(
  86.     string userId,
  87.     int year,
  88.     int month)
  89. {
  90.     var startDate = $"{year}-{month:D2}-01";
  91.     var endDate = $"{year}-{month:D2}-{DateTime.DaysInMonth(year, month):D2}";
  92.     var request = BuildStatsDataRequest(
  93.         statsType: 2, // 月度统计
  94.         startDate: startDate,
  95.         endDate: endDate,
  96.         userIds: new List<string> { userId },
  97.         limit: 1
  98.     );
  99.     var result = await GetStatsDataAsync(request);
  100.     if (result?.Items != null && result.Items.Count > 0)
  101.     {
  102.         var item = result.Items[0];
  103.         return new MonthlyAttendanceStats
  104.         {
  105.             UserId = userId,
  106.             Year = year,
  107.             Month = month,
  108.             WorkDays = item.WorkDays,
  109.             ActualWorkHours = item.ActualWorkHours,
  110.             LateCount = item.LateCount,
  111.             LateMinutes = item.LateMinutes,
  112.             EarlyCount = item.EarlyCount,
  113.             EarlyMinutes = item.EarlyMinutes,
  114.             LeaveHours = item.LeaveHours,
  115.             OvertimeHours = item.OvertimeHours,
  116.             AttendanceResult = item.AttendanceResult
  117.         };
  118.     }
  119.     return new MonthlyAttendanceStats
  120.     {
  121.         UserId = userId,
  122.         Year = year,
  123.         Month = month,
  124.         WorkDays = 0,
  125.         ActualWorkHours = 0
  126.     };
  127. }
  128. /// <summary>
  129. /// 获取部门考勤统计
  130. /// </summary>
  131. public async Task<DepartmentAttendanceStats> GetDepartmentAttendanceStatsAsync(
  132.     string departmentId,
  133.     int year,
  134.     int month)
  135. {
  136.     // 先获取部门下所有员工
  137.     var userIds = await GetDepartmentUserIdsAsync(departmentId);
  138.     if (userIds.Count == 0)
  139.     {
  140.         return new DepartmentAttendanceStats();
  141.     }
  142.     // 分批查询员工考勤数据
  143.     var allStats = new List<MonthlyAttendanceStats>();
  144.     var batchSize = 50;
  145.     for (int i = 0; i < userIds.Count; i += batchSize)
  146.     {
  147.         var batchUsers = userIds.Skip(i).Take(batchSize).ToList();
  148.         var stats = await GetMonthlyAttendanceStatsAsync(batchUsers, year, month);
  149.         allStats.AddRange(stats);
  150.         await Task.Delay(500); // 避免触发限流
  151.     }
  152.     // 汇总部门统计
  153.     return new DepartmentAttendanceStats
  154.     {
  155.         DepartmentId = departmentId,
  156.         Year = year,
  157.         Month = month,
  158.         TotalUsers = userIds.Count,
  159.         TotalWorkDays = allStats.Sum(x => x.WorkDays),
  160.         TotalActualWorkHours = allStats.Sum(x => x.ActualWorkHours),
  161.         TotalLateCount = allStats.Sum(x => x.LateCount),
  162.         TotalOvertimeHours = allStats.Sum(x => x.OvertimeHours),
  163.         TotalLeaveHours = allStats.Sum(x => x.LeaveHours),
  164.         AttendanceRate = CalculateAttendanceRate(allStats)
  165.     };
  166. }
  167. private double CalculateAttendanceRate(List<MonthlyAttendanceStats> stats)
  168. {
  169.     if (stats.Count == 0) return 0;
  170.     var totalWorkDays = stats.Sum(x => x.WorkDays);
  171.     var totalNormalDays = stats.Count * 21; // 假设每月21个工作日
  172.     return totalNormalDays > 0 ? (totalWorkDays / totalNormalDays) * 100 : 0;
  173. }
复制代码
时区坑

问题:
服务器是 UTC 时间,飞书用的是 Asia/Shanghai。第一次同步数据时,发现时间都对不上。
时间流程:
  1. /// <summary>
  2. /// 带缓存的统计数据查询
  3. /// </summary>
  4. public async Task<QueryStatsDatasResult> GetStatsDataWithCacheAsync(
  5.     QueryStatsDatasRequest request,
  6.     TimeSpan cacheDuration)
  7. {
  8.     var cacheKey = $"stats:{request.StatsType}:{request.StartDate}:{request.EndDate}:" +
  9.                     $"{string.Join(",", request.UserIds ?? new List<string>())}";
  10.     // 尝试从缓存获取
  11.     var cachedData = await _cache.GetAsync<QueryStatsDatasResult>(cacheKey);
  12.     if (cachedData != null)
  13.     {
  14.         _logger.LogInformation("从缓存获取统计数据:{CacheKey}", cacheKey);
  15.         return cachedData;
  16.     }
  17.     // 从飞书 API 获取
  18.     var result = await GetStatsDataAsync(request);
  19.     // 存入缓存
  20.     if (result != null)
  21.     {
  22.         await _cache.SetAsync(cacheKey, result, cacheDuration);
  23.     }
  24.     return result;
  25. }
复制代码
解决方案:
  1. // 方案1:使用 SemaphoreSlim 控制并发
  2. private readonly SemaphoreSlim _rateLimiter = new SemaphoreSlim(10); // 最多10个并发
  3. public async Task BatchSyncUsersAsync(List<string> userIds)
  4. {
  5.     var tasks = userIds.Select(async userId =>
  6.     {
  7.         await _rateLimiter.WaitAsync();
  8.         try
  9.         {
  10.             await SyncUserAsync(userId);
  11.         }
  12.         finally
  13.         {
  14.             _rateLimiter.Release();
  15.         }
  16.     });
  17.     await Task.WhenAll(tasks);
  18. }
  19. // 方案2:使用 Polly 的限流策略
  20. builder.Services.AddHttpClient<IFeishuHttpClient>()
  21.     .AddTransientHttpErrorPolicy(p => p
  22.         .OrResult(msg => msg.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
  23.         .WaitAndRetryAsync(3, retryAttempt =>
  24.             TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)), // 指数退避
  25.             onRetry: (outcome, timespan, retryCount, context) =>
  26.             {
  27.                 _logger.LogWarning(
  28.                     "触发限流,等待 {WaitTime} 秒后重试,第 {RetryCount} 次",
  29.                     timespan.TotalSeconds, retryCount);
  30.             }
  31.         )
  32.     );
  33. // 方案3:简单延迟
  34. await Task.Delay(1000); // 每次调用后延迟1秒
复制代码
最佳实践:

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

问题:
员工数据比较敏感,需要做好安全防护。
解决方案:
  1. 用户输入(本地时间)
  2.     ↓
  3. 转换为 UTC 时间(存储到数据库)
  4.     ↓
  5. 与飞书 API 交互时
  6.     ↓
  7. 转换为 Asia/Shanghai 时间
  8.     ↓
  9. 调用飞书 API
  10.     ↓
  11. 飞书 API 返回数据(Asia/Shanghai)
  12.     ↓
  13. 转换为 UTC 时间(存储到数据库)
  14.     ↓
  15. 用户本地时区显示
复制代码
安全检查清单:

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

1. 使用飞书开放平台的"调试工具"
先在飞书开放平台的调试工具中测试 API,确认参数和响应格式正确后再写代码。
2. 开启详细日志
  1. // 统一时区处理工具类
  2. public static class TimeZoneHelper
  3. {
  4.     private static readonly TimeZoneInfo ShanghaiTimeZone =
  5.         TimeZoneInfo.FindSystemTimeZoneById("Asia/Shanghai");
  6.     /// <summary>
  7.     /// UTC 转上海时间
  8.     /// </summary>
  9.     public static DateTime UtcToShanghai(DateTime utcTime)
  10.     {
  11.         return TimeZoneInfo.ConvertTimeFromUtc(utcTime, ShanghaiTimeZone);
  12.     }
  13.     /// <summary>
  14.     /// 上海时间转 UTC
  15.     /// </summary>
  16.     public static DateTime ShanghaiToUtc(DateTime shanghaiTime)
  17.     {
  18.         return TimeZoneInfo.ConvertTimeToUtc(shanghaiTime, ShanghaiTimeZone);
  19.     }
  20.     /// <summary>
  21.     /// 本地时间转 UTC
  22.     /// </summary>
  23.     public static DateTime LocalToUtc(DateTime localTime)
  24.     {
  25.         return localTime.Kind == DateTimeKind.Utc
  26.             ? localTime
  27.             : localTime.ToUniversalTime();
  28.     }
  29.     /// <summary>
  30.     /// 格式化为飞书 API 需要的时间格式
  31.     /// </summary>
  32.     public static string FormatForFeishu(DateTime dateTime)
  33.     {
  34.         var utcTime = LocalToUtc(dateTime);
  35.         return UtcToShanghai(utcTime).ToString("yyyy-MM-dd HH:mm:ss");
  36.     }
  37. }
  38. // 使用示例
  39. var now = DateTime.Now;
  40. var feishuTime = TimeZoneHelper.FormatForFeishu(now);
  41. var request = new QueryAttendanceApprovalsRequest
  42. {
  43.     StartTime = feishuTime,
  44.     EndTime = TimeZoneHelper.FormatForFeishu(now.AddDays(7))
  45. };
复制代码
3. 详细记录请求参数和响应结果
  1. // 1. 数据库加密存储
  2. public class EncryptionService
  3. {
  4.     private readonly IConfiguration _configuration;
  5.     public EncryptionService(IConfiguration configuration)
  6.     {
  7.         _configuration = configuration;
  8.     }
  9.     public string Encrypt(string plainText)
  10.     {
  11.         var key = _configuration["Encryption:Key"];
  12.         var iv = _configuration["Encryption:IV"];
  13.         // 使用 AES 加密
  14.         // ...
  15.     }
  16.     public string Decrypt(string cipherText)
  17.     {
  18.         var key = _configuration["Encryption:Key"];
  19.         var iv = _configuration["Encryption:IV"];
  20.         // 使用 AES 解密
  21.         // ...
  22.     }
  23. }
  24. // 2. 敏感信息脱敏
  25. public class DataMaskingService
  26. {
  27.     public string MaskIdCard(string idCard)
  28.     {
  29.         if (string.IsNullOrEmpty(idCard) || idCard.Length < 4)
  30.             return idCard;
  31.         return idCard.Substring(0, 3) + "********" + idCard.Substring(idCard.Length - 4);
  32.     }
  33.     public string MaskPhone(string phone)
  34.     {
  35.         if (string.IsNullOrEmpty(phone) || phone.Length < 7)
  36.             return phone;
  37.         return phone.Substring(0, 3) + "****" + phone.Substring(phone.Length - 4);
  38.     }
  39. }
  40. // 3. 接口访问权限控制
  41. [Authorize]
  42. [ApiController]
  43. [Route("api/[controller]")]
  44. public class AttendanceController : ControllerBase
  45. {
  46.     [HttpGet("{userId}")]
  47.     public async Task<IActionResult> GetUserAttendance(string userId)
  48.     {
  49.         // 只能查看自己的数据(管理员除外)
  50.         var currentUserId = User.FindFirst("sub")?.Value;
  51.         var isAdmin = User.IsInRole("Admin");
  52.         if (!isAdmin && currentUserId != userId)
  53.         {
  54.             return Forbid();
  55.         }
  56.         // ...
  57.     }
  58. }
  59. // 4. 操作日志记录
  60. public class AuditLogService
  61. {
  62.     public async Task LogOperationAsync(AuditLog log)
  63.     {
  64.         // 记录操作人、操作时间、操作类型、操作内容
  65.         await _auditLogRepository.AddAsync(log);
  66.     }
  67. }
复制代码
项目地址

代码都在这:GitHub  Gitee
有 Demo 可以参考,有问题可以提 Issue。
如果你也在做类似的项目,希望这篇笔记能帮你少踩几个坑。
有问题欢迎交流,让我进步!

来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!

相关推荐

您需要登录后才可以回帖 登录 | 立即注册