Files
gongxue-base/apps/server/src/sync/sync.service.ts
wangziqi 73c1ea7a76 fix: correct studentId-as-userId bugs in 3 files
- 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
2026-07-09 17:04:35 +08:00

444 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
}
}