Files
gongxue-base/apps/server/src/sync/sync.service.ts

235 lines
8.6 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 { ConflictException, Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { randomUUID } from 'node:crypto';
import { Repository } from 'typeorm';
import { SyncLog, SyncState, StudentDingMapping } from '../entities';
import type { SyncPlatform, SyncStatus, SyncType } from '../entities/sync-log.entity';
import { AttendanceImportService } from '../attendance/attendance-import.service';
import { DingTalkService } from '../integration/dingtalk.service';
import { WeComService } from '../integration/wecom.service';
import { ScheduleSyncService } from './schedule-sync.service';
@Injectable()
export class SyncService {
private readonly logger = new Logger(SyncService.name);
private static readonly LEASE_MS = 30 * 60 * 1000;
constructor(
@InjectRepository(SyncLog)
private readonly syncLogRepo: Repository<SyncLog>,
@InjectRepository(SyncState)
private readonly syncStateRepo: Repository<SyncState>,
@InjectRepository(StudentDingMapping)
private readonly studentDingMappingRepo: Repository<StudentDingMapping>,
private readonly dingTalkService: DingTalkService,
private readonly weComService: WeComService,
private readonly attendanceImportService: AttendanceImportService,
private readonly scheduleSyncService: ScheduleSyncService,
) {}
async syncDingTalkStudents(rootDeptId = 1): Promise<SyncLog> {
return this.runSync('dingtalk_students', async () => {
const result = await this.dingTalkService.syncAll(rootDeptId);
return {
recordsCount: result.created + result.updated,
status: result.conflicts.length ? 'partial' : 'success',
message: result.conflicts.length ? JSON.stringify(result.conflicts.slice(0, 20)) : undefined,
};
});
}
async syncDingTalkAttendance(): Promise<SyncLog> {
return this.runSync('dingtalk_attendance', async (lastSyncAt) => {
const endDate = new Date();
const startDate = lastSyncAt ? new Date(lastSyncAt) : new Date(endDate);
if (!lastSyncAt) startDate.setDate(startDate.getDate() - 7);
const mappings = await this.studentDingMappingRepo.find();
const userIds = [...new Set(mappings.map((mapping) => mapping.dingUserId).filter(Boolean))];
if (userIds.length === 0) {
throw new ServiceUnavailableException('没有可用于考勤导入的钉钉学生映射');
}
const result = await this.attendanceImportService.importFromDingTalk({
startDate: startDate.toISOString().slice(0, 10),
endDate: endDate.toISOString().slice(0, 10),
userIds,
autoMatch: true,
});
if (!result.success) {
throw new ServiceUnavailableException(result.errors.join('') || '钉钉考勤导入失败');
}
return { recordsCount: result.imported, status: 'success' };
});
}
async syncWeCom(): Promise<SyncLog> {
return this.runSync('wecom', async () => {
const result = await this.weComService.syncAll();
return { recordsCount: result.userCount, status: 'success' };
});
}
async triggerSync(platform?: SyncPlatform, rootDeptId = 1): Promise<SyncLog[]> {
if (platform === 'dingtalk_students') return [await this.syncDingTalkStudents(rootDeptId)];
if (platform === 'dingtalk_attendance') return [await this.syncDingTalkAttendance()];
if (platform === 'wecom') return [await this.syncWeCom()];
return [
await this.syncDingTalkStudents(rootDeptId),
await this.syncDingTalkAttendance(),
await this.syncWeCom(),
];
}
async getDingTalkOrgTree(rootDeptId = 1) {
return this.dingTalkService.fetchOrgTree(rootDeptId);
}
async getDingTalkOrgTreeWithUsers(rootDeptId = 1) {
return this.dingTalkService.fetchOrgTreeWithUsers(rootDeptId);
}
async getDingTalkAttendanceGroups() {
return this.dingTalkService.queryAttendanceGroups();
}
async deleteAllDingTalkAttendanceGroups() {
const groups = await this.dingTalkService.queryAttendanceGroups();
const deleted: Array<{ groupId: number; groupName: string }> = [];
const failed: Array<{ groupId: number; groupName: string; error: string }> = [];
for (const group of groups) {
try {
await this.dingTalkService.deleteAttendanceGroup(group.group_id);
deleted.push({ groupId: group.group_id, groupName: group.group_name });
} catch (error: unknown) {
failed.push({
groupId: group.group_id,
groupName: group.group_name,
error: error instanceof Error ? error.message : String(error),
});
}
}
return { total: groups.length, deleted, failed };
}
async syncScheduleToDingTalk(
dateFrom?: string,
days = 30,
attendanceMachineOnly = false,
) {
return this.scheduleSyncService.syncAll(dateFrom, days, 'manager', attendanceMachineOnly);
}
async getScheduleSyncStatus(date?: string) {
return this.scheduleSyncService.getStatus(date || new Date().toISOString().slice(0, 10));
}
async getLogs(platform?: SyncPlatform, limit = 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 | 'dingtalk'): Promise<SyncLog | null> {
if (platform === 'dingtalk') {
return this.syncLogRepo.findOne({
where: [{ platform: 'dingtalk_students' }, { platform: 'dingtalk_attendance' }],
order: { createdAt: 'DESC' },
});
}
return this.syncLogRepo.findOne({ where: { platform }, order: { createdAt: 'DESC' } });
}
private async runSync(
platform: SyncPlatform,
operation: (lastSyncAt: Date | null) => Promise<{
recordsCount: number;
status: Extract<SyncStatus, 'success' | 'partial'>;
message?: string;
}>,
): Promise<SyncLog> {
const runId = await this.acquireLease(platform);
let log: SyncLog | undefined;
try {
const lastSyncAt = await this.getLastSyncAt(platform);
log = await this.createSyncLog(platform, lastSyncAt ? 'incremental' : 'full', 'running');
const result = await operation(lastSyncAt);
await this.syncStateRepo.update({ platform }, { lastSyncAt: new Date() });
await this.finishSyncLog(log, result.status, result.recordsCount, result.message);
return log;
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
if (log) await this.finishSyncLog(log, 'failed', 0, message);
this.logger.error(`${platform} sync failed: ${message}`, error instanceof Error ? error.stack : undefined);
throw error;
} finally {
await this.releaseLease(platform, runId);
}
}
private async acquireLease(platform: SyncPlatform): Promise<string> {
await this.syncStateRepo
.createQueryBuilder()
.insert()
.values({ platform, lastSyncAt: null, runId: null, runningSince: null })
.orIgnore()
.execute();
const runId = randomUUID();
const result = await this.syncStateRepo
.createQueryBuilder()
.update()
.set({ runId, runningSince: new Date() })
.where('platform = :platform', { platform })
.andWhere('(running_since IS NULL OR running_since < :staleBefore)', {
staleBefore: new Date(Date.now() - SyncService.LEASE_MS),
})
.execute();
if (result.affected !== 1) throw new ConflictException(`${platform} 同步正在进行中`);
return runId;
}
private async releaseLease(platform: SyncPlatform, runId: string): Promise<void> {
await this.syncStateRepo
.createQueryBuilder()
.update()
.set({ runId: null, runningSince: null })
.where('platform = :platform AND run_id = :runId', { platform, runId })
.execute();
}
private async getLastSyncAt(platform: SyncPlatform): Promise<Date | null> {
const state = await this.syncStateRepo.findOne({ where: { platform } });
return state?.lastSyncAt ?? null;
}
private async createSyncLog(
platform: SyncPlatform,
syncType: SyncType,
status: SyncStatus,
): Promise<SyncLog> {
return this.syncLogRepo.save(
this.syncLogRepo.create({
platform,
syncType,
status,
recordsCount: 0,
startedAt: new Date(),
}),
);
}
private async finishSyncLog(
log: SyncLog,
status: SyncStatus,
recordsCount: number,
errorMessage?: string,
): Promise<void> {
log.status = status;
log.recordsCount = recordsCount;
log.finishedAt = new Date();
log.errorMessage = errorMessage ?? null;
await this.syncLogRepo.save(log);
}
}