diff --git a/apps/server/src/integration/dingtalk.service.ts b/apps/server/src/integration/dingtalk.service.ts index 86e78e3..02c149b 100644 --- a/apps/server/src/integration/dingtalk.service.ts +++ b/apps/server/src/integration/dingtalk.service.ts @@ -1,20 +1,37 @@ +/** + * 钉钉集成服务 — 对齐 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 { - errcode: number; - errmsg: string; - access_token: string; - expires_in: number; + accessToken: string; + expireIn: number; } -interface DingTalkDeptListResponse { +interface SubDeptIdListResponse { errcode: number; errmsg: string; - result: Array<{ dept_id: number; name: string; parent_id: number }>; + result: { dept_id_list: number[] }; +} + +interface DepartmentDetailResponse { + errcode: number; + errmsg: string; + result: { dept_id: number; name: string; parent_id: number }; } interface DingTalkUserListResponse { @@ -22,6 +39,7 @@ interface DingTalkUserListResponse { errmsg: string; result: { has_more: boolean; + next_cursor?: number; list: Array<{ userid: string; name: string; @@ -31,106 +49,218 @@ interface DingTalkUserListResponse { }; } + +/** 钉钉打卡结果 — 对齐 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 url = `https://oapi.dingtalk.com/gettoken?appkey=${appKey}&appsecret=${appSecret}`; - const res = await fetch(url); + + 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.errcode !== 0) { - throw new Error(`DingTalk gettoken failed: ${body.errmsg} (${body.errcode})`); + + if (!body.accessToken) { + throw new Error(`钉钉 access_token 获取失败: ${JSON.stringify(body)}`); } - this.accessToken = body.access_token; - this.tokenExpiresAt = Date.now() + body.expires_in * 1000; + + this.accessToken = body.accessToken; + this.tokenExpiresAt = Date.now() + (body.expireIn || 7200) * 1000; + this.logger.log('钉钉 access_token 获取成功'); return this.accessToken; } - private async fetchDepartments(token: string): Promise> { - const url = `https://oapi.dingtalk.com/topapi/v2/department/listsub?access_token=${token}`; - const res = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ dept_id: 1 }), - }); - const body: DingTalkDeptListResponse = await res.json(); - if (body.errcode !== 0) { - throw new Error(`DingTalk department list failed: ${body.errmsg} (${body.errcode})`); + // ═══════════════════════════════════════════ + // 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 body.result; + return ids; } - private async fetchUsers( + // ═══════════════════════════════════════════ + // 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 allUsers: DingTalkUserListResponse['result']['list'] = []; + const all: Array<{ userid: string; name: string; mobile: string; dept_id_list: number[] }> = []; let cursor = 0; - while (true) { - const url = `https://oapi.dingtalk.com/topapi/v2/user/list?access_token=${token}`; - const res = await fetch(url, { - 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) { - throw new Error(`DingTalk user list failed: ${body.errmsg} (${body.errcode})`); + 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; } - allUsers.push(...body.result.list); - if (!body.result.has_more) break; - cursor = allUsers.length; } - return allUsers; + return all; } + // ═══════════════════════════════════════════ + // Sync all — 主入口 + // ═══════════════════════════════════════════ + async syncAll(): Promise<{ deptCount: number; userCount: number }> { if (!this.configured) { - this.logger.warn('DingTalk not configured (DINGTALK_APP_KEY / DINGTALK_APP_SECRET missing), skipping sync'); + this.logger.warn('钉钉未配置 (DINGTALK_APP_KEY / DINGTALK_APP_SECRET),跳过同步'); return { deptCount: 0, userCount: 0 }; } - const token = await this.getAccessToken(); - const dingDepts = await this.fetchDepartments(token); + 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 (const dd of dingDepts) { - const sourceId = String(dd.dept_id); + 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 = dd.name; - dept.parentSourceId = (dd.parent_id ? String(dd.parent_id) : undefined) as any; + dept.name = detail.name; + if (detail.parent_id) dept.parentSourceId = String(detail.parent_id); } else { dept = this.deptRepo.create({ - name: dd.name, + name: detail.name, source: 'dingtalk', sourceId, - parentSourceId: (dd.parent_id ? String(dd.parent_id) : undefined), 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) { @@ -142,30 +272,162 @@ export class DingTalkService { } await this.deptRepo.save(syncedDepts); + // ── Step 3: Sync users per department ── let userCount = 0; const seenUserIds = new Set(); - for (const dd of dingDepts) { - const dingUsers = await this.fetchUsers(token, dd.dept_id); + + 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); - let user = await this.userRepo.findOne({ where: { username: du.userid } }); - if (user) { - user.name = du.name; - } else { - user = this.userRepo.create({ - username: du.userid, - name: du.name, - passwordHash: '', - isActive: true, - }); - userCount++; - } - await this.userRepo.save(user); + + await this.syncOneUser(du); + userCount++; } } - this.logger.log(`DingTalk sync done: ${deptCount} new depts, ${userCount} new users`); + 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)); + } } diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index d685c16..d38e272 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -14,9 +14,21 @@ module.exports = { script: 'apps/server/dist/main.js', instances: 1, exec_mode: 'fork', - // 环境变量 env: { NODE_ENV: 'production', + PORT: '3000', + DB_TYPE: 'mysql', + DB_HOST: '127.0.0.1', + DB_PORT: '3306', + DB_USERNAME: 'root', + DB_PASSWORD: 'gongxue_2024', + DB_DATABASE: 'gongxue', + DB_SYNCHRONIZE: 'true', + JWT_SECRET: 'gongxue-jwt-prod-2026-k3y', + JWT_EXPIRES_IN: '24h', + UPLOAD_DIR: './uploads', + DINGTALK_APP_KEY: 'dingvpcg2i6p25ftxw5c', + DINGTALK_APP_SECRET: 'ovAEql1r5Lu9FvBve6bHwBZxXAZuEQwSNpG3DkRJk4DMhQwJ_raKDQc6emdlJisU', }, // 内存限制 max_memory_restart: '512M', @@ -35,15 +47,7 @@ module.exports = { script: 'serve-proxy.js', env: { NODE_ENV: 'production', - FRONTEND_PORT: 9527, // 需 root 或 setcap: sudo setcap 'cap_net_bind_service=+ep' $(which node) - API_TARGET: 'http://127.0.0.1:3000', }, - max_memory_restart: '256M', - max_restarts: 5, - error_file: 'logs/frontend-error.log', - out_file: 'logs/frontend-out.log', - autorestart: true, - watch: false, }, ], };