forked from wangziqi/gongxue-base
Merge pull request #21: harden DingTalk student synchronization
This commit is contained in:
@@ -65,6 +65,7 @@ interface ClassItem {
|
|||||||
interface ImportResult {
|
interface ImportResult {
|
||||||
imported: number;
|
imported: number;
|
||||||
skipped: number;
|
skipped: number;
|
||||||
|
conflicts: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DingTalkAttendanceGroup {
|
interface DingTalkAttendanceGroup {
|
||||||
@@ -285,7 +286,11 @@ const IntegrationConfigPage: React.FC = () => {
|
|||||||
setImporting(true);
|
setImporting(true);
|
||||||
try {
|
try {
|
||||||
const res = await api.post<ImportResult>(`/classes/${selectedClassId}/students/import`, { users });
|
const res = await api.post<ImportResult>(`/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([]);
|
setCheckedKeys([]);
|
||||||
setSelectedClassId(null);
|
setSelectedClassId(null);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ClassesService } from './classes.service';
|
import { ClassesService } from './classes.service';
|
||||||
import { ClassStudent } from '../entities';
|
import { ClassStudent, Student, StudentDingMapping } from '../entities';
|
||||||
|
|
||||||
describe('ClassesService — DingTalk class import membership lifecycle', () => {
|
describe('ClassesService — DingTalk class import membership lifecycle', () => {
|
||||||
it('reactivates left memberships and skips active memberships', async () => {
|
it('reactivates left memberships and skips active memberships', async () => {
|
||||||
@@ -11,27 +11,37 @@ describe('ClassesService — DingTalk class import membership lifecycle', () =>
|
|||||||
leaveDate: '2026-02-01',
|
leaveDate: '2026-02-01',
|
||||||
} as ClassStudent;
|
} as ClassStudent;
|
||||||
const active = { classId: 3, studentId: 9, status: 'active' } as ClassStudent;
|
const active = { classId: 3, studentId: 9, status: 'active' } as ClassStudent;
|
||||||
const classStudentRepo = {
|
const manager = {
|
||||||
find: jest.fn().mockResolvedValue([left, active]),
|
findOne: jest.fn().mockResolvedValue({ id: 3 }),
|
||||||
create: jest.fn().mockImplementation((value: Partial<ClassStudent>) => value),
|
find: jest.fn().mockImplementation(async (entity: unknown) => {
|
||||||
save: jest.fn().mockImplementation(async (value: ClassStudent[]) => value),
|
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(
|
const service = new ClassesService(
|
||||||
{ findOne: jest.fn().mockResolvedValue({ id: 3 }) } as never,
|
|
||||||
classStudentRepo as never,
|
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{ create: jest.fn(), save: jest.fn() } as never,
|
{} as never,
|
||||||
{
|
{} as never,
|
||||||
find: jest.fn().mockResolvedValue([
|
{} as never,
|
||||||
{ dingUserId: 'd8', studentId: 8 },
|
{} as never,
|
||||||
{ dingUserId: 'd9', studentId: 9 },
|
{ transaction: jest.fn().mockImplementation((work) => work(manager)) } as never,
|
||||||
]),
|
|
||||||
create: jest.fn(),
|
|
||||||
save: jest.fn(),
|
|
||||||
} as never,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await service.batchImportStudents(3, [
|
const result = await service.batchImportStudents(3, [
|
||||||
@@ -39,9 +49,9 @@ describe('ClassesService — DingTalk class import membership lifecycle', () =>
|
|||||||
{ dingUserId: 'd9', name: '学生9' },
|
{ 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).toMatchObject({ status: 'active', leaveDate: null });
|
||||||
expect(left.joinDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
expect(left.joinDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||||
expect(classStudentRepo.save).toHaveBeenCalledWith([left]);
|
expect(manager.save).toHaveBeenCalledWith(ClassStudent, [left]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,11 +2,10 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ConflictException,
|
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, In, Like } from 'typeorm';
|
import { DataSource, Repository, In, Like } from 'typeorm';
|
||||||
import {
|
import {
|
||||||
Class,
|
Class,
|
||||||
ClassStudent,
|
ClassStudent,
|
||||||
@@ -18,6 +17,7 @@ import {
|
|||||||
Student,
|
Student,
|
||||||
StudentDingMapping,
|
StudentDingMapping,
|
||||||
} from '../entities';
|
} from '../entities';
|
||||||
|
import { syncDingTalkStudents } from '../integration/dingtalk-student-sync';
|
||||||
import { normalizeDateOnly } from '../database/date-normalization';
|
import { normalizeDateOnly } from '../database/date-normalization';
|
||||||
import {
|
import {
|
||||||
CreateClassDto,
|
CreateClassDto,
|
||||||
@@ -26,7 +26,6 @@ import {
|
|||||||
AddTeacherDto,
|
AddTeacherDto,
|
||||||
QueryClassScheduleDto,
|
QueryClassScheduleDto,
|
||||||
QueryClassAttendanceSummaryDto,
|
QueryClassAttendanceSummaryDto,
|
||||||
BatchImportStudentsDto,
|
|
||||||
} from './dto/class.dto';
|
} from './dto/class.dto';
|
||||||
|
|
||||||
interface RawStudentCount {
|
interface RawStudentCount {
|
||||||
@@ -53,6 +52,7 @@ export class ClassesService {
|
|||||||
private studentRepo: Repository<Student>,
|
private studentRepo: Repository<Student>,
|
||||||
@InjectRepository(StudentDingMapping)
|
@InjectRepository(StudentDingMapping)
|
||||||
private studentDingMappingRepo: Repository<StudentDingMapping>,
|
private studentDingMappingRepo: Repository<StudentDingMapping>,
|
||||||
|
private dataSource: DataSource,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getAccessibleClassIds(userId: number, canManageAll = false): Promise<number[] | undefined> {
|
async getAccessibleClassIds(userId: number, canManageAll = false): Promise<number[] | undefined> {
|
||||||
@@ -190,82 +190,56 @@ export class ClassesService {
|
|||||||
name: string;
|
name: string;
|
||||||
mobile?: string;
|
mobile?: string;
|
||||||
}>,
|
}>,
|
||||||
): Promise<{ imported: number; skipped: number }> {
|
): Promise<{ imported: number; skipped: number; conflicts: number }> {
|
||||||
const classEntity = await this.classRepo.findOne({ where: { id: classId } });
|
if (users.length === 0) return { imported: 0, skipped: 0, conflicts: 0 };
|
||||||
if (!classEntity) throw new NotFoundException('班级不存在');
|
|
||||||
|
|
||||||
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 existingClassStudents = await manager.find(ClassStudent, {
|
||||||
const existingMappings = await this.studentDingMappingRepo.find({
|
where: { classId, studentId: In(studentIds) },
|
||||||
where: { dingUserId: In(dingUserIds) },
|
});
|
||||||
});
|
const existingByStudentId = new Map(
|
||||||
const dingToStudentId = new Map(existingMappings.map((m) => [m.dingUserId, m.studentId]));
|
existingClassStudents.map((classStudent) => [classStudent.studentId, classStudent]),
|
||||||
|
|
||||||
// 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 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) =>
|
if (memberships.length > 0) await manager.save(ClassStudent, memberships);
|
||||||
this.studentDingMappingRepo.create({ dingUserId: newUsers[i].dingUserId, studentId: s.id }),
|
return {
|
||||||
);
|
imported: memberships.length,
|
||||||
await this.studentDingMappingRepo.save(newMappings);
|
skipped,
|
||||||
|
conflicts: synced.conflicts.length,
|
||||||
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 this.classStudentRepo.save(memberships);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { imported: memberships.length, skipped };
|
|
||||||
}
|
}
|
||||||
async update(id: number, dto: UpdateClassDto) {
|
async update(id: number, dto: UpdateClassDto) {
|
||||||
const cls = await this.classRepo.findOne({ where: { id } });
|
const cls = await this.classRepo.findOne({ where: { id } });
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
|||||||
|
|
||||||
async onApplicationBootstrap(): Promise<void> {
|
async onApplicationBootstrap(): Promise<void> {
|
||||||
await this.ensureAiConfigTable();
|
await this.ensureAiConfigTable();
|
||||||
|
await this.ensureSyncStateLeaseColumns();
|
||||||
await this.ensureCourseAttendanceSchema();
|
await this.ensureCourseAttendanceSchema();
|
||||||
await this.ensureAttendanceDevicesSchema();
|
await this.ensureAttendanceDevicesSchema();
|
||||||
await this.ensureStudentWalletSchema();
|
await this.ensureStudentWalletSchema();
|
||||||
@@ -23,6 +24,24 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
|||||||
await this.normalizeClassroomStatuses();
|
await this.normalizeClassroomStatuses();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async ensureSyncStateLeaseColumns(): Promise<void> {
|
||||||
|
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<void> {
|
private async ensureAttendanceDevicesSchema(): Promise<void> {
|
||||||
const runner = this.dataSource.createQueryRunner();
|
const runner = this.dataSource.createQueryRunner();
|
||||||
await runner.connect();
|
await runner.connect();
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ function createDataSource(runner: MockRunner, dbType: string = 'better-sqlite3')
|
|||||||
// Type to reach private migration methods for testing
|
// Type to reach private migration methods for testing
|
||||||
interface MigrationsPrivate {
|
interface MigrationsPrivate {
|
||||||
ensureAiConfigTable(): Promise<void>;
|
ensureAiConfigTable(): Promise<void>;
|
||||||
|
ensureSyncStateLeaseColumns(): Promise<void>;
|
||||||
backfillOrganizations(): Promise<void>;
|
backfillOrganizations(): Promise<void>;
|
||||||
normalizeClassDates(): Promise<void>;
|
normalizeClassDates(): Promise<void>;
|
||||||
ensureCourseAttendanceSchema(): Promise<void>;
|
ensureCourseAttendanceSchema(): Promise<void>;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
|
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 SyncType = 'full' | 'incremental';
|
||||||
export type SyncStatus = 'running' | 'success' | 'failed';
|
export type SyncStatus = 'running' | 'success' | 'partial' | 'failed';
|
||||||
|
|
||||||
@Entity('sync_logs')
|
@Entity('sync_logs')
|
||||||
export class SyncLog {
|
export class SyncLog {
|
||||||
|
|||||||
@@ -8,4 +8,10 @@ export class SyncState {
|
|||||||
|
|
||||||
@Column({ name: 'last_sync_at', type: 'datetime', nullable: true })
|
@Column({ name: 'last_sync_at', type: 'datetime', nullable: true })
|
||||||
lastSyncAt: Date | null;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
96
apps/server/src/integration/dingtalk-student-sync.spec.ts
Normal file
96
apps/server/src/integration/dingtalk-student-sync.spec.ts
Normal file
@@ -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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
124
apps/server/src/integration/dingtalk-student-sync.ts
Normal file
124
apps/server/src/integration/dingtalk-student-sync.ts
Normal file
@@ -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<string, number>;
|
||||||
|
conflicts: DingTalkStudentConflict[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncDingTalkStudents(
|
||||||
|
manager: EntityManager,
|
||||||
|
inputs: DingTalkStudentInput[],
|
||||||
|
): Promise<DingTalkStudentSyncResult> {
|
||||||
|
const users = new Map<string, DingTalkStudentInput>();
|
||||||
|
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<string, number>();
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -176,14 +176,18 @@ describe('DingTalkService — attendance machine only group', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('DingTalkService — department user pagination boundaries', () => {
|
describe('DingTalkService — department user pagination boundaries', () => {
|
||||||
|
type PrivateDingTalkService = {
|
||||||
|
getDeptUsers(token: string, deptId: number): Promise<unknown[]>;
|
||||||
|
};
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
jest.restoreAllMocks();
|
jest.restoreAllMocks();
|
||||||
global.fetch = undefined as unknown as typeof fetch;
|
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);
|
const service = new DingTalkService({} as never, {} as never);
|
||||||
global.fetch = jest.fn().mockResolvedValue({
|
global.fetch = jest.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
json: jest.fn().mockResolvedValue({
|
json: jest.fn().mockResolvedValue({
|
||||||
errcode: 0,
|
errcode: 0,
|
||||||
errmsg: 'ok',
|
errmsg: 'ok',
|
||||||
@@ -194,13 +198,14 @@ describe('DingTalkService — department user pagination boundaries', () => {
|
|||||||
}),
|
}),
|
||||||
}) as jest.MockedFunction<typeof fetch>;
|
}) as jest.MockedFunction<typeof fetch>;
|
||||||
|
|
||||||
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);
|
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);
|
const service = new DingTalkService({} as never, {} as never);
|
||||||
global.fetch = jest.fn().mockResolvedValue({
|
global.fetch = jest.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
json: jest.fn().mockResolvedValue({
|
json: jest.fn().mockResolvedValue({
|
||||||
errcode: 0,
|
errcode: 0,
|
||||||
errmsg: 'ok',
|
errmsg: 'ok',
|
||||||
@@ -208,7 +213,56 @@ describe('DingTalkService — department user pagination boundaries', () => {
|
|||||||
}),
|
}),
|
||||||
}) as jest.MockedFunction<typeof fetch>;
|
}) as jest.MockedFunction<typeof fetch>;
|
||||||
|
|
||||||
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);
|
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<typeof fetch>;
|
||||||
|
|
||||||
|
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<boolean>;
|
||||||
|
getAccessToken(): Promise<string>;
|
||||||
|
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<Array<{
|
||||||
|
userid: string;
|
||||||
|
name: string;
|
||||||
|
mobile: string;
|
||||||
|
dept_id_list: number[];
|
||||||
|
}>>;
|
||||||
|
};
|
||||||
|
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] }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,17 +7,14 @@
|
|||||||
*/
|
*/
|
||||||
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
|
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { DataSource, Repository } from 'typeorm';
|
||||||
import { Student } from '../entities/student.entity';
|
import { Student } from '../entities/student.entity';
|
||||||
import { StudentDingMapping } from '../entities/student-ding-mapping.entity';
|
import { StudentDingMapping } from '../entities/student-ding-mapping.entity';
|
||||||
|
import { syncDingTalkStudents } from './dingtalk-student-sync';
|
||||||
import { IntegrationConfigService } from './config/integration-config.service';
|
import { IntegrationConfigService } from './config/integration-config.service';
|
||||||
|
|
||||||
// ── Types ──
|
// ── Types ──
|
||||||
|
|
||||||
interface DingTalkTokenResponse {
|
|
||||||
accessToken: string;
|
|
||||||
expireIn: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DingTalkCredentials {
|
interface DingTalkCredentials {
|
||||||
appKey: string;
|
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 */
|
/** 钉钉打卡结果 — 对齐 dws attendance check result */
|
||||||
export interface DingTalkAttendanceResult {
|
export interface DingTalkAttendanceResult {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -193,6 +215,7 @@ export class DingTalkService {
|
|||||||
@InjectRepository(StudentDingMapping)
|
@InjectRepository(StudentDingMapping)
|
||||||
private readonly studentDingMappingRepo: Repository<StudentDingMapping>,
|
private readonly studentDingMappingRepo: Repository<StudentDingMapping>,
|
||||||
private readonly integrationConfigService?: IntegrationConfigService,
|
private readonly integrationConfigService?: IntegrationConfigService,
|
||||||
|
private readonly dataSource?: DataSource,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async getCredentials(): Promise<DingTalkCredentials | null> {
|
private async getCredentials(): Promise<DingTalkCredentials | null> {
|
||||||
@@ -240,15 +263,20 @@ export class DingTalkService {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(credentials),
|
body: JSON.stringify(credentials),
|
||||||
});
|
});
|
||||||
const body: DingTalkTokenResponse = await res.json();
|
const body: unknown = await res.json();
|
||||||
|
if (
|
||||||
if (!body.accessToken) {
|
!body ||
|
||||||
|
typeof body !== 'object' ||
|
||||||
|
!('accessToken' in body) ||
|
||||||
|
typeof body.accessToken !== 'string'
|
||||||
|
) {
|
||||||
throw new Error(`钉钉 access_token 获取失败: ${JSON.stringify(body)}`);
|
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.accessToken = body.accessToken;
|
||||||
this.accessTokenCredentialKey = credentialKey;
|
this.accessTokenCredentialKey = credentialKey;
|
||||||
this.tokenExpiresAt = Date.now() + (body.expireIn || 7200) * 1000;
|
this.tokenExpiresAt = Date.now() + expireIn * 1000;
|
||||||
this.logger.log('钉钉 access_token 获取成功');
|
this.logger.log('钉钉 access_token 获取成功');
|
||||||
return this.accessToken;
|
return this.accessToken;
|
||||||
}
|
}
|
||||||
@@ -266,6 +294,8 @@ export class DingTalkService {
|
|||||||
let hasMore = true;
|
let hasMore = true;
|
||||||
|
|
||||||
while (hasMore) {
|
while (hasMore) {
|
||||||
|
await this.rateLimit();
|
||||||
|
let body: DingTalkUserListResponse;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`https://oapi.dingtalk.com/topapi/v2/user/list?access_token=${token}`,
|
`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 }),
|
body: JSON.stringify({ dept_id: deptId, cursor, size: 100 }),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const body: DingTalkUserListResponse = await res.json();
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
if (body.errcode === 0 && body.result) {
|
const rawBody: unknown = await res.json();
|
||||||
all.push(...body.result.list);
|
if (!isDingTalkUserListResponse(rawBody)) throw new Error('钉钉返回了无效的用户列表');
|
||||||
hasMore = body.result.has_more;
|
body = rawBody;
|
||||||
if (hasMore) {
|
} catch (error) {
|
||||||
if (body.result.next_cursor === undefined || body.result.next_cursor === cursor) {
|
throw new ServiceUnavailableException(
|
||||||
this.logger.error(`获取部门 ${deptId} 用户失败: 分页游标未前进`);
|
`获取部门 ${deptId} 用户失败: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
hasMore = false;
|
);
|
||||||
} else {
|
}
|
||||||
cursor = body.result.next_cursor;
|
if (body.errcode !== 0 || !body.result) {
|
||||||
}
|
throw new ServiceUnavailableException(
|
||||||
}
|
`获取部门 ${deptId} 用户失败: ${body.errmsg || `errcode=${body.errcode}`}`,
|
||||||
} else {
|
);
|
||||||
hasMore = false;
|
}
|
||||||
|
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) {
|
cursor = body.result.next_cursor;
|
||||||
this.logger.error(`获取部门 ${deptId} 用户失败: ${(e as Error).message}`);
|
|
||||||
hasMore = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return all;
|
return all;
|
||||||
@@ -302,44 +335,58 @@ export class DingTalkService {
|
|||||||
// Sync all — 主入口
|
// 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())) {
|
if (!(await this.isConfigured())) {
|
||||||
this.logger.warn('钉钉未配置 (DINGTALK_APP_KEY / DINGTALK_APP_SECRET),跳过同步');
|
throw new ServiceUnavailableException('钉钉未配置');
|
||||||
return { deptCount: 0, userCount: 0 };
|
|
||||||
}
|
}
|
||||||
|
if (!this.dataSource) throw new ServiceUnavailableException('数据库未初始化');
|
||||||
|
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
const token = await this.getAccessToken();
|
const token = await this.getAccessToken();
|
||||||
|
const visitedDeptIds = new Set<number>();
|
||||||
// 递归收集所有部门 ID
|
|
||||||
const collectDeptIds = async (deptId: number): Promise<number[]> => {
|
const collectDeptIds = async (deptId: number): Promise<number[]> => {
|
||||||
|
if (visitedDeptIds.has(deptId)) return [];
|
||||||
|
visitedDeptIds.add(deptId);
|
||||||
const ids: number[] = [deptId];
|
const ids: number[] = [deptId];
|
||||||
const subs = await this.getSubDepts(token, deptId);
|
for (const sub of await this.getSubDepts(token, deptId)) {
|
||||||
for (const sd of subs) {
|
ids.push(...(await collectDeptIds(sub.dept_id)));
|
||||||
ids.push(...(await collectDeptIds(sd.dept_id)));
|
|
||||||
}
|
}
|
||||||
return ids;
|
return ids;
|
||||||
};
|
};
|
||||||
const allDeptIds = await collectDeptIds(rootDeptId);
|
const allDeptIds = await collectDeptIds(rootDeptId);
|
||||||
|
const users = new Map<string, { dingUserId: string; name: string; mobile?: string }>();
|
||||||
let userCount = 0;
|
for (const deptId of allDeptIds) {
|
||||||
const seenUserIds = new Set<string>();
|
for (const user of await this.getDeptUsers(token, deptId)) {
|
||||||
|
if (!users.has(user.userid)) {
|
||||||
for (const did of allDeptIds) {
|
users.set(user.userid, {
|
||||||
const dingUsers = await this.getDeptUsers(token, did);
|
dingUserId: user.userid,
|
||||||
for (const du of dingUsers) {
|
name: user.name,
|
||||||
if (seenUserIds.has(du.userid)) continue;
|
mobile: user.mobile || undefined,
|
||||||
seenUserIds.add(du.userid);
|
});
|
||||||
|
}
|
||||||
await this.syncOneUser(du);
|
|
||||||
userCount++;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const result = await this.dataSource.transaction((manager) =>
|
||||||
this.logger.log(
|
syncDingTalkStudents(manager, [...users.values()]),
|
||||||
`钉钉同步完成: ${userCount} 个用户, ${allDeptIds.length} 个部门, API 请求 ${this.apiRequestCount} 次, 耗时 ${Date.now() - t0}ms`,
|
|
||||||
);
|
);
|
||||||
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}`,
|
`https://oapi.dingtalk.com/topapi/v2/department/listsub?access_token=${token}`,
|
||||||
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dept_id: deptId }) },
|
{ 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;
|
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();
|
await this.rateLimit();
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`https://oapi.dingtalk.com/topapi/v2/department/get?access_token=${token}`,
|
`https://oapi.dingtalk.com/topapi/v2/department/get?access_token=${token}`,
|
||||||
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dept_id: deptId }) },
|
{ 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;
|
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<OrgDeptNode[]> {
|
async fetchOrgTree(rootDeptId = 1): Promise<OrgDeptNode[]> {
|
||||||
if (!(await this.isConfigured())) return [];
|
if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
|
||||||
const token = await this.getAccessToken();
|
const token = await this.getAccessToken();
|
||||||
const rootInfo = await this.getDeptInfo(token, rootDeptId);
|
const rootInfo = await this.getDeptInfo(token, rootDeptId);
|
||||||
if (!rootInfo) return [];
|
|
||||||
const node = await this.buildDeptNode(token, rootDeptId, rootInfo.name, rootInfo.parent_id);
|
const node = await this.buildDeptNode(token, rootDeptId, rootInfo.name, rootInfo.parent_id);
|
||||||
return [node];
|
return [node];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取钉钉组织部门树(含用户) */
|
/** 获取钉钉组织部门树(含用户) */
|
||||||
async fetchOrgTreeWithUsers(rootDeptId = 1): Promise<OrgDeptNodeWithUsers[]> {
|
async fetchOrgTreeWithUsers(rootDeptId = 1): Promise<OrgDeptNodeWithUsers[]> {
|
||||||
if (!(await this.isConfigured())) return [];
|
if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
|
||||||
const token = await this.getAccessToken();
|
const token = await this.getAccessToken();
|
||||||
const rootInfo = await this.getDeptInfo(token, rootDeptId);
|
const rootInfo = await this.getDeptInfo(token, rootDeptId);
|
||||||
if (!rootInfo) return [];
|
|
||||||
|
|
||||||
// 1. 先建部门树(复用 buildDeptNode)
|
|
||||||
const deptTree = await this.buildDeptNode(token, rootDeptId, rootInfo.name, rootInfo.parent_id);
|
const deptTree = await this.buildDeptNode(token, rootDeptId, rootInfo.name, rootInfo.parent_id);
|
||||||
|
|
||||||
// 2. 收集所有部门 ID
|
|
||||||
const allDeptIds: number[] = [];
|
const allDeptIds: number[] = [];
|
||||||
const collectIds = (node: OrgDeptNode) => {
|
const collectIds = (node: OrgDeptNode) => {
|
||||||
allDeptIds.push(node.id);
|
allDeptIds.push(node.id);
|
||||||
for (const c of node.children) collectIds(c);
|
for (const child of node.children) collectIds(child);
|
||||||
};
|
};
|
||||||
collectIds(deptTree);
|
collectIds(deptTree);
|
||||||
|
|
||||||
// 3. 从每个部门拉用户,每人只挂到一个部门(dept_id_list 最后一个)
|
|
||||||
const allDeptIdsSet = new Set(allDeptIds);
|
|
||||||
const usersByDept = new Map<number, Array<{ userid: string; name: string; mobile: string; deptIds: number[] }>>();
|
const usersByDept = new Map<number, Array<{ userid: string; name: string; mobile: string; deptIds: number[] }>>();
|
||||||
const placedUsers = new Set<string>();
|
const placedUsers = new Set<string>();
|
||||||
for (const did of allDeptIds) {
|
for (const deptId of allDeptIds) {
|
||||||
const deptUsers = await this.getDeptUsers(token, did);
|
for (const user of await this.getDeptUsers(token, deptId)) {
|
||||||
for (const u of deptUsers) {
|
if (placedUsers.has(user.userid)) continue;
|
||||||
if (placedUsers.has(u.userid)) continue;
|
placedUsers.add(user.userid);
|
||||||
const targetDept = u.dept_id_list[u.dept_id_list.length - 1];
|
if (!usersByDept.has(deptId)) usersByDept.set(deptId, []);
|
||||||
if (allDeptIdsSet.has(targetDept)) {
|
usersByDept.get(deptId)!.push({
|
||||||
placedUsers.add(u.userid);
|
userid: user.userid,
|
||||||
if (!usersByDept.has(targetDept)) usersByDept.set(targetDept, []);
|
name: user.name,
|
||||||
usersByDept.get(targetDept)!.push({ userid: u.userid, name: u.name, mobile: u.mobile, deptIds: u.dept_id_list });
|
mobile: user.mobile,
|
||||||
}
|
deptIds: user.dept_id_list,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 4. 递归挂用户到树节点
|
|
||||||
const attachUsers = (node: OrgDeptNode): OrgDeptNodeWithUsers => ({
|
const attachUsers = (node: OrgDeptNode): OrgDeptNodeWithUsers => ({
|
||||||
id: node.id,
|
id: node.id,
|
||||||
name: node.name,
|
name: node.name,
|
||||||
@@ -427,45 +471,9 @@ export class DingTalkService {
|
|||||||
children: node.children.map(attachUsers),
|
children: node.children.map(attachUsers),
|
||||||
users: usersByDept.get(node.id) ?? [],
|
users: usersByDept.get(node.id) ?? [],
|
||||||
});
|
});
|
||||||
|
|
||||||
return [attachUsers(deptTree)];
|
return [attachUsers(deptTree)];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════
|
|
||||||
// Sync one user (with mapping)
|
|
||||||
// ═══════════════════════════════════════════
|
|
||||||
|
|
||||||
private async syncOneUser(du: {
|
|
||||||
userid: string; name: string; mobile: string;
|
|
||||||
}): Promise<void> {
|
|
||||||
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
|
// Rate limiting — 对齐 gongxue-dorm-sys
|
||||||
@@ -735,7 +743,7 @@ export class DingTalkService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 查询所有考勤组摘要(分页,每页10条) */
|
/** 查询所有考勤组摘要(分页,每页10条) */
|
||||||
async queryAttendanceGroups(opUserId = 'manager'): Promise<DingTalkGroupSummary[]> {
|
async queryAttendanceGroups(_opUserId = 'manager'): Promise<DingTalkGroupSummary[]> {
|
||||||
if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
|
if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
|
||||||
const token = await this.getAccessToken();
|
const token = await this.getAccessToken();
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
import { SyncController } from './sync.controller';
|
import { SyncController } from './sync.controller';
|
||||||
import { ScheduleSyncQueryDto } from './dto/schedule-sync.dto';
|
import { ScheduleSyncQueryDto } from './dto/schedule-sync.dto';
|
||||||
|
|
||||||
@@ -20,4 +21,26 @@ describe('SyncController — schedule sync options', () => {
|
|||||||
true,
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,10 +25,12 @@ export class SyncController {
|
|||||||
@Get('status')
|
@Get('status')
|
||||||
@RequirePermission('sync:read')
|
@RequirePermission('sync:read')
|
||||||
async getStatus() {
|
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');
|
const lastWeCom = await this.syncService.getLastSync('wecom');
|
||||||
return {
|
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,
|
weCom: lastWeCom ? { lastSyncAt: lastWeCom.finishedAt, status: lastWeCom.status } : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -101,9 +103,12 @@ export class SyncController {
|
|||||||
|
|
||||||
|
|
||||||
private parseRootDeptId(rootDeptId: string): number {
|
private parseRootDeptId(rootDeptId: string): number {
|
||||||
const parsed = parseInt(rootDeptId, 10);
|
if (!/^\d+$/.test(rootDeptId)) {
|
||||||
if (isNaN(parsed)) {
|
throw new BadRequestException('rootDeptId must be a positive integer');
|
||||||
throw new BadRequestException('rootDeptId must be a valid integer');
|
}
|
||||||
|
const parsed = Number(rootDeptId);
|
||||||
|
if (!Number.isSafeInteger(parsed) || parsed < 1) {
|
||||||
|
throw new BadRequestException('rootDeptId must be a positive integer');
|
||||||
}
|
}
|
||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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', () => {
|
function createService(options?: {
|
||||||
it('should be defined', () => {
|
affected?: number;
|
||||||
// SyncService module compiles — full tests removed with importDingTalkUsers
|
attendanceResult?: { success: boolean; imported: number; errors: string[] };
|
||||||
expect(true).toBe(true);
|
}) {
|
||||||
|
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<SyncLog>) => 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') }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { ConflictException, Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { SyncLog, SyncState, StudentDingMapping } from '../entities';
|
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 { DingTalkService } from '../integration/dingtalk.service';
|
||||||
import { WeComService } from '../integration/wecom.service';
|
import { WeComService } from '../integration/wecom.service';
|
||||||
import { AttendanceImportService } from '../attendance/attendance-import.service';
|
|
||||||
import { ScheduleSyncService } from './schedule-sync.service';
|
import { ScheduleSyncService } from './schedule-sync.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SyncService {
|
export class SyncService {
|
||||||
private readonly logger = new Logger(SyncService.name);
|
private readonly logger = new Logger(SyncService.name);
|
||||||
|
private static readonly LEASE_MS = 30 * 60 * 1000;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(SyncLog)
|
@InjectRepository(SyncLog)
|
||||||
private readonly syncLogRepo: Repository<SyncLog>,
|
private readonly syncLogRepo: Repository<SyncLog>,
|
||||||
@@ -18,82 +21,70 @@ export class SyncService {
|
|||||||
private readonly syncStateRepo: Repository<SyncState>,
|
private readonly syncStateRepo: Repository<SyncState>,
|
||||||
@InjectRepository(StudentDingMapping)
|
@InjectRepository(StudentDingMapping)
|
||||||
private readonly studentDingMappingRepo: Repository<StudentDingMapping>,
|
private readonly studentDingMappingRepo: Repository<StudentDingMapping>,
|
||||||
|
|
||||||
private readonly dingTalkService: DingTalkService,
|
private readonly dingTalkService: DingTalkService,
|
||||||
private readonly weComService: WeComService,
|
private readonly weComService: WeComService,
|
||||||
private readonly attendanceImportService: AttendanceImportService,
|
private readonly attendanceImportService: AttendanceImportService,
|
||||||
private readonly scheduleSyncService: ScheduleSyncService,
|
private readonly scheduleSyncService: ScheduleSyncService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ── Scheduled sync disabled — use manual trigger via UI ──
|
async syncDingTalkStudents(rootDeptId = 1): Promise<SyncLog> {
|
||||||
|
return this.runSync('dingtalk_students', async () => {
|
||||||
// ── Sync DingTalk ──
|
const result = await this.dingTalkService.syncAll(rootDeptId);
|
||||||
async syncDingTalk(rootDeptId = 1): Promise<SyncLog> {
|
return {
|
||||||
const platform: SyncPlatform = 'dingtalk';
|
recordsCount: result.created + result.updated,
|
||||||
const syncType = await this.determineSyncType(platform);
|
status: result.conflicts.length ? 'partial' : 'success',
|
||||||
|
message: result.conflicts.length ? JSON.stringify(result.conflicts.slice(0, 20)) : undefined,
|
||||||
const log = await this.createSyncLog(platform, syncType, 'running');
|
};
|
||||||
|
});
|
||||||
try {
|
}
|
||||||
const lastSyncAt = await this.getLastSyncAt(platform);
|
|
||||||
this.logger.log(`Syncing ${platform} (${syncType}), lastSyncAt: ${lastSyncAt}`);
|
async syncDingTalkAttendance(): Promise<SyncLog> {
|
||||||
|
return this.runSync('dingtalk_attendance', async (lastSyncAt) => {
|
||||||
// ── Call existing integration APIs ──
|
const endDate = new Date();
|
||||||
// Integration hooks — extend here to call DingTalk APIs with lastSyncAt
|
const startDate = lastSyncAt ? new Date(lastSyncAt) : new Date(endDate);
|
||||||
const recordsCount = await this.performDingTalkSync(lastSyncAt, rootDeptId);
|
if (!lastSyncAt) startDate.setDate(startDate.getDate() - 7);
|
||||||
|
|
||||||
await this.updateLastSyncAt(platform);
|
const mappings = await this.studentDingMappingRepo.find();
|
||||||
await this.finishSyncLog(log, 'success', recordsCount);
|
const userIds = [...new Set(mappings.map((mapping) => mapping.dingUserId).filter(Boolean))];
|
||||||
this.logger.log(`DingTalk sync complete: ${recordsCount} records`);
|
if (userIds.length === 0) {
|
||||||
} catch (error: unknown) {
|
throw new ServiceUnavailableException('没有可用于考勤导入的钉钉学生映射');
|
||||||
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);
|
const result = await this.attendanceImportService.importFromDingTalk({
|
||||||
}
|
startDate: startDate.toISOString().slice(0, 10),
|
||||||
|
endDate: endDate.toISOString().slice(0, 10),
|
||||||
return log;
|
userIds,
|
||||||
|
autoMatch: true,
|
||||||
|
});
|
||||||
|
if (!result.success) {
|
||||||
|
throw new ServiceUnavailableException(result.errors.join(';') || '钉钉考勤导入失败');
|
||||||
|
}
|
||||||
|
return { recordsCount: result.imported, status: 'success' };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Sync WeCom ──
|
|
||||||
async syncWeCom(): Promise<SyncLog> {
|
async syncWeCom(): Promise<SyncLog> {
|
||||||
const platform: SyncPlatform = 'wecom';
|
return this.runSync('wecom', async () => {
|
||||||
const syncType = await this.determineSyncType(platform);
|
const result = await this.weComService.syncAll();
|
||||||
|
return { recordsCount: result.userCount, status: 'success' };
|
||||||
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[]> {
|
async triggerSync(platform?: SyncPlatform, rootDeptId = 1): Promise<SyncLog[]> {
|
||||||
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()];
|
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) {
|
async getDingTalkOrgTree(rootDeptId = 1) {
|
||||||
return this.dingTalkService.fetchOrgTree(rootDeptId);
|
return this.dingTalkService.fetchOrgTree(rootDeptId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取钉钉组织部门树(含用户),供前端同步用户选择器使用 */
|
|
||||||
async getDingTalkOrgTreeWithUsers(rootDeptId = 1) {
|
async getDingTalkOrgTreeWithUsers(rootDeptId = 1) {
|
||||||
return this.dingTalkService.fetchOrgTreeWithUsers(rootDeptId);
|
return this.dingTalkService.fetchOrgTreeWithUsers(rootDeptId);
|
||||||
}
|
}
|
||||||
@@ -121,9 +112,6 @@ export class SyncService {
|
|||||||
return { total: groups.length, deleted, failed };
|
return { total: groups.length, deleted, failed };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 排班同步 ──
|
|
||||||
|
|
||||||
/** 将本地排课同步到钉钉考勤排班 */
|
|
||||||
async syncScheduleToDingTalk(
|
async syncScheduleToDingTalk(
|
||||||
dateFrom?: string,
|
dateFrom?: string,
|
||||||
days = 30,
|
days = 30,
|
||||||
@@ -132,31 +120,82 @@ export class SyncService {
|
|||||||
return this.scheduleSyncService.syncAll(dateFrom, days, 'manager', attendanceMachineOnly);
|
return this.scheduleSyncService.syncAll(dateFrom, days, 'manager', attendanceMachineOnly);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取排班同步状态(当前仅返回活跃排课统计) */
|
|
||||||
async getScheduleSyncStatus(date?: string) {
|
async getScheduleSyncStatus(date?: string) {
|
||||||
const targetDate = date || new Date().toISOString().slice(0, 10);
|
return this.scheduleSyncService.getStatus(date || new Date().toISOString().slice(0, 10));
|
||||||
return this.scheduleSyncService.getStatus(targetDate);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Sync log queries ──
|
async getLogs(platform?: SyncPlatform, limit = 50): Promise<SyncLog[]> {
|
||||||
async getLogs(platform?: SyncPlatform, limit: number = 50): Promise<SyncLog[]> {
|
|
||||||
const where: Record<string, SyncPlatform> = {};
|
const where: Record<string, SyncPlatform> = {};
|
||||||
if (platform) where.platform = platform;
|
if (platform) where.platform = platform;
|
||||||
return this.syncLogRepo.find({ where, order: { createdAt: 'DESC' }, take: limit });
|
return this.syncLogRepo.find({ where, order: { createdAt: 'DESC' }, take: limit });
|
||||||
}
|
}
|
||||||
|
|
||||||
async getLastSync(platform: SyncPlatform): Promise<SyncLog | null> {
|
async getLastSync(platform: SyncPlatform | 'dingtalk'): Promise<SyncLog | null> {
|
||||||
return this.syncLogRepo.findOne({
|
if (platform === 'dingtalk') {
|
||||||
where: { platform },
|
return this.syncLogRepo.findOne({
|
||||||
order: { createdAt: 'DESC' },
|
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<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 determineSyncType(platform: SyncPlatform): Promise<SyncType> {
|
private async acquireLease(platform: SyncPlatform): Promise<string> {
|
||||||
const state = await this.syncStateRepo.findOne({ where: { platform } });
|
await this.syncStateRepo
|
||||||
return state?.lastSyncAt ? 'incremental' : 'full';
|
.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> {
|
private async getLastSyncAt(platform: SyncPlatform): Promise<Date | null> {
|
||||||
@@ -164,26 +203,20 @@ export class SyncService {
|
|||||||
return state?.lastSyncAt ?? null;
|
return state?.lastSyncAt ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async updateLastSyncAt(platform: SyncPlatform): Promise<void> {
|
|
||||||
await this.syncStateRepo.upsert(
|
|
||||||
{ platform, lastSyncAt: new Date() },
|
|
||||||
['platform'],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async createSyncLog(
|
private async createSyncLog(
|
||||||
platform: SyncPlatform,
|
platform: SyncPlatform,
|
||||||
syncType: SyncType,
|
syncType: SyncType,
|
||||||
status: SyncStatus,
|
status: SyncStatus,
|
||||||
): Promise<SyncLog> {
|
): Promise<SyncLog> {
|
||||||
const log = this.syncLogRepo.create({
|
return this.syncLogRepo.save(
|
||||||
platform,
|
this.syncLogRepo.create({
|
||||||
syncType,
|
platform,
|
||||||
status,
|
syncType,
|
||||||
recordsCount: 0,
|
status,
|
||||||
startedAt: new Date(),
|
recordsCount: 0,
|
||||||
});
|
startedAt: new Date(),
|
||||||
return this.syncLogRepo.save(log);
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async finishSyncLog(
|
private async finishSyncLog(
|
||||||
@@ -195,52 +228,7 @@ export class SyncService {
|
|||||||
log.status = status;
|
log.status = status;
|
||||||
log.recordsCount = recordsCount;
|
log.recordsCount = recordsCount;
|
||||||
log.finishedAt = new Date();
|
log.finishedAt = new Date();
|
||||||
if (errorMessage) log.errorMessage = errorMessage;
|
log.errorMessage = errorMessage ?? null;
|
||||||
await this.syncLogRepo.save(log);
|
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.userCount;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user