去年给公司做 HR 系统选型,最终选择了飞书考勤。但用了两个月后发现——原生功能再强,也架不住企业那些奇奇怪怪的业务规则。
比如:我们公司的请假审批要过三级(直属领导→部门负责人→HR),但飞书考勤的审批流只支持两级。还有,我们的薪资系统需要实时同步考勤数据做工资计算,但飞书没有开放这种级别的 API 集成。
最后只能自己开发一个中间层,把飞书考勤和内部系统打通。这篇笔记就是这段时间踩坑总结下来的。
如果你也在做类似的事情,这篇文章能帮你避开几个坑。
系统架构设计
整体架构
在动手写代码前,先想清楚系统怎么搭。我们的架构是这样的:

为什么要加中间层?
解耦:内部系统和飞书解耦,飞书 API 变了不用改核心业务代码
数据转换:两边数据结构不一样,中间层负责转换
统一认证:令牌管理、重试、限流这些脏活交给 SDK
灵活扩展:以后要对接其他系统(比如钉钉),加一层适配就行
数据流转

快速上手
飞书开放平台配置
先说重点——权限别漏了。第一次开发时我漏配了 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 AttendanceSystemcd AttendanceSystem# 安装 SDKdotnet 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.csusing 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:员工 IDuser_name:员工姓名department_id:部门 IDdepartment_name:部门名称
考勤组信息:
group_id:考勤组 IDgroup_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);
}
}安全检查清单:
调试技巧
1. 使用飞书开放平台的"调试工具"
先在飞书开放平台的调试工具中测试 API,确认参数和响应格式正确后再写代码。
2. 开启详细日志
// 开启 Mud.Feishu 的 DebugLogbuilder.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;
}
}项目地址
有 Demo 可以参考,有问题可以提 Issue。
如果你也在做类似的项目,希望这篇笔记能帮你少踩几个坑。
有问题欢迎交流,让我进步!