diff --git a/apps/server/src/sync/sync.controller.ts b/apps/server/src/sync/sync.controller.ts index 09b034f..7fda1bb 100644 --- a/apps/server/src/sync/sync.controller.ts +++ b/apps/server/src/sync/sync.controller.ts @@ -54,7 +54,7 @@ export class SyncController { @Post('dingtalk/import-users') @RequirePermission('sync:trigger') async importDingTalkUsers(@Body() body: ImportUsersDto) { - const result = await this.syncService.importDingTalkUsers(body.users); + const result = await this.syncService.importDingTalkUsers(body.users, body.classes); return { success: true, ...result }; } diff --git a/apps/server/src/sync/sync.module.ts b/apps/server/src/sync/sync.module.ts index ae0b92e..d7b8ff7 100644 --- a/apps/server/src/sync/sync.module.ts +++ b/apps/server/src/sync/sync.module.ts @@ -13,6 +13,8 @@ import { User, Student, Role, + Class, + ClassStudent, } from '../entities'; import { SyncService } from './sync.service'; import { SyncController } from './sync.controller'; @@ -31,6 +33,8 @@ import { ScheduleSyncService } from './schedule-sync.service'; User, Student, Role, + Class, + ClassStudent, ]), IntegrationModule, AttendanceModule, diff --git a/apps/server/src/sync/sync.service.ts b/apps/server/src/sync/sync.service.ts index 54a6422..8089dda 100644 --- a/apps/server/src/sync/sync.service.ts +++ b/apps/server/src/sync/sync.service.ts @@ -1,7 +1,8 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, Repository } from 'typeorm'; -import { SyncLog, SyncState, UserDingMapping } from '../entities'; +import { SyncLog, SyncState, UserDingMapping, ClassStudent, ClassTeacher } 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'; @@ -11,12 +12,14 @@ 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() @@ -35,6 +38,10 @@ export class SyncService { private readonly studentRepo: Repository, @InjectRepository(Role) private readonly roleRepo: Repository, + @InjectRepository(ClassEntity) + private readonly classRepo: Repository, + @InjectRepository(ClassStudent) + private readonly classStudentRepo: Repository, private readonly dingTalkService: DingTalkService, private readonly weComService: WeComService, private readonly attendanceImportService: AttendanceImportService, @@ -117,85 +124,147 @@ export class SyncService { /** * 从钉钉导入用户:roleId 非 null → 老师(User + 指定角色),roleId null → 学生(User + Student)。 + * 支持同时创建班级并建立师生关联。 * 已存在 UserDingMapping 的记录跳过。 */ - async importDingTalkUsers(users: ImportUserDto[]): Promise<{ + async importDingTalkUsers( + users: ImportUserDto[], + classes?: ImportClassItemDto[], + ): Promise<{ teacherCount: number; studentCount: number; + classCount: number; skipped: number; warnings: string[]; }> { - let teacherCount = 0; - let studentCount = 0; - let skipped = 0; + const classItems = classes ?? []; const warnings: string[] = []; - for (const u of users) { - const existing = await this.mappingRepo.findOne({ - where: { dingUserId: u.dingUserId }, - }); - if (existing) { - skipped++; - continue; - } - - try { - await this.dataSource.transaction(async (manager) => { - const username = `dd_${u.dingUserId}`; - const passwordHash = await bcrypt.hash('123456', 10); - - const user = manager.create(User, { - username, - name: u.name, - passwordHash, - isActive: true, - }); - await manager.save(user); - - if (u.roleId != null) { - const role = await manager.findOne(Role, { where: { id: u.roleId } }); - if (role) { - user.roles = [role]; - await manager.save(user); - teacherCount++; - } else { - const msg = `角色 id=${u.roleId} 不存在,跳过用户 ${u.name}(${u.dingUserId})`; - this.logger.warn(msg); - warnings.push(msg); - skipped++; - throw new Error('SKIP_USER'); - } - } else { - const student = manager.create(Student, { - name: u.name, - phone: u.mobile || undefined, - userId: user.id, - status: 'active', - }); - await manager.save(student); - studentCount++; - } - - const mapping = manager.create(UserDingMapping, { - dingUserId: u.dingUserId, - userId: user.id, - dingName: u.name, - dingMobile: u.mobile, - }); - await manager.save(mapping); - }); - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - if (msg !== 'SKIP_USER') { - this.logger.error(`导入用户 ${u.name}(${u.dingUserId}) 失败: ${msg}`); - } + // 预检查班级编码重复 + 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) => { + // 1. 创建班级 + const deptClassMap = new Map(); // deptId -> classId + for (const c of classItems) { + // ponytail: Class keyword conflicts with TypeORM DeepPartial resolution + const result = await manager.save(ClassEntity, { + name: c.name, + code: c.code, + 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(UserDingMapping, { + where: { dingUserId: u.dingUserId }, + }); + if (existingMapping) { + skipped++; + continue; + } + + const username = `dd_${u.dingUserId}`; + const passwordHash = await bcrypt.hash('123456', 10); + + const user = manager.create(User, { + username, + name: u.name, + passwordHash, + isActive: true, + }); + await manager.save(user); + + let isTeacher = false; + + 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 { + const student = manager.create(Student, { + name: u.name, + phone: u.mobile || undefined, + userId: user.id, + status: 'active', + }); + await manager.save(student); + studentCount++; + } + + // 钉钉映射 + const mapping = manager.create(UserDingMapping, { + dingUserId: u.dingUserId, + userId: user.id, + dingName: u.name, + dingMobile: u.mobile, + }); + 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 ct = manager.create(ClassTeacher, { + classId, + userId: user.id, + roleType: 'teacher', + }); + await manager.save(ct); + } else { + const studentRecord = await manager.findOne(Student, { where: { userId: user.id } }); + if (!studentRecord) continue; + 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 this.classRepo.findOne({ where: { id: classId } }); + warnings.push(`班级 "${cls?.name}" (deptId=${deptId}) 无任何师生`); + } + } + }); + this.logger.log( - `钉钉用户导入完成: ${teacherCount} 位老师, ${studentCount} 位学生, ${skipped} 跳过`, + `钉钉用户导入完成: ${teacherCount} 位老师, ${studentCount} 位学生, ${classItems.length} 个班级, ${skipped} 跳过`, ); - return { teacherCount, studentCount, skipped, warnings }; + return { teacherCount, studentCount, classCount: classItems.length, skipped, warnings }; } // ── 排班同步 ──