当前位置: 首页 > news >正文

C#获取钉钉平台考勤记录

首先,**需要登录钉钉开放平台。同时需要所在企业的管理员权限。**这是前期准备工作。如下:
在这里插入图片描述

登录完成后,需要自建应用:在这里插入图片描述

然后你就能获取到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{// 全局 HttpClientprivate 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; }             // 钉钉UserIdpublic 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}"});}}

这是拿原始考勤数据,至于汇总统计,需要自己结合实际需求再去开发了。以上。

http://www.dtcms.com/a/499347.html

相关文章:

  • Java JVM “调优” 面试清单(含超通俗生活案例与深度理解)
  • opencv gpu cuda python c++版本测试代码
  • 建设旅游门户网站安徽网站建设推荐 晨飞网络
  • 鸿蒙Next Wear Engine Kit:打造无缝连接的穿戴应用体验
  • 哪里有免费的网站推广软件网站源码上传完后怎么做
  • 快手测开面试题总结合并版(按分类标注序号+出现频率)
  • P1005 [NOIP 2007 提高组] 矩阵取数游戏
  • JAVA面试复习笔记(待完善)
  • 七、WEB APIs(二)
  • LLMs-from-scratch :多种字节对编码(BPE)对比
  • 济南哪里有网站建设公司网站类网站开发源代码
  • 做笔记的网站源码wordpress手机版论坛
  • 网站推广有哪些举措域名需要跟网站名称一致么
  • 具身神经-机器人通讯架构与实现系列
  • [GO]gin框架:ShouldBindJSON与其他常见绑定方法
  • KUKA库卡焊接机器人二氧化碳节气
  • 机器人、具身智能的起步——线性系统理论|【三】线性、因果与时不变
  • 服务器做php网站吗wordpress评论贴图
  • 网站建设与管理的心得怎样做音乐网站
  • 请例举 Android 中常用布局类型,并简述其用法以及排版效率
  • Android 约束布局(ConstraintLayout)的权重机制:用法与对比解析
  • 编程与数学 03-007 《看潮资源管理器》项目开发 07 主窗口设计(3-3)
  • 基于单片机的架空线路接地故障检测与报警系统
  • 鸿蒙实现滴滴出行项目之乘客支付订单功能
  • 如何把自己做的网站放到网上360建筑网怎样取消发布的消息
  • 做网站有哪个空间网站建设优化推广贵州
  • 西电25年A测 语音识别机械臂方案与教程
  • 数据结构——队列的链式存储结构
  • 媒体135网站口碑好的宜昌网站建设
  • 湖南省建设银行网站官网深圳龙华网站建设公司