折腾考勤系统这事儿只有亲自从零到一收拾过的人才说得清坑在哪。先说结论在 .NET 生态里完整对接一套飞书考勤不是小工程但把整条链路的环节摸透了两三天也够落地一套稳定可跑的版本。核心就四块飞书开放平台的权限申请、令牌与加密事件处理、按天拉取原始打卡记录落库、最后在自有服务里做工时规则计算和异常汇总。这篇内容没有照着官方文档念全部来自实际踩坑后的整理适合正在做企业内部系统集成、准备自建考勤中台的开发者参考。这几年很多公司用上了飞书考勤后台看报表很方便但一到月底要导出数据、跟现有HR系统或薪酬系统联动就会明显感觉原始能力不够用。飞书开放平台提供了考勤相关的 OpenAPI但数据怎么拉、怎么存、怎么跟内部排班规则对上完全没有现成答案。而我这边当时接到需求是要把飞书打卡数据、请假审批结果统一汇总到内部薪资系统避免人工导出再录入。这个项目没有太复杂的算法更多的是工程化落地把 API 封装好、定时任务铺好、异常数据筛查清楚、回调事件接稳整个系统就算立住了。1. 项目整体设计与选型思路1.1 核心需求拆解这个项目的真实需求并不只是“把飞书考勤数据同步过来”而是建设一条从飞书到内部 HR 系统的数据通路。具体拆解下来需要覆盖几个能力员工打卡记录的自动采集请休假及审批结果的回流考勤异常如迟到、早退、缺卡的自动判别以及同步过程和结果的留档审计。飞书后台虽然自带日报、月报但数据下钻和二次计算能力有限尤其是多考勤组混合排班时自定义规则根本没法在飞书后台实现。我当时梳理出的核心逻辑是飞书作为数据源只负责把考勤原始数据吐出来自定义的考勤规则全部放到自己的服务里计算。这样飞书版本升级或考勤配置变化时只需要调整适配层业务规则永远在自己可控的代码里。1.2 技术选型为什么落在 .NET团队当时技术栈以 .NET 为主现有的薪酬、人事模块都是 ASP.NET Core 服务所以在中间集成层继续使用 .NET 是最省事的选择。不过这里有个很现实的问题飞书官方没有 .NET SDK官方主推的是 Java、Go、Python 和 Node.js。这就意味着所有 API 调用、事件回调、加密解密都要自己用 HttpClient 封装工作量和踩坑概率都会增加。但换个角度看这反而是个优势。不依赖 SDK 的话核心请求只是 HTTP POST/GET什么语言都能接。把飞书开放平台认证、签名、回调验证的细节摸清楚这层封装在后面很多飞书扩展需求中都可以复用。当时我们的做法是单独建了一个基础通信库考勤只是它服务的第一个业务模块。1.3 项目模块划分整个项目我拆成了四个子模块职责非常清晰API 封装层处理应用令牌获取、刷新、API 路径管理、请求响应统一解析。这一层不关心考勤业务只做通用通信。数据同步层包含定时任务和实时回调两条链路。定时任务负责拉取前一天或指定时间段的打卡数据回调负责接收飞书推送的考勤记录变更事件。业务计算层将拉取到的原始打卡记录与排班规则做匹配算出正常出勤、迟到、早退、缺卡、加班等情况并将请假数据合并进来。数据落库层负责将计算结果写入数据库同时维护同步日志与异常记录。这样做的好处是任何一个模块出问题都能独立排查不会因为业务逻辑变动而连带底层通信代码一起改。2. 前置准备:应用申请与关键配置2.1 飞书自建应用与权限开通飞书开放平台上创建应用时一定要选择自建应用而不是商店应用。自建应用属于企业内部的私有集成可以自由配置权限范围和回调事件商店应用则要过审核、走分发流程明显不适合内部系统对接。创建完成后最关键的步骤是开通权限。考勤相关的接口至少需要以下权限权限标识用途attendance:record:read读取打卡记录attendance:group:read读取考勤组信息attendance:user_task:read读取用户考勤任务attendance:day_off:read读取请假信息approval:instance:read读取审批实例contact:user.base:read读取用户基础信息这里非常容易踩的坑是只开通部分权限结果接口返回 99991663 之类的权限错误。开通权限后必须发布应用版本飞书开放平台上的权限不是开通即时生效的需要创建版本并发布发布后还要等一两分钟缓存才刷新。2.2 事件订阅与回调地址配置事件回调是这套系统的实时性保障飞书考勤有记录变更事件审批流程也有实例事件。在应用后台的事件订阅配置中需要把回调地址填成自己服务的 Webhook 地址。配置回调地址时有两个关键参数Encrypt Key 和 Verification Token。Encrypt Key 用于回调内容加密这个建议必须开。不开加密的话回调内容明文传输员工考勤信息这种隐私数据裸奔在公网上无论如何都说不过去。开启后每一次回调推送都会带上一个加密字段服务端需要用 Encrypt Key 做解密。回调地址填好后飞书会发送一个验证请求需要服务端正确响应 Challenge 值才能配置成功。这个验证逻辑要提前写进代码里。2.3 .NET 项目基础搭建项目从一个大而全的 ASP.NET Core Web API 起步就行不需要拆得太细。当时我创建的是 .NET 8使用 HostedService 容纳定时同步逻辑同时开放一个 Webhook 控制器接收飞书回调。用到的 NuGet 包就几个Microsoft.EntityFrameworkCore.Sqlite # 本地交付用数据库 Microsoft.EntityFrameworkCore.Design System.Text.Json前期的调用直接按普通 JSON 处理不引入太重的东西。核心配置用环境变量注入避免把 App Secret 写死在代码里后面部署和交接时安全性会好很多。3. 核心封装:Token 管理与 API 请求3.1 租户访问令牌缓存与刷新飞书开放平台接口绝大多数都要带tenant_access_token它是应用级别的访问令牌不是用户维度。获取方式是一名内网应用凭 App ID 和 App Secret 换令牌官方推荐有效期约 2 小时到期后需要重新申请。这里最容易出现的错误是每次请求前都现拿 Token既浪费请求又可能出现并发下令牌刷新竞争。我的做法是做一个单例的 TokenService内部用内存缓存维护当前令牌并记录过期时间。public class TenantTokenService { private readonly HttpClient _httpClient; private readonly IConfiguration _config; private string? _accessToken; private DateTime _expireAt; public TenantTokenService(HttpClient httpClient, IConfiguration config) { _httpClient httpClient; _config config; } public async Taskstring GetTokenAsync() { if (_accessToken ! null _expireAt DateTime.UtcNow.AddMinutes(5)) { return _accessToken; } var appId _config[Feishu:AppId]; var appSecret _config[Feishu:AppSecret]; var payload new { app_id appId, app_secret appSecret }; using var resp await _httpClient.PostAsJsonAsync( https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal, payload); var body await resp.Content.ReadAsStringAsync(); using var doc JsonDocument.Parse(body); var root doc.RootElement; if (root.GetProperty(code).GetInt32() ! 0) { throw new InvalidOperationException($获取令牌失败: {root.GetProperty(msg).GetString()}); } _accessToken root.GetProperty(tenant_access_token).GetString(); var expire root.GetProperty(expire).GetInt32(); _expireAt DateTime.UtcNow.AddSeconds(expire); return _accessToken!; } }提前五分钟刷新是一个细节避免令牌刚好在请求过程中过期导致的鉴权失败。飞书接口层或网关层偶尔有延迟留出缓冲时间实际效果好很多。3.2 考勤任务查询 API 对接飞书考勤数据最核心的接口是用户考勤任务查询拉取到的是某个用户在某段时间内的每日考勤小结里面包含考勤组、打卡记录、请假记录等。这个接口返回的数据结构比较深封装时建议直接用强类型模型映射不要每次用 JsonDocument 动态解析。public class UserTaskQueryRequest { public Liststring user_ids { get; set; } new(); public long work_from_ts { get; set; } public long work_to_ts { get; set; } public bool need_day_off_records { get; set; } public string user_id_type { get; set; } open_id; } public class CheckRecordDto { public long check_time { get; set; } public string io_state { get; set; } // I/O public string method { get; set; } public double? latitude { get; set; } public double? longitude { get; set; } } public class UserTaskDto { public string user_id { get; set; } public long date { get; set; } public ListUserTaskGroupDto groups { get; set; } new(); }对应查询请求需要调用POST /open-apis/attendance/v1/user_tasks/query接口。查询时要传入 Unix 秒级时间戳很多业务系统习惯用毫秒级这里直接照搬飞书要求就行。我当时封装了一个查询方法public async TaskListUserTaskDto QueryUserTasksAsync( string accessToken, Liststring userIds, DateTime from, DateTime to, CancellationToken ct default) { var reqBody new UserTaskQueryRequest { user_ids userIds, work_from_ts new DateTimeOffset(from.ToUniversalTime()).ToUnixTimeSeconds(), work_to_ts new DateTimeOffset(to.ToUniversalTime()).ToUnixTimeSeconds(), need_day_off_records true }; using var httpReq new HttpRequestMessage(HttpMethod.Post, https://open.feishu.cn/open-apis/attendance/v1/user_tasks/query); httpReq.Headers.TryAddWithoutValidation(Authorization, $Bearer {accessToken}); httpReq.Content JsonContent.Create(reqBody); using var resp await _httpClient.SendAsync(httpReq, ct); var body await resp.Content.ReadAsStringAsync(ct); // 按 code 判断响应是否成功 using var doc JsonDocument.Parse(body); var root doc.RootElement; if (root.GetProperty(code).GetInt32() ! 0) { throw new FeishuApiException(root.GetProperty(code).GetInt32(), root.GetProperty(msg).GetString()); } var tasksJson root.GetProperty(data).GetProperty(user_tasks); return tasksJson.DeserializeListUserTaskDto(JsonOptions.Value) ?? new(); }有个细节要提醒查询时间范围有长度限制时间跨度超过一个月建议拆成多次查询。我当时是按天循环查询每次只拉一天的数据虽然请求量多了点但稳定性和问题排查难度都友好很多。3.3 响应结构与错误码处理飞书所有 OpenAPI 的响应结构是统一的最外层有code、msg和data三个字段。特别需要注意的是HTTP 状态码正常返回 200不代表业务就成功业务错误全都在code字段里体现。常见的几个错误码我整理成了速查表code含义处理建议99991661权限不足检查应用权限并重新发布版本99991663接口不存在或不可用检查接口路径与版本99991664获取访问令牌失败检查 AppId/AppSecret99991668访问频率超限增加退避重试降低调用频率99991672令牌失效清理缓存并重新获取在封装公共层时要将code和msg包装到自定义异常里流水日志里同时记录请求体、响应体和耗时。出错时排查问题会非常省事。4. 数据同步链路与定时任务4.1 数据库表设计同步链路的核心是两张表原始打卡记录表和每日考勤汇总表。原始记录表忠实地保存从飞书拉下来的数据这张表是追溯用的不允许任何业务修改每日汇总表则存经过计算后的结果供薪酬和报表模块查询。建表脚本如下CREATE TABLE attendance_raw_record ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, check_time DATETIME NOT NULL, io_state TEXT NOT NULL, method TEXT, group_id TEXT, group_name TEXT, location_name TEXT, raw_json TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE attendance_daily_summary ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, work_date TEXT NOT NULL, should_hours REAL, actual_minutes INTEGER, late_minutes INTEGER, early_leave_minutes INTEGER, absent INTEGER DEFAULT 0, unpaid OVERTIME INTEGER DEFAULT 0, day_off_type TEXT, status TEXT, UNIQUE(user_id, work_date) );数据库最初用的是 SQLite一人一机或中小团队环境完全够用。后面要从内网服务升级成多实例部署再把数据库切到 PostgreSQL 也比较简单因为所有 SQL 都集中在 EF Core 仓储层。4.2 后台定时同步实现同步用BackgroundService做了个简单的定时循环每两小时触发一次。由于考勤记录是当天实时产生的做实时同步没有很大意义最终以凌晨把前一天的完整数据拉齐为主。Worker 的核心逻辑是查数据库里有哪些员工需要同步按用户维度循环调用飞书接口拿到结果后先写入原始记录表再做汇总计算。public class AttendanceSyncWorker : BackgroundService { private readonly TenantTokenService _tokenService; private readonly AttendanceApiService _apiService; private readonly AttendanceCalculator _calculator; private readonly IServiceScopeFactory _scopeFactory; protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { try { await SyncYesterdayAsync(stoppingToken); } catch (Exception ex) { _logger.LogError(ex, 考勤同步任务异常); } await Task.Delay(TimeSpan.FromHours(2), stoppingToken); } } }为了避免某个用户的数据导致整个批次失败同步循环里要对单用户做 try-catch。拉取失败的记入同步失败表下一轮自动重试连续失败超过三次再人工介入。4.3 open_id、user_id 与 employee_id 的映射关系这是集成飞书时最绕的一个点。飞书接口可能要求传用户ID返回的用户标识字段名却又有可能是另一个体系。比如user_tasks/query接口返回的user_id字段实际到底对应 open_id 还是 employee_id取决于请求参数里的user_id_type。我项目里的统一口径是内部系统全部用open_id作为唯一标识。员工开通飞书后调用一次通讯录接口把open_id和内部员工工号建立映射表之后所有查询都走 open_id绝不混用。如果你拿到的数据源是花名册系统导出的员工编号一定得先确认那是什么类型的ID。因为 open_id 是一个应用维度下的唯一标识同一个用户在 A 应用和 B 应用下的 open_id 是不同的不能直接拿 A 应用的 open_id 去另一个应用换数据。5. 回调事件与审批联动5.1 回调验签与数据解密飞书事件回调开启加密后请求体只有encrypt字段里面是用 Encrypt Key 加密后的业务 JSON。解密算法是 AES-256-GCM不少人在这一步翻车。我自己第一次对接时也拼错过 nonce 和 tag 的位置解密一直报错后来把二进制输出的十六进制对了一遍才定位问题。下面是可行的解密方法基于 .NET 内置的 AES-GCMpublic static string FeishuEventDecrypt(string encrypt, string encryptKey) { var payload Convert.FromBase64String(encrypt); var nonce payload.AsSpan(0, 12); // 前 12 字节是 nonce var cipherAndTag payload.AsSpan(12); // 剩余是密文 16 字节 tag var tag cipherAndTag.Slice(cipherAndTag.Length - 16); var cipherBytes cipherAndTag.Slice(0, cipherAndTag.Length - 16); using var aes new AesGcm(Convert.FromBase64String(encryptKey), 16); var plainBytes new byte[cipherBytes.Length]; aes.Decrypt(nonce, cipherBytes, tag, plainBytes); return Encoding.UTF8.GetString(plainBytes); }注意这里的 Encrypt Key 不是直接当字符串用而是一个 Base64 编码的 32 字节密钥先解码再传入算法。解密成功后的 JSON通常会包含type、challenge或event字段再按事件类型分发处理。5.2 考勤记录变更事件飞书有考勤记录变化的事件员工完成打卡后平台会向回调地址推送事件。考虑到定时同步的延迟和异常这个实时事件的价值在于可以立刻感知异常打卡比如员工在非打卡点打卡或者重复打卡。事件结构一般是 schema 2.0 的格式外层有header.event_type里面才是event数据体。我在控制器里按 event_type 做了个简单分发器[HttpPost(webhook/feishu)] public async TaskIActionResult HandleFeishuEvent(JsonElement body) { // 解密与验签逻辑略 var eventType body.GetProperty(header).GetProperty(event_type).GetString(); switch (eventType) { case attendance.record_change: await HandleAttendanceRecordChangeAsync(body.GetProperty(event)); break; case approval.instance.v2: await HandleApprovalInstanceAsync(body.GetProperty(event)); break; } return Ok(new { code 0 }); }回调处理一定不要做耗时操作飞书对响应时间有限制。建议在 Worker 里做异步处理接口先快速返回 HTTP 200把事件内容放入内存队列或数据库队列表后台再消化。5.3 审批实例事件与请假数据合并请假数据并不在打卡记录接口里而是走审批流。飞书审批实例事件推送内容包括审批类型、发起人、通过状态等。真正到月底算工资时不仅要看有没有打卡还要看有没有请假单。我的处理方式是监听approval.instance.v2事件当审批状态变为通过时将审批信息落库。随后在每日考勤汇总计算时如果当天存在已通过的请假单就将当天标记为请假不再做迟到缺卡判定。有一个业务细节得说审批通过事件可能滞后如果员工在月底补交请假单会导致之前已经算好的汇总数据变化。为此汇总记录里加了一个last_calc_version字段每次重新计算时直接以最新审批结果覆盖不保留历史中间态。6. 工时计算与异常判定规则6.1 考勤组规则建模每个考勤组的班次规则、休息时间、上班时间都不同。飞书接口返回的time_rule字段包含规则明细但不同企业排班逻辑差异很大不宜直接拿默认规则套用。我设计的方案是把规则映射表做成可配置项后台可以维护。一条规则包含规则名、上班时间、下班时间、迟到容忍分钟数、早退容忍分钟数、是否弹性打卡。用一张attendance_rule表维护。计算公式是实际出勤分钟数 下班打卡时间 - 上班打卡时间 - 休息分钟数 迟到分钟数 上班打卡时间超过规定上班时间的部分 早退分钟数 下班打卡时间早于规定下班时间的部分展开说一个容易忽略的细节考勤系统里上班打卡和下班打卡分别对应io_state为 I 和 O。同一天可能有多条打卡记录正常上班一次、下班一次有人中午外出还会打多次卡。汇总逻辑里每个方向取最合理的那条比如上班卡取最早一条下班卡取最晚一条。这看起来简单却有业务依据大多数公司执行的规则就是上班卡按最早下班卡按最晚。6.2 异常状态自动分类汇总完成后将每个员工每天的状态分成几类状态判定条件正常有上下班打卡且实际工时达标迟到上班打卡时间晚于规则时间且超容忍值早退下班打卡时间早于规则时间且超容忍值缺卡缺少上班或下班任一打卡请假当天有已通过的请假审批旷工当天无打卡且无请假状态判定代码不复杂但边界条件要细致。比如法定节假日或调休这类日期需要先标记到节假日表里汇总计算遇到节假日直接跳过不产生旷工结果。6.3 工时报表输出计算结果最后落到attendance_daily_summary同时支持按月导出。薪酬系统对接时直接查询汇总表不需要逐条核对原始打卡记录。只有薪酬系统对数据有疑问时才人工翻查原始记录表。这个输出阶段有一个值得注意的问题时区。飞书的date字段以及打卡时间戳都是 Unix 秒默认按零时区解析。业务侧展示时必须转成北京时间否则每天的时间都偏移八小时。我当时在查询层就直接统一转成了 DateTimeOffset 类型后续业务不会再因为时区问题重复踩坑。7. 部署细节与常见问题排查7.1 部署形态与环境变量管理部署上我选了最简单的方案单体服务加 SQLite打一个 Linux 容器发布到内网服务器。.NET 8 镜像本来就比较小发布后的容器只有一百兆左右即使跑在低配服务器上也毫不吃力。环境变量统一从配置中心注入测试环境和正式环境使用不同的 AppId 和 AppSecret。最忌讳的是把正式环境的 Secret 写到 appsettings.json 里提交到代码仓库一旦泄露别人的应用就能伪装你的服务身份调用飞书接口。建议在发布脚本里用如下环境变量export FEISHU_APP_IDcli_xxx export FEISHU_APP_SECRETyour_secret_here export FEISHU_ENCRYPT_KEYyour_encrypt_key代码里用IConfiguration统一读取开发环境从环境变量取值生产环境走配置中心。7.2 高频踩坑与解决实录以下问题的优先级从高到低排序一旦遇到先看这一节请求 99991668 频率超限。飞书开放平台对每个应用的接口调用频率有限制。实际场景里如果全公司几百人一起拉数据很容易把配额打爆。解决方法应该是增加本地缓存不要对同一用户重复拉取同一时间段的数据同时在代码里做线程安全控制防止定时任务和手动触发的同步任务并发执行。首次同步时公司历史数据量大导致超时。飞书接口单次查询支持批量用户但批量太大容易超时。我实测下来每次查询 20 个用户比较稳配合分页处理一万人的企业也就几百个请求均匀分布在一个小时内完全没问题。回调验签报错。绝大多数开发者在对接飞书回调时出现验签失败都是因为加了自签证书或者本地代理中间人改写请求导致签名不匹配。测试阶段可以先把验签临时跳过单测跑通业务再补上验签逻辑。真要线上验签失败优先检查服务器时间飞书签名校验依赖时间戳时钟漂移同样会导致失败。同步任务静默失效。定时任务最怕的不是报错而是什么也不做。日志系统里一定要记录每次同步的起始时间、拉取员工数量、成功数和失败数。我养成的习惯是每天凌晨同步结束后给运维邮箱发一封汇总邮件因为如果第二天早上汇总数据异常及时发现成本远比月底对账低得多。7.3 性能与稳定性优化建议这套系统的数据量级撑住一万人的日粒度同步完全没问题但考虑长期运行还是要做两个优化。第一HttpClient 一定要通过依赖注入复用不要每次 new。连接池复用对飞书接口的长连接开销优化非常明显服务器连接数从几百降到个位数。第二同步队列化。员工数量多时把用户 ID 分批丢进 Channel多个并发消费者从 Channel 取批次请求。这种生产消费模型比粗暴的 Task.WhenAll 更可控限流和重试都更容易实现。数据库层的索引也不可忽视。attendance_raw_record表要按 user_id 和 check_time 建联合索引否则一个月的数据量查起来就开始明显变慢。SQLite 的话记得开 WAL 模式读写并发会有明显提升。8. 关键设计经验沉淀项目做完后回看最有价值的不是这段代码本身而是几个贯穿始终的工程判断。任何与外部平台对接的系统必须把第三方接口的不确定性隔离在一个独立适配层里。飞书考勤的数据结构从第一次对接到现在迭代过几次如果业务代码里到处散落着飞书字段任何一次接口调整都够团队喝一壶。适配层里做字段映射和版本兼容内部业务代码永远用自有的模型才能保证核心逻辑的稳定性。定时任务一定要有失败可观察性。我看过太多数据同步服务出问题时不报错、只是不写数据业务人员月底拿着报表找过来才知道系统已经默默坏了两周。同步日志、结果统计、失败重试、超时告警这些看起来不产生业务价值的东西恰恰是这类系统的生命线。对照飞书开放平台做集成时“能跑通”只是第一关能持续稳定地每天跑、每月对账不出错才是真正落地的标准。当时项目交付后我把整个代码库里飞书回调里所有字段的文档链接、权限申请记录、常见错误码处理方式都整理成了一份交付文档。后面的维护人员接手系统时不用重新趟坑这一点我至今觉得做得最值。最后分享一个小教训即使飞书开放平台自带了测试工具也请务必用一个独立的测试企业租户做联调别在正式企业里反复打测试数据。考勤数据是会进工资计算的任何一次脏数据注入都可能变成月底薪酬对账的噩梦。