fix: harden DingTalk student synchronization
This commit is contained in:
@@ -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<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([]);
|
||||
setSelectedClassId(null);
|
||||
} catch (e: unknown) {
|
||||
|
||||
@@ -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<ClassStudent>) => 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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Student>,
|
||||
@InjectRepository(StudentDingMapping)
|
||||
private studentDingMappingRepo: Repository<StudentDingMapping>,
|
||||
private dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async getAccessibleClassIds(userId: number, canManageAll = false): Promise<number[] | undefined> {
|
||||
@@ -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 } });
|
||||
|
||||
@@ -10,6 +10,7 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
||||
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
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<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> {
|
||||
const runner = this.dataSource.createQueryRunner();
|
||||
await runner.connect();
|
||||
|
||||
@@ -49,6 +49,7 @@ function createDataSource(runner: MockRunner, dbType: string = 'better-sqlite3')
|
||||
// Type to reach private migration methods for testing
|
||||
interface MigrationsPrivate {
|
||||
ensureAiConfigTable(): Promise<void>;
|
||||
ensureSyncStateLeaseColumns(): Promise<void>;
|
||||
backfillOrganizations(): Promise<void>;
|
||||
normalizeClassDates(): Promise<void>;
|
||||
ensureCourseAttendanceSchema(): Promise<void>;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
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', () => {
|
||||
type PrivateDingTalkService = {
|
||||
getDeptUsers(token: string, deptId: number): Promise<unknown[]>;
|
||||
};
|
||||
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<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);
|
||||
});
|
||||
|
||||
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<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);
|
||||
});
|
||||
|
||||
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 { 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<StudentDingMapping>,
|
||||
private readonly integrationConfigService?: IntegrationConfigService,
|
||||
private readonly dataSource?: DataSource,
|
||||
) {}
|
||||
|
||||
private async getCredentials(): Promise<DingTalkCredentials | null> {
|
||||
@@ -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<number>();
|
||||
const collectDeptIds = async (deptId: number): Promise<number[]> => {
|
||||
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<string>();
|
||||
|
||||
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<string, { dingUserId: string; name: string; mobile?: string }>();
|
||||
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<OrgDeptNode[]> {
|
||||
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<OrgDeptNodeWithUsers[]> {
|
||||
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<number, Array<{ userid: string; name: string; mobile: string; deptIds: number[] }>>();
|
||||
const placedUsers = new Set<string>();
|
||||
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<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
|
||||
@@ -735,7 +743,7 @@ export class DingTalkService {
|
||||
}
|
||||
|
||||
/** 查询所有考勤组摘要(分页,每页10条) */
|
||||
async queryAttendanceGroups(opUserId = 'manager'): Promise<DingTalkGroupSummary[]> {
|
||||
async queryAttendanceGroups(_opUserId = 'manager'): Promise<DingTalkGroupSummary[]> {
|
||||
if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
|
||||
const token = await this.getAccessToken();
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<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 { 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<SyncLog>,
|
||||
@@ -18,82 +21,70 @@ export class SyncService {
|
||||
private readonly syncStateRepo: Repository<SyncState>,
|
||||
@InjectRepository(StudentDingMapping)
|
||||
private readonly studentDingMappingRepo: Repository<StudentDingMapping>,
|
||||
|
||||
private readonly dingTalkService: DingTalkService,
|
||||
private readonly weComService: WeComService,
|
||||
private readonly attendanceImportService: AttendanceImportService,
|
||||
private readonly scheduleSyncService: ScheduleSyncService,
|
||||
) {}
|
||||
|
||||
// ── Scheduled sync disabled — use manual trigger via UI ──
|
||||
|
||||
// ── Sync DingTalk ──
|
||||
async syncDingTalk(rootDeptId = 1): Promise<SyncLog> {
|
||||
const platform: SyncPlatform = 'dingtalk';
|
||||
const syncType = await this.determineSyncType(platform);
|
||||
|
||||
const log = await this.createSyncLog(platform, syncType, 'running');
|
||||
|
||||
try {
|
||||
const lastSyncAt = await this.getLastSyncAt(platform);
|
||||
this.logger.log(`Syncing ${platform} (${syncType}), lastSyncAt: ${lastSyncAt}`);
|
||||
|
||||
// ── Call existing integration APIs ──
|
||||
// Integration hooks — extend here to call DingTalk APIs with lastSyncAt
|
||||
const recordsCount = await this.performDingTalkSync(lastSyncAt, rootDeptId);
|
||||
|
||||
await this.updateLastSyncAt(platform);
|
||||
await this.finishSyncLog(log, 'success', recordsCount);
|
||||
this.logger.log(`DingTalk sync complete: ${recordsCount} records`);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await this.finishSyncLog(log, 'failed', 0, message);
|
||||
this.logger.error(`DingTalk sync failed: ${message}`, error instanceof Error ? error.stack : undefined);
|
||||
}
|
||||
|
||||
return log;
|
||||
async syncDingTalkStudents(rootDeptId = 1): Promise<SyncLog> {
|
||||
return this.runSync('dingtalk_students', async () => {
|
||||
const result = await this.dingTalkService.syncAll(rootDeptId);
|
||||
return {
|
||||
recordsCount: result.created + result.updated,
|
||||
status: result.conflicts.length ? 'partial' : 'success',
|
||||
message: result.conflicts.length ? JSON.stringify(result.conflicts.slice(0, 20)) : undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async syncDingTalkAttendance(): Promise<SyncLog> {
|
||||
return this.runSync('dingtalk_attendance', async (lastSyncAt) => {
|
||||
const endDate = new Date();
|
||||
const startDate = lastSyncAt ? new Date(lastSyncAt) : new Date(endDate);
|
||||
if (!lastSyncAt) startDate.setDate(startDate.getDate() - 7);
|
||||
|
||||
const mappings = await this.studentDingMappingRepo.find();
|
||||
const userIds = [...new Set(mappings.map((mapping) => mapping.dingUserId).filter(Boolean))];
|
||||
if (userIds.length === 0) {
|
||||
throw new ServiceUnavailableException('没有可用于考勤导入的钉钉学生映射');
|
||||
}
|
||||
|
||||
const result = await this.attendanceImportService.importFromDingTalk({
|
||||
startDate: startDate.toISOString().slice(0, 10),
|
||||
endDate: endDate.toISOString().slice(0, 10),
|
||||
userIds,
|
||||
autoMatch: true,
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new ServiceUnavailableException(result.errors.join(';') || '钉钉考勤导入失败');
|
||||
}
|
||||
return { recordsCount: result.imported, status: 'success' };
|
||||
});
|
||||
}
|
||||
|
||||
// ── Sync WeCom ──
|
||||
async syncWeCom(): Promise<SyncLog> {
|
||||
const platform: SyncPlatform = 'wecom';
|
||||
const syncType = await this.determineSyncType(platform);
|
||||
|
||||
const log = await this.createSyncLog(platform, syncType, 'running');
|
||||
|
||||
try {
|
||||
const lastSyncAt = await this.getLastSyncAt(platform);
|
||||
this.logger.log(`Syncing ${platform} (${syncType}), lastSyncAt: ${lastSyncAt}`);
|
||||
|
||||
// ── Call existing integration APIs ──
|
||||
// Integration hooks — extend here to call WeCom APIs with lastSyncAt
|
||||
const recordsCount = await this.performWeComSync(lastSyncAt);
|
||||
|
||||
await this.updateLastSyncAt(platform);
|
||||
await this.finishSyncLog(log, 'success', recordsCount);
|
||||
this.logger.log(`WeCom sync complete: ${recordsCount} records`);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await this.finishSyncLog(log, 'failed', 0, message);
|
||||
this.logger.error(`WeCom sync failed: ${message}`, error instanceof Error ? error.stack : undefined);
|
||||
}
|
||||
|
||||
return log;
|
||||
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<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()];
|
||||
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<SyncLog[]> {
|
||||
async getLogs(platform?: SyncPlatform, limit = 50): Promise<SyncLog[]> {
|
||||
const where: Record<string, SyncPlatform> = {};
|
||||
if (platform) where.platform = platform;
|
||||
return this.syncLogRepo.find({ where, order: { createdAt: 'DESC' }, take: limit });
|
||||
}
|
||||
|
||||
async getLastSync(platform: SyncPlatform): Promise<SyncLog | null> {
|
||||
return this.syncLogRepo.findOne({
|
||||
where: { platform },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
async getLastSync(platform: SyncPlatform | 'dingtalk'): Promise<SyncLog | null> {
|
||||
if (platform === 'dingtalk') {
|
||||
return this.syncLogRepo.findOne({
|
||||
where: [{ platform: 'dingtalk_students' }, { platform: 'dingtalk_attendance' }],
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
return this.syncLogRepo.findOne({ where: { platform }, order: { createdAt: 'DESC' } });
|
||||
}
|
||||
|
||||
// ── Private 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> {
|
||||
const state = await this.syncStateRepo.findOne({ where: { platform } });
|
||||
return state?.lastSyncAt ? 'incremental' : 'full';
|
||||
private async acquireLease(platform: SyncPlatform): Promise<string> {
|
||||
await this.syncStateRepo
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.values({ platform, lastSyncAt: null, runId: null, runningSince: null })
|
||||
.orIgnore()
|
||||
.execute();
|
||||
|
||||
const runId = randomUUID();
|
||||
const result = await this.syncStateRepo
|
||||
.createQueryBuilder()
|
||||
.update()
|
||||
.set({ runId, runningSince: new Date() })
|
||||
.where('platform = :platform', { platform })
|
||||
.andWhere('(running_since IS NULL OR running_since < :staleBefore)', {
|
||||
staleBefore: new Date(Date.now() - SyncService.LEASE_MS),
|
||||
})
|
||||
.execute();
|
||||
if (result.affected !== 1) throw new ConflictException(`${platform} 同步正在进行中`);
|
||||
return runId;
|
||||
}
|
||||
|
||||
private async releaseLease(platform: SyncPlatform, runId: string): Promise<void> {
|
||||
await this.syncStateRepo
|
||||
.createQueryBuilder()
|
||||
.update()
|
||||
.set({ runId: null, runningSince: null })
|
||||
.where('platform = :platform AND run_id = :runId', { platform, runId })
|
||||
.execute();
|
||||
}
|
||||
|
||||
private async getLastSyncAt(platform: SyncPlatform): Promise<Date | null> {
|
||||
@@ -164,26 +203,20 @@ export class SyncService {
|
||||
return state?.lastSyncAt ?? null;
|
||||
}
|
||||
|
||||
private async updateLastSyncAt(platform: SyncPlatform): Promise<void> {
|
||||
await this.syncStateRepo.upsert(
|
||||
{ platform, lastSyncAt: new Date() },
|
||||
['platform'],
|
||||
);
|
||||
}
|
||||
|
||||
private async createSyncLog(
|
||||
platform: SyncPlatform,
|
||||
syncType: SyncType,
|
||||
status: SyncStatus,
|
||||
): Promise<SyncLog> {
|
||||
const log = this.syncLogRepo.create({
|
||||
platform,
|
||||
syncType,
|
||||
status,
|
||||
recordsCount: 0,
|
||||
startedAt: new Date(),
|
||||
});
|
||||
return this.syncLogRepo.save(log);
|
||||
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<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