import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, Repository } from 'typeorm'; import { SyncLog, SyncState, StudentDingMapping, ClassStudent, ClassTeacher, Department } from '../entities'; import { Class as ClassEntity } from '../entities/class.entity'; import type { SyncPlatform, SyncType, SyncStatus } from '../entities/sync-log.entity'; import { DingTalkService } from '../integration/dingtalk.service'; import { WeComService } from '../integration/wecom.service'; import { AttendanceImportService } from '../attendance/attendance-import.service'; import { ScheduleSyncService } from './schedule-sync.service'; import { User } from '../entities/user.entity'; import { Student } from '../entities/student.entity'; import { Role } from '../entities/role.entity'; import * as bcrypt from 'bcryptjs'; import type { ImportClassItemDto } from './dto/import-users.dto'; export interface ImportUserDto { dingUserId: string; name: string; mobile: string; roleId: number | null; dingDeptIds: number[]; } @Injectable() export class SyncService { private readonly logger = new Logger(SyncService.name); constructor( @InjectRepository(SyncLog) private readonly syncLogRepo: Repository, @InjectRepository(SyncState) private readonly syncStateRepo: Repository, @InjectRepository(StudentDingMapping) private readonly studentDingMappingRepo: Repository, @InjectRepository(User) private readonly userRepo: Repository, @InjectRepository(Student) private readonly studentRepo: Repository, @InjectRepository(Role) private readonly roleRepo: Repository, @InjectRepository(ClassEntity) private readonly classRepo: Repository, private readonly dingTalkService: DingTalkService, private readonly weComService: WeComService, private readonly attendanceImportService: AttendanceImportService, private readonly scheduleSyncService: ScheduleSyncService, private readonly dataSource: DataSource, ) {} // ── Scheduled sync disabled — use manual trigger via UI ── // ── Sync DingTalk ── async syncDingTalk(rootDeptId = 1): Promise { const platform: SyncPlatform = 'dingtalk'; const syncType = await this.determineSyncType(platform); const log = await this.createSyncLog(platform, syncType, 'running'); try { const lastSyncAt = await this.getLastSyncAt(platform); this.logger.log(`Syncing ${platform} (${syncType}), lastSyncAt: ${lastSyncAt}`); // ── Call existing integration APIs ── // Integration hooks — extend here to call DingTalk APIs with lastSyncAt const recordsCount = await this.performDingTalkSync(lastSyncAt, rootDeptId); await this.updateLastSyncAt(platform); await this.finishSyncLog(log, 'success', recordsCount); this.logger.log(`DingTalk sync complete: ${recordsCount} records`); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); await this.finishSyncLog(log, 'failed', 0, message); this.logger.error(`DingTalk sync failed: ${message}`, error instanceof Error ? error.stack : undefined); } return log; } // ── Sync WeCom ── async syncWeCom(): Promise { const platform: SyncPlatform = 'wecom'; const syncType = await this.determineSyncType(platform); const log = await this.createSyncLog(platform, syncType, 'running'); try { const lastSyncAt = await this.getLastSyncAt(platform); this.logger.log(`Syncing ${platform} (${syncType}), lastSyncAt: ${lastSyncAt}`); // ── Call existing integration APIs ── // Integration hooks — extend here to call WeCom APIs with lastSyncAt const recordsCount = await this.performWeComSync(lastSyncAt); await this.updateLastSyncAt(platform); await this.finishSyncLog(log, 'success', recordsCount); this.logger.log(`WeCom sync complete: ${recordsCount} records`); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); await this.finishSyncLog(log, 'failed', 0, message); this.logger.error(`WeCom sync failed: ${message}`, error instanceof Error ? error.stack : undefined); } return log; } // ── Manual trigger ── async triggerSync(platform?: SyncPlatform, rootDeptId = 1): Promise { if (platform === 'dingtalk') return [await this.syncDingTalk(rootDeptId)]; if (platform === 'wecom') return [await this.syncWeCom()]; return [await this.syncDingTalk(rootDeptId), await this.syncWeCom()]; } /** 获取钉钉组织部门树,供前端选择同步起点 */ async getDingTalkOrgTree(rootDeptId = 1) { return this.dingTalkService.fetchOrgTree(rootDeptId); } /** 获取钉钉组织部门树(含用户),供前端同步用户选择器使用 */ async getDingTalkOrgTreeWithUsers(rootDeptId = 1) { return this.dingTalkService.fetchOrgTreeWithUsers(rootDeptId); } /** * 从钉钉导入用户:roleId 非 null → 老师(User + 指定角色),roleId null → 学生(User + Student)。 * 支持同时创建班级并建立师生关联。 * 已存在 StudentDingMapping 的记录跳过。 */ async importDingTalkUsers( users: ImportUserDto[], classes?: ImportClassItemDto[], ): Promise<{ teacherCount: number; studentCount: number; classCount: number; skipped: number; warnings: string[]; }> { const classItems = classes ?? []; const warnings: string[] = []; // 预检查班级编码重复 if (classItems.length > 0) { const codes = classItems.map((c) => c.code); const existing = await this.classRepo.find({ where: codes.map((code) => ({ code })) }); if (existing.length > 0) { const dup = existing.map((c) => c.code).join(', '); throw new BadRequestException(`班级编码已存在: ${dup}`); } } let teacherCount = 0; let studentCount = 0; let skipped = 0; await this.dataSource.transaction(async (manager) => { // 0. 同步部门:为每个班级标记的钉钉部门创建/查找本地 Department const deptLocalIdMap = new Map(); // dingDeptId -> local deptId for (const c of classItems) { let dept = await manager.findOne(Department, { where: { source: 'dingtalk', sourceId: String(c.deptId) }, }); if (!dept) { dept = manager.create(Department, { name: c.name, type: 'department', source: 'dingtalk', sourceId: String(c.deptId), }); dept = await manager.save(dept); } deptLocalIdMap.set(c.deptId, dept.id); } // 1. 创建班级 const deptClassMap = new Map(); // deptId -> classId for (const c of classItems) { const deptId = deptLocalIdMap.get(c.deptId) ?? null; const result = await manager.save(ClassEntity, { name: c.name, code: c.code, departmentId: deptId, classType: c.classType, startDate: c.startDate ?? null, endDate: c.endDate ?? null, maxStudents: c.maxStudents ?? 0, notes: c.notes ?? null, } as unknown as Record); deptClassMap.set(c.deptId, (result as ClassEntity).id); } // 2. 导入用户(逐用户) for (const u of users) { const existingMapping = await manager.findOne(StudentDingMapping, { where: { dingUserId: u.dingUserId }, }); let userId: number | null = null; let isTeacher = false; if (existingMapping) { skipped++; // ponytail: studentDingMapping.studentId is Student FK, not User; full rewrite in Task 4 userId = existingMapping.studentId; isTeacher = false; } else { // 检查是否已存在(syncAll 或历史导入),避免撞 username 唯一约束 const username = `dd_${u.dingUserId}`; const whereConditions: Record[] = [{ username }]; if (u.mobile) whereConditions.push({ username: u.mobile }); let user = await manager.findOne(User, { where: whereConditions }); if (!user) { const passwordHash = await bcrypt.hash('123456', 10); user = manager.create(User, { username, name: u.name, passwordHash, isActive: true, }); await manager.save(user); } else { // 已存在:更新姓名 user.name = u.name; await manager.save(user); } userId = user.id; // 解析学生所属部门 const studentDeptId = u.roleId == null && classItems.length > 0 && u.dingDeptIds?.length > 0 ? deptLocalIdMap.get(u.dingDeptIds.find((d) => deptLocalIdMap.has(d)) ?? -1) ?? undefined : undefined; if (u.roleId != null) { const role = await manager.findOne(Role, { where: { id: u.roleId } }); if (!role) throw new BadRequestException(`角色 id=${u.roleId} 不存在`); user.roles = [role]; await manager.save(user); isTeacher = true; teacherCount++; } else { // 学生:检查 Student 是否已存在,不存在则创建 let student = await manager.findOne(Student, { where: { userId: user.id } }); if (!student) { student = manager.create(Student, { name: u.name, phone: u.mobile || undefined, userId: user.id, departmentId: studentDeptId, status: 'active', }); await manager.save(student); } studentCount++; } // 钉钉映射(幂等:可能已被 syncAll 创建) const existingMappingForUser = await manager.findOne(StudentDingMapping, { where: { dingUserId: u.dingUserId }, }); if (!existingMappingForUser) { const mapping = manager.create(StudentDingMapping, { dingUserId: u.dingUserId, studentId: (user as any).id, }); await manager.save(mapping); } } // 3. 建立班级关联(新建/已存在用户都处理) if (classItems.length > 0 && u.dingDeptIds?.length > 0) { for (const deptId of u.dingDeptIds) { const classId = deptClassMap.get(deptId); if (!classId) continue; if (isTeacher) { const existingCT = await manager.findOne(ClassTeacher, { where: { classId, userId }, }); if (!existingCT) { const ct = manager.create(ClassTeacher, { classId, userId, roleType: 'teacher', }); await manager.save(ct); } } else { const studentRecord = await manager.findOne(Student, { where: { userId }, }); if (!studentRecord) continue; const existingCS = await manager.findOne(ClassStudent, { where: { classId, studentId: studentRecord.id }, }); if (!existingCS) { const cs = manager.create(ClassStudent, { classId, studentId: studentRecord.id, status: 'active', }); await manager.save(cs); } } } } } // 4. 检查空班级 for (const [deptId, classId] of deptClassMap) { const tc = await manager.count(ClassTeacher, { where: { classId } }); const sc = await manager.count(ClassStudent, { where: { classId } }); if (tc === 0 && sc === 0) { const cls = await manager.findOne(ClassEntity, { where: { id: classId } }); warnings.push(`班级 "${cls?.name}" (deptId=${deptId}) 无任何师生`); } } }); this.logger.log( `钉钉用户导入完成: ${teacherCount} 位老师, ${studentCount} 位学生, ${classItems.length} 个班级, ${skipped} 跳过`, ); return { teacherCount, studentCount, classCount: classItems.length, skipped, warnings }; } // ── 排班同步 ── /** 将本地排课同步到钉钉考勤排班 */ async syncScheduleToDingTalk(dateFrom?: string, days = 30) { return this.scheduleSyncService.syncAll(dateFrom, days); } /** 获取排班同步状态(当前仅返回活跃排课统计) */ async getScheduleSyncStatus(date?: string) { const targetDate = date || new Date().toISOString().slice(0, 10); return this.scheduleSyncService.getStatus(targetDate); } // ── Sync log queries ── async getLogs(platform?: SyncPlatform, limit: number = 50): Promise { const where: Record = {}; if (platform) where.platform = platform; return this.syncLogRepo.find({ where, order: { createdAt: 'DESC' }, take: limit }); } async getLastSync(platform: SyncPlatform): Promise { return this.syncLogRepo.findOne({ where: { platform }, order: { createdAt: 'DESC' }, }); } // ── Private helpers ── private async determineSyncType(platform: SyncPlatform): Promise { const state = await this.syncStateRepo.findOne({ where: { platform } }); return state?.lastSyncAt ? 'incremental' : 'full'; } private async getLastSyncAt(platform: SyncPlatform): Promise { const state = await this.syncStateRepo.findOne({ where: { platform } }); return state?.lastSyncAt ?? null; } private async updateLastSyncAt(platform: SyncPlatform): Promise { await this.syncStateRepo.upsert( { platform, lastSyncAt: new Date() }, ['platform'], ); } private async createSyncLog( platform: SyncPlatform, syncType: SyncType, status: SyncStatus, ): Promise { const log = this.syncLogRepo.create({ platform, syncType, status, recordsCount: 0, startedAt: new Date(), }); return this.syncLogRepo.save(log); } private async finishSyncLog( log: SyncLog, status: SyncStatus, recordsCount: number, errorMessage?: string, ): Promise { log.status = status; log.recordsCount = recordsCount; log.finishedAt = new Date(); if (errorMessage) log.errorMessage = errorMessage; await this.syncLogRepo.save(log); } private async performDingTalkSync(lastSyncAt: Date | null, rootDeptId = 1): Promise { // Stage 1: Sync departments and users const result = await this.dingTalkService.syncAll(rootDeptId); let total = result.deptCount + result.userCount; // Stage 2: Import attendance data (last 7 days or since last sync) try { const endDate = new Date(); const startDate = new Date(); // If never synced, import last 7 days; otherwise import since last sync if (lastSyncAt) { startDate.setTime(lastSyncAt.getTime()); } else { startDate.setDate(startDate.getDate() - 7); } const start = startDate.toISOString().slice(0, 10); const end = endDate.toISOString().slice(0, 10); this.logger.log(`Importing DingTalk attendance: ${start} ~ ${end}`); const mappings = await this.studentDingMappingRepo.find(); const userIds = mappings.map((m) => m.dingUserId); const importResult = await this.attendanceImportService.importFromDingTalk({ startDate: start, endDate: end, userIds: userIds.length > 0 ? userIds : undefined, autoMatch: true, }); total += importResult.imported; this.logger.log(`DingTalk attendance import: ${importResult.imported} imported, ${importResult.skipped} skipped`); } catch (err: unknown) { // Attendance import failure should not block the sync const msg = err instanceof Error ? err.message : String(err); this.logger.warn(`DingTalk attendance import failed (non-fatal): ${msg}`); } return total; } private async performWeComSync(_lastSyncAt: Date | null): Promise { const result = await this.weComService.syncAll(); return result.deptCount + result.userCount; } }