去年给公司做 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 权限,搞了一下午才发现是权限问题。
创建自建应用步骤:
- 登录飞书开放平台(open.feishu.cn/)
- 进入"开发者后台",点击"创建企业自建应用"
- 填写应用名称、描述
- 选择应用类型为"企业自建应用"
获取凭证:
创建应用后,在应用详情页的"凭证与基础信息"中获取:
App ID:应用唯一标识App Secret:应用密钥(记得保密)
必配权限清单:
| 权限点 | 描述 | 必要性 |
|---|---|---|
| attendance:approval | 考勤审批相关权限 | 必需 |
| attendance:leave | 考勤休假相关权限 | 必需 |
| attendance:stats | 考勤统计相关权限 | 必需 |
| attendance:remedy | 考勤补卡相关权限 | 必需 |
| approval:instance | 审批实例相关权限 | 必需 |
| attendance:shift | 考勤班次相关权限 | 可选 |
| attendance:group | 考勤组相关权限 | 可选 |
配置事件订阅(可选):
如果需要实时接收审批状态变更等事件,需要配置事件订阅:
- 在"事件订阅"中配置请求 URL(接收事件的回调地址)
- 选择需要订阅的事件,如
approval_instance_change - 配置加密密钥和验证令牌
事件订阅类型对比:
| 方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| Webhook | 简单、飞书主动推送 | 需要公网 IP | 实时性要求高 |
| WebSocket | 长连接、实时性强 | 需要处理断线重连 | 需要即时响应 |
| 定时轮询 | 实现简单 | 有延迟、浪费资源 | 实时性要求不高 |
项目搭建
创建项目:
# 创建项目
dotnet new webapi -n AttendanceSystem
cd AttendanceSystem
# 安装 SDK
dotnet add package Mud.Feishu
# 如果需要 Redis 缓存
dotnet add package Mud.Feishu.Redis
配置文件:
// appsettings.json
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"Feishu": {
"Apps": [
{
"AppKey": "default",
"AppId": "cli_xxxxxxxxxxxxxxxx",
"AppSecret": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"BaseUrl": "https://open.feishu.cn",
"IsDefault": true,
"TimeOut": 30,
"RetryCount": 3
}
]
}
}
多应用配置示例:
{
"Feishu": {
"Apps": [
{
"AppKey": "default",
"AppId": "cli_xxx",
"AppSecret": "dsk_xxx",
"IsDefault": true
},
{
"AppKey": "hr-app",
"AppId": "cli_yyy",
"AppSecret": "dsk_yyy"
}
]
}
}
服务注册
// Program.cs
using Mud.Feishu;
var builder = WebApplication.CreateBuilder(args);
// 方式1:一行代码注册所有飞书服务(懒人模式)
builder.Services.AddFeishuServices(builder.Configuration);
// 方式2:使用构造者模式,按需注册(推荐)
builder.Services.CreateFeishuServicesBuilder(builder.Configuration)
.AddOrganizationApi() // 组织架构
.AddMessageApi() // 消息服务
.AddApprovalApi() // 审批流程(包含考勤审批)
.AddTaskApi() // 任务管理
.AddCalendarApi() // 日程管理
.Build();
// 方式3:代码配置
builder.Services.CreateFeishuServicesBuilder(options =>
{
options.Apps = new List<FeishuAppConfig>
{
new FeishuAppConfig
{
AppKey = "default",
AppId = "cli_xxx",
AppSecret = "dsk_xxx",
BaseUrl = "https://open.feishu.cn",
TimeOut = 30,
RetryCount = 3,
TokenRefreshThreshold = 300
}
};
})
.AddOrganizationApi()
.AddApprovalApi()
.Build();
// 注册自己的业务服务
builder.Services.AddScoped<IApprovalService, ApprovalService>();
builder.Services.AddScoped<ILeaveService, LeaveService>();
builder.Services.AddScoped<IRemedyService, RemedyService>();
builder.Services.AddScoped<IStatsService, StatsService>();
var app = builder.Build();
// 配置中间件...
app.Run();
服务注册方式对比:
| 方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
AddFeishuServices() | 简单,一行搞定 | 注册了所有服务 | 快速开发、测试环境 |
CreateFeishuServicesBuilder() | 按需注册,更灵活 | 需要指定模块 | 生产环境、性能优化 |
| 代码配置 | 完全可控 | 配置写死在代码里 | 复杂配置需求 |
核心功能一:审批管理
业务场景
飞书考勤支持四种审批类型:
| 类型 | 代码值 | 说明 | 常见字段 |
|---|---|---|---|
| 请假 | leave | 员工因个人原因需要请假 | leave_type(请假类型) |
| 加班 | overtime | 员工因工作需要加班 | overtime_type(加班类型) |
| 外出 | out | 员工因工作需要外出 | - |
| 出差 | business | 员工因工作需要出差 | destination(目的地) |
企业典型场景:
- 内向外写:员工在内部系统发起审批 → 内部系统审批通过 → 写入飞书考勤
- 外向内写:员工在飞书发起审批 → 飞书审批完成 → 同步回内部系统
- 双向同步:两边都可以发起,通过 OutId 关联,确保数据一致
查询审批数据
完整示例:
public class ApprovalService : IApprovalService
{
private readonly IFeishuTenantV1AttendanceApprovals _approvalsClient;
private readonly ILogger<ApprovalService> _logger;
private readonly IFeishuAppManager _appManager;
public ApprovalService(
IFeishuTenantV1AttendanceApprovals approvalsClient,
ILogger<ApprovalService> logger,
IFeishuAppManager appManager)
{
_approvalsClient = approvalsClient;
_logger = logger;
_appManager = appManager;
}
/// <summary>
/// 查询单个员工的审批数据
/// </summary>
public async Task<QueryAttendanceApprovalsResult> GetUserApprovalsAsync(
string userId,
DateTime startTime,
DateTime endTime,
string approvalType = null)
{
var request = new QueryAttendanceApprovalsRequest
{
UserId = userId,
StartTime = startTime.ToString("yyyy-MM-dd HH:mm:ss"),
EndTime = endTime.ToString("yyyy-MM-dd HH:mm:ss"),
Type = approvalType, // leave、overtime、out、business
Limit = 100,
Offset = 0
};
_logger.LogInformation("查询员工 {UserId} 的审批数据", userId);
var result = await _approvalsClient.QueryUserApprovalAsync(request);
if (result?.Code == 0 && result.Data != null)
{
_logger.LogInformation("成功获取审批数据,共 {Count} 条",
result.Data.Items?.Count ?? 0);
return result.Data;
}
_logger.LogError("获取审批数据失败:{Message}", result?.Message ?? "未知错误");
return null;
}
/// <summary>
/// 批量查询多个员工的审批数据(带并发控制)
/// </summary>
public async Task<Dictionary<string, List<ApprovalItem>>> GetBatchUserApprovalsAsync(
List<string> userIds,
DateTime startTime,
DateTime endTime,
int maxConcurrency = 5)
{
var results = new Dictionary<string, List<ApprovalItem>>();
var semaphore = new SemaphoreSlim(maxConcurrency);
var tasks = userIds.Select(async userId =>
{
await semaphore.WaitAsync();
try
{
var approvalData = await GetUserApprovalsAsync(userId, startTime, endTime);
if (approvalData?.Items != null)
{
lock (results)
{
results[userId] = approvalData.Items.ToList();
}
}
}
finally
{
semaphore.Release();
}
});
await Task.WhenAll(tasks);
return results;
}
/// <summary>
/// 分页查询所有审批数据
/// </summary>
public async Task<List<ApprovalItem>> GetAllApprovalsAsync(
string userId,
DateTime startTime,
DateTime endTime,
string approvalType = null)
{
var allItems = new List<ApprovalItem>();
int offset = 0;
int limit = 100;
bool hasMore = true;
while (hasMore)
{
var request = new QueryAttendanceApprovalsRequest
{
UserId = userId,
StartTime = startTime.ToString("yyyy-MM-dd HH:mm:ss"),
EndTime = endTime.ToString("yyyy-MM-dd HH:mm:ss"),
Type = approvalType,
Limit = limit,
Offset = offset
};
var result = await _approvalsClient.QueryUserApprovalAsync(request);
if (result?.Code == 0 && result.Data?.Items != null)
{
allItems.AddRange(result.Data.Items);
hasMore = result.Data.Items.Count >= limit;
offset += limit;
}
else
{
hasMore = false;
}
// 避免触发限流
if (hasMore)
{
await Task.Delay(100);
}
}
return allItems;
}
}
写入审批数据
完整示例:
/// <summary>
/// 创建审批数据,将内部系统的审批结果写入飞书考勤
/// </summary>
public async Task<CreateUserApprovalResult> CreateUserApprovalAsync(
InternalApprovalRequest internalRequest)
{
// 转换内部审批请求为飞书审批请求
var request = MapToFeishuRequest(internalRequest);
_logger.LogInformation("创建审批数据,员工ID:{UserId},类型:{Type}",
request.UserId, request.Type);
var result = await _approvalsClient.CreateUserApprovalAsync(request);
if (result?.Code == 0 && result.Data != null)
{
_logger.LogInformation("成功创建审批数据,审批ID:{ApprovalId}",
result.Data.ApprovalId);
// 保存 OutId 映射关系,方便后续查询和更新
await SaveApprovalMappingAsync(
internalRequest.InternalId,
result.Data.ApprovalId,
result.Data.OutId);
return result.Data;
}
_logger.LogError("创建审批数据失败:{Message}", result?.Message ?? "未知错误");
throw new FeishuApiException($"创建审批数据失败:{result?.Message}");
}
/// <summary>
/// 构建请假审批请求
/// </summary>
public CreateUserApprovalRequest BuildLeaveRequest(
string userId,
string leaveType,
DateTime startTime,
DateTime endTime,
double duration,
string reason,
string internalId = null)
{
return new CreateUserApprovalRequest
{
UserId = userId,
Type = "leave", // 请假类型
StartTime = startTime.ToString("yyyy-MM-dd HH:mm:ss"),
EndTime = endTime.ToString("yyyy-MM-dd HH:mm:ss"),
Duration = duration,
LeaveType = leaveType,
Reason = reason,
OutId = internalId ?? Guid.NewGuid().ToString() // 外部系统唯一标识
};
}
/// <summary>
/// 构建加班审批请求
/// </summary>
public CreateUserApprovalRequest BuildOvertimeRequest(
string userId,
string overtimeType,
DateTime startTime,
DateTime endTime,
double duration,
string reason,
string internalId = null)
{
return new CreateUserApprovalRequest
{
UserId = userId,
Type = "overtime",
StartTime = startTime.ToString("yyyy-MM-dd HH:mm:ss"),
EndTime = endTime.ToString("yyyy-MM-dd HH:mm:ss"),
Duration = duration,
OvertimeType = overtimeType,
Reason = reason,
OutId = internalId ?? Guid.NewGuid().ToString()
};
}
/// <summary>
/// 内部审批请求映射到飞书审批请求
/// </summary>
private CreateUserApprovalRequest MapToFeishuRequest(InternalApprovalRequest internal)
{
return internal.ApprovalType switch
{
"leave" => BuildLeaveRequest(
internal.UserId,
internal.LeaveType,
internal.StartTime,
internal.EndTime,
internal.Duration,
internal.Reason,
internal.InternalId
),
"overtime" => BuildOvertimeRequest(
internal.UserId,
internal.OvertimeType,
internal.StartTime,
internal.EndTime,
internal.Duration,
internal.Reason,
internal.InternalId
),
"out" => BuildOutRequest(
internal.UserId,
internal.StartTime,
internal.EndTime,
internal.Reason,
internal.InternalId
),
"business" => BuildBusinessRequest(
internal.UserId,
internal.StartTime,
internal.EndTime,
internal.Destination,
internal.Reason,
internal.InternalId
),
_ => throw new NotSupportedException($"不支持的审批类型:{internal.ApprovalType}")
};
}
OutId 的作用:
OutId 是外部系统的唯一标识,非常重要:
- 关联查询:可以通过 OutId 找到内部系统的审批记录
- 防止重复:同一笔审批多次写入时,可以通过 OutId 判断是否已存在
- 状态同步:飞书审批状态变更时,通过 OutId 找到内部记录进行更新
// 保存 OutId 映射
await SaveApprovalMappingAsync(internalId, feishuApprovalId, outId);
// 根据 OutId 查询内部审批
var internalApproval = await GetInternalApprovalByOutId(outId);
// 根据 OutId 更新内部审批状态
await UpdateInternalApprovalStatusAsync(outId, newStatus);
更新审批状态
完整示例:
/// <summary>
/// 更新审批状态
/// </summary>
public async Task<UpdateAttendanceApprovalInfoResult> UpdateApprovalStatusAsync(
string approvalId,
ApprovalStatus status,
string outId = null)
{
var request = new UpdateApprovalInfosRequest
{
ApprovalInfos = new List<ApprovalInfo>
{
new ApprovalInfo
{
ApprovalId = approvalId,
Status = (int)status, // 1=通过,2=不通过,3=撤销
OutId = outId
}
}
};
_logger.LogInformation("更新审批状态,审批ID:{ApprovalId},状态:{Status}",
approvalId, status);
var result = await _approvalsClient.ProcessApprovalInfoAsync(request);
if (result?.Code == 0 && result.Data != null)
{
_logger.LogInformation("成功更新审批状态");
return result.Data;
}
_logger.LogError("更新审批状态失败:{Message}", result?.Message ?? "未知错误");
throw new FeishuApiException($"更新审批状态失败:{result?.Message}");
}
/// <summary>
/// 批量更新审批状态
/// </summary>
public async Task<UpdateAttendanceApprovalInfoResult> BatchUpdateApprovalStatusAsync(
List<ApprovalUpdateRequest> updates)
{
var approvalInfos = updates.Select(u => new ApprovalInfo
{
ApprovalId = u.ApprovalId,
Status = (int)u.Status,
OutId = u.OutId
}).ToList();
var request = new UpdateApprovalInfosRequest
{
ApprovalInfos = approvalInfos
};
_logger.LogInformation("批量更新审批状态,共 {Count} 条", updates.Count);
var result = await _approvalsClient.ProcessApprovalInfoAsync(request);
if (result?.Code == 0 && result.Data != null)
{
_logger.LogInformation("成功批量更新审批状态");
return result.Data;
}
_logger.LogError("批量更新审批状态失败:{Message}", result?.Message ?? "未知错误");
throw new FeishuApiException($"批量更新审批状态失败:{result?.Message}");
}
/// <summary>
/// 根据内部审批ID更新飞书审批状态
/// </summary>
public async Task UpdateApprovalByInternalIdAsync(
string internalId,
ApprovalStatus status)
{
// 先根据内部ID查找飞书审批信息
var mapping = await GetApprovalMappingAsync(internalId);
if (mapping == null)
{
_logger.LogWarning("未找到内部审批 {InternalId} 对应的飞书审批", internalId);
return;
}
// 更新飞书审批状态
await UpdateApprovalStatusAsync(mapping.ApprovalId, status, mapping.OutId);
// 更新映射记录
await UpdateApprovalMappingStatusAsync(internalId, status);
}
审批状态枚举:
public enum ApprovalStatus
{
Approved = 1, // 通过
Rejected = 2, // 不通过
Revoked = 3 // 撤销
}
事件订阅处理
Webhook 示例:
// 如果使用 Webhook,需要在控制器中处理回调
[HttpPost("api/webhook/feishu")]
[Route("api/webhook/feishu")]
public async Task<IActionResult> HandleFeishuWebhook([FromBody] WebhookEvent webhookEvent)
{
try
{
// 验证签名
if (!ValidateWebhookSignature(webhookEvent))
{
_logger.LogWarning("Webhook 签名验证失败");
return Unauthorized();
}
// 解密事件数据(如果需要)
var eventData = DecryptEventData(webhookEvent);
// 根据事件类型分发处理
await _eventDispatcher.DispatchAsync(eventData);
return Ok(new { code = 0, msg = "success" });
}
catch (Exception ex)
{
_logger.LogError(ex, "处理 Webhook 事件失败");
return StatusCode(500, new { code = -1, msg = "internal error" });
}
}
WebSocket 示例:
// 如果使用 WebSocket, Mud.Feishu 提供了完整的支持
// 注册 WebSocket 服务
builder.Services.AddFeishuWebSocketBuilder()
.ConfigureFrom(builder.Configuration)
.UseMultiHandler()
.AddHandler<ApprovalInstanceChangeEventHandler>()
.AddHandler<ApprovalApprovedEventHandler>()
.AddHandler<ApprovalRejectedEventHandler>()
.Build();
// 审批实例变更事件处理器
public class ApprovalInstanceChangeEventHandler : IFeishuEventHandler
{
private readonly IApprovalService _approvalService;
private readonly ILogger<ApprovalInstanceChangeEventHandler> _logger;
public ApprovalInstanceChangeEventHandler(
IApprovalService approvalService,
ILogger<ApprovalInstanceChangeEventHandler> logger)
{
_approvalService = approvalService;
_logger = logger;
}
public string SupportedEventType => FeishuEventTypes.ApprovalInstanceV1;
public async Task HandleAsync(EventData eventData, CancellationToken cancellationToken = default)
{
_logger.LogInformation("收到审批实例变更事件:{EventId}", eventData.EventId);
try
{
// 解析事件数据
var approvalEvent = JsonSerializer.Deserialize<ApprovalInstanceEvent>(
eventData.Event?.ToString() ?? "{}");
if (approvalEvent?.ApprovalId == null)
{
_logger.LogWarning("审批ID为空,跳过处理");
return;
}
// 根据审批ID获取详情
var approvalDetail = await _approvalService.GetApprovalDetailAsync(
approvalEvent.ApprovalId);
if (approvalDetail?.OutId == null)
{
_logger.LogWarning("OutId为空,无法同步到内部系统");
return;
}
// 同步到内部系统
await _approvalService.SyncApprovalToInternalAsync(
approvalDetail.OutId,
approvalDetail.Status);
_logger.LogInformation("成功同步审批到内部系统");
}
catch (Exception ex)
{
_logger.LogError(ex, "处理审批实例变更事件失败");
throw;
}
}
}
实战建议
1. 使用事件订阅,不要定时轮询
// ❌ 错误:定时轮询
while (true)
{
var approvals = await GetPendingApprovalsAsync();
foreach (var approval in approvals)
{
await SyncApprovalStatusAsync(approval);
}
await Task.Delay(60000); // 每分钟轮询一次
}
// ✅ 正确:使用事件订阅
// Webhook 或 WebSocket 自动推送,实时处理
2. 做好幂等处理
// 同一个审批可能收到多次事件,需要做幂等
public async Task HandleApprovalEventAsync(EventData eventData)
{
// 检查事件是否已处理
if (await IsEventProcessedAsync(eventData.EventId))
{
_logger.LogInformation("事件 {EventId} 已处理,跳过", eventData.EventId);
return;
}
// 处理业务逻辑
await ProcessApprovalAsync(eventData);
// 标记事件已处理
await MarkEventProcessedAsync(eventData.EventId);
}
3. 数据一致性保障
// 本地系统和飞书系统要设计好同步机制
public async Task SyncApprovalAsync(string internalId)
{
// 获取本地审批状态
var localApproval = await GetLocalApprovalAsync(internalId);
// 获取飞书审批状态
var feishuApproval = await GetFeishuApprovalAsync(localApproval.OutId);
// 比较状态,不一致则同步
if (localApproval.Status != feishuApproval.Status)
{
await UpdateLocalApprovalStatusAsync(internalId, feishuApproval.Status);
}
}
4. 错误处理和重试
// 使用 Mud.Feishu 内置的重试机制,或者自己实现
public async Task<CreateUserApprovalResult> CreateUserApprovalWithRetryAsync(
CreateUserApprovalRequest request,
int maxRetries = 3)
{
int retryCount = 0;
while (retryCount < maxRetries)
{
try
{
return await _approvalsClient.CreateUserApprovalAsync(request);
}
catch (FeishuApiException ex) when (ex.ErrorCode == 429) // 限流
{
retryCount++;
_logger.LogWarning("触发限流,{RetryCount}/{MaxRetries},等待后重试",
retryCount, maxRetries);
await Task.Delay(1000 * retryCount); // 指数退避
}
catch (Exception ex)
{
_logger.LogError(ex, "创建审批失败");
throw;
}
}
throw new FeishuApiException("达到最大重试次数,创建审批失败");
}
核心功能二:休假管理
业务场景
休假管理主要涉及:
- 假期类型管理:年假、病假、事假、调休等
- 假期发放记录:每年年初发放年假、入职时发放年假等
- 假期余额查询:员工查看还有多少天假期可用
- 假期余额调整:HR 手动调整(比如补偿假期)
查询假期类型
public class LeaveService : ILeaveService
{
private readonly IFeishuV1AttendanceLeave_Tenant _leaveClient;
private readonly IFeishuTenantV1AttendanceGroups _groupsClient;
private readonly ILogger<LeaveService> _logger;
public async Task<List<LeaveType>> GetLeaveTypesAsync()
{
// 通过考勤组查询假期类型配置
var groupsResult = await _groupsClient.GetGroupAsync(new GetGroupRequest
{
GroupId = "default"
});
if (groupsResult?.Code == 0 && groupsResult.Data != null)
{
return groupsResult.Data.LeaveTypes ?? new List<LeaveType>();
}
return new List<LeaveType>();
}
}
查询发放记录
完整示例:
/// <summary>
/// 查询员工的假期发放记录
/// </summary>
public async Task<LeaveBalance> GetLeaveBalanceAsync(
string userId,
string leaveId)
{
var now = DateTime.Now;
var request = new LeaveEmployExpireRecordsRequest
{
StartTime = new DateTime(now.Year, 1, 1).ToString("yyyy-MM-dd"),
EndTime = new DateTime(now.Year, 12, 31).ToString("yyyy-MM-dd"),
UserIds = new List<string> { userId },
Limit = 100,
Offset = 0
};
var result = await _leaveClient.GetLeaveEmployExpireRecordAsync(request, leaveId);
if (result?.Code == 0 && result.Data?.Items != null)
{
// 计算可用天数
var totalGranted = result.Data.Items.Sum(x => x.Quota);
var totalUsed = result.Data.Items.Sum(x => x.Used);
var available = totalGranted - totalUsed;
return new LeaveBalance
{
UserId = userId,
LeaveId = leaveId,
TotalGranted = totalGranted,
TotalUsed = totalUsed,
Available = available,
Records = result.Data.Items.ToList()
};
}
return new LeaveBalance
{
UserId = userId,
LeaveId = leaveId,
TotalGranted = 0,
TotalUsed = 0,
Available = 0,
Records = new List<LeaveEmployExpireRecord>()
};
}
/// <summary>
/// 查询即将过期的假期
/// </summary>
public async Task<List<LeaveEmployExpireRecord>> GetExpiringLeavesAsync(
string userId,
int daysBeforeExpire = 30)
{
var now = DateTime.Now;
var expireDate = now.AddDays(daysBeforeExpire);
var request = new LeaveEmployExpireRecordsRequest
{
StartTime = now.ToString("yyyy-MM-dd"),
EndTime = expireDate.ToString("yyyy-MM-dd"),
UserIds = new List<string> { userId },
Limit = 100,
Offset = 0
};
var allRecords = new List<LeaveEmployExpireRecord>();
// 遍历所有假期类型
var leaveTypes = await GetLeaveTypesAsync();
foreach (var leaveType in leaveTypes)
{
var result = await _leaveClient.GetLeaveEmployExpireRecordAsync(
request, leaveType.LeaveId);
if (result?.Code == 0 && result.Data?.Items != null)
{
allRecords.AddRange(result.Data.Items);
}
}
return allRecords;
}
更新发放记录
完整示例:
/// <summary>
/// 手动调整员工假期余额
/// </summary>
public async Task<LeaveAccrualRecordResult> AdjustLeaveBalanceAsync(
string userId,
string leaveId,
double adjustmentAmount,
string reason,
string operatorId)
{
// 先获取当前发放记录
var currentRecords = await GetCurrentLeaveRecordsAsync(userId, leaveId);
if (currentRecords.Count == 0)
{
// 如果没有发放记录,创建新的
var createRequest = new LeaveAccrualRecordRequest
{
UserId = userId,
LeaveId = leaveId,
Quota = adjustmentAmount,
ExpireDate = DateTime.Now.AddYears(1).ToString("yyyy-MM-dd"),
Remark = $"手动调整:{reason},操作人:{operatorId}"
};
return await _leaveClient.CreateLeaveAccrualRecordAsync(createRequest, leaveId);
}
else
{
// 更新现有记录
var latestRecord = currentRecords.OrderByDescending(x => x.CreateTime).First();
var newQuota = latestRecord.Quota + adjustmentAmount;
if (newQuota < 0)
{
throw new InvalidOperationException("调整后的假期余额不能为负数");
}
var updateRequest = new LeaveAccrualRecordRequest
{
UserId = userId,
LeaveId = leaveId,
RecordId = latestRecord.RecordId,
Quota = newQuota,
Remark = $"手动调整:{reason},操作人:{operatorId},原始余额:{latestRecord.Quota},调整:{adjustmentAmount},新余额:{newQuota}"
};
return await _leaveClient.ModifyLeaveAccrualRecordAsync(updateRequest, leaveId);
}
}
/// <summary>
/// 年初批量发放年假
/// </summary>
public async Task BatchGrantAnnualLeaveAsync(
List<string> userIds,
string leaveId,
int annualDays,
string operatorId)
{
var successCount = 0;
var failCount = 0;
var errors = new List<string>();
foreach (var userId in userIds)
{
try
{
await AdjustLeaveBalanceAsync(
userId,
leaveId,
annualDays,
$"年初发放{annualDays}天年假",
operatorId);
successCount++;
_logger.LogInformation("成功为用户 {UserId} 发放年假", userId);
}
catch (Exception ex)
{
failCount++;
errors.Add($"用户 {UserId} 发放失败:{ex.Message}");
_logger.LogError(ex, "为用户 {UserId} 发放年假失败", userId);
}
// 避免触发限流
await Task.Delay(200);
}
// 记录操作日志
await LogBatchOperationAsync(
"批量发放年假",
$"成功:{successCount},失败:{failCount}",
errors);
}
休假计算注意事项
1. 跨年处理
// 年假是否跨年取决于企业政策
public async Task<List<LeaveBalance>> GetLeaveBalanceWithYearAsync(
string userId,
string leaveId)
{
var now = DateTime.Now;
var results = new List<LeaveBalance>();
// 当前年度
var currentYearBalance = await GetLeaveBalanceAsync(
userId,
leaveId,
now.Year);
// 上一年度(如果政策允许跨年)
var lastYearBalance = await GetLeaveBalanceAsync(
userId,
leaveId,
now.Year - 1);
results.Add(currentYearBalance);
results.Add(lastYearBalance);
return results;
}
2. 休假类型计算规则
// 不同休假类型有不同的计算规则
public class LeaveCalculator
{
/// <summary>
/// 计算请假天数
/// </summary>
public double CalculateLeaveDays(
DateTime startTime,
DateTime endTime,
string leaveType)
{
return leaveType switch
{
"annual" => CalculateAnnualLeaveDays(startTime, endTime),
"sick" => CalculateSickLeaveDays(startTime, endTime),
"personal" => CalculatePersonalLeaveDays(startTime, endTime),
"maternity" => CalculateMaternityLeaveDays(startTime, endTime),
_ => CalculateDefaultLeaveDays(startTime, endTime)
};
}
/// <summary>
/// 年假计算:只计算工作日
/// </summary>
private double CalculateAnnualLeaveDays(DateTime startTime, DateTime endTime)
{
var workDays = 0;
var current = startTime.Date;
while (current <= endTime.Date)
{
if (IsWorkDay(current))
{
workDays++;
}
current = current.AddDays(1);
}
// 按小时计算
return workDays * 8;
}
/// <summary>
/// 病假计算:包括节假日
/// </summary>
private double CalculateSickLeaveDays(DateTime startTime, DateTime endTime)
{
var totalHours = (endTime - startTime).TotalHours;
return totalHours;
}
private bool IsWorkDay(DateTime date)
{
// 判断是否为工作日
return date.DayOfWeek != DayOfWeek.Saturday &&
date.DayOfWeek != DayOfWeek.Sunday &&
!IsHoliday(date);
}
}
核心功能三:补卡管理
业务场景
补卡管理的典型场景:
- 员工忘记打卡:早上忘记打上班卡,需要补卡
- 设备故障:打卡机故障导致无法打卡
- 外出办公:外出办公无法打卡
- 批量处理:需要批量审批补卡申请
创建补卡审批
完整示例:
public class RemedyService : IRemedyService
{
private readonly IFeishuTenantV1AttendanceRemedys _remedyClient;
private readonly IFeishuTenantV1AttendanceApprovals _approvalsClient;
private readonly ILogger<RemedyService> _logger;
public RemedyService(
IFeishuTenantV1AttendanceRemedys remedyClient,
IFeishuTenantV1AttendanceApprovals approvalsClient,
ILogger<RemedyService> logger)
{
_remedyClient = remedyClient;
_approvalsClient = approvalsClient;
_logger = logger;
}
/// <summary>
/// 创建补卡审批
/// </summary>
public async Task<AttendanceRemedysResult> CreateRemedyAsync(
RemedyRequest internalRequest)
{
// 先查询员工当天可以补的打卡时间
var allowedTimes = await GetAllowedRemedyTimesAsync(
internalRequest.UserId,
internalRequest.Date,
internalRequest.Type);
if (allowedTimes?.AllowedTimes == null || allowedTimes.AllowedTimes.Count == 0)
{
throw new InvalidOperationException("当天无可补卡时间");
}
// 验证补卡时间是否在允许范围内
var remedyTime = DateTime.Parse(internalRequest.Time);
var timeRange = allowedTimes.AllowedTimes.FirstOrDefault();
if (timeRange != null &&
(remedyTime < timeRange.EarliestTime || remedyTime > timeRange.LatestTime))
{
throw new InvalidOperationException(
$"补卡时间不在允许范围内:{timeRange.EarliestTime:HH:mm:ss} - {timeRange.LatestTime:HH:mm:ss}");
}
// 构建补卡请求
var request = new AttendanceRemedysRequest
{
UserId = internalRequest.UserId,
Date = internalRequest.Date.ToString("yyyy-MM-dd"),
Time = internalRequest.Time,
Type = internalRequest.Type, // 1=上班,2=下班
Reason = internalRequest.Reason,
OutId = internalRequest.InternalId ?? Guid.NewGuid().ToString()
};
_logger.LogInformation("创建补卡审批,员工ID:{UserId},日期:{Date},时间:{Time}",
request.UserId, request.Date, request.Time);
var result = await _remedyClient.CreateUserTaskRemedyAsync(request);
if (result?.Code == 0 && result.Data != null)
{
_logger.LogInformation("成功创建补卡审批,任务ID:{TaskId}", result.Data.TaskId);
return result.Data;
}
_logger.LogError("创建补卡审批失败:{Message}", result?.Message ?? "未知错误");
throw new FeishuApiException($"创建补卡审批失败:{result?.Message}");
}
/// <summary>
/// 构建补卡请求
/// </summary>
public AttendanceRemedysRequest BuildRemedyRequest(
string userId,
DateTime date,
DateTime time,
int type,
string reason,
string outId = null)
{
return new AttendanceRemedysRequest
{
UserId = userId,
Date = date.ToString("yyyy-MM-dd"),
Time = time.ToString("HH:mm:ss"),
Type = type, // 1=上班,2=下班
Reason = reason,
OutId = outId ?? Guid.NewGuid().ToString()
};
}
}
查询可补卡时间
/// <summary>
/// 查询用户某天可以补的第几次上/下班卡的时间
/// </summary>
public async Task<QueryUserAllowedRemedysResult> GetAllowedRemedyTimesAsync(
string userId,
DateTime date,
int type)
{
var request = new AllowedRemedysRequest
{
UserId = userId,
Date = date.ToString("yyyy-MM-dd"),
Type = type // 1=上班,2=下班
};
_logger.LogInformation("查询用户 {UserId} 在 {Date} 的可补卡时间,类型:{Type}",
userId, date, type);
var result = await _remedyClient.QueryUserAllowedRemedysUserTaskRemedyAsync(request);
if (result?.Code == 0 && result.Data != null)
{
_logger.LogInformation("成功查询可补卡时间,共 {Count} 个时间段",
result.Data.AllowedTimes?.Count ?? 0);
return result.Data;
}
_logger.LogError("查询可补卡时间失败:{Message}", result?.Message ?? "未知错误");
return null;
}
/// <summary>
/// 验证补卡时间是否有效
/// </summary>
public async Task<bool> ValidateRemedyTimeAsync(
string userId,
DateTime date,
DateTime time,
int type)
{
var allowedTimes = await GetAllowedRemedyTimesAsync(userId, date, type);
if (allowedTimes?.AllowedTimes == null || allowedTimes.AllowedTimes.Count == 0)
{
return false;
}
var timeOfDay = time.TimeOfDay;
return allowedTimes.AllowedTimes.Any(t =>
timeOfDay >= t.EarliestTime.TimeOfDay &&
timeOfDay <= t.LatestTime.TimeOfDay);
}
查询补卡记录
完整示例:
/// <summary>
/// 获取用户的补卡记录
/// </summary>
public async Task<QueryUserRemedysResult> GetRemedyRecordsAsync(
string userId,
DateTime startDate,
DateTime endDate,
int? status = null,
int limit = 100,
int offset = 0)
{
var request = new QueryUserRemedysRequest
{
UserId = userId,
StartDate = startDate.ToString("yyyy-MM-dd"),
EndDate = endDate.ToString("yyyy-MM-dd"),
Status = status, // 1=审批中,2=通过,3=拒绝
Limit = limit,
Offset = offset
};
_logger.LogInformation("获取用户 {UserId} 的补卡记录,时间范围:{StartDate} 至 {EndDate}",
userId, startDate, endDate);
var result = await _remedyClient.QueryUserTaskRemedyAsync(request);
if (result?.Code == 0 && result.Data != null)
{
_logger.LogInformation("成功获取补卡记录,共 {Count} 条", result.Data.Items?.Count ?? 0);
return result.Data;
}
_logger.LogError("获取补卡记录失败:{Message}", result?.Message ?? "未知错误");
return null;
}
/// <summary>
/// 批量获取多个员工的补卡记录
/// </summary>
public async Task<Dictionary<string, List<RemedyRecord>>> GetBatchRemedyRecordsAsync(
List<string> userIds,
DateTime startDate,
DateTime endDate)
{
var results = new Dictionary<string, List<RemedyRecord>>();
foreach (var userId in userIds)
{
var remedyData = await GetRemedyRecordsAsync(userId, startDate, endDate);
if (remedyData?.Items != null)
{
results[userId] = remedyData.Items.Select(x => new RemedyRecord
{
TaskId = x.TaskId,
UserId = x.UserId,
Date = x.Date,
Time = x.Time,
Type = x.Type,
Status = x.Status,
Reason = x.Reason,
OutId = x.OutId
}).ToList();
}
// 避免触发限流
await Task.Delay(100);
}
return results;
}
/// <summary>
/// 获取员工的补卡统计
/// </summary>
public async Task<RemedyStatistics> GetRemedyStatisticsAsync(
string userId,
DateTime startDate,
DateTime endDate)
{
var allRecords = await GetRemedyRecordsAsync(userId, startDate, endDate);
if (allRecords?.Items == null)
{
return new RemedyStatistics();
}
return new RemedyStatistics
{
TotalCount = allRecords.Items.Count,
ApprovedCount = allRecords.Items.Count(x => x.Status == 2),
RejectedCount = allRecords.Items.Count(x => x.Status == 3),
PendingCount = allRecords.Items.Count(x => x.Status == 1),
CheckInCount = allRecords.Items.Count(x => x.Type == 1),
CheckOutCount = allRecords.Items.Count(x => x.Type == 2)
};
}
补卡审批流程
完整流程:
/// <summary>
/// 补卡审批完整流程
/// </summary>
public async Task ProcessRemedyApprovalAsync(string internalRemedyId)
{
// 1. 获取内部补卡申请
var internalRemedy = await GetInternalRemedyAsync(internalRemedyId);
if (internalRemedy == null)
{
throw new NotFoundException($"未找到补卡申请:{internalRemedyId}");
}
// 2. 创建飞书补卡审批
var feishuRemedy = await CreateRemedyAsync(new RemedyRequest
{
UserId = internalRemedy.UserId,
Date = internalRemedy.Date,
Time = internalRemedy.Time,
Type = internalRemedy.Type,
Reason = internalRemedy.Reason,
InternalId = internalRemedyId
});
// 3. 保存映射关系
await SaveRemedyMappingAsync(internalRemedyId, feishuRemedy.TaskId, feishuRemedy.OutId);
// 4. 等待飞书审批结果(通过事件订阅)
// 事件处理器会监听审批状态变更并更新内部记录
}
/// <summary>
/// 审批通过后的处理
/// </summary>
public async Task HandleRemedyApprovedAsync(string taskId)
{
// 获取补卡记录
var remedyRecord = await GetRemedyRecordAsync(taskId);
// 获取映射的内部记录
var internalRemedy = await GetInternalRemedyByOutIdAsync(remedyRecord.OutId);
if (internalRemedy != null)
{
// 更新内部审批状态
await UpdateInternalRemedyStatusAsync(internalRemedy.Id, RemedyStatus.Approved);
// 发送通知
await SendNotificationAsync(internalRemedy.UserId, "补卡申请已通过");
}
}
补卡规则配置
/// <summary>
/// 补卡规则配置
/// </summary>
public class RemedyRuleService
{
/// <summary>
/// 检查补卡申请是否符合规则
/// </summary>
public async Task<RemedyRuleCheckResult> CheckRemedyRuleAsync(
string userId,
DateTime date,
int type)
{
var rules = await GetRemedyRulesAsync(userId);
// 检查补卡次数限制
var currentMonthRemedyCount = await GetMonthRemedyCountAsync(userId, date);
if (currentMonthRemedyCount >= rules.MaxMonthlyRemedyCount)
{
return new RemedyRuleCheckResult
{
IsAllowed = false,
Reason = $"本月补卡次数已达上限({rules.MaxMonthlyRemedyCount}次)"
};
}
// 检查补卡时间限制
var isWithinAllowedTime = await IsWithinAllowedTimeAsync(userId, date, type);
if (!isWithinAllowedTime)
{
return new RemedyRuleCheckResult
{
IsAllowed = false,
Reason = "补卡时间不在允许范围内"
};
}
// 检查是否需要审批
if (rules.RequireApproval)
{
return new RemedyRuleCheckResult
{
IsAllowed = true,
RequireApproval = true
};
}
return new RemedyRuleCheckResult
{
IsAllowed = true,
RequireApproval = false
};
}
}
核心功能四:考勤统计
统计字段说明
飞书考勤统计支持丰富的字段,按类别分为:
基本信息:
user_id:员工 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);
}
}
安全检查清单:
- 敏感字段加密存储(身份证、手机号等)
- HTTP 传输使用 HTTPS
- 接口访问权限控制(基于角色的访问控制 RBAC)
- 操作日志记录(记录谁在什么时候做了什么)
- 定期安全审计
- 防止 SQL 注入、XSS 等常见攻击
调试技巧
1. 使用飞书开放平台的"调试工具"
先在飞书开放平台的调试工具中测试 API,确认参数和响应格式正确后再写代码。
2. 开启详细日志
// 开启 Mud.Feishu 的 DebugLog
builder.Services.CreateFeishuServicesBuilder(options =>
{
options.AppId = builder.Configuration["Feishu:AppId"];
options.AppSecret = builder.Configuration["Feishu:AppSecret"];
options.EnableDebugLog = true; // 开启调试日志
})
.AddApprovalApi()
.Build();
// 日志配置
{
"Logging": {
"LogLevel": {
"Mud.Feishu": "Debug", // 开启 SDK 调试日志
"Default": "Information"
}
}
}
3. 详细记录请求参数和响应结果
public async Task<FeishuApiResult<T>> CallFeishuApiAsync<T>(string apiName, object request)
{
var requestId = Guid.NewGuid().ToString();
_logger.LogInformation("[{RequestId}] 调用飞书 API:{ApiName}", requestId, apiName);
_logger.LogDebug("[{RequestId}] 请求参数:{Request}", requestId,
JsonSerializer.Serialize(request));
try
{
var result = await _feishuApi.CallAsync<T>(request);
_logger.LogInformation("[{RequestId}] API 调用成功,Code:{Code}", requestId, result?.Code);
_logger.LogDebug("[{RequestId}] 响应结果:{Response}", requestId,
JsonSerializer.Serialize(result));
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "[{RequestId}] API 调用失败", requestId);
throw;
}
}
项目地址
有 Demo 可以参考,有问题可以提 Issue。
如果你也在做类似的项目,希望这篇笔记能帮你少踩几个坑。
有问题欢迎交流,让我进步!