Merge pull request #21: harden DingTalk student synchronization

This commit is contained in:
2026-07-18 06:52:33 +00:00
15 changed files with 758 additions and 356 deletions

View File

@@ -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) {

View File

@@ -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]);
});
});

View File

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

View File

@@ -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();

View File

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

View File

@@ -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 {

View File

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

View 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' });
});
});

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

View File

@@ -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] }),
]);
});
});

View File

@@ -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();

View File

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

View File

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

View File

@@ -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') }),
);
});
});

View File

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