去年接了一个需求:公司用飞书打卡,但HR每个月都要把打卡记录导出来,再和钉钉那边的请假数据、项目部的加班单对一遍,输出一份手工调整过的考勤汇总。费时不说,还容易出错。我当时就想着,能不能用.NET直接对接飞书开放平台,把打卡、审批、假期这些数据自动同步到自己的数据库,然后按公司的规则出报表。系统上线之后,HR从每月两三天的工作量降到了几分钟。这篇文章把整套系统的搭建思路、核心代码和落地过程中踩的坑都整理出来,给正打算做同类系统的人一个参考。
我默认的读者是:有C#基础,了解ASP.NET Core,但没接触过飞书开放平台的人。所以飞书那边的概念我会讲得细一些,代码部分则直接给出能用的封装。
1. 为什么不能直接拿飞书考勤报表,非要自己搭一套
1.1 飞书原生后台的边界在哪
飞书自带的管理后台确实能看考勤数据,也能导出Excel。但一旦公司规模上来,或者业务规则稍复杂,它的瓶颈就非常明显:
- 多数据源难融合。请假、加班审批在飞书里,但调休余额可能在另一个系统,项目工时又在Excel里。HR最终要的是一张融合后的表,手工去合并就是一个巨大的坑。
- 报表规则定制困难。比如"晚于9点打卡不算迟到,但如果前一天加班到23点后,第二天可以10点前打卡"这类规则,飞书后台的统计设置能覆盖一部分,但遇到公司自定义的考勤组规则,往往要拼凑好几个功能才能实现。
- 数据资产不在自己手里。考勤数据属于敏感数据,很多公司希望落库归档,保存五年以上。飞书的免费版和企业版对数据保留时长和导出范围都有一定限制。
这些点,本质上都是"飞书解决了打卡这一动作,但没有解决考勤数据与公司其他系统联动的问题"。自己搭一套系统,核心目的不是替代飞书,而是做数据层的同步与二次加工。
1.2 业务最终要的是"一张能用的报表"
技术人容易犯的毛病是,一上来就研究API、写代码。但在做这个项目前,一定要和业务方把口径对齐。
我问了三条关键问题:
- 迟到、早退、缺卡的定义是什么?按分钟算还是按次算?
- 加班需要审批吗?审批通过才算加班,还是打卡超时就算?
- 外勤、出差、居家办公怎么处理?
这些问题直接影响后续的表结构设计和统计逻辑。建议在开发前就让HR明文写一份考勤规则说明,哪怕是一页纸也好,作为需求基线。系统迭代时,这些规则就是测试用例。
1.3 技术选型:为什么是.NET而不是Python或Go
飞书开放平台提供的是标准REST API,理论上任何语言都能对接。我选.NET有几个实际考虑:
- 公司现有的技术栈就是.NET,人力资源相关系统后续可能还要对接薪酬、绩效模块,统一技术栈维护成本最低。
- ASP.NET Core 在 Windows 和 Linux 上都能跑,部署形态灵活。后面我会提到,这套系统我最终用 Docker 部署在内网服务器上,跨平台能力很重要。
- .NET 有完善的定时任务、HTTP客户端生态,比如 BackgroundService、Hangfire、Polly 重试库,做数据同步这类场景非常顺手。
如果你是从零开始、团队又没人熟悉C#,那用 Python 写脚本也一样能实现核心功能。但如果你问的是"能不能用.NET做并落地",答案是完全可以,而且很合适。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 动手前必须搞清的飞书开放平台几个设定
这是最容易看文档看得一头雾水的地方。飞书开放平台做了一堆概念包装,本质就这几个东西。
2.1 自建应用的凭证:App ID、App Secret 与 token
你需要在飞书开放平台后台创建一个"企业自建应用",创建后会得到一对密钥:App ID 和 App Secret。后续所有API调用都用这对密钥先换一个临时凭证。
凭证分两种:
tenant_access_token:代表应用身份,大多数数据查询接口用它就够了。有效期默认2小时。user_access_token:代表某个用户的身份,需要走OAuth授权流程。如果你要读取某个员工的敏感数据且权限校验严格,才需要用它。考勤系统里大部分场景用tenant_access_token即可,只有实现"员工自助查询自己的考勤"这类页面时才会用到用户凭证。
获取 tenant_access_token 的调用很简单,一个 HTTP POST 就能完成,代码我放在第4章。需要注意的点是:token 有时效,必须在代码里做缓存,不能每调一次接口就重新获取一次,否则很容易触发频率限制。
2.2 权限申请:考勤数据不是开箱即用的
创建应用后,默认没有任何 API 权限。你需要在"权限管理"页面手动开通以下权限(名称以飞书后台实际显示为准):
| 权限名称 | 用途 |
|---|---|
| 考勤打卡记录查询 | 读取员工的打卡流水 |
| 考勤统计查询 | 读取考勤组、班次、统计规则 |
| 审批实例读取 | 获取请假、加班、补卡审批的实例数据 |
| 通讯录基本信息读取 | 获取员工姓名、部门、user_id |
| 事件订阅 | 接收审批、打卡等相关事件的推送 |
这些权限申请后,部分需要企业管理员审核。这里有个实际经验:权限不要一口气把整个应用都申请完,按最小可用原则,用哪个开哪个。否则审核管理员看到一堆敏感权限,容易卡流程。
2.3 事件订阅:让系统"被动"接收变更
定时拉取数据可以解决80%的同步需求,比如每天晚上拉昨天的打卡记录。但有些场景,比如员工提交了补卡审批,你希望管理员系统里能实时看到或实时联动,定时轮询就慢了。这时需要用到事件订阅。
飞书的事件订阅工作方式是:你在后台配置一个回调URL,飞书那边发生指定事件时,会往这个URL推送一个POST请求。你的系统需要做两件事:
- 返回一个 challenge 值,完成URL验证。
- 收到事件后,做业务处理,然后返回成功。如果返回失败,飞书会按策略重试。
事件订阅的实时性很好,但引入了消息幂等、重试处理等复杂度。如果第一版想快速落地,可以先用定时同步顶上,第二版再接入事件订阅。这套系统我先上了定时同步,后来才补了审批事件订阅。
3. 落地方案:系统架构与数据模型设计
3.1 模块划分
我把整个系统拆成了五个模块,职责明确,后面维护起来不痛苦:
- 同步引擎:负责调用飞书API,拉取打卡、审批、员工信息数据,写入本地库。这是最核心的模块。
- 规则引擎:负责把原始打卡记录按公司考勤规则计算成"迟到/早退/缺卡/正常"等结果。规则独立成模块,方便改。
- 管理后台:ASP.NET Core MVC 页面,给HR用,用来查看出错的同步记录、修正异常数据、导出报表。
- 报表导出:按月份、部门、员工导出Excel或PDF,直接给HR用。
- 通知服务:同步失败、连续异常等场景下,通过飞书机器人或企业微信机器人发告警。
模块之间用简单的项目分离,Controller只做请求分发,业务逻辑放在独立的Service类里。这套系统逻辑不算复杂,不需要上微服务,一个应用进程就够了。
3.2 数据模型
数据库我用的是 SQL Server,实际上用 PostgreSQL 或 MySQL 也完全没问题。核心表结构设计如下:
sql复制-- 员工维度表
CREATE TABLE Employees (
EmployeeId INT IDENTITY PRIMARY KEY,
FeishuUserId NVARCHAR(64) NOT NULL, -- 飞书user_id,注意是哪种类型(见5.4)
Name NVARCHAR(50) NOT NULL,
Department NVARCHAR(100) NULL,
HireDate DATE NULL,
LeaveDate DATE NULL,
IsActive BIT DEFAULT 1,
UNIQUE (FeishuUserId)
);
-- 打卡原始记录表
CREATE TABLE AttendanceRecords (
RecordId INT IDENTITY PRIMARY KEY,
EmployeeId INT NOT NULL REFERENCES Employees(EmployeeId),
WorkDate DATE NOT NULL, -- 业务日期
CheckInTime DATETIME NULL, -- 上班打卡时间
CheckOutTime DATETIME NULL, -- 下班打卡时间
CheckInMethod NVARCHAR(50) NULL, -- 打卡方式:GPS、WiFi、考勤机等
CheckOutMethod NVARCHAR(50) NULL,
SourceRemark NVARCHAR(200) NULL, -- 原始备注,如"外勤打卡"
CreatedAt DATETIME DEFAULT GETDATE(),
UNIQUE (EmployeeId, WorkDate, CheckInTime, CheckOutTime)
);
-- 考勤汇总表
CREATE TABLE AttendanceSummary (
SummaryId INT IDENTITY PRIMARY KEY,
EmployeeId INT NOT NULL,
Year INT NOT NULL,
Month INT NOT NULL,
LateCount INT DEFAULT 0,
EarlyLeaveCount INT DEFAULT 0,
AbsentDays INT DEFAULT 0,
OvertimeHours DECIMAL(5,2) DEFAULT 0,
PaidLeaveDays DECIMAL(5,2) DEFAULT 0,
UnpaidLeaveDays DECIMAL(5,2) DEFAULT 0,
UNIQUE (EmployeeId, Year, Month)
);
-- 同步日志表
CREATE TABLE SyncLogs (
LogId INT IDENTITY PRIMARY KEY,
SyncType NVARCHAR(50) NOT NULL, -- Employee/Attendance/Approval
StartTime DATETIME NOT NULL,
EndTime DATETIME NULL,
Status NVARCHAR(20) NOT NULL, -- Success/Failed/Partial
Detail NVARCHAR(MAX) NULL
);
注意几个关键设计点:打卡记录表加了唯一约束,防止重复同步插入;汇总表按月粒度存储,报表查询直接查汇总表,不需要每次现算。同步日志表是排查问题的关键,第一次上线时尤其重要。
3.3 数据同步策略
我的同步策略是"全量+增量"结合:
- 员工信息(通讯录)每天凌晨全量同步一次,因为变化不大。
- 打卡记录每天运行一次增量同步,拉取前一天的记录。
- 当天的打卡记录通过上午和下午各一次的定时任务拉取,保证HR想看当天数据时基本是新的。
- 审批数据在定时同步之外,同时接入事件订阅。审批事件触发时实时落库,其他数据定时兜底。
定时任务我用的 ASP.NET Core 原生 BackgroundService,配合一个简单的时间表。如果任务多了,建议换成 Hangfire,自带面板可以查看任务执行情况,重试策略也可配置。
4. 核心代码实战:.NET对接飞书考勤API
这一章直接给可用的代码。我用的是 .NET 8 + ASP.NET Core Web API,依赖注入和日志都按标准方式做。
4.1 封装一个飞书客户端
先封装一个飞书HTTP客户端,统一处理token和请求:
csharp复制public class FeishuClient
{
private readonly HttpClient _http;
private readonly ILogger<FeishuClient> _logger;
private readonly FeishuOptions _options;
private string _tenantAccessToken;
private DateTime _tokenExpireTime;
public FeishuClient(HttpClient http, IOptions<FeishuOptions> options, ILogger<FeishuClient> logger)
{
_http = http;
_logger = logger;
_options = options.Value;
}
public async Task<string> GetTenantAccessTokenAsync(CancellationToken ct = default)
{
// 如果token还没过期,直接返回缓存
if (!string.IsNullOrEmpty(_tenantAccessToken) && DateTime.UtcNow < _tokenExpireTime)
{
return _tenantAccessToken;
}
var body = new { app_id = _options.AppId, app_secret = _options.AppSecret };
var resp = await _http.PostAsJsonAsync("https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", body, ct);
var json = await resp.Content.ReadFromJsonAsync<JsonElement>(ct);
if (json.GetProperty("code").GetInt32() != 0)
{
var msg = json.GetProperty("msg").GetString();
throw new FeishuApiException($"获取tenant_access_token失败: {msg}");
}
_tenantAccessToken = json.GetProperty("tenant_access_token").GetString();
var expire = json.GetProperty("expire").GetInt32(); // 单位秒,一般是7200
_tokenExpireTime = DateTime.UtcNow.AddSeconds(expire - 60); // 提前60秒过期,避免边界
return _tenantAccessToken;
}
public async Task<JsonElement> CallApiAsync(string method, string path, object body = null, CancellationToken ct = default)
{
var token = await GetTenantAccessTokenAsync(ct);
var request = new HttpRequestMessage(new HttpMethod(method), "https://open.feishu.cn" + path);
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
if (body != null)
{
request.Content = JsonContent.Create(body);
}
var resp = await _http.SendAsync(request, ct);
var json = await resp.Content.ReadFromJsonAsync<JsonElement>(ct);
var code = json.GetProperty("code").GetInt32();
if (code != 0)
{
_logger.LogWarning("调用飞书API失败: {Method} {Path}, code={Code}, msg={Msg}", method, path, code, json.GetProperty("msg").GetString());
throw new FeishuApiException(json.GetProperty("msg").GetString(), code);
}
return json.GetProperty("data");
}
}
这里有个细节值得说明:token的过期时间我提前了60秒刷新,而不是等到真正过期再去获取。因为一旦在调用中间token过期,那一次请求就会失败,而失败后的重试逻辑会明显增加复杂度。宁可多刷新一次,也不要卡在边界条件上。
4.2 拉取打卡记录并落库
假设飞书那边接口返回的数据结构为(实际字段以你申请到的API文档为准):
json复制{
"records": [
{
"user_id": "gxxxxx",
"work_date": "2024-05-20",
"checkin_time": "2024-05-20 08:58:00",
"checkout_time": "2024-05-20 18:05:00",
"checkin_method": "GPS",
"checkout_method": "GPS"
}
],
"page_token": "xxx",
"has_more": true
}
同步代码大致如下:
csharp复制public class AttendanceSyncService
{
private readonly FeishuClient _feishu;
private readonly AttendanceDbContext _db;
private readonly ILogger<AttendanceSyncService> _logger;
public async Task<int> SyncDailyRecordsAsync(DateTime startDate, DateTime endDate, CancellationToken ct)
{
// 这里用实际API路径替换
const string path = "/open-apis/attendance/v1/user_daily_results";
var card = 0;
var pageToken = "";
do
{
var query = $"{path}?query_date_from={startDate:yyyy-MM-dd}&query_date_to={endDate:yyyy-MM-dd}&page_size=100";
if (!string.IsNullOrEmpty(pageToken))
{
query += $"&page_token={pageToken}";
}
var data = await _feishu.CallApiAsync("GET", query, ct: ct);
var records = data.GetProperty("records");
foreach (var item in records.EnumerateArray())
{
await SaveRecordAsync(item, ct);
card++;
}
if (data.TryGetProperty("has_more", out var hasMore) && hasMore.GetBoolean())
{
pageToken = data.GetProperty("page_token").GetString();
}
else
{
pageToken = null;
}
} while (!string.IsNullOrEmpty(pageToken));
return card;
}
private async Task SaveRecordAsync(JsonElement item, CancellationToken ct)
{
var userId = item.GetProperty("user_id").GetString();
var workDate = DateTime.Parse(item.GetProperty("work_date").GetString());
var employee = await _db.Employees.FirstOrDefaultAsync(e => e.FeishuUserId == userId, ct);
if (employee == null)
{
_logger.LogWarning("找不到员工: {UserId}", userId);
return;
}
var record = new AttendanceRecord
{
EmployeeId = employee.EmployeeId,
WorkDate = workDate.Date,
CheckInTime = ParseNullableDateTime(item, "checkin_time"),
CheckOutTime = ParseNullableDateTime(item, "checkout_time"),
CheckInMethod = ParseNullableString(item, "checkin_method"),
CheckOutMethod = ParseNullableString(item, "checkout_method"),
SourceRemark = ParseNullableString(item, "remark")
};
await _db.AttendanceRecords.AddAsync(record, ct);
await _db.SaveChangesAsync(ct); // 生产环境建议改成批量处理或引入EF的批量扩展
}
}
这段代码有几个生产级细节:分页循环是必须的,否则数据量大时会漏数据;员工映射查找做了容错,遇到未知员工只记日志,不中断整个同步;保存时注意唯一约束,如果上次同步已经写入,需要在这里做 upsert 而不是简单 Add。我第一版就是直接 Add,结果重复执行同一时间段任务时直接主键冲突,后来改成按业务键查重再更新。
4.3 事件回调的处理
事件订阅的核心接口是一个接收飞书回调的Controller:
csharp复制[ApiController]
[Route("api/feishu/events")]
public class FeishuEventController : ControllerBase
{
private readonly IEventHandler _handler;
private readonly ILogger<FeishuEventController> _logger;
public FeishuEventController(IEventHandler handler, ILogger<FeishuEventController> logger)
{
_handler = handler;
_logger = logger;
}
[HttpPost]
public async Task<IActionResult> Receive(FeishuEventRequest request)
{
// 1. 验证challenge,完成URL校验
if (!string.IsNullOrEmpty(request.Challenge))
{
// 如果是加密模式,challenge本身就是密文,需要先解密再返回
var plainChallenge = FeishuCryptoHelper.Decrypt(request.Challenge, _encryptKey);
return Ok(new { challenge = plainChallenge });
}
// 2. 正常事件处理
try
{
var handled = await _handler.HandleAsync(request, HttpContext.RequestAborted);
if (handled)
{
return Ok(new { code = 0 });
}
return StatusCode(500, new { code = 1 });
}
catch (Exception ex)
{
_logger.LogError(ex, "处理飞书事件失败");
return StatusCode(500, new { code = 1 });
}
}
}
事件处理的核心是幂等。飞书推送事件时,如果我们的系统返回了非2xx,飞书会按一定策略重试。这就要求处理器里不能"收到一次就处理一次",必须做去重。我的做法是:在事件处理的最前面按事件ID查一下有没有处理过,处理过就直接返回成功。
有一个细节容易被忽略:飞书的事件推送有两种模式,明文模式和加密模式。明文模式直接就能看到事件类型和内容;加密模式下整个请求体里的 encrypted 字段是一段密文,需要先解密再处理。建议直接用加密模式,毕竟考勤数据敏感。解密算法在飞书官方文档里有完整示例,核心是 AES-256-CBC,密钥由应用凭证派生而来,具体代码直接抄官方SDK即可,不要自己造轮子。
5. 从"能跑"到"能落地":我踩过的那些坑
如果说代码编写花了三周,那调通和踩坑修正花了两个多月。这部分才是"能落地"的关键。
5.1 时间戳与时区
飞书接口返回的时间,有的字段是带时区的ISO 8601字符串,例如 "2024-05-20T08:58:00+08:00",有的则是毫秒级Unix时间戳。最开始我把所有时间都当本地时间处理,结果有几天同步进来的打卡时间差了8个小时。
解决方式:
csharp复制private static DateTime? ParseFeishuDateTime(JsonElement item, string propertyName)
{
if (!item.TryGetProperty(propertyName, out var prop) || prop.ValueKind != JsonValueKind.String)
{
return null;
}
var raw = prop.GetString();
if (DateTimeOffset.TryParse(raw, out var dto))
{
// 统一转成北京时间
var beijing = TimeZoneInfo.FindSystemTimeZoneById("China Standard Time");
return TimeZoneInfo.ConvertTime(dto, beijing).DateTime;
}
if (long.TryParse(raw, out var ms))
{
return DateTimeOffset.FromUnixTimeMilliseconds(ms).ToOffset(TimeSpan.FromHours(8)).DateTime;
}
return null;
}
注意在Linux容器里,TimeZoneInfo.FindSystemTimeZoneById 的参数要传 "Asia/Shanghai",Windows环境下则是 "China Standard Time"。如果你要在两个平台跑,最好做一层映射,或者干脆统一用 TimeZoneInfo.CreateCustomTimeZone 创建固定偏移的时区。
5.2 分页与数据量
飞书大多数列表接口都有分页限制,默认每页20条或100条。踩坑之前,我以为自己只拉一天的记录不会有问题,结果到了月初同步上月全量数据时,直接漏掉了几百条。
分页处理的核心:
- 永远判断接口返回的
has_more字段,不要自己根据返回数量推断。 - 每次翻页必须把上一页返回的
page_token原样传回,顺序不能乱。 - 多指标同步时(例如同步多天的数据),外层按日期循环,内层按分页循环,循环顺序不能反,否则接口压力大且容易超时。
我当时还加了一个保护机制:每次同步任务结束后,对比本地的记录数和飞书接口返回的 total(如果有),不一致就报告警。这样即使循环写错了也能通过日志快速发现。
5.3 限流与重试
飞书API对单应用的调用频次有限制。刚开始没注意,同步程序在拉取全量员工时并发调了五六个接口,结果触发了限流,返回的错误码类似"请求频率超限"。
我的处理方案:
- 所有API调用统一走一个出口,在
FeishuClient里加一个简单的令牌桶,限制每秒最多打N个请求。N的取值视应用QPS而定,我测试后设成了5。 - 遇到限流错误码,不立即重试,而是指数退避,第一次等1秒,第二次2秒,第三次4秒,最多五次。
- 关键同步任务失败后,由定时任务在下一个周期自动补偿,尽量不依赖一次执行全成功。
用 Polly 库可以直接搞定重试和退避逻辑,不需要自己写循环:
csharp复制services.AddHttpClient<FeishuClient>().AddPolicyHandler(
Policy<HttpResponseMessage>
.Handle<FeishuApiException>(ex => ex.IsRateLimit)
.OrResult(r => r.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
.WaitAndRetryAsync(5, retry => TimeSpan.FromSeconds(Math.Pow(2, retry)))
);
Polly 12 的语法和旧版略有不同,但核心思路一致。注意把限流识别放在异常或结果判断里,而不是对所有异常都重试,否则遇到参数错误时会白白浪费重试次数。
5.4 user_id 类型混乱
这是飞书开放平台坑最多、文档说明容易看晕的地方。飞书体系里一个用户有三种ID:
| ID类型 | 含义 | 特点 |
|---|---|---|
| open_id | 应用内用户ID | 同一用户在不同应用下open_id不同 |
| union_id | 开放平台用户ID | 同一开发者下的应用共用 |
| user_id | 企业内员工ID | 企业管理员可设置,调用接口时需指定employee_type |
考勤接口返回的 user_id 字段,可能默认是 open_id,也可能你请求时通过参数指定了 employee_type 之后返回的是 user_id(工号)。如果没管这个,同步落库时经常发生:"这个人明明在通讯录里,却匹配不上。"
我的做法:在系统里同时存 open_id 和 user_id 两个字段,映射表建唯一索引。调用考勤接口时按自己的需要指定 employee_type,并保存返回的ID。后续所有查询都用同一套ID体系,不混用。
5.5 回调重复与幂等
事件订阅的重复推送,比我预想的要频繁。飞书在系统升级、网络抖动、后台重试时都会重新推送事件。如果处理逻辑没有幂等,重复插入的请假记录会直接让汇总数据翻倍。
解决思路很简单:
csharp复制// 审批实例表
CREATE TABLE ApprovalInstances
(
InstanceId NVARCHAR(64) PRIMARY KEY, -- 飞书审批实例ID,天然唯一
EventId NVARCHAR(64) NOT NULL,
ProcessedAt DATETIME DEFAULT GETDATE()
);
处理事件时先查 EventId 是否已存在,存在就直接返回成功。这样即使飞书重复推送,我们也只处理一次。
另一种做法是用 InstanceId 做唯一约束,插入冲突时忽略。但要注意:有些事件可能不是新增,而是状态变更(比如审批被撤回),这时候不能简单忽略,应该转为Update操作。所以用事件ID做幂等判断会更安全。
6. 部署上线:生产环境还要处理的事
6.1 部署形态选择
这套系统可以跑在 Windows Server 上用 IIS 托管,也可以跑在 Linux 上用 Nginx 反代 Docker 容器。我的选择是后者:一个 Docker 容器跑 Web 应用,一个容器跑 SQL Server(或者用已有的数据库实例),用 docker-compose 统一编排。
.NET 8 的容器镜像已经非常成熟,Dockerfile 很简单:
dockerfile复制FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
EXPOSE 8080
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY ["AttendanceSystem.csproj", "."]
RUN dotnet restore "AttendanceSystem.csproj"
COPY . .
RUN dotnet publish "AttendanceSystem.csproj" -c Release -o /app/publish
FROM base AS final
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "AttendanceSystem.dll"]
需要注意:如果部署在内网,且需要访问外网的飞书API,记得确认网络的出站策略没有把 api 域名给拦了。我遇到过在测试环境跑得好好的,一上生产环境就超时,排查后发现是防火墙只放通了一部分域名。
6.2 日志与监控
日志我用的 Serilog,输出到文件和控制台,按天滚动。除了应用日志,还有两块必须监控:
- 同步任务执行状态:每次同步结束后写一张 SyncLogs 表,持续失败超过两次就通过飞书机器人发告警。
- 接口异常率:如果飞书API连续返回5xx或超时,说明对方服务可能不稳,或者我们的token出问题了,都需要马上知道。
没有上高深的监控系统,一个简单的告警机器人就够了。定时任务检查SyncLogs表,把异常情况推到群里,运维看到再去处理。这套系统的核心是同步,同步断了而不自知是最危险的。
6.3 安全加固
考勤数据是个人敏感信息,上线前要过一遍安全清单:
app_secret和数据库连接串不走配置文件直接提交到代码仓库,用环境变量或配置中心注入。- 管理后台必须走身份认证,建议对接公司已有的SSO,不要单独做一套账号体系。
- 飞书API的token在代码里做了缓存,但绝不能打印到日志里。日志里出现token、secret等敏感信息会带来额外的泄露风险。
- 对外暴露的回调接口做好验签,防止别人伪造事件推送。飞书文档里有验签逻辑,必须实现,不能只靠一个路径保密。
另外,数据库账号建议单独建一个最小权限账号,只给读写业务库的权限,不给DDL权限。避免万一Web应用被攻破,攻击者可以直接删表跑路。
这套系统从启动到稳定运行,我最大的体会是:考勤这类系统,技术难度不高,真正费时间的是业务口径的确认、边界情况的处理和同步可靠性。飞书API文档更新也快,开发时以官方文档为准,不要迷信网上任何人写的教程(包括这篇)。但核心的设计思路和数据流程,只要照着这个架构走,大概率能少走很多弯路。
如果你也正在做类似的事情,我最后再分享一个小技巧:开发阶段先在飞书开放平台创建一个测试企业,把数据源切到测试企业,随便造数据随便调用,等逻辑全部稳定了再切到正式企业。这样做能避免在开发调试阶段就把生产环境的频率限制打满,也能防止误操作把真实考勤数据处理坏了。
