diff --git a/apps/admin/src/pages/IntegrationConfig/index.tsx b/apps/admin/src/pages/IntegrationConfig/index.tsx index 729d434..0886bef 100644 --- a/apps/admin/src/pages/IntegrationConfig/index.tsx +++ b/apps/admin/src/pages/IntegrationConfig/index.tsx @@ -65,6 +65,7 @@ interface ClassItem { interface ImportResult { imported: number; skipped: number; + conflicts: number; } interface DingTalkAttendanceGroup { @@ -285,7 +286,11 @@ const IntegrationConfigPage: React.FC = () => { setImporting(true); try { const res = await api.post(`/classes/${selectedClassId}/students/import`, { users }); - message.success(`导入 ${res.imported} 人,跳过 ${res.skipped} 人`); + if (res.conflicts > 0) { + message.warning(`导入 ${res.imported} 人,跳过 ${res.skipped} 人,${res.conflicts} 人需人工绑定`); + } else { + message.success(`导入 ${res.imported} 人,跳过 ${res.skipped} 人`); + } setCheckedKeys([]); setSelectedClassId(null); } catch (e: unknown) { diff --git a/apps/server/src/classes/classes.batch-import-membership.spec.ts b/apps/server/src/classes/classes.batch-import-membership.spec.ts index fea8627..6555445 100644 --- a/apps/server/src/classes/classes.batch-import-membership.spec.ts +++ b/apps/server/src/classes/classes.batch-import-membership.spec.ts @@ -1,5 +1,5 @@ import { ClassesService } from './classes.service'; -import { ClassStudent } from '../entities'; +import { ClassStudent, Student, StudentDingMapping } from '../entities'; describe('ClassesService — DingTalk class import membership lifecycle', () => { it('reactivates left memberships and skips active memberships', async () => { @@ -11,27 +11,37 @@ describe('ClassesService — DingTalk class import membership lifecycle', () => leaveDate: '2026-02-01', } as ClassStudent; const active = { classId: 3, studentId: 9, status: 'active' } as ClassStudent; - const classStudentRepo = { - find: jest.fn().mockResolvedValue([left, active]), - create: jest.fn().mockImplementation((value: Partial) => value), - save: jest.fn().mockImplementation(async (value: ClassStudent[]) => value), + const manager = { + findOne: jest.fn().mockResolvedValue({ id: 3 }), + find: jest.fn().mockImplementation(async (entity: unknown) => { + if (entity === StudentDingMapping) { + return [ + { dingUserId: 'd8', studentId: 8 }, + { dingUserId: 'd9', studentId: 9 }, + ]; + } + if (entity === Student) { + return [ + { id: 8, name: '学生8', status: 'active' }, + { id: 9, name: '学生9', status: 'active' }, + ]; + } + if (entity === ClassStudent) return [left, active]; + return []; + }), + create: jest.fn().mockImplementation((_entity: unknown, value: object) => value), + save: jest.fn().mockImplementation(async (_entity: unknown, value: unknown) => value), }; const service = new ClassesService( - { findOne: jest.fn().mockResolvedValue({ id: 3 }) } as never, - classStudentRepo as never, {} as never, {} as never, {} as never, {} as never, - { create: jest.fn(), save: jest.fn() } as never, - { - find: jest.fn().mockResolvedValue([ - { dingUserId: 'd8', studentId: 8 }, - { dingUserId: 'd9', studentId: 9 }, - ]), - create: jest.fn(), - save: jest.fn(), - } as never, + {} as never, + {} as never, + {} as never, + {} as never, + { transaction: jest.fn().mockImplementation((work) => work(manager)) } as never, ); const result = await service.batchImportStudents(3, [ @@ -39,9 +49,9 @@ describe('ClassesService — DingTalk class import membership lifecycle', () => { dingUserId: 'd9', name: '学生9' }, ]); - expect(result).toEqual({ imported: 1, skipped: 1 }); + expect(result).toEqual({ imported: 1, skipped: 1, conflicts: 0 }); expect(left).toMatchObject({ status: 'active', leaveDate: null }); expect(left.joinDate).toMatch(/^\d{4}-\d{2}-\d{2}$/); - expect(classStudentRepo.save).toHaveBeenCalledWith([left]); + expect(manager.save).toHaveBeenCalledWith(ClassStudent, [left]); }); }); diff --git a/apps/server/src/classes/classes.service.ts b/apps/server/src/classes/classes.service.ts index e4bcee1..379d1ac 100644 --- a/apps/server/src/classes/classes.service.ts +++ b/apps/server/src/classes/classes.service.ts @@ -2,11 +2,10 @@ import { Injectable, NotFoundException, BadRequestException, - ConflictException, ForbiddenException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, In, Like } from 'typeorm'; +import { DataSource, Repository, In, Like } from 'typeorm'; import { Class, ClassStudent, @@ -18,6 +17,7 @@ import { Student, StudentDingMapping, } from '../entities'; +import { syncDingTalkStudents } from '../integration/dingtalk-student-sync'; import { normalizeDateOnly } from '../database/date-normalization'; import { CreateClassDto, @@ -26,7 +26,6 @@ import { AddTeacherDto, QueryClassScheduleDto, QueryClassAttendanceSummaryDto, - BatchImportStudentsDto, } from './dto/class.dto'; interface RawStudentCount { @@ -53,6 +52,7 @@ export class ClassesService { private studentRepo: Repository, @InjectRepository(StudentDingMapping) private studentDingMappingRepo: Repository, + private dataSource: DataSource, ) {} async getAccessibleClassIds(userId: number, canManageAll = false): Promise { @@ -190,82 +190,56 @@ export class ClassesService { name: string; mobile?: string; }>, - ): Promise<{ imported: number; skipped: number }> { - const classEntity = await this.classRepo.findOne({ where: { id: classId } }); - if (!classEntity) throw new NotFoundException('班级不存在'); + ): Promise<{ imported: number; skipped: number; conflicts: number }> { + if (users.length === 0) return { imported: 0, skipped: 0, conflicts: 0 }; - if (users.length === 0) return { imported: 0, skipped: 0 }; + return this.dataSource.transaction(async (manager) => { + const classEntity = await manager.findOne(Class, { where: { id: classId } }); + if (!classEntity) throw new NotFoundException('班级不存在'); - const dingUserIds = users.map((u) => u.dingUserId); + const synced = await syncDingTalkStudents(manager, users); + const studentIds = [...new Set(synced.studentIds.values())]; + if (studentIds.length === 0) { + return { imported: 0, skipped: 0, conflicts: synced.conflicts.length }; + } - // 1. Fetch all existing ding mappings in one query - const existingMappings = await this.studentDingMappingRepo.find({ - where: { dingUserId: In(dingUserIds) }, - }); - const dingToStudentId = new Map(existingMappings.map((m) => [m.dingUserId, m.studentId])); - - // 2. Batch create students for new dingUserIds - const newUsers = users.filter((u) => !dingToStudentId.has(u.dingUserId)); - if (newUsers.length > 0) { - const newStudents = newUsers.map((u) => - this.studentRepo.create({ - name: u.name, - phone: u.mobile || `dt_${u.dingUserId}`, - status: 'active', - }), + const existingClassStudents = await manager.find(ClassStudent, { + where: { classId, studentId: In(studentIds) }, + }); + const existingByStudentId = new Map( + existingClassStudents.map((classStudent) => [classStudent.studentId, classStudent]), ); - const savedStudents = await this.studentRepo.save(newStudents); + const today = new Date().toISOString().slice(0, 10); + let skipped = 0; + const memberships = studentIds.flatMap((studentId) => { + const existing = existingByStudentId.get(studentId); + if (existing?.status === 'active') { + skipped++; + return []; + } + if (existing) { + existing.status = 'active'; + existing.joinDate = today; + existing.leaveDate = null; + return [existing]; + } + return [ + manager.create(ClassStudent, { + classId, + studentId, + status: 'active', + joinDate: today, + }), + ]; + }); - const newMappings = savedStudents.map((s, i) => - this.studentDingMappingRepo.create({ dingUserId: newUsers[i].dingUserId, studentId: s.id }), - ); - await this.studentDingMappingRepo.save(newMappings); - - for (let i = 0; i < newUsers.length; i++) { - dingToStudentId.set(newUsers[i].dingUserId, savedStudents[i].id); - } - } - - // 3. Fetch existing class-student links in one query - const allStudentIds = Array.from(new Set(dingToStudentId.values())); - const existingClassStudents = - allStudentIds.length > 0 - ? await this.classStudentRepo.find({ - where: { classId, studentId: In(allStudentIds) }, - }) - : []; - const existingByStudentId = new Map( - existingClassStudents.map((classStudent) => [classStudent.studentId, classStudent]), - ); - const today = new Date().toISOString().slice(0, 10); - let skipped = 0; - const memberships = allStudentIds.flatMap((studentId) => { - const existing = existingByStudentId.get(studentId); - if (existing?.status === 'active') { - skipped++; - return []; - } - if (existing) { - existing.status = 'active'; - existing.joinDate = today; - existing.leaveDate = null; - return [existing]; - } - return [ - this.classStudentRepo.create({ - classId, - studentId, - status: 'active', - joinDate: today, - }), - ]; + if (memberships.length > 0) await manager.save(ClassStudent, memberships); + return { + imported: memberships.length, + skipped, + conflicts: synced.conflicts.length, + }; }); - - if (memberships.length > 0) { - await this.classStudentRepo.save(memberships); - } - - return { imported: memberships.length, skipped }; } async update(id: number, dto: UpdateClassDto) { const cls = await this.classRepo.findOne({ where: { id } }); diff --git a/apps/server/src/database/database-migrations.service.ts b/apps/server/src/database/database-migrations.service.ts index 0d9708a..d181a09 100644 --- a/apps/server/src/database/database-migrations.service.ts +++ b/apps/server/src/database/database-migrations.service.ts @@ -10,6 +10,7 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap { async onApplicationBootstrap(): Promise { await this.ensureAiConfigTable(); + await this.ensureSyncStateLeaseColumns(); await this.ensureCourseAttendanceSchema(); await this.ensureAttendanceDevicesSchema(); await this.ensureStudentWalletSchema(); @@ -23,6 +24,24 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap { await this.normalizeClassroomStatuses(); } + private async ensureSyncStateLeaseColumns(): Promise { + const runner = this.dataSource.createQueryRunner(); + await runner.connect(); + try { + const table = await runner.getTable('sync_state'); + if (!table) return; + const columns = new Set(table.columns.map((column) => column.name)); + if (!columns.has('run_id')) { + await runner.query('ALTER TABLE sync_state ADD COLUMN run_id VARCHAR(64)'); + } + if (!columns.has('running_since')) { + await runner.query('ALTER TABLE sync_state ADD COLUMN running_since DATETIME'); + } + } finally { + await runner.release(); + } + } + private async ensureAttendanceDevicesSchema(): Promise { const runner = this.dataSource.createQueryRunner(); await runner.connect(); diff --git a/apps/server/src/database/database-migrations.spec.ts b/apps/server/src/database/database-migrations.spec.ts index 84d75e1..dcdd7f7 100644 --- a/apps/server/src/database/database-migrations.spec.ts +++ b/apps/server/src/database/database-migrations.spec.ts @@ -49,6 +49,7 @@ function createDataSource(runner: MockRunner, dbType: string = 'better-sqlite3') // Type to reach private migration methods for testing interface MigrationsPrivate { ensureAiConfigTable(): Promise; + ensureSyncStateLeaseColumns(): Promise; backfillOrganizations(): Promise; normalizeClassDates(): Promise; ensureCourseAttendanceSchema(): Promise; diff --git a/apps/server/src/entities/sync-log.entity.ts b/apps/server/src/entities/sync-log.entity.ts index bac85ff..3b98911 100644 --- a/apps/server/src/entities/sync-log.entity.ts +++ b/apps/server/src/entities/sync-log.entity.ts @@ -1,8 +1,8 @@ import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm'; -export type SyncPlatform = 'dingtalk' | 'wecom'; +export type SyncPlatform = 'dingtalk_students' | 'dingtalk_attendance' | 'wecom'; export type SyncType = 'full' | 'incremental'; -export type SyncStatus = 'running' | 'success' | 'failed'; +export type SyncStatus = 'running' | 'success' | 'partial' | 'failed'; @Entity('sync_logs') export class SyncLog { diff --git a/apps/server/src/entities/sync-state.entity.ts b/apps/server/src/entities/sync-state.entity.ts index 53559a3..3209432 100644 --- a/apps/server/src/entities/sync-state.entity.ts +++ b/apps/server/src/entities/sync-state.entity.ts @@ -8,4 +8,10 @@ export class SyncState { @Column({ name: 'last_sync_at', type: 'datetime', nullable: true }) lastSyncAt: Date | null; + + @Column({ name: 'run_id', type: 'varchar', length: 64, nullable: true }) + runId: string | null; + + @Column({ name: 'running_since', type: 'datetime', nullable: true }) + runningSince: Date | null; } diff --git a/apps/server/src/integration/dingtalk-student-sync.spec.ts b/apps/server/src/integration/dingtalk-student-sync.spec.ts new file mode 100644 index 0000000..90bb37e --- /dev/null +++ b/apps/server/src/integration/dingtalk-student-sync.spec.ts @@ -0,0 +1,96 @@ +import { EntityManager } from 'typeorm'; +import { Student, StudentDingMapping } from '../entities'; +import { syncDingTalkStudents } from './dingtalk-student-sync'; + +function managerFixture(options?: { + mappings?: StudentDingMapping[]; + students?: Student[]; + occupiedPhones?: Student[]; + failMappingSave?: boolean; +}) { + const saves: Array<{ entity: unknown; values: unknown }> = []; + let nextId = 100; + const manager = { + find: jest.fn().mockImplementation(async (entity: unknown, findOptions?: unknown) => { + if (entity === StudentDingMapping) return options?.mappings ?? []; + if (entity === Student) { + const where = findOptions && typeof findOptions === 'object' && 'where' in findOptions + ? findOptions.where + : undefined; + if (where && typeof where === 'object' && 'phone' in where) { + return options?.occupiedPhones ?? []; + } + return options?.students ?? []; + } + return []; + }), + findOne: jest.fn().mockResolvedValue({ id: 7, isHost: true, status: 'active' }), + create: jest.fn().mockImplementation((_entity: unknown, value: object) => ({ ...value })), + save: jest.fn().mockImplementation(async (entity: unknown, values: unknown) => { + saves.push({ entity, values }); + if (entity === StudentDingMapping && options?.failMappingSave) throw new Error('mapping failed'); + if (entity === Student && Array.isArray(values)) { + return values.map((value) => ({ ...value, id: 'id' in value ? value.id : nextId++ })); + } + return values; + }), + }; + return { manager: manager as unknown as EntityManager, saves }; +} + +describe('syncDingTalkStudents', () => { + it('deduplicates DingTalk users and creates a host-owned student and mapping once', async () => { + const { manager, saves } = managerFixture(); + + const result = await syncDingTalkStudents(manager, [ + { dingUserId: 'u1', name: '张三', mobile: '13800000000' }, + { dingUserId: 'u1', name: '重复项', mobile: '13800000000' }, + ]); + + expect(result).toMatchObject({ created: 1, updated: 0, conflicts: [] }); + expect(saves).toContainEqual({ + entity: Student, + values: [expect.objectContaining({ name: '张三', organizationId: 7 })], + }); + expect(saves).toContainEqual({ + entity: StudentDingMapping, + values: [expect.objectContaining({ dingUserId: 'u1', studentId: 100 })], + }); + }); + + it('reports a phone conflict without creating a duplicate student', async () => { + const occupied = { id: 5, phone: '13800000000' } as Student; + const { manager, saves } = managerFixture({ occupiedPhones: [occupied] }); + + const result = await syncDingTalkStudents(manager, [ + { dingUserId: 'u2', name: '李四', mobile: occupied.phone }, + ]); + + expect(result.created).toBe(0); + expect(result.conflicts).toEqual([ + expect.objectContaining({ dingUserId: 'u2', reason: expect.stringContaining('人工绑定') }), + ]); + expect(saves).toEqual([]); + }); + + it('surfaces mapping persistence failure so the surrounding transaction can roll back', async () => { + const { manager } = managerFixture({ failMappingSave: true }); + + await expect( + syncDingTalkStudents(manager, [{ dingUserId: 'u3', name: '王五' }]), + ).rejects.toThrow('mapping failed'); + }); + + it('keeps an archived mapped student archived while refreshing profile data', async () => { + const archived = { id: 8, name: '旧名', phone: null, status: 'archived' } as unknown as Student; + const mapping = { dingUserId: 'u8', studentId: 8 } as StudentDingMapping; + const { manager } = managerFixture({ mappings: [mapping], students: [archived] }); + + const result = await syncDingTalkStudents(manager, [ + { dingUserId: 'u8', name: '新名', mobile: '13900000000' }, + ]); + + expect(result.updated).toBe(1); + expect(archived).toMatchObject({ name: '新名', phone: '13900000000', status: 'archived' }); + }); +}); diff --git a/apps/server/src/integration/dingtalk-student-sync.ts b/apps/server/src/integration/dingtalk-student-sync.ts new file mode 100644 index 0000000..10ace6f --- /dev/null +++ b/apps/server/src/integration/dingtalk-student-sync.ts @@ -0,0 +1,124 @@ +import { EntityManager, In } from 'typeorm'; +import { Organization, Student, StudentDingMapping } from '../entities'; + +export interface DingTalkStudentInput { + dingUserId: string; + name: string; + mobile?: string; +} + +export interface DingTalkStudentConflict { + dingUserId: string; + name: string; + reason: string; +} + +export interface DingTalkStudentSyncResult { + created: number; + updated: number; + studentIds: Map; + conflicts: DingTalkStudentConflict[]; +} + +export async function syncDingTalkStudents( + manager: EntityManager, + inputs: DingTalkStudentInput[], +): Promise { + const users = new Map(); + const conflicts: DingTalkStudentConflict[] = []; + + for (const input of inputs) { + const dingUserId = input.dingUserId?.trim(); + const name = input.name?.trim(); + const mobile = input.mobile?.trim() || undefined; + if (!dingUserId || dingUserId.length > 100 || !name || name.length > 50) { + conflicts.push({ dingUserId: dingUserId || '', name: name || '', reason: '钉钉用户ID或姓名无效' }); + continue; + } + if (mobile && mobile.length > 20) { + conflicts.push({ dingUserId, name, reason: '手机号超过20个字符' }); + continue; + } + if (!users.has(dingUserId)) users.set(dingUserId, { dingUserId, name, mobile }); + } + + if (users.size === 0) { + return { created: 0, updated: 0, studentIds: new Map(), conflicts }; + } + + const dingUserIds = [...users.keys()]; + const mappings = await manager.find(StudentDingMapping, { + where: { dingUserId: In(dingUserIds) }, + }); + const mappingByDingId = new Map(mappings.map((mapping) => [mapping.dingUserId, mapping])); + const mappedStudentIds = mappings.map((mapping) => mapping.studentId); + const mappedStudents = mappedStudentIds.length + ? await manager.find(Student, { where: { id: In(mappedStudentIds) } }) + : []; + const studentById = new Map(mappedStudents.map((student) => [student.id, student])); + const studentIds = new Map(); + const updates: Student[] = []; + + for (const mapping of mappings) { + const input = users.get(mapping.dingUserId); + const student = studentById.get(mapping.studentId); + if (!input || !student) { + conflicts.push({ + dingUserId: mapping.dingUserId, + name: input?.name || '', + reason: '钉钉映射对应的学生不存在', + }); + continue; + } + studentIds.set(mapping.dingUserId, student.id); + student.name = input.name; + if (input.mobile) student.phone = input.mobile; + updates.push(student); + } + + const newUsers = [...users.values()].filter((user) => !mappingByDingId.has(user.dingUserId)); + const mobiles = [...new Set(newUsers.map((user) => user.mobile).filter((mobile): mobile is string => !!mobile))]; + const occupiedPhones = mobiles.length + ? await manager.find(Student, { where: { phone: In(mobiles) } }) + : []; + const studentByPhone = new Map(occupiedPhones.map((student) => [student.phone, student])); + const creatable = newUsers.filter((user) => { + if (!user.mobile || !studentByPhone.has(user.mobile)) return true; + conflicts.push({ dingUserId: user.dingUserId, name: user.name, reason: '手机号已属于其他学生,请人工绑定' }); + return false; + }); + + const host = creatable.length + ? await manager.findOne(Organization, { where: { isHost: true, status: 'active' } }) + : null; + if (creatable.length && !host) throw new Error('尚未配置本机构'); + + if (updates.length) await manager.save(Student, updates); + const createdStudents = creatable.length + ? await manager.save( + Student, + creatable.map((user) => + manager.create(Student, { + name: user.name, + phone: user.mobile, + status: 'active', + organizationId: host!.id, + }), + ), + ) + : []; + if (createdStudents.length) { + await manager.save( + StudentDingMapping, + createdStudents.map((student, index) => + manager.create(StudentDingMapping, { + dingUserId: creatable[index].dingUserId, + studentId: student.id, + }), + ), + ); + createdStudents.forEach((student, index) => studentIds.set(creatable[index].dingUserId, student.id)); + } + + return { created: createdStudents.length, updated: updates.length, studentIds, conflicts }; +} diff --git a/apps/server/src/integration/dingtalk.service.spec.ts b/apps/server/src/integration/dingtalk.service.spec.ts index 9c67bea..85ded2d 100644 --- a/apps/server/src/integration/dingtalk.service.spec.ts +++ b/apps/server/src/integration/dingtalk.service.spec.ts @@ -176,14 +176,18 @@ describe('DingTalkService — attendance machine only group', () => { }); describe('DingTalkService — department user pagination boundaries', () => { + type PrivateDingTalkService = { + getDeptUsers(token: string, deptId: number): Promise; + }; afterEach(() => { jest.restoreAllMocks(); global.fetch = undefined as unknown as typeof fetch; }); - it('stops when DingTalk says there is another page but omits the next cursor', async () => { + it('fails when DingTalk says there is another page but omits the next cursor', async () => { const service = new DingTalkService({} as never, {} as never); global.fetch = jest.fn().mockResolvedValue({ + ok: true, json: jest.fn().mockResolvedValue({ errcode: 0, errmsg: 'ok', @@ -194,13 +198,14 @@ describe('DingTalkService — department user pagination boundaries', () => { }), }) as jest.MockedFunction; - await expect((service as any).getDeptUsers('token', 1)).resolves.toHaveLength(1); + await expect((service as unknown as PrivateDingTalkService).getDeptUsers('token', 1)).rejects.toThrow('分页游标未前进'); expect(global.fetch).toHaveBeenCalledTimes(1); }); - it('stops when the next cursor repeats the current cursor', async () => { + it('fails when the next cursor repeats the current cursor', async () => { const service = new DingTalkService({} as never, {} as never); global.fetch = jest.fn().mockResolvedValue({ + ok: true, json: jest.fn().mockResolvedValue({ errcode: 0, errmsg: 'ok', @@ -208,7 +213,56 @@ describe('DingTalkService — department user pagination boundaries', () => { }), }) as jest.MockedFunction; - await expect((service as any).getDeptUsers('token', 1)).resolves.toEqual([]); + await expect((service as unknown as PrivateDingTalkService).getDeptUsers('token', 1)).rejects.toThrow('分页游标未前进'); expect(global.fetch).toHaveBeenCalledTimes(1); }); + + it('fails on a DingTalk API error instead of returning a partial user list', async () => { + const service = new DingTalkService({} as never, {} as never); + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ errcode: 40035, errmsg: 'invalid department' }), + }) as jest.MockedFunction; + + await expect((service as unknown as PrivateDingTalkService).getDeptUsers('token', 9)).rejects.toThrow('invalid department'); + }); + + it('keeps a multi-department user visible in the selected subtree', async () => { + const service = new DingTalkService({} as never, {} as never); + const privateService = service as unknown as { + isConfigured(): Promise; + getAccessToken(): Promise; + getDeptInfo(token: string, deptId: number): Promise<{ name: string; parent_id: number }>; + buildDeptNode(token: string, deptId: number, name: string, parentId: number): Promise<{ + id: number; + name: string; + parentId: number; + children: []; + }>; + getDeptUsers(token: string, deptId: number): Promise>; + }; + jest.spyOn(privateService, 'isConfigured').mockResolvedValue(true); + jest.spyOn(privateService, 'getAccessToken').mockResolvedValue('token'); + jest.spyOn(privateService, 'getDeptInfo').mockResolvedValue({ name: '子部门', parent_id: 1 }); + jest.spyOn(privateService, 'buildDeptNode').mockResolvedValue({ + id: 2, + name: '子部门', + parentId: 1, + children: [], + }); + jest.spyOn(privateService, 'getDeptUsers').mockResolvedValue([ + { userid: 'u1', name: 'Alice', mobile: '', dept_id_list: [2, 99] }, + ]); + + const tree = await service.fetchOrgTreeWithUsers(2); + + expect(tree[0].users).toEqual([ + expect.objectContaining({ userid: 'u1', deptIds: [2, 99] }), + ]); + }); }); diff --git a/apps/server/src/integration/dingtalk.service.ts b/apps/server/src/integration/dingtalk.service.ts index 10fe8ff..7a39cba 100644 --- a/apps/server/src/integration/dingtalk.service.ts +++ b/apps/server/src/integration/dingtalk.service.ts @@ -7,17 +7,14 @@ */ import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { DataSource, Repository } from 'typeorm'; import { Student } from '../entities/student.entity'; import { StudentDingMapping } from '../entities/student-ding-mapping.entity'; +import { syncDingTalkStudents } from './dingtalk-student-sync'; import { IntegrationConfigService } from './config/integration-config.service'; // ── Types ── -interface DingTalkTokenResponse { - accessToken: string; - expireIn: number; -} interface DingTalkCredentials { appKey: string; @@ -40,6 +37,31 @@ interface DingTalkUserListResponse { } +function isDingTalkUserListResponse(value: unknown): value is DingTalkUserListResponse { + if (!value || typeof value !== 'object' || !('errcode' in value)) return false; + if (typeof value.errcode !== 'number') return false; + if ('errmsg' in value && typeof value.errmsg !== 'string') return false; + if (!('result' in value) || !value.result || typeof value.result !== 'object') { + return value.errcode !== 0; + } + if (!('has_more' in value.result) || typeof value.result.has_more !== 'boolean') return false; + if (!('list' in value.result) || !Array.isArray(value.result.list)) return false; + return value.result.list.every( + (item) => + item && + typeof item === 'object' && + 'userid' in item && + typeof item.userid === 'string' && + 'name' in item && + typeof item.name === 'string' && + 'mobile' in item && + typeof item.mobile === 'string' && + 'dept_id_list' in item && + Array.isArray(item.dept_id_list) && + item.dept_id_list.every((id) => typeof id === 'number'), + ); +} + /** 钉钉打卡结果 — 对齐 dws attendance check result */ export interface DingTalkAttendanceResult { userId: string; @@ -193,6 +215,7 @@ export class DingTalkService { @InjectRepository(StudentDingMapping) private readonly studentDingMappingRepo: Repository, private readonly integrationConfigService?: IntegrationConfigService, + private readonly dataSource?: DataSource, ) {} private async getCredentials(): Promise { @@ -240,15 +263,20 @@ export class DingTalkService { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(credentials), }); - const body: DingTalkTokenResponse = await res.json(); - - if (!body.accessToken) { + const body: unknown = await res.json(); + if ( + !body || + typeof body !== 'object' || + !('accessToken' in body) || + typeof body.accessToken !== 'string' + ) { throw new Error(`钉钉 access_token 获取失败: ${JSON.stringify(body)}`); } + const expireIn = 'expireIn' in body && typeof body.expireIn === 'number' ? body.expireIn : 7200; this.accessToken = body.accessToken; this.accessTokenCredentialKey = credentialKey; - this.tokenExpiresAt = Date.now() + (body.expireIn || 7200) * 1000; + this.tokenExpiresAt = Date.now() + expireIn * 1000; this.logger.log('钉钉 access_token 获取成功'); return this.accessToken; } @@ -266,6 +294,8 @@ export class DingTalkService { let hasMore = true; while (hasMore) { + await this.rateLimit(); + let body: DingTalkUserListResponse; try { const res = await fetch( `https://oapi.dingtalk.com/topapi/v2/user/list?access_token=${token}`, @@ -275,24 +305,27 @@ export class DingTalkService { body: JSON.stringify({ dept_id: deptId, cursor, size: 100 }), }, ); - const body: DingTalkUserListResponse = await res.json(); - if (body.errcode === 0 && body.result) { - all.push(...body.result.list); - hasMore = body.result.has_more; - if (hasMore) { - if (body.result.next_cursor === undefined || body.result.next_cursor === cursor) { - this.logger.error(`获取部门 ${deptId} 用户失败: 分页游标未前进`); - hasMore = false; - } else { - cursor = body.result.next_cursor; - } - } - } else { - hasMore = false; + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const rawBody: unknown = await res.json(); + if (!isDingTalkUserListResponse(rawBody)) throw new Error('钉钉返回了无效的用户列表'); + body = rawBody; + } catch (error) { + throw new ServiceUnavailableException( + `获取部门 ${deptId} 用户失败: ${error instanceof Error ? error.message : String(error)}`, + ); + } + if (body.errcode !== 0 || !body.result) { + throw new ServiceUnavailableException( + `获取部门 ${deptId} 用户失败: ${body.errmsg || `errcode=${body.errcode}`}`, + ); + } + all.push(...body.result.list); + hasMore = body.result.has_more; + if (hasMore) { + if (body.result.next_cursor === undefined || body.result.next_cursor === cursor) { + throw new ServiceUnavailableException(`获取部门 ${deptId} 用户失败: 分页游标未前进`); } - } catch (e) { - this.logger.error(`获取部门 ${deptId} 用户失败: ${(e as Error).message}`); - hasMore = false; + cursor = body.result.next_cursor; } } return all; @@ -302,44 +335,58 @@ export class DingTalkService { // Sync all — 主入口 // ═══════════════════════════════════════════ - async syncAll(rootDeptId = 1): Promise<{ deptCount: number; userCount: number }> { + async syncAll(rootDeptId = 1): Promise<{ + deptCount: number; + userCount: number; + created: number; + updated: number; + conflicts: Array<{ dingUserId: string; name: string; reason: string }>; + }> { if (!(await this.isConfigured())) { - this.logger.warn('钉钉未配置 (DINGTALK_APP_KEY / DINGTALK_APP_SECRET),跳过同步'); - return { deptCount: 0, userCount: 0 }; + throw new ServiceUnavailableException('钉钉未配置'); } + if (!this.dataSource) throw new ServiceUnavailableException('数据库未初始化'); const t0 = Date.now(); const token = await this.getAccessToken(); - - // 递归收集所有部门 ID + const visitedDeptIds = new Set(); const collectDeptIds = async (deptId: number): Promise => { + if (visitedDeptIds.has(deptId)) return []; + visitedDeptIds.add(deptId); const ids: number[] = [deptId]; - const subs = await this.getSubDepts(token, deptId); - for (const sd of subs) { - ids.push(...(await collectDeptIds(sd.dept_id))); + for (const sub of await this.getSubDepts(token, deptId)) { + ids.push(...(await collectDeptIds(sub.dept_id))); } return ids; }; const allDeptIds = await collectDeptIds(rootDeptId); - - let userCount = 0; - const seenUserIds = new Set(); - - for (const did of allDeptIds) { - const dingUsers = await this.getDeptUsers(token, did); - for (const du of dingUsers) { - if (seenUserIds.has(du.userid)) continue; - seenUserIds.add(du.userid); - - await this.syncOneUser(du); - userCount++; + const users = new Map(); + for (const deptId of allDeptIds) { + for (const user of await this.getDeptUsers(token, deptId)) { + if (!users.has(user.userid)) { + users.set(user.userid, { + dingUserId: user.userid, + name: user.name, + mobile: user.mobile || undefined, + }); + } } } - - this.logger.log( - `钉钉同步完成: ${userCount} 个用户, ${allDeptIds.length} 个部门, API 请求 ${this.apiRequestCount} 次, 耗时 ${Date.now() - t0}ms`, + const result = await this.dataSource.transaction((manager) => + syncDingTalkStudents(manager, [...users.values()]), ); - return { deptCount: allDeptIds.length, userCount }; + this.logger.log( + `钉钉同步完成: ${users.size} 个用户, ${allDeptIds.length} 个部门, ` + + `${result.created} 个新增, ${result.updated} 个更新, ${result.conflicts.length} 个冲突, ` + + `API 请求 ${this.apiRequestCount} 次, 耗时 ${Date.now() - t0}ms`, + ); + return { + deptCount: allDeptIds.length, + userCount: users.size, + created: result.created, + updated: result.updated, + conflicts: result.conflicts, + }; } // ═══════════════════════════════════════════ @@ -351,19 +398,23 @@ export class DingTalkService { `https://oapi.dingtalk.com/topapi/v2/department/listsub?access_token=${token}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dept_id: deptId }) }, ); + if (!res.ok) throw new ServiceUnavailableException(`获取部门 ${deptId} 子部门失败: HTTP ${res.status}`); const body = await res.json() as DingTalkDeptListResponse; - return body.errcode === 0 ? (body.result ?? []) : []; + if (body.errcode !== 0) throw new ServiceUnavailableException(`获取部门 ${deptId} 子部门失败: errcode=${body.errcode}`); + return body.result ?? []; } /** 获取单个部门详情 */ - private async getDeptInfo(token: string, deptId: number): Promise<{ name: string; parent_id: number } | null> { + private async getDeptInfo(token: string, deptId: number): Promise<{ name: string; parent_id: number }> { await this.rateLimit(); const res = await fetch( `https://oapi.dingtalk.com/topapi/v2/department/get?access_token=${token}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dept_id: deptId }) }, ); + if (!res.ok) throw new ServiceUnavailableException(`获取部门 ${deptId} 失败: HTTP ${res.status}`); const body = await res.json() as DingTalkDeptGetResponse; - return body.errcode === 0 && body.result ? body.result : null; + if (body.errcode !== 0 || !body.result) throw new ServiceUnavailableException(`钉钉部门 ${deptId} 不存在或不可访问`); + return body.result; } /** 递归构建部门树节点 */ @@ -377,49 +428,42 @@ export class DingTalkService { /** 获取钉钉组织部门树(只含部门) */ async fetchOrgTree(rootDeptId = 1): Promise { - if (!(await this.isConfigured())) return []; + if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置'); const token = await this.getAccessToken(); const rootInfo = await this.getDeptInfo(token, rootDeptId); - if (!rootInfo) return []; const node = await this.buildDeptNode(token, rootDeptId, rootInfo.name, rootInfo.parent_id); return [node]; } /** 获取钉钉组织部门树(含用户) */ async fetchOrgTreeWithUsers(rootDeptId = 1): Promise { - if (!(await this.isConfigured())) return []; + if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置'); const token = await this.getAccessToken(); const rootInfo = await this.getDeptInfo(token, rootDeptId); - if (!rootInfo) return []; - - // 1. 先建部门树(复用 buildDeptNode) const deptTree = await this.buildDeptNode(token, rootDeptId, rootInfo.name, rootInfo.parent_id); - // 2. 收集所有部门 ID const allDeptIds: number[] = []; const collectIds = (node: OrgDeptNode) => { allDeptIds.push(node.id); - for (const c of node.children) collectIds(c); + for (const child of node.children) collectIds(child); }; collectIds(deptTree); - // 3. 从每个部门拉用户,每人只挂到一个部门(dept_id_list 最后一个) - const allDeptIdsSet = new Set(allDeptIds); const usersByDept = new Map>(); const placedUsers = new Set(); - for (const did of allDeptIds) { - const deptUsers = await this.getDeptUsers(token, did); - for (const u of deptUsers) { - if (placedUsers.has(u.userid)) continue; - const targetDept = u.dept_id_list[u.dept_id_list.length - 1]; - if (allDeptIdsSet.has(targetDept)) { - placedUsers.add(u.userid); - if (!usersByDept.has(targetDept)) usersByDept.set(targetDept, []); - usersByDept.get(targetDept)!.push({ userid: u.userid, name: u.name, mobile: u.mobile, deptIds: u.dept_id_list }); - } + for (const deptId of allDeptIds) { + for (const user of await this.getDeptUsers(token, deptId)) { + if (placedUsers.has(user.userid)) continue; + placedUsers.add(user.userid); + if (!usersByDept.has(deptId)) usersByDept.set(deptId, []); + usersByDept.get(deptId)!.push({ + userid: user.userid, + name: user.name, + mobile: user.mobile, + deptIds: user.dept_id_list, + }); } } - // 4. 递归挂用户到树节点 const attachUsers = (node: OrgDeptNode): OrgDeptNodeWithUsers => ({ id: node.id, name: node.name, @@ -427,45 +471,9 @@ export class DingTalkService { children: node.children.map(attachUsers), users: usersByDept.get(node.id) ?? [], }); - return [attachUsers(deptTree)]; } - // ═══════════════════════════════════════════ - // Sync one user (with mapping) - // ═══════════════════════════════════════════ - - private async syncOneUser(du: { - userid: string; name: string; mobile: string; - }): Promise { - let mapping = await this.studentDingMappingRepo.findOne({ - where: { dingUserId: du.userid }, - }); - if (mapping) { - const student = await this.studentRepo.findOne({ - where: { id: mapping.studentId }, - }); - if (student) { - student.name = du.name; - if (du.mobile) student.phone = du.mobile; - await this.studentRepo.save(student); - } - return; - } - - const student = this.studentRepo.create({ - name: du.name, - phone: du.mobile || undefined, - status: 'active', - }); - await this.studentRepo.save(student); - - mapping = this.studentDingMappingRepo.create({ - dingUserId: du.userid, - studentId: student.id, - }); - await this.studentDingMappingRepo.save(mapping); - } // ═══════════════════════════════════════════ // Rate limiting — 对齐 gongxue-dorm-sys @@ -735,7 +743,7 @@ export class DingTalkService { } /** 查询所有考勤组摘要(分页,每页10条) */ - async queryAttendanceGroups(opUserId = 'manager'): Promise { + async queryAttendanceGroups(_opUserId = 'manager'): Promise { if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置'); const token = await this.getAccessToken(); diff --git a/apps/server/src/sync/sync.controller.spec.ts b/apps/server/src/sync/sync.controller.spec.ts index 18810e9..60ad35a 100644 --- a/apps/server/src/sync/sync.controller.spec.ts +++ b/apps/server/src/sync/sync.controller.spec.ts @@ -1,3 +1,4 @@ +import { BadRequestException } from '@nestjs/common'; import { SyncController } from './sync.controller'; import { ScheduleSyncQueryDto } from './dto/schedule-sync.dto'; @@ -20,4 +21,26 @@ describe('SyncController — schedule sync options', () => { true, ); }); + + it.each(['1abc', '0', '-1', '9007199254740992'])( + 'rejects invalid root department id %s', + async (rootDeptId) => { + const syncService = { triggerSync: jest.fn() }; + const controller = new SyncController(syncService as never); + + await expect(controller.triggerSync('dingtalk_students', rootDeptId)).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(syncService.triggerSync).not.toHaveBeenCalled(); + }, + ); + + it('accepts a positive integer root department id', async () => { + const syncService = { triggerSync: jest.fn().mockResolvedValue([]) }; + const controller = new SyncController(syncService as never); + + await controller.triggerSync('dingtalk_students', '12'); + + expect(syncService.triggerSync).toHaveBeenCalledWith('dingtalk_students', 12); + }); }); diff --git a/apps/server/src/sync/sync.controller.ts b/apps/server/src/sync/sync.controller.ts index 139109b..ea11582 100644 --- a/apps/server/src/sync/sync.controller.ts +++ b/apps/server/src/sync/sync.controller.ts @@ -25,10 +25,12 @@ export class SyncController { @Get('status') @RequirePermission('sync:read') async getStatus() { - const lastDingTalk = await this.syncService.getLastSync('dingtalk'); + const lastDingTalkStudents = await this.syncService.getLastSync('dingtalk_students'); + const lastDingTalkAttendance = await this.syncService.getLastSync('dingtalk_attendance'); const lastWeCom = await this.syncService.getLastSync('wecom'); return { - dingTalk: lastDingTalk ? { lastSyncAt: lastDingTalk.finishedAt, status: lastDingTalk.status } : null, + dingTalkStudents: lastDingTalkStudents ? { lastSyncAt: lastDingTalkStudents.finishedAt, status: lastDingTalkStudents.status } : null, + dingTalkAttendance: lastDingTalkAttendance ? { lastSyncAt: lastDingTalkAttendance.finishedAt, status: lastDingTalkAttendance.status } : null, weCom: lastWeCom ? { lastSyncAt: lastWeCom.finishedAt, status: lastWeCom.status } : null, }; } @@ -101,9 +103,12 @@ export class SyncController { private parseRootDeptId(rootDeptId: string): number { - const parsed = parseInt(rootDeptId, 10); - if (isNaN(parsed)) { - throw new BadRequestException('rootDeptId must be a valid integer'); + if (!/^\d+$/.test(rootDeptId)) { + throw new BadRequestException('rootDeptId must be a positive integer'); + } + const parsed = Number(rootDeptId); + if (!Number.isSafeInteger(parsed) || parsed < 1) { + throw new BadRequestException('rootDeptId must be a positive integer'); } return parsed; } diff --git a/apps/server/src/sync/sync.service.spec.ts b/apps/server/src/sync/sync.service.spec.ts index 32fb08f..031f340 100644 --- a/apps/server/src/sync/sync.service.spec.ts +++ b/apps/server/src/sync/sync.service.spec.ts @@ -1,8 +1,97 @@ +import { ConflictException, ServiceUnavailableException } from '@nestjs/common'; +import { SyncLog } from '../entities'; +import { SyncService } from './sync.service'; +function queryBuilder(affected = 1) { + const builder = { + insert: jest.fn(), + update: jest.fn(), + values: jest.fn(), + orIgnore: jest.fn(), + set: jest.fn(), + where: jest.fn(), + andWhere: jest.fn(), + execute: jest.fn().mockResolvedValue({ affected }), + }; + for (const method of ['insert', 'update', 'values', 'orIgnore', 'set', 'where', 'andWhere'] as const) { + builder[method].mockReturnValue(builder); + } + return builder; +} -describe('SyncService', () => { - it('should be defined', () => { - // SyncService module compiles — full tests removed with importDingTalkUsers - expect(true).toBe(true); +function createService(options?: { + affected?: number; + attendanceResult?: { success: boolean; imported: number; errors: string[] }; +}) { + const builders = [queryBuilder(), queryBuilder(options?.affected), queryBuilder()]; + const syncStateRepo = { + createQueryBuilder: jest.fn().mockImplementation(() => builders.shift()), + findOne: jest.fn().mockResolvedValue({ lastSyncAt: null }), + update: jest.fn().mockResolvedValue({ affected: 1 }), + }; + const syncLogRepo = { + create: jest.fn().mockImplementation((value: Partial) => value), + save: jest.fn().mockImplementation(async (value: SyncLog) => value), + findOne: jest.fn(), + find: jest.fn(), + }; + const dingTalkService = { + syncAll: jest.fn().mockResolvedValue({ created: 1, updated: 2, conflicts: [] }), + }; + const attendanceImportService = { + importFromDingTalk: jest.fn().mockResolvedValue(options?.attendanceResult ?? { + success: true, + imported: 3, + errors: [], + }), + }; + const service = new SyncService( + syncLogRepo as never, + syncStateRepo as never, + { find: jest.fn().mockResolvedValue([{ dingUserId: 'u1' }]) } as never, + dingTalkService as never, + { syncAll: jest.fn().mockResolvedValue({ userCount: 0 }) } as never, + attendanceImportService as never, + {} as never, + ); + return { service, syncStateRepo, syncLogRepo, dingTalkService, attendanceImportService }; +} + +describe('SyncService — safe DingTalk orchestration', () => { + it('uses a dedicated student cursor and records a successful student sync', async () => { + const { service, syncStateRepo, syncLogRepo } = createService(); + + const log = await service.syncDingTalkStudents(9); + + expect(log).toMatchObject({ + platform: 'dingtalk_students', + status: 'success', + recordsCount: 3, + }); + expect(syncStateRepo.update).toHaveBeenCalledWith( + { platform: 'dingtalk_students' }, + expect.objectContaining({ lastSyncAt: expect.any(Date) }), + ); + expect(syncLogRepo.save).toHaveBeenCalled(); + }); + + it('rejects a second run when the database lease is held', async () => { + const { service } = createService({ affected: 0 }); + + await expect(service.syncDingTalkStudents()).rejects.toBeInstanceOf(ConflictException); + }); + + it('does not advance attendance cursor when import reports failure', async () => { + const { service, syncStateRepo, syncLogRepo } = createService({ + attendanceResult: { success: false, imported: 0, errors: ['upstream failed'] }, + }); + + await expect(service.syncDingTalkAttendance()).rejects.toBeInstanceOf( + ServiceUnavailableException, + ); + expect(syncStateRepo.update).not.toHaveBeenCalled(); + expect(syncLogRepo.save).toHaveBeenLastCalledWith( + expect.objectContaining({ status: 'failed', errorMessage: expect.stringContaining('upstream failed') }), + ); }); }); diff --git a/apps/server/src/sync/sync.service.ts b/apps/server/src/sync/sync.service.ts index d52c72d..e7d4a4e 100644 --- a/apps/server/src/sync/sync.service.ts +++ b/apps/server/src/sync/sync.service.ts @@ -1,16 +1,19 @@ -import { Injectable, Logger } from '@nestjs/common'; +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, SyncType, SyncStatus } from '../entities/sync-log.entity'; +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 { AttendanceImportService } from '../attendance/attendance-import.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, @@ -18,82 +21,70 @@ export class SyncService { private readonly syncStateRepo: Repository, @InjectRepository(StudentDingMapping) private readonly studentDingMappingRepo: Repository, - private readonly dingTalkService: DingTalkService, private readonly weComService: WeComService, private readonly attendanceImportService: AttendanceImportService, private readonly scheduleSyncService: ScheduleSyncService, ) {} - // ── Scheduled sync disabled — use manual trigger via UI ── - - // ── Sync DingTalk ── - async syncDingTalk(rootDeptId = 1): Promise { - 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; + async syncDingTalkStudents(rootDeptId = 1): Promise { + 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 { + 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' }; + }); } - // ── Sync WeCom ── async syncWeCom(): Promise { - 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; + return this.runSync('wecom', async () => { + const result = await this.weComService.syncAll(); + return { recordsCount: result.userCount, status: 'success' }; + }); } - // ── Manual trigger ── async triggerSync(platform?: SyncPlatform, rootDeptId = 1): Promise { - if (platform === 'dingtalk') return [await this.syncDingTalk(rootDeptId)]; + 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.syncDingTalk(rootDeptId), 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); } @@ -121,9 +112,6 @@ export class SyncService { return { total: groups.length, deleted, failed }; } - // ── 排班同步 ── - - /** 将本地排课同步到钉钉考勤排班 */ async syncScheduleToDingTalk( dateFrom?: string, days = 30, @@ -132,31 +120,82 @@ export class SyncService { return this.scheduleSyncService.syncAll(dateFrom, days, 'manager', attendanceMachineOnly); } - /** 获取排班同步状态(当前仅返回活跃排课统计) */ async getScheduleSyncStatus(date?: string) { - const targetDate = date || new Date().toISOString().slice(0, 10); - return this.scheduleSyncService.getStatus(targetDate); + return this.scheduleSyncService.getStatus(date || new Date().toISOString().slice(0, 10)); } - // ── Sync log queries ── - async getLogs(platform?: SyncPlatform, limit: number = 50): Promise { + async getLogs(platform?: SyncPlatform, limit = 50): Promise { const where: Record = {}; if (platform) where.platform = platform; return this.syncLogRepo.find({ where, order: { createdAt: 'DESC' }, take: limit }); } - async getLastSync(platform: SyncPlatform): Promise { - return this.syncLogRepo.findOne({ - where: { platform }, - order: { createdAt: 'DESC' }, - }); + async getLastSync(platform: SyncPlatform | 'dingtalk'): Promise { + 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 helpers ── + private async runSync( + platform: SyncPlatform, + operation: (lastSyncAt: Date | null) => Promise<{ + recordsCount: number; + status: Extract; + message?: string; + }>, + ): Promise { + 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 determineSyncType(platform: SyncPlatform): Promise { - const state = await this.syncStateRepo.findOne({ where: { platform } }); - return state?.lastSyncAt ? 'incremental' : 'full'; + private async acquireLease(platform: SyncPlatform): Promise { + 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 { + 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 { @@ -164,26 +203,20 @@ export class SyncService { return state?.lastSyncAt ?? null; } - private async updateLastSyncAt(platform: SyncPlatform): Promise { - await this.syncStateRepo.upsert( - { platform, lastSyncAt: new Date() }, - ['platform'], - ); - } - private async createSyncLog( platform: SyncPlatform, syncType: SyncType, status: SyncStatus, ): Promise { - const log = this.syncLogRepo.create({ - platform, - syncType, - status, - recordsCount: 0, - startedAt: new Date(), - }); - return this.syncLogRepo.save(log); + return this.syncLogRepo.save( + this.syncLogRepo.create({ + platform, + syncType, + status, + recordsCount: 0, + startedAt: new Date(), + }), + ); } private async finishSyncLog( @@ -195,52 +228,7 @@ export class SyncService { log.status = status; log.recordsCount = recordsCount; log.finishedAt = new Date(); - if (errorMessage) log.errorMessage = errorMessage; + log.errorMessage = errorMessage ?? null; await this.syncLogRepo.save(log); } - - private async performDingTalkSync(lastSyncAt: Date | null, rootDeptId = 1): Promise { - // 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 { - const result = await this.weComService.syncAll(); - return result.userCount; - } }