/** * 钉钉集成服务 — 对齐 gongxue-dorm-sys * * 提供: * - OAuth2 access_token(新版 API + 缓存) * - BFS 遍历所有部门 + 用户(带限流) * - 用户同步(自动建 Student + StudentDingMapping) */ import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Department } from '../entities/department.entity'; import { Student } from '../entities/student.entity'; import { Class } from '../entities/class.entity'; import { StudentDingMapping } from '../entities/student-ding-mapping.entity'; // ── Types ── interface DingTalkTokenResponse { accessToken: string; expireIn: number; } interface SubDeptIdListResponse { errcode: number; errmsg: string; result: { dept_id_list: number[] }; } interface DepartmentDetailResponse { errcode: number; errmsg: string; result: { dept_id: number; name: string; parent_id: number }; } interface DingTalkUserListResponse { errcode: number; errmsg: string; result: { has_more: boolean; next_cursor?: number; list: Array<{ userid: string; name: string; mobile: string; dept_id_list: number[]; }>; }; } /** 钉钉打卡结果 — 对齐 dws attendance check result */ export interface DingTalkAttendanceResult { userId: string; userName: string; workDate: string; timeResult: string; locationResult: string; planCheckTime: string; actualCheckTime: string; checkId: string; checkType: string; } /** 钉钉部门树节点,供前端选择器使用 */ export interface DingOrgTreeNode { id: number; name: string; parentId: number; children: DingOrgTreeNode[]; } /** 钉钉部门树节点(含用户),供同步用户选择器使用 */ export interface DingOrgTreeNodeWithUsers { id: number; name: string; parentId: number; children: DingOrgTreeNodeWithUsers[]; users: Array<{ userid: string; name: string; mobile: string; deptIds: number[]; }>; } // ── 考勤排班 API 类型 ── /** 班次卡段打卡时间 */ export interface DingTalkShiftTime { check_type: 'OnDuty' | 'OffDuty'; across: number; check_time: string; begin_min?: number; end_min?: number; free_check?: boolean; } /** 班次卡段 */ export interface DingTalkShiftSection { times: DingTalkShiftTime[]; } /** 班次配置 */ export interface DingTalkShiftSetting { is_flexible?: boolean; serious_late_minutes?: number; absenteeism_late_minutes?: number; } /** 创建/修改班次参数 */ export interface DingTalkShiftParams { id?: number; name: string; owner?: string; sections: DingTalkShiftSection[]; setting?: DingTalkShiftSetting; } /** 班次摘要(查询返回) */ export interface DingTalkShiftSummary { id: number; name: string; } /** 考勤组成员 */ export interface DingTalkGroupMember { role: string; type: 'StaffMember' | 'DeptMember'; user_id: string; } /** 创建考勤组参数 */ export interface DingTalkGroupParams { name: string; type: 'TURN'; owner: string; members: DingTalkGroupMember[]; shift_ids?: number[]; enable_emp_select_class?: boolean; disable_check_without_schedule?: boolean; disable_check_when_rest?: boolean; } /** 考勤组摘要(查询返回) */ export interface DingTalkGroupSummary { group_id: number; group_name: string; type: string; member_count: number; } /** 排班参数(单条) */ export interface DingTalkScheduleItem { userid: string; work_date: number; shift_id: number; is_rest?: boolean; } /** 排班查询结果 */ export interface DingTalkScheduleResult { userid: string; work_date: string; shift_id: number; is_rest: string; check_type: string; plan_check_time: string; group_id: number; id: number; } @Injectable() export class DingTalkService { private readonly logger = new Logger(DingTalkService.name); private accessToken: string | null = null; private tokenExpiresAt = 0; private apiRequestCount = 0; /** 钉钉 API 限流:每秒最多 20 次 */ private static readonly RATE_LIMIT = 20; private static readonly MIN_INTERVAL = 1000 / DingTalkService.RATE_LIMIT; constructor( @InjectRepository(Department) private readonly deptRepo: Repository, @InjectRepository(Student) private readonly studentRepo: Repository, @InjectRepository(StudentDingMapping) private readonly studentDingMappingRepo: Repository, @InjectRepository(Class) private readonly classRepo: Repository, ) {} private get configured(): boolean { return !!(process.env.DINGTALK_APP_KEY && process.env.DINGTALK_APP_SECRET); } // ═══════════════════════════════════════════ // Token — 对齐 gongxue-dorm-sys getAccessToken // ═══════════════════════════════════════════ private async getAccessToken(): Promise { if (this.accessToken && Date.now() < this.tokenExpiresAt - 60_000) { return this.accessToken; } const appKey = process.env.DINGTALK_APP_KEY!; const appSecret = process.env.DINGTALK_APP_SECRET!; const res = await fetch('https://api.dingtalk.com/v1.0/oauth2/accessToken', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ appKey, appSecret }), }); const body: DingTalkTokenResponse = await res.json(); if (!body.accessToken) { throw new Error(`钉钉 access_token 获取失败: ${JSON.stringify(body)}`); } this.accessToken = body.accessToken; this.tokenExpiresAt = Date.now() + (body.expireIn || 7200) * 1000; this.logger.log('钉钉 access_token 获取成功'); return this.accessToken; } // ═══════════════════════════════════════════ // Department BFS — 对齐 gongxue-dorm-sys getAllSubDepartmentIds // ═══════════════════════════════════════════ private async getAllDeptIds(token: string, rootDeptId = 1): Promise { const ids: number[] = []; const queue: number[] = [rootDeptId]; while (queue.length > 0) { const deptId = queue.shift()!; ids.push(deptId); try { await this.rateLimit(); const res = await fetch( `https://oapi.dingtalk.com/topapi/v2/department/listsubid?access_token=${token}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dept_id: deptId }), }, ); const body: SubDeptIdListResponse = await res.json(); if (body.errcode === 0 && body.result?.dept_id_list) { queue.push(...body.result.dept_id_list); } } catch (e) { this.logger.error(`获取部门 ${deptId} 子部门失败: ${(e as Error).message}`); } } return ids; } // ═══════════════════════════════════════════ // Department detail // ═══════════════════════════════════════════ private async getDeptDetail( token: string, deptId: number, ): Promise<{ dept_id: number; name: string; parent_id: number } | null> { try { const res = await fetch( `https://oapi.dingtalk.com/topapi/v2/department/get?access_token=${token}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dept_id: deptId, language: 'zh_CN' }), }, ); const body: DepartmentDetailResponse = await res.json(); return body.errcode === 0 ? body.result : null; } catch (e) { this.logger.error(`获取部门 ${deptId} 详情失败: ${(e as Error).message}`); return null; } } // ═══════════════════════════════════════════ // Users by department — 对齐 gongxue-dorm-sys getUsersByDepartment // ═══════════════════════════════════════════ private async getDeptUsers( token: string, deptId: number, ): Promise> { const all: Array<{ userid: string; name: string; mobile: string; dept_id_list: number[] }> = []; let cursor = 0; let hasMore = true; while (hasMore) { try { const res = await fetch( `https://oapi.dingtalk.com/topapi/v2/user/list?access_token=${token}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dept_id: deptId, cursor, size: 100 }), }, ); const body: DingTalkUserListResponse = await res.json(); if (body.errcode === 0 && body.result) { all.push(...body.result.list); hasMore = body.result.has_more; if (hasMore && body.result.next_cursor !== undefined) { cursor = body.result.next_cursor; } } else { hasMore = false; } } catch (e) { this.logger.error(`获取部门 ${deptId} 用户失败: ${(e as Error).message}`); hasMore = false; } } return all; } // ═══════════════════════════════════════════ // Sync all — 主入口 // ═══════════════════════════════════════════ async syncAll(rootDeptId = 1): Promise<{ deptCount: number; userCount: number }> { if (!this.configured) { this.logger.warn('钉钉未配置 (DINGTALK_APP_KEY / DINGTALK_APP_SECRET),跳过同步'); return { deptCount: 0, userCount: 0 }; } const t0 = Date.now(); const token = await this.getAccessToken(); // ── Step 1: BFS traverse all departments ── this.logger.log('开始 BFS 遍历钉钉部门...'); const deptIds = await this.getAllDeptIds(token, rootDeptId); this.logger.log(`共发现 ${deptIds.length} 个部门`); // ── Step 2: Sync departments ── let deptCount = 0; for (let i = 0; i < deptIds.length; i++) { const deptId = deptIds[i]; if (i > 0) await this.delay(i); const detail = await this.getDeptDetail(token, deptId); if (!detail) continue; const sourceId = String(detail.dept_id); let dept = await this.deptRepo.findOne({ where: { source: 'dingtalk', sourceId } }); if (dept) { dept.name = detail.name; if (detail.parent_id) dept.parentSourceId = String(detail.parent_id); } else { dept = this.deptRepo.create({ name: detail.name, source: 'dingtalk', sourceId, type: 'department', } as Department); if (detail.parent_id) dept.parentSourceId = String(detail.parent_id); deptCount++; } await this.deptRepo.save(dept); } // Set parent relationships const syncedDepts = await this.deptRepo.find({ where: { source: 'dingtalk' } }); const idMap = new Map(syncedDepts.map((d) => [d.sourceId, d.id])); for (const dept of syncedDepts) { if (dept.parentSourceId && idMap.has(dept.parentSourceId)) { dept.parentId = idMap.get(dept.parentSourceId)!; } else if (dept.parentSourceId === '1' || dept.parentSourceId === '0') { dept.parentId = undefined as any; } } await this.deptRepo.save(syncedDepts); // ── Step 2.5: Auto-create Class for leaf departments ── const parentIds = new Set(syncedDepts.map((d) => d.parentSourceId)); const leafDepts = syncedDepts.filter((d) => !parentIds.has(d.sourceId)); let classCreated = 0; for (const leaf of leafDepts) { const code = `DT_${leaf.sourceId}`; const exists = await this.classRepo.findOne({ where: { code } }); if (!exists) { const cls = this.classRepo.create({ name: leaf.name, code, departmentId: leaf.id, classType: 'culture', status: 'enrolling', }); await this.classRepo.save(cls); classCreated++; } } if (classCreated > 0) this.logger.log(`从钉钉叶子部门自动创建 ${classCreated} 个班级`); // ── Step 3: Sync users per department ── let userCount = 0; const seenUserIds = new Set(); for (let i = 0; i < deptIds.length; i++) { const deptId = deptIds[i]; const dingUsers = await this.getDeptUsers(token, deptId); for (const du of dingUsers) { if (seenUserIds.has(du.userid)) continue; seenUserIds.add(du.userid); await this.syncOneUser(du); userCount++; } } this.logger.log( `钉钉同步完成: ${deptCount} 个新部门, ${userCount} 个用户, API 请求 ${this.apiRequestCount} 次, 耗时 ${Date.now() - t0}ms`, ); return { deptCount, userCount }; } /** * 获取钉钉组织部门树(只含部门,不含用户),供前端选择同步起点。 * 返回从指定 rootDeptId 开始的树;默认根部门 1。 */ async fetchOrgTree(rootDeptId = 1): Promise { if (!this.configured) { throw new ServiceUnavailableException('钉钉未配置'); } const token = await this.getAccessToken(); const deptIds = await this.getAllDeptIds(token, rootDeptId); // 拉每个部门详情 const nodes: DingOrgTreeNode[] = []; for (let i = 0; i < deptIds.length; i++) { if (i > 0) await this.delay(i); const detail = await this.getDeptDetail(token, deptIds[i]); if (detail) { nodes.push({ id: detail.dept_id, name: detail.name, parentId: detail.parent_id, children: [], }); } } // 组装成树 const map = new Map(); nodes.forEach((n) => map.set(n.id, n)); const roots: DingOrgTreeNode[] = []; for (const node of nodes) { const parent = map.get(node.parentId); if (parent && node.id !== rootDeptId) { parent.children.push(node); } else { roots.push(node); } } return roots; } /** * 获取钉钉组织部门树(含用户),供前端同步用户选择器使用。 * 返回从指定 rootDeptId 开始的树,每个部门节点含 users 数组。 */ async fetchOrgTreeWithUsers(rootDeptId = 1): Promise { if (!this.configured) { throw new ServiceUnavailableException('钉钉未配置'); } const token = await this.getAccessToken(); const deptIds = await this.getAllDeptIds(token, rootDeptId); // 拉每个部门详情 const nodes: DingOrgTreeNodeWithUsers[] = []; // Before dedup: collect deptIds per user const userDeptMap = new Map(); for (let i = 0; i < deptIds.length; i++) { if (i > 0) await this.delay(i); const detail = await this.getDeptDetail(token, deptIds[i]); if (!detail) continue; // 拉该部门下的用户 const dingUsers = await this.getDeptUsers(token, deptIds[i]); this.logger.log(`[dingtalk] dept ${deptIds[i]} (${detail.name}): ${dingUsers.length} users`); nodes.push({ id: detail.dept_id, name: detail.name, parentId: detail.parent_id, children: [], users: dingUsers.map((u) => ({ userid: u.userid, name: u.name, mobile: u.mobile, deptIds: [], })), }); // Record which departments each user belongs to for (const u of dingUsers) { if (!userDeptMap.has(u.userid)) { userDeptMap.set(u.userid, []); } userDeptMap.get(u.userid)!.push(detail.dept_id); } } // 全局去重:同一个 dingUserId 可能在多个部门出现 const seenUserIds = new Set(); for (const node of nodes) { node.users = node.users .filter((u) => { if (seenUserIds.has(u.userid)) return false; seenUserIds.add(u.userid); return true; }) .map((u) => ({ ...u, deptIds: userDeptMap.get(u.userid) || [], })); } // 组装成树(父节点可能已被过滤,缺失的父节点 → 节点提升为根) const map = new Map(); nodes.forEach((n) => map.set(n.id, n)); const roots: DingOrgTreeNodeWithUsers[] = []; for (const node of nodes) { const parent = map.get(node.parentId); if (parent && node.id !== rootDeptId) { parent.children.push(node); } else { roots.push(node); } } return roots; } // ═══════════════════════════════════════════ // Sync one user (with mapping) // ═══════════════════════════════════════════ private async syncOneUser(du: { userid: string; name: string; mobile: string; }): Promise { let mapping = await this.studentDingMappingRepo.findOne({ where: { dingUserId: du.userid }, }); if (mapping) { const student = await this.studentRepo.findOne({ where: { id: mapping.studentId }, }); if (student) { student.name = du.name; if (du.mobile) student.phone = du.mobile; await this.studentRepo.save(student); } return; } const student = this.studentRepo.create({ name: du.name, phone: du.mobile || undefined, status: 'active', }); await this.studentRepo.save(student); mapping = this.studentDingMappingRepo.create({ dingUserId: du.userid, studentId: student.id, }); await this.studentDingMappingRepo.save(mapping); } // ═══════════════════════════════════════════ // Rate limiting — 对齐 gongxue-dorm-sys // ═══════════════════════════════════════════ private async rateLimit(): Promise { await this.sleep(DingTalkService.MIN_INTERVAL); this.apiRequestCount++; } // ═══════════════════════════════════════════ // 考勤打卡结果 — 对齐 dws attendance check result // ═══════════════════════════════════════════ async fetchAttendanceResults(params: { startDate: string; endDate: string; userIds?: string[]; offset?: number; limit?: number; }): Promise { if (!this.configured) throw new Error('DingTalk not configured'); const token = await this.getAccessToken(); const dateFrom = params.startDate.includes(' ') ? params.startDate : `${params.startDate} 00:00:00`; const dateTo = params.endDate.includes(' ') ? params.endDate : `${params.endDate} 23:59:59`; const body: Record = { checkDateFrom: dateFrom, checkDateTo: dateTo, }; if (params.userIds?.length) body.userIds = params.userIds; if (params.offset !== undefined) body.offset = params.offset; if (params.limit !== undefined) body.limit = params.limit; const res = await fetch( `https://oapi.dingtalk.com/attendance/listRecord?access_token=${token}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }, ); const data = await res.json() as { errcode: number; errmsg: string; recordresult?: Array<{ id: number; userId: string; workDate: number; userCheckTime: number; sourceType: string; checkType?: string; timeResult?: string; locationResult?: string; locationMethod?: string; userAddress?: string; userLongitude?: number; userLatitude?: number; }>; }; if (data.errcode !== 0) throw new Error(`钉钉考勤获取失败: ${data.errmsg}`); return (data.recordresult ?? []).map((r) => ({ userId: r.userId, userName: '', workDate: new Date(r.workDate).toISOString().slice(0, 10), timeResult: r.timeResult ?? r.sourceType ?? '', locationResult: r.locationResult ?? r.locationMethod ?? r.userAddress ?? '', planCheckTime: '', actualCheckTime: new Date(r.userCheckTime).toISOString(), checkId: String(r.id), checkType: r.checkType ?? r.sourceType ?? '', })); } // ═══════════════════════════════════════════ // 考勤排班 — 班次管理 // ═══════════════════════════════════════════ /** 创建或修改班次。id 不传=创建,传了=修改 */ async upsertShift(params: DingTalkShiftParams): Promise { if (!this.configured) throw new ServiceUnavailableException('钉钉未配置'); const token = await this.getAccessToken(); const body: Record = { op_user_id: params.owner || 'manager', shift: { name: params.name, owner: params.owner, sections: params.sections.map((s) => ({ times: s.times.map((t) => ({ check_type: t.check_type, across: t.across, check_time: t.check_time, begin_min: t.begin_min ?? -1, end_min: t.end_min ?? -1, free_check: t.free_check ?? false, })), })), setting: params.setting ? { is_flexible: params.setting.is_flexible ?? false, serious_late_minutes: params.setting.serious_late_minutes ?? -1, absenteeism_late_minutes: params.setting.absenteeism_late_minutes ?? -1, } : undefined, }, }; if (params.id) (body.shift as Record).id = params.id; await this.rateLimit(); const res = await fetch( `https://oapi.dingtalk.com/topapi/attendance/shift/add?access_token=${token}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }, ); const data = (await res.json()) as { errcode: number; errmsg: string; result?: { id: number; name: string }; }; if (data.errcode !== 0) { throw new Error(`钉钉班次操作失败: ${data.errmsg} (code=${data.errcode})`); } this.logger.log(`钉钉班次 ${params.id ? '更新' : '创建'} 成功: ${data.result?.name} (id=${data.result?.id})`); return data.result!.id; } /** 查询所有班次摘要 */ async queryShifts(opUserId = 'manager'): Promise { if (!this.configured) throw new ServiceUnavailableException('钉钉未配置'); const token = await this.getAccessToken(); await this.rateLimit(); const res = await fetch( `https://oapi.dingtalk.com/topapi/attendance/shift/list?access_token=${token}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ op_user_id: opUserId }), }, ); const data = (await res.json()) as { errcode: number; errmsg: string; result?: Array<{ id: number; name: string }>; }; if (data.errcode !== 0) { throw new Error(`钉钉查询班次失败: ${data.errmsg} (code=${data.errcode})`); } return (data.result ?? []).map((s) => ({ id: s.id, name: s.name })); } /** 按名称查找班次,不存在则创建 */ async findOrCreateShift(name: string, startTime: string, endTime: string, opUserId = 'manager'): Promise { const existing = await this.queryShifts(opUserId); const found = existing.find((s) => s.name === name); if (found) return found.id; return this.upsertShift({ name, owner: opUserId, sections: [{ times: [ { check_type: 'OnDuty', across: 0, check_time: `1970-01-01 ${startTime}:00`, free_check: false }, { check_type: 'OffDuty', across: 0, check_time: `1970-01-01 ${endTime}:00`, free_check: false }, ], }], setting: { is_flexible: false, serious_late_minutes: -1, absenteeism_late_minutes: -1 }, }); } // ═══════════════════════════════════════════ // 考勤排班 — 考勤组管理 // ═══════════════════════════════════════════ /** 创建排班制考勤组 */ async createAttendanceGroup(params: DingTalkGroupParams): Promise { if (!this.configured) throw new ServiceUnavailableException('钉钉未配置'); const token = await this.getAccessToken(); const topGroup: Record = { name: params.name, type: params.type, owner: params.owner, members: params.members.map((m) => ({ role: m.role, type: m.type, user_id: m.user_id, })), enable_emp_select_class: params.enable_emp_select_class ?? true, disable_check_without_schedule: params.disable_check_without_schedule ?? false, disable_check_when_rest: params.disable_check_when_rest ?? true, }; if (params.shift_ids?.length) { topGroup.shift_vo_list = params.shift_ids.map((id) => ({ id })); } const body = { op_user_id: params.owner, top_group: topGroup }; await this.rateLimit(); const res = await fetch( `https://oapi.dingtalk.com/topapi/attendance/group/add?access_token=${token}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }, ); const data = (await res.json()) as { errcode: number; errmsg: string; result?: { id: number }; }; if (data.errcode !== 0) { throw new Error(`钉钉创建考勤组失败: ${data.errmsg} (code=${data.errcode})`); } this.logger.log(`钉钉考勤组创建成功: ${params.name} (id=${data.result?.id})`); return data.result!.id; } /** 查询所有考勤组摘要(分页,每页10条) */ async queryAttendanceGroups(opUserId = 'manager'): Promise { if (!this.configured) throw new ServiceUnavailableException('钉钉未配置'); const token = await this.getAccessToken(); const all: DingTalkGroupSummary[] = []; let offset = 0; let hasMore = true; while (hasMore) { await this.rateLimit(); const res = await fetch( `https://oapi.dingtalk.com/topapi/attendance/getsimplegroups?access_token=${token}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ offset, size: 10 }), }, ); const data = (await res.json()) as { errcode: number; errmsg: string; result?: { has_more: boolean; groups: Array<{ group_id: number; group_name: string; type: string; member_count: number }>; }; }; if (data.errcode !== 0) { throw new Error(`钉钉查询考勤组失败: ${data.errmsg} (code=${data.errcode})`); } if (data.result?.groups) { all.push(...data.result.groups.map((g) => ({ group_id: g.group_id, group_name: g.group_name, type: g.type, member_count: g.member_count, }))); } hasMore = data.result?.has_more ?? false; offset += 10; } return all; } /** 按名称查找考勤组,不存在则创建 */ async findOrCreateAttendanceGroup( name: string, ownerUserId: string, memberUserIds: string[], shiftIds: number[], ): Promise { const existing = await this.queryAttendanceGroups(ownerUserId); const found = existing.find((g) => g.group_name === name); if (found) return found.group_id; return this.createAttendanceGroup({ name, type: 'TURN', owner: ownerUserId, members: memberUserIds.map((uid) => ({ role: 'Attendance', type: 'StaffMember', user_id: uid, })), shift_ids: shiftIds, enable_emp_select_class: true, disable_check_without_schedule: false, disable_check_when_rest: true, }); } // ═══════════════════════════════════════════ // 考勤排班 — 排班分配 // ═══════════════════════════════════════════ /** 批量排班(单次最多200条) */ async scheduleUsers( groupId: number, schedules: DingTalkScheduleItem[], opUserId = 'manager', ): Promise { if (!this.configured) throw new ServiceUnavailableException('钉钉未配置'); if (schedules.length === 0) return; if (schedules.length > 200) { throw new Error(`排班单次最多200条,当前 ${schedules.length} 条`); } const token = await this.getAccessToken(); const body = { op_user_id: opUserId, group_id: groupId, schedules: schedules.map((s) => ({ userid: s.userid, work_date: s.work_date, shift_id: s.shift_id, is_rest: s.is_rest ?? false, })), }; await this.rateLimit(); const res = await fetch( `https://oapi.dingtalk.com/topapi/attendance/group/schedule/async?access_token=${token}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }, ); const data = (await res.json()) as { errcode: number; errmsg: string; }; if (data.errcode !== 0) { throw new Error(`钉钉排班失败: ${data.errmsg} (code=${data.errcode})`); } this.logger.log(`钉钉排班成功: groupId=${groupId}, ${schedules.length} 条`); } /** 查询指定用户的排班信息(7天内,最多50人) */ async queryScheduleByUsers( userIds: string[], fromDate: number, toDate: number, opUserId = 'manager', ): Promise { if (!this.configured) throw new ServiceUnavailableException('钉钉未配置'); const token = await this.getAccessToken(); await this.rateLimit(); const res = await fetch( `https://oapi.dingtalk.com/topapi/attendance/schedule/listbyusers?access_token=${token}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ op_user_id: opUserId, userids: userIds.join(','), from_date_time: fromDate, to_date_time: toDate, }), }, ); const data = (await res.json()) as { errcode: number; errmsg: string; result?: Array<{ userid: string; work_date: string; shift_id: number; is_rest: string; check_type: string; plan_check_time: string; group_id: number; id: number; }>; }; if (data.errcode !== 0) { throw new Error(`钉钉查询排班失败: ${data.errmsg} (code=${data.errcode})`); } return (data.result ?? []).map((r) => ({ userid: r.userid, work_date: r.work_date, shift_id: r.shift_id, is_rest: r.is_rest, check_type: r.check_type, plan_check_time: r.plan_check_time, group_id: r.group_id, id: r.id, })); } private delay(requestIndex: number): Promise { const ms = requestIndex % 10 === 0 ? 200 : DingTalkService.MIN_INTERVAL; return this.sleep(ms); } private sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } }