forked from wangziqi/gongxue-base
feat(sync): importDingTalkUsers supports class creation and teacher/student linking
This commit is contained in:
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,85 +124,147 @@ 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[];
|
||||||
}> {
|
}> {
|
||||||
let teacherCount = 0;
|
const classItems = classes ?? [];
|
||||||
let studentCount = 0;
|
|
||||||
let skipped = 0;
|
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
|
||||||
for (const u of users) {
|
// 预检查班级编码重复
|
||||||
const existing = await this.mappingRepo.findOne({
|
if (classItems.length > 0) {
|
||||||
where: { dingUserId: u.dingUserId },
|
const codes = classItems.map((c) => c.code);
|
||||||
});
|
const existing = await this.classRepo.find({ where: codes.map((code) => ({ code })) });
|
||||||
if (existing) {
|
if (existing.length > 0) {
|
||||||
skipped++;
|
const dup = existing.map((c) => c.code).join(', ');
|
||||||
continue;
|
throw new BadRequestException(`班级编码已存在: ${dup}`);
|
||||||
}
|
|
||||||
|
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let teacherCount = 0;
|
||||||
|
let studentCount = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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(
|
this.logger.log(
|
||||||
`钉钉用户导入完成: ${teacherCount} 位老师, ${studentCount} 位学生, ${skipped} 跳过`,
|
`钉钉用户导入完成: ${teacherCount} 位老师, ${studentCount} 位学生, ${classItems.length} 个班级, ${skipped} 跳过`,
|
||||||
);
|
);
|
||||||
return { teacherCount, studentCount, skipped, warnings };
|
return { teacherCount, studentCount, classCount: classItems.length, skipped, warnings };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 排班同步 ──
|
// ── 排班同步 ──
|
||||||
|
|||||||
Reference in New Issue
Block a user