- dingtalk.service.ts syncOneUser: find Student by userId before mapping, use Student.id (not User.id) as studentId FK - sync.service.ts importDingTalkUsers: type coercion for studentId (method deleted in Task 4, minimal compile fix) - schedule-sync.service.ts getStatus: remove broken teacher mapping query (teacher scheduling deprecated); hardcode mappedTeachers=0, totalTeachers=0
444 lines
16 KiB
TypeScript
444 lines
16 KiB
TypeScript
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<SyncLog>,
|
||
@InjectRepository(SyncState)
|
||
private readonly syncStateRepo: Repository<SyncState>,
|
||
@InjectRepository(StudentDingMapping)
|
||
private readonly studentDingMappingRepo: Repository<StudentDingMapping>,
|
||
@InjectRepository(User)
|
||
private readonly userRepo: Repository<User>,
|
||
@InjectRepository(Student)
|
||
private readonly studentRepo: Repository<Student>,
|
||
@InjectRepository(Role)
|
||
private readonly roleRepo: Repository<Role>,
|
||
@InjectRepository(ClassEntity)
|
||
private readonly classRepo: Repository<ClassEntity>,
|
||
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<SyncLog> {
|
||
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<SyncLog> {
|
||
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<SyncLog[]> {
|
||
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<number, number>(); // 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<number, number>(); // 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<string, unknown>);
|
||
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<string, unknown>[] = [{ 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<SyncLog[]> {
|
||
const where: Record<string, SyncPlatform> = {};
|
||
if (platform) where.platform = platform;
|
||
return this.syncLogRepo.find({ where, order: { createdAt: 'DESC' }, take: limit });
|
||
}
|
||
|
||
async getLastSync(platform: SyncPlatform): Promise<SyncLog | null> {
|
||
return this.syncLogRepo.findOne({
|
||
where: { platform },
|
||
order: { createdAt: 'DESC' },
|
||
});
|
||
}
|
||
|
||
// ── Private helpers ──
|
||
|
||
private async determineSyncType(platform: SyncPlatform): Promise<SyncType> {
|
||
const state = await this.syncStateRepo.findOne({ where: { platform } });
|
||
return state?.lastSyncAt ? 'incremental' : 'full';
|
||
}
|
||
|
||
private async getLastSyncAt(platform: SyncPlatform): Promise<Date | null> {
|
||
const state = await this.syncStateRepo.findOne({ where: { platform } });
|
||
return state?.lastSyncAt ?? null;
|
||
}
|
||
|
||
private async updateLastSyncAt(platform: SyncPlatform): Promise<void> {
|
||
await this.syncStateRepo.upsert(
|
||
{ platform, lastSyncAt: new Date() },
|
||
['platform'],
|
||
);
|
||
}
|
||
|
||
private async createSyncLog(
|
||
platform: SyncPlatform,
|
||
syncType: SyncType,
|
||
status: SyncStatus,
|
||
): Promise<SyncLog> {
|
||
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<void> {
|
||
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<number> {
|
||
// 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<number> {
|
||
const result = await this.weComService.syncAll();
|
||
return result.deptCount + result.userCount;
|
||
}
|
||
}
|