首先,**需要登录钉钉开放平台。同时需要所在企业的管理员权限。**这是前期准备工作。如下:
登录完成后,需要自建应用:
然后你就能获取到appkey 和sercet:
通过权限管理,去申请相关的权限:
这里要用到很多权限:比如读取部门,读取用户,读取考勤数据的权限 ,这个你可以在后面参照我的代码请求,根据代码报错,去开通对应权限即可。
前期准备工作完毕。
以下为正文部分:
1.获取钉钉的token:
#region === 获取 AccessToken(缓存+线程安全) ===
/// <summary>
/// 新版token
/// </summary>
/// <returns></returns>
/// <exception cref="Exception"></exception>
public async Task<string> GetAccessTokenAsync()
{
// 有缓存且未过期
if (!string.IsNullOrEmpty(_accessToken) && DateTime.Now < _expireTime)
return _accessToken;
lock (_lock)
{
if (!string.IsNullOrEmpty(_accessToken) && DateTime.Now < _expireTime)
return _accessToken;
}
string url = "https://api.dingtalk.com/v1.0/oauth2/accessToken";
var payload = new { appKey = _appKey, appSecret = _appSecret };
var content = new StringContent(JsonConvert.SerializeObject(payload), Encoding.UTF8, "application/json");
var response = await _client.PostAsync(url, content);
response.EnsureSuccessStatusCode();
string json = await response.Content.ReadAsStringAsync();
var obj = JObject.Parse(json);
if (obj["accessToken"] == null)
throw new Exception($"获取AccessToken失败: {json}");
string token = obj["accessToken"].ToString();
int expiresIn = obj["expireIn"].ToObject<int>();
lock (_lock)
{
_accessToken = token;
_expireTime = DateTime.Now.AddSeconds(expiresIn - 60);
}
return _accessToken;
}
/// <summary>
/// 旧版token
/// </summary>
/// <returns></returns>
/// <exception cref="Exception"></exception>
public async Task<string> GetLastAccessTokenAsync()
{
// 若缓存未过期
if (!string.IsNullOrEmpty(_accessToken) && DateTime.Now < _expireTime)
return _accessToken;
lock (_lock)
{
if (!string.IsNullOrEmpty(_accessToken) && DateTime.Now < _expireTime)
return _accessToken;
}
string url = $"https://oapi.dingtalk.com/gettoken?appkey={_appKey}&appsecret={_appSecret}";
var response = await _client.GetAsync(url);
response.EnsureSuccessStatusCode();
string json = await response.Content.ReadAsStringAsync();
var obj = JObject.Parse(json);
if (obj["access_token"] == null)
throw new Exception($"获取AccessToken失败: {json}");
string token = obj["access_token"].ToString();
int expiresIn = obj["expires_in"]?.ToObject<int>() ?? 7200;
lock (_lock)
{
_accessToken = token;
_expireTime = DateTime.Now.AddSeconds(expiresIn - 60);
}
return _accessToken;
}
#endregion
因为钉钉有拦截策略,频繁请求会被拦截策略拦截。因此,我写了缓存。
2.获取钉钉组织架构下的部门:
#region === 获取所有部门 ===
/// <summary>
/// 获取企业所有部门ID(递归)
/// </summary>
public async Task<List<long>> GetAllDepartmentIdsAsync(long parentDeptId = 1)
{
var deptIds = new List<long> { parentDeptId };
string token = await GetAccessTokenAsync();
try
{
string url = $"https://oapi.dingtalk.com/topapi/v2/department/listsub?access_token={token}";
var payload = new { dept_id = parentDeptId };
var content = new StringContent(JsonConvert.SerializeObject(payload), Encoding.UTF8, "application/json");
var response = await _client.PostAsync(url, content);
response.EnsureSuccessStatusCode();
string json = await response.Content.ReadAsStringAsync();
var obj = JObject.Parse(json);
if ((int)obj["errcode"] != 0)
throw new Exception($"获取部门列表失败: {obj["errmsg"]}");
var deptList = JsonConvert.DeserializeObject<List<DingDepartment>>(obj["result"].ToString());
foreach (var dept in deptList)
{
deptIds.Add(dept.DeptId);
// 递归调用获取子部门
try
{
var subDepts = await GetAllDepartmentIdsAsync(dept.DeptId);
deptIds.AddRange(subDepts);
}
catch (Exception ex)
{
Console.WriteLine($"子部门 {dept.Name}(ID: {dept.DeptId})无访问权限,跳过。{ex.Message}");
}
}
}
catch (Exception ex)
{
Console.WriteLine($" 获取部门 {parentDeptId} 下级失败: {ex.Message}");
}
return deptIds.Distinct().ToList();
}
#endregion
3.获取全部人员:
#region === 获取部门下所有员工ID ===
/// <summary>
/// 获取指定部门下的所有员工ID(分页方式)
/// </summary>
public async Task<List<string>> GetAllUserIdsAsync(long deptId)
{
string token = await GetAccessTokenAsync();
var userIds = new List<string>();
int cursor = 0;
int size = 100;
bool hasMore = true;
try
{
while (hasMore)
{
string url = $"https://oapi.dingtalk.com/topapi/v2/user/list?access_token={token}";
var payload = new { dept_id = deptId, cursor, size };
var content = new StringContent(JsonConvert.SerializeObject(payload), Encoding.UTF8, "application/json");
var response = await _client.PostAsync(url, content);
response.EnsureSuccessStatusCode();
string json = await response.Content.ReadAsStringAsync();
var result = JsonConvert.DeserializeObject<DingUserListResponse>(json);
if (result == null || result.ErrCode != 0)
{
Console.WriteLine($"❌ 获取部门 {deptId} 用户失败:{result?.ErrMsg ?? "接口异常"}");
break;
}
if (result.Result?.List != null && result.Result.List.Count > 0)
{
foreach (var user in result.Result.List)
{
if (!string.IsNullOrEmpty(user.UserId))
userIds.Add(user.UserId);
}
}
hasMore = result.Result?.HasMore ?? false;
cursor = result.Result?.NextCursor ?? 0;
}
}
catch (Exception ex)
{
Console.WriteLine($"⚠️ 获取部门 {deptId} 用户失败: {ex.Message}");
}
return userIds.Distinct().ToList();
}
#endregion
4.获取原始考勤数据:
#region === 获取全员考勤数据 ===
/// <summary>
/// 获取企业全员考勤数据(含班次名称)
/// </summary>
public async Task<List<AttendanceRecordAll>> GetAllAttendanceAsync(DateTime startDate, DateTime endDate)
{
var deptIds = await GetAllDepartmentIdsAsync();
var allUserIds = new List<string>();
foreach (var deptId in deptIds)
{
Console.WriteLine($" 正在获取部门 {deptId} 用户...");
var ids = await GetAllUserIdsAsync(deptId);
if (ids.Count > 0)
{
Console.WriteLine($" 部门 {deptId} 获取到 {ids.Count} 人");
allUserIds.AddRange(ids);
}
}
allUserIds = allUserIds.Distinct().ToList();
return await GetAttendanceAsync(startDate, endDate, allUserIds);
}
public async Task<List<AttendanceRecordAll>> GetAttendanceAsync(DateTime startDate, DateTime endDate, List<string> userIds)
{
if (userIds == null || userIds.Count == 0)
return new List<AttendanceRecordAll>();
string token = await GetLastAccessTokenAsync();
var records = new List<AttendanceRecordAll>();
int batchSize = 50; // 每次最多 50 个用户
for (int i = 0; i < userIds.Count; i += batchSize)
{
var batch = userIds.Skip(i).Take(batchSize).ToList();
string url = $"https://oapi.dingtalk.com/attendance/listRecord?access_token={token}";
var payload = new
{
userIds = batch,
checkDateFrom = startDate.ToString("yyyy-MM-dd HH:mm:ss"),
checkDateTo = endDate.ToString("yyyy-MM-dd HH:mm:ss"),
isI18n = false
};
var request = new HttpRequestMessage(HttpMethod.Post, url);
request.Content = new StringContent(JsonConvert.SerializeObject(payload), Encoding.UTF8, "application/json");
var response = await _client.SendAsync(request);
string json = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
Console.WriteLine($"❌ 调用考勤接口失败: {response.StatusCode}, 内容: {json}");
continue;
}
var obj = JObject.Parse(json);
var data = obj["recordresult"];
if (data == null)
{
Console.WriteLine($"⚠️ 考勤接口返回异常: {json}");
continue;
}
foreach (var item in data)
{
try
{
var record = new AttendanceRecordAll
{
Id = item["id"]?.ToObject<long>() ?? 0,
BizId = item["bizId"]?.ToString(),
CorpId = item["corpId"]?.ToString(),
UserId = item["userId"]?.ToString(),
CheckType = item["checkType"]?.ToString(),
TimeResult = item["timeResult"]?.ToString(),
LocationResult = item["locationResult"]?.ToString(),
IsLegal = item["isLegal"]?.ToString(),
SourceType = item["sourceType"]?.ToString(),
LocationMethod = item["locationMethod"]?.ToString(),
UserAddress = item["userAddress"]?.ToString(),
BaseMacAddr = item["baseMacAddr"]?.ToString(),
DeviceSN = item["deviceSN"]?.ToString(),
GroupId = item["groupId"]?.ToObject<long>() ?? 0,
ClassId = item["classId"]?.ToObject<long>() ?? 0,
PlanId = item["planId"]?.ToObject<long>() ?? 0,
GmtCreate = ConvertTimestamp(item["gmtCreate"]?.ToObject<long>()),
GmtModified = ConvertTimestamp(item["gmtModified"]?.ToObject<long>()),
UserCheckTime = ConvertTimestamp(item["userCheckTime"]?.ToObject<long>()),
BaseCheckTime = ConvertTimestamp(item["baseCheckTime"]?.ToObject<long>()),
PlanCheckTime = ConvertTimestamp(item["planCheckTime"]?.ToObject<long>()),
WorkDate = ConvertTimestamp(item["workDate"]?.ToObject<long>())
};
records.Add(record);
}
catch (Exception ex)
{
Console.WriteLine($"解析考勤记录出错: {ex.Message}");
}
}
}
return records;
}
private DateTime ConvertTimestamp(long? timestamp)
{
if (timestamp == null || timestamp == 0)
return DateTime.MinValue;
return DateTimeOffset.FromUnixTimeMilliseconds(timestamp.Value).ToLocalTime().DateTime;
}
/// <summary>
/// 将考勤原始记录汇总为按员工+日期的统计信息
/// </summary>
public List<AttendanceSummary> GetAttendanceSummary(List<AttendanceRecordAll> records)
{
return records
.GroupBy(r => new { r.UserId, r.WorkDate.Date })
.Select(g =>
{
var onDuty = g.FirstOrDefault(x => x.CheckType == "OnDuty");
var offDuty = g.FirstOrDefault(x => x.CheckType == "OffDuty");
string status;
if (onDuty == null && offDuty == null) status = "缺卡";
else if (onDuty?.TimeResult == "Late") status = "迟到";
else if (offDuty?.TimeResult == "Early") status = "早退";
else if (onDuty?.TimeResult == "NotSigned" || offDuty?.TimeResult == "NotSigned") status = "未打卡";
else status = "正常";
return new AttendanceSummary
{
UserId = g.Key.UserId,
UserName = onDuty?.UserId ?? offDuty?.UserId ?? "",
WorkDate = g.Key.Date,
OnDutyTime = onDuty?.UserCheckTime.ToString("HH:mm:ss"),
OffDutyTime = offDuty?.UserCheckTime.ToString("HH:mm:ss"),
OnDutyResult = onDuty?.TimeResult ?? "无",
OffDutyResult = offDuty?.TimeResult ?? "无",
Status = status
};
})
.OrderBy(x => x.UserId)
.ThenBy(x => x.WorkDate)
.ToList();
}
///// <summary>
///// 获取指定员工考勤记录(方法 A:v1.0/attendance/listRecord)
///// </summary>
///// <param name="startDate">考勤开始时间</param>
///// <param name="endDate">考勤结束时间</param>
///// <param name="userIds">员工 ID 列表</param>
///// <returns>返回考勤记录列表</returns>
//public async Task<List<AttendanceRecord>> GetAttendanceAsync(DateTime startDate, DateTime endDate, List<string> userIds)
//{
// string token = await GetAccessTokenAsync(); // 获取 AccessToken
// var records = new List<AttendanceRecord>();
// int batchSize = 50; // 每批请求员工数量
// for (int i = 0; i < userIds.Count; i += batchSize)
// {
// var batch = userIds.Skip(i).Take(batchSize).ToList();
// string url = "https://api.dingtalk.com/v1.0/attendance/listRecord";
// var payload = new
// {
// userIds = batch,
// checkDateFrom = startDate.ToString("yyyy-MM-dd HH:mm:ss"),
// checkDateTo = endDate.ToString("yyyy-MM-dd HH:mm:ss"),
// isI18n = false
// };
// var request = new HttpRequestMessage(HttpMethod.Post, url);
// request.Headers.Add("x-acs-dingtalk-access-token", token);
// request.Content = new StringContent(JsonConvert.SerializeObject(payload), Encoding.UTF8, "application/json");
// var response = await _client.SendAsync(request);
// string json = await response.Content.ReadAsStringAsync();
// if (!response.IsSuccessStatusCode)
// {
// Console.WriteLine($"❌ 调用考勤接口失败: {response.StatusCode}, 内容: {json}");
// continue;
// }
// var obj = JObject.Parse(json);
// if (obj["records"] == null)
// {
// Console.WriteLine($"⚠️ 考勤接口返回异常: {json}");
// continue;
// }
// foreach (var item in obj["records"])
// {
// try
// {
// var record = new AttendanceRecord
// {
// UserId = item["userId"]?.ToString(),
// UserName = item["userName"]?.ToString(),
// Shift = item["className"]?.ToString() ?? "默认班次",
// WorkDate = DateTimeOffset.FromUnixTimeMilliseconds(item["baseCheckTime"]?.ToObject<long>() ?? 0).DateTime,
// OnDuty = item["checkType"]?.ToString() == "OnDuty"
// ? DateTimeOffset.FromUnixTimeMilliseconds(item["userCheckTime"]?.ToObject<long>() ?? 0).ToLocalTime().ToString("HH:mm:ss")
// : null,
// OffDuty = item["checkType"]?.ToString() == "OffDuty"
// ? DateTimeOffset.FromUnixTimeMilliseconds(item["userCheckTime"]?.ToObject<long>() ?? 0).ToLocalTime().ToString("HH:mm:ss")
// : null
// };
// records.Add(record);
// }
// catch (Exception ex)
// {
// Console.WriteLine($"解析考勤记录出错: {ex.Message}");
// }
// }
// }
// return records;
//}
#endregion
全部代码:
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace Common
{
/// <summary>
/// 钉钉考勤 API 封装类
/// </summary>
public class DingDingAPI
{
// 全局 HttpClient
private static readonly HttpClient _client = new HttpClient();
private string _accessToken;
private DateTime _expireTime;
private readonly object _lock = new object();
private readonly string _appKey;
private readonly string _appSecret;
/// <summary>
/// 构造函数
/// </summary>
public DingDingAPI(string appKey, string appSecret)
{
_appKey = appKey;
_appSecret = appSecret;
}
#region === 数据实体 ===
/// <summary>
/// 钉钉部门信息实体(对应 topapi/v2/department/listsub 等接口返回的部门结构)
/// </summary>
public class DingDepartment
{
/// <summary>
/// 部门ID(钉钉内部唯一标识)
/// 示例: 101988311
/// </summary>
[JsonProperty("dept_id")]
public long DeptId { get; set; }
/// <summary>
/// 部门名称
/// 示例: "UMH杭州同创顶立机械有限公司"
/// </summary>
[JsonProperty("name")]
public string Name { get; set; }
/// <summary>
/// 父级部门ID(根部门 parent_id 通常为 0 或 1,视企业组织结构而定)
/// 示例: 1
/// </summary>
[JsonProperty("parent_id")]
public long ParentId { get; set; }
/// <summary>
/// 是否自动将新成员加入该部门对应的群(true/false)
/// 该字段由钉钉后台配置控制,表示用户加入部门时是否自动加入部门群。
/// </summary>
[JsonProperty("auto_add_user")]
public bool AutoAddUser { get; set; }
/// <summary>
/// 是否为该部门自动创建企业群(true/false)
/// 如果为 true,钉钉会为该部门创建/维护一个部门群。
/// </summary>
[JsonProperty("create_dept_group")]
public bool CreateDeptGroup { get; set; }
/// <summary>
/// 部门扩展字段(JSON 字符串),内容由钉钉或企业自定义,例如包含 faceCount 等统计信息。
/// 使用时需要再反序列化此 JSON 字符串以读取具体扩展字段。
/// 示例: "{\"faceCount\":\"85\"}"
/// </summary>
[JsonProperty("ext")]
public string Ext { get; set; }
}
/// <summary>
/// 考勤记录实体(用于承载钉钉考勤接口返回的关键信息,便于前端/后端处理)
/// 注意:具体字段来源依赖于 topapi/attendance/list 返回的数据结构,部分字段可能为 null。
/// </summary>
public class AttendanceRecord
{
/// <summary>
/// 员工姓名(钉钉中的显示名称)
/// 示例: "张三"
/// </summary>
public string UserName { get; set; }
/// <summary>
/// 员工ID(钉钉唯一标识,用于后续查询或关联系统中的用户)
/// 示例: "033514396029073460"
/// </summary>
public string UserId { get; set; }
/// <summary>
/// 考勤日期(对应当天的日期,时间部分一般为 00:00:00)
/// 示例: 2025-10-14
/// 说明:若考勤记录来自打卡时间戳,会将其转换为本地日期(Date 部分)。
/// </summary>
public DateTime WorkDate { get; set; }
/// <summary>
/// 上班打卡时间,格式 "HH:mm:ss"
/// 若当天存在多次上班类打卡,可在取第一条或根据业务规则聚合。
/// 可能为 null(例如未打卡或接口未返回上班记录)。
/// 示例: "09:00:00"
/// </summary>
public string OnDuty { get; set; }
/// <summary>
/// 下班打卡时间,格式 "HH:mm:ss"
/// 若当天存在多次下班类打卡,可在取最后一条或根据业务规则聚合。
/// 可能为 null(例如未打卡或接口未返回下班记录)。
/// 示例: "18:00:00"
/// </summary>
public string OffDuty { get; set; }
/// <summary>
/// 班次名称(scheduleName),由钉钉排班系统维护
/// 如果员工当日未排班或接口未返回班次,建议填充默认值(例如 "默认班次")或保留为 null。
/// 示例: "白班"
/// </summary>
public string Shift { get; set; }
}
/// <summary>
/// 钉钉用户信息实体(简化版)
/// </summary>
public class DingUserInfo
{
[JsonProperty("userid")]
public string UserId { get; set; }
[JsonProperty("name")]
public string Name { get; set; }
[JsonProperty("job_number")]
public string JobNumber { get; set; }
[JsonProperty("title")]
public string Title { get; set; }
[JsonProperty("dept_id_list")]
public List<long> DeptIdList { get; set; }
[JsonProperty("active")]
public bool Active { get; set; }
[JsonProperty("leader")]
public bool Leader { get; set; }
}
/// <summary>
/// 钉钉用户列表接口返回结果
/// </summary>
public class DingUserListResponse
{
[JsonProperty("errcode")]
public int ErrCode { get; set; }
[JsonProperty("errmsg")]
public string ErrMsg { get; set; }
[JsonProperty("result")]
public DingUserListResult Result { get; set; }
}
/// <summary>
/// 钉钉用户列表接口 Result 部分
/// </summary>
public class DingUserListResult
{
[JsonProperty("has_more")]
public bool HasMore { get; set; }
[JsonProperty("list")]
public List<DingUserInfo> List { get; set; }
[JsonProperty("next_cursor")]
public int? NextCursor { get; set; }
}
/// <summary>
/// 钉钉考勤记录实体(对应接口:/attendance/listRecord)
/// 说明:适用于旧版企业内部应用接口,返回字段 recordresult。
/// </summary>
public class AttendanceRecordAll
{
/// <summary>
/// 考勤记录唯一 ID
/// </summary>
public long Id { get; set; }
/// <summary>
/// 业务唯一标识(钉钉生成的 BizId)
/// </summary>
public string BizId { get; set; }
/// <summary>
/// 企业 CorpId(组织唯一标识)
/// </summary>
public string CorpId { get; set; }
/// <summary>
/// 员工 UserId(钉钉用户唯一 ID)
/// </summary>
public string UserId { get; set; }
/// <summary>
/// 打卡类型:
/// - OnDuty:上班打卡
/// - OffDuty:下班打卡
/// </summary>
public string CheckType { get; set; }
/// <summary>
/// 时间结果:
/// - Normal:正常
/// - Early:早退
/// - Late:迟到
/// - SeriousLate:严重迟到
/// - Absenteeism:旷工迟到
/// - NotSigned:未打卡
/// </summary>
public string TimeResult { get; set; }
/// <summary>
/// 定位结果:
/// - Normal:正常
/// - Outside:外勤
/// </summary>
public string LocationResult { get; set; }
/// <summary>
/// 是否合法:
/// - Y:合法
/// - N:不合法(可能伪造或设备异常)
/// </summary>
public string IsLegal { get; set; }
/// <summary>
/// 打卡来源:
/// - ATM:考勤机
/// - MOBILE:手机端
/// - SYSTEM:系统自动生成
/// - BOSS:管理员补卡
/// </summary>
public string SourceType { get; set; }
/// <summary>
/// 定位方式:
/// - GPS:GPS定位
/// - WIFI:Wi-Fi定位
/// - LBS:基站定位
/// - ATM:考勤机定位
/// </summary>
public string LocationMethod { get; set; }
/// <summary>
/// 打卡地点描述(例如设备名或地理位置)
/// </summary>
public string UserAddress { get; set; }
/// <summary>
/// 基站 MAC 地址(若通过考勤机或基站定位)
/// </summary>
public string BaseMacAddr { get; set; }
/// <summary>
/// 考勤设备序列号(例如人脸机编号)
/// </summary>
public string DeviceSN { get; set; }
/// <summary>
/// 考勤分组 ID(对应钉钉考勤组)
/// </summary>
public long GroupId { get; set; }
/// <summary>
/// 班次 ID(对应考勤班次)
/// </summary>
public long ClassId { get; set; }
/// <summary>
/// 考勤计划 ID(钉钉排班信息)
/// </summary>
public long PlanId { get; set; }
/// <summary>
/// 记录创建时间(时间戳转为 DateTime)
/// </summary>
public DateTime GmtCreate { get; set; }
/// <summary>
/// 记录修改时间(时间戳转为 DateTime)
/// </summary>
public DateTime GmtModified { get; set; }
/// <summary>
/// 员工打卡时间(实际打卡时间)
/// </summary>
public DateTime UserCheckTime { get; set; }
/// <summary>
/// 班次基准打卡时间(计划上班/下班时间)
/// </summary>
public DateTime BaseCheckTime { get; set; }
/// <summary>
/// 计划打卡时间(排班定义时间点)
/// </summary>
public DateTime PlanCheckTime { get; set; }
/// <summary>
/// 工作日期(即所属工作日)
/// </summary>
public DateTime WorkDate { get; set; }
/// <summary>
/// 格式化后的工作日期字符串(yyyy-MM-dd)
/// </summary>
public string WorkDateStr => WorkDate == DateTime.MinValue ? "" : WorkDate.ToString("yyyy-MM-dd");
/// <summary>
/// 格式化后的打卡时间字符串(HH:mm:ss)
/// </summary>
public string CheckTimeStr => UserCheckTime == DateTime.MinValue ? "" : UserCheckTime.ToString("HH:mm:ss");
}
/// <summary>
/// 考勤汇总结果(按员工+日期统计)
/// 说明:通过钉钉考勤原始记录(AttendanceRecordAll)汇总生成
/// </summary>
public class AttendanceSummary
{
/// <summary>
/// 员工ID(钉钉唯一标识)
/// </summary>
public string UserId { get; set; }
/// <summary>
/// 员工姓名(可从UserId映射或缓存获取)
/// </summary>
public string UserName { get; set; }
/// <summary>
/// 工作日期(对应上班日)
/// </summary>
public DateTime WorkDate { get; set; }
/// <summary>
/// 上班打卡时间(格式 HH:mm:ss)
/// </summary>
public string OnDutyTime { get; set; }
/// <summary>
/// 下班打卡时间(格式 HH:mm:ss)
/// </summary>
public string OffDutyTime { get; set; }
/// <summary>
/// 上班打卡结果(例如 Normal、Late、NotSigned 等)
/// </summary>
public string OnDutyResult { get; set; }
/// <summary>
/// 下班打卡结果(例如 Normal、Early、NotSigned 等)
/// </summary>
public string OffDutyResult { get; set; }
/// <summary>
/// 当日汇总状态:
/// - 正常:上班/下班均正常
/// - 迟到:上班迟到
/// - 早退:下班早退
/// - 缺卡:未打卡
/// - 未打卡:有打卡记录但未成功登记
/// </summary>
public string Status { get; set; }
}
public class DingUserInfoDto
{
public string UserId { get; set; }
public string Name { get; set; }
}
/// <summary>
/// 员工考勤汇总实体--结果统计
/// </summary>
public class AttendanceSummaryExcel
{
// 基本信息
public string UserName { get; set; } // 姓名
public string AttendanceGroup { get; set; } // 考勤组
public string Department { get; set; } // 部门
public string EmployeeCode { get; set; } // 工号
public string Position { get; set; } // 职位
public string UserId { get; set; } // 钉钉UserId
public List<string> RelatedApprovalForms { get; set; } = new List<string>(); // 关联审批单
// 出勤统计
public int AttendanceDays { get; set; } // 出勤天数
public List<string> AttendanceShifts { get; set; } = new List<string>(); // 出勤班次,如 白班、晚班
public int RestDays { get; set; } // 休息天数
public double WorkHours { get; set; } // 工作时长(小时)
// 迟到/早退/缺卡
public int LateCount { get; set; } // 迟到次数
public double LateHours { get; set; } // 迟到时长(小时)
public int SevereLateCount { get; set; } // 严重迟到次数
public double SevereLateHours { get; set; } // 严重迟到时长(小时)
public int AbsenceLateDays { get; set; } // 旷工迟到天数
public int EarlyLeaveCount { get; set; } // 早退次数
public double EarlyLeaveHours { get; set; } // 早退时长(小时)
public int MissingCheckInCount { get; set; } // 上班缺卡次数
public int MissingCheckOutCount { get; set; } // 下班缺卡次数
public int AbsenceDays { get; set; } // 旷工天数
// 外勤/出差/请假
public double BusinessTripHours { get; set; } // 出差时长(小时)
public double OutingHours { get; set; } // 外出时长(小时)
// 请假分类
public double AnnualLeaveDays { get; set; } // 年假(天)
public double PersonalLeaveHours { get; set; } // 事假(小时)
public double SickLeaveHours { get; set; } // 病假(小时)
public double CompensatoryLeaveHours { get; set; } // 调休(小时)
public double MaternityLeaveDays { get; set; } // 产假(天)
public double PaternityLeaveDays { get; set; } // 陪产假(天)
public double MarriageLeaveDays { get; set; } // 婚假(天)
public double MenstruationLeaveDays { get; set; } // 例假(天)
public double BereavementLeaveDays { get; set; } // 丧假(天)
public double NursingLeaveHours { get; set; } // 哺乳假(小时)
// 加班
public double OvertimeTotalHours { get; set; } // 加班总时长(小时)
public double OvertimeCalculatedHours { get; set; } // 按规则计算的加班时长(小时)
// 考勤结果
public Dictionary<int, string> DailyStatus { get; set; } = new Dictionary<int, string>();
// Key = 日(1-31),Value = 当天考勤状态,如 "正常"/"迟到"/"缺卡"
}
#endregion
#region === 获取 AccessToken(缓存+线程安全) ===
/// <summary>
/// 新版token
/// </summary>
/// <returns></returns>
/// <exception cref="Exception"></exception>
public async Task<string> GetAccessTokenAsync()
{
// 有缓存且未过期
if (!string.IsNullOrEmpty(_accessToken) && DateTime.Now < _expireTime)
return _accessToken;
lock (_lock)
{
if (!string.IsNullOrEmpty(_accessToken) && DateTime.Now < _expireTime)
return _accessToken;
}
string url = "https://api.dingtalk.com/v1.0/oauth2/accessToken";
var payload = new { appKey = _appKey, appSecret = _appSecret };
var content = new StringContent(JsonConvert.SerializeObject(payload), Encoding.UTF8, "application/json");
var response = await _client.PostAsync(url, content);
response.EnsureSuccessStatusCode();
string json = await response.Content.ReadAsStringAsync();
var obj = JObject.Parse(json);
if (obj["accessToken"] == null)
throw new Exception($"获取AccessToken失败: {json}");
string token = obj["accessToken"].ToString();
int expiresIn = obj["expireIn"].ToObject<int>();
lock (_lock)
{
_accessToken = token;
_expireTime = DateTime.Now.AddSeconds(expiresIn - 60);
}
return _accessToken;
}
/// <summary>
/// 旧版token
/// </summary>
/// <returns></returns>
/// <exception cref="Exception"></exception>
public async Task<string> GetLastAccessTokenAsync()
{
// 若缓存未过期
if (!string.IsNullOrEmpty(_accessToken) && DateTime.Now < _expireTime)
return _accessToken;
lock (_lock)
{
if (!string.IsNullOrEmpty(_accessToken) && DateTime.Now < _expireTime)
return _accessToken;
}
string url = $"https://oapi.dingtalk.com/gettoken?appkey={_appKey}&appsecret={_appSecret}";
var response = await _client.GetAsync(url);
response.EnsureSuccessStatusCode();
string json = await response.Content.ReadAsStringAsync();
var obj = JObject.Parse(json);
if (obj["access_token"] == null)
throw new Exception($"获取AccessToken失败: {json}");
string token = obj["access_token"].ToString();
int expiresIn = obj["expires_in"]?.ToObject<int>() ?? 7200;
lock (_lock)
{
_accessToken = token;
_expireTime = DateTime.Now.AddSeconds(expiresIn - 60);
}
return _accessToken;
}
#endregion
#region === 获取所有部门 ===
/// <summary>
/// 获取企业所有部门ID(递归)
/// </summary>
public async Task<List<long>> GetAllDepartmentIdsAsync(long parentDeptId = 1)
{
var deptIds = new List<long> { parentDeptId };
string token = await GetAccessTokenAsync();
try
{
string url = $"https://oapi.dingtalk.com/topapi/v2/department/listsub?access_token={token}";
var payload = new { dept_id = parentDeptId };
var content = new StringContent(JsonConvert.SerializeObject(payload), Encoding.UTF8, "application/json");
var response = await _client.PostAsync(url, content);
response.EnsureSuccessStatusCode();
string json = await response.Content.ReadAsStringAsync();
var obj = JObject.Parse(json);
if ((int)obj["errcode"] != 0)
throw new Exception($"获取部门列表失败: {obj["errmsg"]}");
var deptList = JsonConvert.DeserializeObject<List<DingDepartment>>(obj["result"].ToString());
foreach (var dept in deptList)
{
deptIds.Add(dept.DeptId);
// 递归调用获取子部门
try
{
var subDepts = await GetAllDepartmentIdsAsync(dept.DeptId);
deptIds.AddRange(subDepts);
}
catch (Exception ex)
{
Console.WriteLine($"子部门 {dept.Name}(ID: {dept.DeptId})无访问权限,跳过。{ex.Message}");
}
}
}
catch (Exception ex)
{
Console.WriteLine($" 获取部门 {parentDeptId} 下级失败: {ex.Message}");
}
return deptIds.Distinct().ToList();
}
#endregion
#region === 获取部门下所有员工ID ===
/// <summary>
/// 获取指定部门下的所有员工ID(分页方式)
/// </summary>
public async Task<List<string>> GetAllUserIdsAsync(long deptId)
{
string token = await GetAccessTokenAsync();
var userIds = new List<string>();
int cursor = 0;
int size = 100;
bool hasMore = true;
try
{
while (hasMore)
{
string url = $"https://oapi.dingtalk.com/topapi/v2/user/list?access_token={token}";
var payload = new { dept_id = deptId, cursor, size };
var content = new StringContent(JsonConvert.SerializeObject(payload), Encoding.UTF8, "application/json");
var response = await _client.PostAsync(url, content);
response.EnsureSuccessStatusCode();
string json = await response.Content.ReadAsStringAsync();
var result = JsonConvert.DeserializeObject<DingUserListResponse>(json);
if (result == null || result.ErrCode != 0)
{
Console.WriteLine($"❌ 获取部门 {deptId} 用户失败:{result?.ErrMsg ?? "接口异常"}");
break;
}
if (result.Result?.List != null && result.Result.List.Count > 0)
{
foreach (var user in result.Result.List)
{
if (!string.IsNullOrEmpty(user.UserId))
userIds.Add(user.UserId);
}
}
hasMore = result.Result?.HasMore ?? false;
cursor = result.Result?.NextCursor ?? 0;
}
}
catch (Exception ex)
{
Console.WriteLine($"⚠️ 获取部门 {deptId} 用户失败: {ex.Message}");
}
return userIds.Distinct().ToList();
}
#endregion
#region === 获取全员考勤数据 ===
/// <summary>
/// 获取企业全员考勤数据(含班次名称)
/// </summary>
public async Task<List<AttendanceRecordAll>> GetAllAttendanceAsync(DateTime startDate, DateTime endDate)
{
var deptIds = await GetAllDepartmentIdsAsync();
var allUserIds = new List<string>();
foreach (var deptId in deptIds)
{
Console.WriteLine($" 正在获取部门 {deptId} 用户...");
var ids = await GetAllUserIdsAsync(deptId);
if (ids.Count > 0)
{
Console.WriteLine($" 部门 {deptId} 获取到 {ids.Count} 人");
allUserIds.AddRange(ids);
}
}
allUserIds = allUserIds.Distinct().ToList();
return await GetAttendanceAsync(startDate, endDate, allUserIds);
}
public async Task<List<AttendanceRecordAll>> GetAttendanceAsync(DateTime startDate, DateTime endDate, List<string> userIds)
{
if (userIds == null || userIds.Count == 0)
return new List<AttendanceRecordAll>();
string token = await GetLastAccessTokenAsync();
var records = new List<AttendanceRecordAll>();
int batchSize = 50; // 每次最多 50 个用户
for (int i = 0; i < userIds.Count; i += batchSize)
{
var batch = userIds.Skip(i).Take(batchSize).ToList();
string url = $"https://oapi.dingtalk.com/attendance/listRecord?access_token={token}";
var payload = new
{
userIds = batch,
checkDateFrom = startDate.ToString("yyyy-MM-dd HH:mm:ss"),
checkDateTo = endDate.ToString("yyyy-MM-dd HH:mm:ss"),
isI18n = false
};
var request = new HttpRequestMessage(HttpMethod.Post, url);
request.Content = new StringContent(JsonConvert.SerializeObject(payload), Encoding.UTF8, "application/json");
var response = await _client.SendAsync(request);
string json = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
Console.WriteLine($"❌ 调用考勤接口失败: {response.StatusCode}, 内容: {json}");
continue;
}
var obj = JObject.Parse(json);
var data = obj["recordresult"];
if (data == null)
{
Console.WriteLine($"⚠️ 考勤接口返回异常: {json}");
continue;
}
foreach (var item in data)
{
try
{
var record = new AttendanceRecordAll
{
Id = item["id"]?.ToObject<long>() ?? 0,
BizId = item["bizId"]?.ToString(),
CorpId = item["corpId"]?.ToString(),
UserId = item["userId"]?.ToString(),
CheckType = item["checkType"]?.ToString(),
TimeResult = item["timeResult"]?.ToString(),
LocationResult = item["locationResult"]?.ToString(),
IsLegal = item["isLegal"]?.ToString(),
SourceType = item["sourceType"]?.ToString(),
LocationMethod = item["locationMethod"]?.ToString(),
UserAddress = item["userAddress"]?.ToString(),
BaseMacAddr = item["baseMacAddr"]?.ToString(),
DeviceSN = item["deviceSN"]?.ToString(),
GroupId = item["groupId"]?.ToObject<long>() ?? 0,
ClassId = item["classId"]?.ToObject<long>() ?? 0,
PlanId = item["planId"]?.ToObject<long>() ?? 0,
GmtCreate = ConvertTimestamp(item["gmtCreate"]?.ToObject<long>()),
GmtModified = ConvertTimestamp(item["gmtModified"]?.ToObject<long>()),
UserCheckTime = ConvertTimestamp(item["userCheckTime"]?.ToObject<long>()),
BaseCheckTime = ConvertTimestamp(item["baseCheckTime"]?.ToObject<long>()),
PlanCheckTime = ConvertTimestamp(item["planCheckTime"]?.ToObject<long>()),
WorkDate = ConvertTimestamp(item["workDate"]?.ToObject<long>())
};
records.Add(record);
}
catch (Exception ex)
{
Console.WriteLine($"解析考勤记录出错: {ex.Message}");
}
}
}
return records;
}
private DateTime ConvertTimestamp(long? timestamp)
{
if (timestamp == null || timestamp == 0)
return DateTime.MinValue;
return DateTimeOffset.FromUnixTimeMilliseconds(timestamp.Value).ToLocalTime().DateTime;
}
/// <summary>
/// 将考勤原始记录汇总为按员工+日期的统计信息
/// </summary>
public List<AttendanceSummary> GetAttendanceSummary(List<AttendanceRecordAll> records)
{
return records
.GroupBy(r => new { r.UserId, r.WorkDate.Date })
.Select(g =>
{
var onDuty = g.FirstOrDefault(x => x.CheckType == "OnDuty");
var offDuty = g.FirstOrDefault(x => x.CheckType == "OffDuty");
string status;
if (onDuty == null && offDuty == null) status = "缺卡";
else if (onDuty?.TimeResult == "Late") status = "迟到";
else if (offDuty?.TimeResult == "Early") status = "早退";
else if (onDuty?.TimeResult == "NotSigned" || offDuty?.TimeResult == "NotSigned") status = "未打卡";
else status = "正常";
return new AttendanceSummary
{
UserId = g.Key.UserId,
UserName = onDuty?.UserId ?? offDuty?.UserId ?? "",
WorkDate = g.Key.Date,
OnDutyTime = onDuty?.UserCheckTime.ToString("HH:mm:ss"),
OffDutyTime = offDuty?.UserCheckTime.ToString("HH:mm:ss"),
OnDutyResult = onDuty?.TimeResult ?? "无",
OffDutyResult = offDuty?.TimeResult ?? "无",
Status = status
};
})
.OrderBy(x => x.UserId)
.ThenBy(x => x.WorkDate)
.ToList();
}
///// <summary>
///// 获取指定员工考勤记录(方法 A:v1.0/attendance/listRecord)
///// </summary>
///// <param name="startDate">考勤开始时间</param>
///// <param name="endDate">考勤结束时间</param>
///// <param name="userIds">员工 ID 列表</param>
///// <returns>返回考勤记录列表</returns>
//public async Task<List<AttendanceRecord>> GetAttendanceAsync(DateTime startDate, DateTime endDate, List<string> userIds)
//{
// string token = await GetAccessTokenAsync(); // 获取 AccessToken
// var records = new List<AttendanceRecord>();
// int batchSize = 50; // 每批请求员工数量
// for (int i = 0; i < userIds.Count; i += batchSize)
// {
// var batch = userIds.Skip(i).Take(batchSize).ToList();
// string url = "https://api.dingtalk.com/v1.0/attendance/listRecord";
// var payload = new
// {
// userIds = batch,
// checkDateFrom = startDate.ToString("yyyy-MM-dd HH:mm:ss"),
// checkDateTo = endDate.ToString("yyyy-MM-dd HH:mm:ss"),
// isI18n = false
// };
// var request = new HttpRequestMessage(HttpMethod.Post, url);
// request.Headers.Add("x-acs-dingtalk-access-token", token);
// request.Content = new StringContent(JsonConvert.SerializeObject(payload), Encoding.UTF8, "application/json");
// var response = await _client.SendAsync(request);
// string json = await response.Content.ReadAsStringAsync();
// if (!response.IsSuccessStatusCode)
// {
// Console.WriteLine($"❌ 调用考勤接口失败: {response.StatusCode}, 内容: {json}");
// continue;
// }
// var obj = JObject.Parse(json);
// if (obj["records"] == null)
// {
// Console.WriteLine($"⚠️ 考勤接口返回异常: {json}");
// continue;
// }
// foreach (var item in obj["records"])
// {
// try
// {
// var record = new AttendanceRecord
// {
// UserId = item["userId"]?.ToString(),
// UserName = item["userName"]?.ToString(),
// Shift = item["className"]?.ToString() ?? "默认班次",
// WorkDate = DateTimeOffset.FromUnixTimeMilliseconds(item["baseCheckTime"]?.ToObject<long>() ?? 0).DateTime,
// OnDuty = item["checkType"]?.ToString() == "OnDuty"
// ? DateTimeOffset.FromUnixTimeMilliseconds(item["userCheckTime"]?.ToObject<long>() ?? 0).ToLocalTime().ToString("HH:mm:ss")
// : null,
// OffDuty = item["checkType"]?.ToString() == "OffDuty"
// ? DateTimeOffset.FromUnixTimeMilliseconds(item["userCheckTime"]?.ToObject<long>() ?? 0).ToLocalTime().ToString("HH:mm:ss")
// : null
// };
// records.Add(record);
// }
// catch (Exception ex)
// {
// Console.WriteLine($"解析考勤记录出错: {ex.Message}");
// }
// }
// }
// return records;
//}
#endregion
/// <summary>
/// 获取员工详细信息
/// </summary>
/// <param name="userIds"></param>
/// <returns></returns>
public async Task<Dictionary<string, string>> GetUserNamesAsync(List<string> userIds)
{
var result = new ConcurrentDictionary<string, string>();
if (userIds == null || userIds.Count == 0)
return new Dictionary<string, string>();
string token = await GetAccessTokenAsync();
int batchSize = 20; // 并发数量,可以根据网络情况调整
var batches = userIds
.Select((id, index) => new { id, index })
.GroupBy(x => x.index / batchSize)
.Select(g => g.Select(x => x.id).ToList())
.ToList();
foreach (var batch in batches)
{
var tasks = batch.Select(async userId =>
{
try
{
string url = $"https://oapi.dingtalk.com/topapi/v2/user/get?access_token={token}";
var payload = new { userid = userId };
var content = new StringContent(JsonConvert.SerializeObject(payload), Encoding.UTF8, "application/json");
var response = await _client.PostAsync(url, content);
response.EnsureSuccessStatusCode();
string json = await response.Content.ReadAsStringAsync();
var obj = JObject.Parse(json);
if ((int)obj["errcode"] != 0)
{
Console.WriteLine($"获取用户 {userId} 信息失败: {obj["errmsg"]}");
return;
}
string name = obj["result"]?["name"]?.ToString();
if (!string.IsNullOrEmpty(name))
result[userId] = name;
}
catch (Exception ex)
{
Console.WriteLine($"获取用户 {userId} 信息异常: {ex.Message}");
}
});
await Task.WhenAll(tasks);
}
return result.ToDictionary(k => k.Key, v => v.Value);
}
/// <summary>
/// 汇总
/// </summary>
/// <param name="startDate"></param>
/// <param name="endDate"></param>
/// <param name="deptIds"></param>
/// <param name="userIds"></param>
/// <param name="pageIndex"></param>
/// <param name="pageSize"></param>
/// <returns></returns>
public async Task<List<AttendanceSummaryExcel>> GenerateAttendanceSummaryExcelAsync(
DateTime startDate,
DateTime endDate,
List<long> deptIds = null,
List<string> userIds = null,
int pageIndex = 1,
int pageSize = 50)
{
// 1. 获取员工列表
List<string> allUserIds = new List<string>();
if (userIds != null && userIds.Any())
{
allUserIds = userIds.Distinct().ToList();
}
else
{
if (deptIds == null || !deptIds.Any())
deptIds = await GetAllDepartmentIdsAsync();
foreach (var deptId in deptIds)
{
var ids = await GetAllUserIdsAsync(deptId);
if (ids?.Count > 0)
allUserIds.AddRange(ids);
}
allUserIds = allUserIds.Distinct().ToList();
}
if (!allUserIds.Any())
return new List<AttendanceSummaryExcel>();
// 2. 获取考勤原始记录
var attendanceRecords = await GetAttendanceAsync(startDate, endDate, allUserIds);
// 3. 获取员工姓名
var userNames = await GetUserNamesAsync(allUserIds);
// 4. 汇总统计
var summaryList = new List<AttendanceSummaryExcel>();
var groupedRecords = attendanceRecords.GroupBy(r => r.UserId);
foreach (var userGroup in groupedRecords)
{
var summary = new AttendanceSummaryExcel
{
UserId = userGroup.Key,
UserName = userNames.ContainsKey(userGroup.Key) ? userNames[userGroup.Key] : userGroup.Key,
};
var dailyGroups = userGroup.GroupBy(r => r.WorkDate.Date);
foreach (var dayGroup in dailyGroups)
{
int day = dayGroup.Key.Day;
var onDuty = dayGroup.FirstOrDefault(x => x.CheckType == "OnDuty");
var offDuty = dayGroup.FirstOrDefault(x => x.CheckType == "OffDuty");
string status;
if (onDuty == null && offDuty == null) status = "缺卡";
else if (dayGroup.Any(x => x.TimeResult == "SeriousLate")) status = "严重迟到";
else if (dayGroup.Any(x => x.TimeResult == "Late")) status = "迟到";
else if (dayGroup.Any(x => x.TimeResult == "Early")) status = "早退";
else status = "正常";
summary.DailyStatus[day] = status;
// 出勤统计(上下班都打卡才计算工作时长)
if (onDuty != null && offDuty != null)
{
summary.AttendanceDays++;
double workHours = (offDuty.UserCheckTime - onDuty.UserCheckTime).TotalHours;
summary.WorkHours += workHours > 0 ? workHours : 0;
summary.AttendanceShifts.AddRange(dayGroup.Select(x => x.ClassId.ToString()));
}
// 迟到/早退统计
if (onDuty != null && onDuty.TimeResult == "Late")
summary.LateCount++;
if (onDuty != null && onDuty.TimeResult == "Late")
summary.LateHours += (onDuty.UserCheckTime - onDuty.PlanCheckTime).TotalHours;
if (onDuty != null && onDuty.TimeResult == "SeriousLate")
summary.SevereLateCount++;
if (onDuty != null && onDuty.TimeResult == "SeriousLate")
summary.SevereLateHours += (onDuty.UserCheckTime - onDuty.PlanCheckTime).TotalHours;
if (offDuty != null && offDuty.TimeResult == "Early")
summary.EarlyLeaveCount++;
if (offDuty != null && offDuty.TimeResult == "Early")
summary.EarlyLeaveHours += (offDuty.PlanCheckTime - offDuty.UserCheckTime).TotalHours;
// 缺卡统计
summary.MissingCheckInCount += dayGroup.Count(x => x.CheckType == "OnDuty" && x.TimeResult == "NotSigned");
summary.MissingCheckOutCount += dayGroup.Count(x => x.CheckType == "OffDuty" && x.TimeResult == "NotSigned");
}
// 班次去重
summary.AttendanceShifts = summary.AttendanceShifts.Distinct().ToList();
summaryList.Add(summary);
}
// 5. 分页返回
return summaryList.Skip((pageIndex - 1) * pageSize).Take(pageSize).ToList();
}
}
}
调用:
/// <summary>
/// 根据前端传入的开始时间和结束时间获取考勤信息(自动获取所有部门与员工)
/// </summary>
/// <param name = "startTime" > 开始时间,格式 yyyy-MM-dd</param>
/// <param name = "endTime" > 结束时间,格式 yyyy-MM-dd</param>
/// <returns>返回考勤数据 JSON</returns>
[HttpGet]
public async Task<IActionResult> GetAttendance(string startTime, string endTime)
{
try
{
// 参数检查
if (string.IsNullOrEmpty(startTime) || string.IsNullOrEmpty(endTime))
return Json(new { success = false, message = "开始时间或结束时间不能为空" });
// 时间格式解析
if (!DateTime.TryParseExact(startTime, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var startDate))
return Json(new { success = false, message = "开始时间格式错误,应为 yyyy-MM-dd" });
if (!DateTime.TryParseExact(endTime, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var endDate))
return Json(new { success = false, message = "结束时间格式错误,应为 yyyy-MM-dd" });
// 钉钉API初始化
var dingApi = new DingDingAPI(
appKey: "xxxxx",
appSecret: "xxxxxxxxxxxxxx"
);
// 获取原始考勤记录
var rawRecords = await dingApi.GetAllAttendanceAsync(startDate, endDate);
if (rawRecords == null || rawRecords.Count == 0)
return Json(new { success = false, message = "未获取到任何考勤记录" });
// 汇总统计(按员工+日期)
var summaryRecords = dingApi.GetAttendanceSummary(rawRecords);
// 只返回汇总记录
return Json(new
{
success = true,
count = summaryRecords.Count,
data = summaryRecords
});
}
catch (Exception ex)
{
return Json(new
{
success = false,
message = $"获取考勤失败:{ex.Message}"
});
}
}
这是拿原始考勤数据,至于汇总统计,需要自己结合实际需求再去开发了。以上。