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
    {
        // 全局 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}"
                });
            }
        }

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

相关推荐
承渊政道3 小时前
动态内存管理
c语言·c++·经验分享·c#·visual studio
best_virtuoso3 小时前
PostgreSQL 常见数组操作函数语法、功能
java·数据结构·postgresql
yudiandian20143 小时前
02 Oracle JDK 下载及配置(解压缩版)
java·开发语言
future_studio3 小时前
聊聊 Unity(小白专享、C# 小程序 之 播放器)
unity·小程序·c#
楚韵天工4 小时前
宠物服务平台(程序+文档)
java·网络·数据库·spring cloud·编辑器·intellij-idea·宠物
helloworddm4 小时前
Orleans Stream SubscriptionId 生成机制详解
java·系统架构·c#
失散134 小时前
分布式专题——43 ElasticSearch概述
java·分布式·elasticsearch·架构
ajsbxi4 小时前
【Java 基础】核心知识点梳理
java·开发语言·笔记
向宇it4 小时前
【unity实战】MapMagic 2实战例子
游戏·3d·unity·c#·游戏引擎