feat(sync): importDingTalkUsers supports class creation and teacher/student linking

This commit is contained in:
2026-07-09 12:33:10 +08:00
parent 5b92521426
commit 00de6cbd3a
3 changed files with 141 additions and 68 deletions

View File

@@ -54,7 +54,7 @@ export class SyncController {
@Post('dingtalk/import-users') @Post('dingtalk/import-users')
@RequirePermission('sync:trigger') @RequirePermission('sync:trigger')
async importDingTalkUsers(@Body() body: ImportUsersDto) { 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 }; return { success: true, ...result };
} }

View File

@@ -13,6 +13,8 @@ import {
User, User,
Student, Student,
Role, Role,
Class,
ClassStudent,
} from '../entities'; } from '../entities';
import { SyncService } from './sync.service'; import { SyncService } from './sync.service';
import { SyncController } from './sync.controller'; import { SyncController } from './sync.controller';
@@ -31,6 +33,8 @@ import { ScheduleSyncService } from './schedule-sync.service';
User, User,
Student, Student,
Role, Role,
Class,
ClassStudent,
]), ]),
IntegrationModule, IntegrationModule,
AttendanceModule, AttendanceModule,

View File

@@ -1,7 +1,8 @@
import { Injectable, Logger } from '@nestjs/common'; import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository } from '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 type { SyncPlatform, SyncType, SyncStatus } from '../entities/sync-log.entity';
import { DingTalkService } from '../integration/dingtalk.service'; import { DingTalkService } from '../integration/dingtalk.service';
import { WeComService } from '../integration/wecom.service'; import { WeComService } from '../integration/wecom.service';
@@ -11,12 +12,14 @@ import { User } from '../entities/user.entity';
import { Student } from '../entities/student.entity'; import { Student } from '../entities/student.entity';
import { Role } from '../entities/role.entity'; import { Role } from '../entities/role.entity';
import * as bcrypt from 'bcryptjs'; import * as bcrypt from 'bcryptjs';
import type { ImportClassItemDto } from './dto/import-users.dto';
export interface ImportUserDto { export interface ImportUserDto {
dingUserId: string; dingUserId: string;
name: string; name: string;
mobile: string; mobile: string;
roleId: number | null; roleId: number | null;
dingDeptIds: number[];
} }
@Injectable() @Injectable()
@@ -35,6 +38,10 @@ export class SyncService {
private readonly studentRepo: Repository<Student>, private readonly studentRepo: Repository<Student>,
@InjectRepository(Role) @InjectRepository(Role)
private readonly roleRepo: Repository<Role>, private readonly roleRepo: Repository<Role>,
@InjectRepository(ClassEntity)
private readonly classRepo: Repository<ClassEntity>,
@InjectRepository(ClassStudent)
private readonly classStudentRepo: Repository<ClassStudent>,
private readonly dingTalkService: DingTalkService, private readonly dingTalkService: DingTalkService,
private readonly weComService: WeComService, private readonly weComService: WeComService,
private readonly attendanceImportService: AttendanceImportService, private readonly attendanceImportService: AttendanceImportService,
@@ -117,30 +124,63 @@ export class SyncService {
/** /**
* 从钉钉导入用户roleId 非 null → 老师User + 指定角色roleId null → 学生User + Student * 从钉钉导入用户roleId 非 null → 老师User + 指定角色roleId null → 学生User + Student
* 支持同时创建班级并建立师生关联。
* 已存在 UserDingMapping 的记录跳过。 * 已存在 UserDingMapping 的记录跳过。
*/ */
async importDingTalkUsers(users: ImportUserDto[]): Promise<{ async importDingTalkUsers(
users: ImportUserDto[],
classes?: ImportClassItemDto[],
): Promise<{
teacherCount: number; teacherCount: number;
studentCount: number; studentCount: number;
classCount: number;
skipped: number; skipped: number;
warnings: string[]; 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 teacherCount = 0;
let studentCount = 0; let studentCount = 0;
let skipped = 0; let skipped = 0;
const warnings: string[] = [];
await this.dataSource.transaction(async (manager) => {
// 1. 创建班级
const deptClassMap = new Map<number, number>(); // 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<string, unknown>);
deptClassMap.set(c.deptId, (result as ClassEntity).id);
}
// 2. 导入用户(逐用户)
for (const u of users) { for (const u of users) {
const existing = await this.mappingRepo.findOne({ const existingMapping = await manager.findOne(UserDingMapping, {
where: { dingUserId: u.dingUserId }, where: { dingUserId: u.dingUserId },
}); });
if (existing) { if (existingMapping) {
skipped++; skipped++;
continue; continue;
} }
try {
await this.dataSource.transaction(async (manager) => {
const username = `dd_${u.dingUserId}`; const username = `dd_${u.dingUserId}`;
const passwordHash = await bcrypt.hash('123456', 10); const passwordHash = await bcrypt.hash('123456', 10);
@@ -152,19 +192,17 @@ export class SyncService {
}); });
await manager.save(user); await manager.save(user);
let isTeacher = false;
if (u.roleId != null) { if (u.roleId != null) {
const role = await manager.findOne(Role, { where: { id: u.roleId } }); const role = await manager.findOne(Role, { where: { id: u.roleId } });
if (role) { if (!role) {
throw new BadRequestException(`角色 id=${u.roleId} 不存在`);
}
user.roles = [role]; user.roles = [role];
await manager.save(user); await manager.save(user);
isTeacher = true;
teacherCount++; 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 { } else {
const student = manager.create(Student, { const student = manager.create(Student, {
name: u.name, name: u.name,
@@ -176,6 +214,7 @@ export class SyncService {
studentCount++; studentCount++;
} }
// 钉钉映射
const mapping = manager.create(UserDingMapping, { const mapping = manager.create(UserDingMapping, {
dingUserId: u.dingUserId, dingUserId: u.dingUserId,
userId: user.id, userId: user.id,
@@ -183,19 +222,49 @@ export class SyncService {
dingMobile: u.mobile, dingMobile: u.mobile,
}); });
await manager.save(mapping); 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',
}); });
} catch (err: unknown) { await manager.save(ct);
const msg = err instanceof Error ? err.message : String(err); } else {
if (msg !== 'SKIP_USER') { const studentRecord = await manager.findOne(Student, { where: { userId: user.id } });
this.logger.error(`导入用户 ${u.name}(${u.dingUserId}) 失败: ${msg}`); 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( this.logger.log(
`钉钉用户导入完成: ${teacherCount} 位老师, ${studentCount} 位学生, ${skipped} 跳过`, `钉钉用户导入完成: ${teacherCount} 位老师, ${studentCount} 位学生, ${classItems.length} 个班级, ${skipped} 跳过`,
); );
return { teacherCount, studentCount, skipped, warnings }; return { teacherCount, studentCount, classCount: classItems.length, skipped, warnings };
} }
// ── 排班同步 ── // ── 排班同步 ──