/** * 钉钉集成服务 — 对齐 gongxue-dorm-sys * * 提供: * - OAuth2 access_token(新版 API + 缓存) * - 用户同步(自动建 Student + StudentDingMapping) */ import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Student } from '../entities/student.entity'; import { StudentDingMapping } from '../entities/student-ding-mapping.entity'; import { IntegrationConfigService } from './config/integration-config.service'; // ── Types ── interface DingTalkTokenResponse { accessToken: string; expireIn: number; } interface DingTalkCredentials { appKey: string; appSecret: string; } 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; /** 钉钉返回的打卡来源,例如 ATM / USER / BEACON。 */ sourceType: string; /** 部分钉钉租户会额外返回考勤机名称或编号。 */ deviceName?: string; deviceId?: string; } // ── 组织架构 API 类型 ── interface DingTalkDeptListResponse { errcode: number; result?: Array<{ dept_id: number; name: string; parent_id: number }>; } interface DingTalkDeptGetResponse { errcode: number; result?: { name: string; parent_id: number }; } export interface OrgDeptNode { id: number; name: string; parentId: number; children: OrgDeptNode[]; } export interface OrgDeptNodeWithUsers extends OrgDeptNode { 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; /** 关闭外勤、定位、Wi-Fi 和手机蓝牙打卡,仅保留考勤机打卡入口 */ attendance_machine_only?: boolean; } /** 修改考勤组参数 */ export interface DingTalkGroupUpdateParams extends DingTalkGroupParams { id: number; } /** 考勤组摘要(查询返回) */ 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 accessTokenCredentialKey: 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(Student) private readonly studentRepo: Repository, @InjectRepository(StudentDingMapping) private readonly studentDingMappingRepo: Repository, private readonly integrationConfigService?: IntegrationConfigService, ) {} private async getCredentials(): Promise { const rawConfig = await this.integrationConfigService?.getRawConfig('DINGTALK'); const dbAppKey = typeof rawConfig?.agentId === 'string' ? rawConfig.agentId.trim() : ''; const dbAppSecret = typeof rawConfig?.appSecret === 'string' ? rawConfig.appSecret.trim() : ''; if (dbAppKey && dbAppSecret) { return { appKey: dbAppKey, appSecret: dbAppSecret }; } const envAppKey = process.env.DINGTALK_APP_KEY?.trim(); const envAppSecret = process.env.DINGTALK_APP_SECRET?.trim(); if (envAppKey && envAppSecret) { return { appKey: envAppKey, appSecret: envAppSecret }; } return null; } private async isConfigured(): Promise { return !!(await this.getCredentials()); } // ═══════════════════════════════════════════ // Token — 对齐 gongxue-dorm-sys getAccessToken // ═══════════════════════════════════════════ private async getAccessToken(): Promise { const credentials = await this.getCredentials(); if (!credentials) { throw new Error('DingTalk not configured'); } const credentialKey = `${credentials.appKey}:${credentials.appSecret}`; if ( this.accessToken && this.accessTokenCredentialKey === credentialKey && Date.now() < this.tokenExpiresAt - 60_000 ) { return this.accessToken; } const res = await fetch('https://api.dingtalk.com/v1.0/oauth2/accessToken', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(credentials), }); const body: DingTalkTokenResponse = await res.json(); if (!body.accessToken) { throw new Error(`钉钉 access_token 获取失败: ${JSON.stringify(body)}`); } this.accessToken = body.accessToken; this.accessTokenCredentialKey = credentialKey; this.tokenExpiresAt = Date.now() + (body.expireIn || 7200) * 1000; this.logger.log('钉钉 access_token 获取成功'); return this.accessToken; } // ═══════════════════════════════════════════ // 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) { if (body.result.next_cursor === undefined || body.result.next_cursor === cursor) { this.logger.error(`获取部门 ${deptId} 用户失败: 分页游标未前进`); hasMore = false; } else { 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 (!(await this.isConfigured())) { this.logger.warn('钉钉未配置 (DINGTALK_APP_KEY / DINGTALK_APP_SECRET),跳过同步'); return { deptCount: 0, userCount: 0 }; } const t0 = Date.now(); const token = await this.getAccessToken(); // 递归收集所有部门 ID const collectDeptIds = async (deptId: number): Promise => { const ids: number[] = [deptId]; const subs = await this.getSubDepts(token, deptId); for (const sd of subs) { ids.push(...(await collectDeptIds(sd.dept_id))); } return ids; }; const allDeptIds = await collectDeptIds(rootDeptId); let userCount = 0; const seenUserIds = new Set(); for (const did of allDeptIds) { const dingUsers = await this.getDeptUsers(token, did); for (const du of dingUsers) { if (seenUserIds.has(du.userid)) continue; seenUserIds.add(du.userid); await this.syncOneUser(du); userCount++; } } this.logger.log( `钉钉同步完成: ${userCount} 个用户, ${allDeptIds.length} 个部门, API 请求 ${this.apiRequestCount} 次, 耗时 ${Date.now() - t0}ms`, ); return { deptCount: allDeptIds.length, userCount }; } // ═══════════════════════════════════════════ /** 递归获取子部门列表(含名称) */ private async getSubDepts(token: string, deptId: number): Promise> { await this.rateLimit(); const res = await fetch( `https://oapi.dingtalk.com/topapi/v2/department/listsub?access_token=${token}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dept_id: deptId }) }, ); const body = await res.json() as DingTalkDeptListResponse; return body.errcode === 0 ? (body.result ?? []) : []; } /** 获取单个部门详情 */ private async getDeptInfo(token: string, deptId: number): Promise<{ name: string; parent_id: number } | null> { await this.rateLimit(); 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 }) }, ); const body = await res.json() as DingTalkDeptGetResponse; return body.errcode === 0 && body.result ? body.result : null; } /** 递归构建部门树节点 */ private async buildDeptNode(token: string, deptId: number, name: string, parentId: number): Promise { const subDepts = await this.getSubDepts(token, deptId); const children = await Promise.all( subDepts.map(sd => this.buildDeptNode(token, sd.dept_id, sd.name, sd.parent_id)), ); return { id: deptId, name, parentId, children }; } /** 获取钉钉组织部门树(只含部门) */ async fetchOrgTree(rootDeptId = 1): Promise { if (!(await this.isConfigured())) return []; const token = await this.getAccessToken(); const rootInfo = await this.getDeptInfo(token, rootDeptId); if (!rootInfo) return []; const node = await this.buildDeptNode(token, rootDeptId, rootInfo.name, rootInfo.parent_id); return [node]; } /** 获取钉钉组织部门树(含用户) */ async fetchOrgTreeWithUsers(rootDeptId = 1): Promise { if (!(await this.isConfigured())) return []; const token = await this.getAccessToken(); const rootInfo = await this.getDeptInfo(token, rootDeptId); if (!rootInfo) return []; // 1. 先建部门树(复用 buildDeptNode) const deptTree = await this.buildDeptNode(token, rootDeptId, rootInfo.name, rootInfo.parent_id); // 2. 收集所有部门 ID const allDeptIds: number[] = []; const collectIds = (node: OrgDeptNode) => { allDeptIds.push(node.id); for (const c of node.children) collectIds(c); }; collectIds(deptTree); // 3. 从每个部门拉用户,每人只挂到一个部门(dept_id_list 最后一个) const allDeptIdsSet = new Set(allDeptIds); const usersByDept = new Map>(); const placedUsers = new Set(); for (const did of allDeptIds) { const deptUsers = await this.getDeptUsers(token, did); for (const u of deptUsers) { if (placedUsers.has(u.userid)) continue; const targetDept = u.dept_id_list[u.dept_id_list.length - 1]; if (allDeptIdsSet.has(targetDept)) { placedUsers.add(u.userid); if (!usersByDept.has(targetDept)) usersByDept.set(targetDept, []); usersByDept.get(targetDept)!.push({ userid: u.userid, name: u.name, mobile: u.mobile, deptIds: u.dept_id_list }); } } } // 4. 递归挂用户到树节点 const attachUsers = (node: OrgDeptNode): OrgDeptNodeWithUsers => ({ id: node.id, name: node.name, parentId: node.parentId, children: node.children.map(attachUsers), users: usersByDept.get(node.id) ?? [], }); return [attachUsers(deptTree)]; } // ═══════════════════════════════════════════ // 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[]; }): Promise { if (!(await this.isConfigured())) throw new Error('DingTalk not configured'); if (!params.userIds?.length) throw new Error('钉钉考勤 userIds 不能为空'); if (params.userIds.length > 50) throw new Error('钉钉考勤单次最多查询50人'); 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, }; body.userIds = params.userIds; 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; deviceName?: string; deviceId?: string | number; attendanceMachineName?: string; attendanceMachineId?: string | number; }>; }; if (data.errcode !== 0) throw new Error(`钉钉考勤获取失败: ${data.errmsg}`); const records = data.recordresult ?? []; return records.map((r) => ({ userId: r.userId, userName: '', workDate: new Date(r.workDate + 8 * 60 * 60 * 1000).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 ?? '', sourceType: r.sourceType ?? '', deviceName: r.deviceName ?? r.attendanceMachineName, deviceId: String(r.deviceId ?? r.attendanceMachineId ?? '') || undefined, })); } // ═══════════════════════════════════════════ // 考勤排班 — 班次管理 // ═══════════════════════════════════════════ /** 创建或修改班次。id 不传=创建,传了=修改 */ async upsertShift(params: DingTalkShiftParams): Promise { if (!(await this.isConfigured())) 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; } /** 查询所有班次摘要(每页最多200条) */ async queryShifts(opUserId = 'manager'): Promise { if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置'); const token = await this.getAccessToken(); const all: DingTalkShiftSummary[] = []; let cursor = 0; let hasMore = true; while (hasMore) { 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, cursor }), }, ); const data = (await res.json()) as { errcode: number; errmsg: string; result?: { cursor?: number; has_more?: boolean; result?: Array<{ id: number; name: string }>; }; }; if (data.errcode !== 0) { throw new Error(`钉钉查询班次失败: ${data.errmsg} (code=${data.errcode})`); } const page = data.result; all.push(...(page?.result ?? []).map((s) => ({ id: s.id, name: s.name }))); hasMore = page?.has_more ?? false; if (hasMore) { if (page?.cursor === undefined || page.cursor === cursor) { throw new Error('钉钉查询班次失败: 分页游标无效'); } cursor = page.cursor; } } return all; } // ═══════════════════════════════════════════ // 考勤排班 — 考勤组管理 // ═══════════════════════════════════════════ /** 创建排班制考勤组 */ async createAttendanceGroup(params: DingTalkGroupParams): Promise { if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置'); const token = await this.getAccessToken(); const topGroup = this.buildAttendanceGroupBody(params); 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; } /** 更新排班制考勤组,确保复用考勤组时同步最新打卡限制 */ async updateAttendanceGroup(params: DingTalkGroupUpdateParams): Promise { if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置'); const token = await this.getAccessToken(); const topGroup = { ...this.buildAttendanceGroupBody(params), id: params.id }; await this.rateLimit(); const res = await fetch( `https://oapi.dingtalk.com/topapi/attendance/group/modify?access_token=${token}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ op_user_id: params.owner, top_group: topGroup }), }, ); const data = (await res.json()) as { errcode?: number; errmsg?: string; success?: boolean; message?: string; }; const succeeded = data.success === true || data.errcode === 0; if (!succeeded) { throw new Error( `钉钉更新考勤组失败: ${data.message || data.errmsg || '未知错误'} ` + `(code=${data.errcode ?? 'unknown'})`, ); } this.logger.log(`钉钉考勤组更新成功: ${params.name} (id=${params.id})`); } private buildAttendanceGroupBody(params: DingTalkGroupParams): Record { const machineOnly = params.attendance_machine_only ?? false; 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: machineOnly ? false : (params.enable_emp_select_class ?? true), disable_check_without_schedule: machineOnly ? true : (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 })); } if (machineOnly) { Object.assign(topGroup, { enable_outside_check: false, enable_position_ble: false, positions: [], wifis: [], }); } return topGroup; } /** 查询所有考勤组摘要(分页,每页10条) */ async queryAttendanceGroups(opUserId = 'manager'): Promise { if (!(await this.isConfigured())) 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 deleteAttendanceGroup(groupId: number, opUserId = 'manager'): Promise { if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置'); const token = await this.getAccessToken(); await this.rateLimit(); const keyResponse = await fetch( `https://oapi.dingtalk.com/topapi/attendance/groups/idtokey?access_token=${token}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ op_user_id: opUserId, group_id: groupId }), }, ); const keyData = await keyResponse.json() as { errcode: number; errmsg: string; result?: string; }; if (keyData.errcode !== 0 || !keyData.result) { throw new Error(`钉钉考勤组ID转换失败: ${keyData.errmsg} (code=${keyData.errcode})`); } await this.rateLimit(); const deleteResponse = await fetch( `https://oapi.dingtalk.com/topapi/attendance/group/delete?access_token=${token}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ op_userid: opUserId, group_key: keyData.result }), }, ); const deleteData = await deleteResponse.json() as { errcode: number; errmsg: string; success?: boolean; }; if (deleteData.errcode !== 0 || deleteData.success !== true) { throw new Error(`钉钉删除考勤组失败: ${deleteData.errmsg} (code=${deleteData.errcode})`); } } // ═══════════════════════════════════════════ // 考勤排班 — 排班分配 // ═══════════════════════════════════════════ /** 批量排班(单次最多200条) */ async scheduleUsers( groupId: number, schedules: DingTalkScheduleItem[], opUserId = 'manager', ): Promise { if (!(await this.isConfigured())) 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 (!(await this.isConfigured())) 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)); } }