/** * 钉钉集成服务 — 对齐 gongxue-dorm-sys * * 提供: * - OAuth2 access_token(新版 API + 缓存) * - BFS 遍历所有部门 + 用户(带限流) * - 用户同步(自动建 User + Student + UserDingMapping) */ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import * as bcrypt from 'bcryptjs'; import { Department } from '../entities/department.entity'; import { User } from '../entities/user.entity'; import { Student } from '../entities/student.entity'; import { UserDingMapping } from '../entities/user-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; workDate: string; timeResult: string; locationResult: string; planCheckTime: string; actualCheckTime: string; checkId: string; checkType: string; } @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(User) private readonly userRepo: Repository, @InjectRepository(Student) private readonly studentRepo: Repository, @InjectRepository(UserDingMapping) private readonly mappingRepo: 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): Promise { const ids: number[] = []; const queue: number[] = [1]; 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(): 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); 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 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 }; } // ═══════════════════════════════════════════ // Sync one user (with mapping) // ═══════════════════════════════════════════ private async syncOneUser(du: { userid: string; name: string; mobile: string; }): Promise { // Look up by mapping first let mapping = await this.mappingRepo.findOne({ where: { dingUserId: du.userid } }); let user: User | null = null; if (mapping) { user = await this.userRepo.findOne({ where: { id: mapping.userId } }); if (user) { user.name = du.name; await this.userRepo.save(user); } mapping.dingName = du.name; mapping.dingMobile = du.mobile; await this.mappingRepo.save(mapping); return; } // No mapping → find or create const username = du.mobile || `dd_${du.userid}`; user = await this.userRepo.findOne({ where: { username } }); if (!user) { const passwordHash = await bcrypt.hash('123456', 10); user = this.userRepo.create({ username, name: du.name, passwordHash, isActive: true, }); await this.userRepo.save(user); const student = this.studentRepo.create({ name: du.name, phone: du.mobile || undefined, userId: user.id, status: 'active', }); await this.studentRepo.save(student); } else { // Update existing user user.name = du.name; await this.userRepo.save(user); // Ensure Student record exists (backfill for users synced before this logic) const existingStudent = await this.studentRepo.findOne({ where: { userId: user.id } }); if (!existingStudent) { const student = this.studentRepo.create({ name: du.name, phone: du.mobile || undefined, userId: user.id, status: 'active', }); await this.studentRepo.save(student); } } // Create mapping mapping = this.mappingRepo.create({ dingUserId: du.userid, userId: user.id, dingName: du.name, dingMobile: du.mobile, }); await this.mappingRepo.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 columnIdList = ['1','2','3','4','5','6','8','9']; const body: Record = { column_id_list: columnIdList.join(','), from_date: params.startDate, to_date: params.endDate, offset: params.offset ?? 0, limit: params.limit ?? 50, }; if (params.userIds?.length) body.userid_list = params.userIds.join(','); const res = await fetch( `https://oapi.dingtalk.com/topapi/attendance/getcolumnval?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: { hasMore: boolean; column_vals?: Array<{ column_vals: string[] }> }; }; if (data.errcode !== 0) throw new Error(`钉钉考勤获取失败: ${data.errmsg}`); return (data.result?.column_vals ?? []).map((col) => { const v = col.column_vals ?? []; return { userId: v[0]??'', workDate: v[1]??'', timeResult: v[2]??'', locationResult: v[3]??'', planCheckTime: v[4]??'', actualCheckTime: v[5]??'', checkId: v[7]??'', checkType: v[8]??'' }; }); } 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)); } }