feat: 钉钉学生同步支持仅绑定手机号及学生列表修复

This commit is contained in:
2026-08-04 14:41:48 +08:00
parent 50c44e4410
commit 216a20ffaf
9 changed files with 334 additions and 36 deletions

View File

@@ -58,7 +58,7 @@ describe('syncDingTalkStudents', () => {
});
});
it('reports a phone conflict without creating a duplicate student', async () => {
it('binds an existing student by unique phone and creates a mapping', async () => {
const occupied = { id: 5, phone: '13800000000' } as Student;
const { manager, saves } = managerFixture({ occupiedPhones: [occupied] });
@@ -66,13 +66,79 @@ describe('syncDingTalkStudents', () => {
{ dingUserId: 'u2', name: '李四', mobile: occupied.phone },
]);
expect(result.created).toBe(0);
expect(result).toMatchObject({ created: 0, updated: 1, matched: 1, conflicts: [] });
expect(saves).toContainEqual({
entity: Student,
values: [expect.objectContaining({ id: 5, name: '李四' })],
});
expect(saves).toContainEqual({
entity: StudentDingMapping,
values: [expect.objectContaining({ dingUserId: 'u2', studentId: 5 })],
});
});
it('reports a conflict when a phone matches multiple students', async () => {
const occupied = [
{ id: 5, phone: '13800000000' },
{ id: 6, phone: '13800000000' },
] as Student[];
const { manager, saves } = managerFixture({ occupiedPhones: occupied });
const result = await syncDingTalkStudents(manager, [
{ dingUserId: 'u2', name: '李四', mobile: '13800000000' },
]);
expect(result).toMatchObject({ created: 0, updated: 0, matched: 0 });
expect(result.conflicts).toEqual([
expect.objectContaining({ dingUserId: 'u2', reason: expect.stringContaining('人工绑定') }),
expect.objectContaining({ reason: expect.stringContaining('多名学生') }),
]);
expect(saves).toEqual([]);
});
it('skips unmatched users when createMissing is false', async () => {
const { manager, saves } = managerFixture();
const result = await syncDingTalkStudents(
manager,
[{ dingUserId: 'u4', name: '赵六', mobile: '13700000000' }],
{ createMissing: false, updateProfile: false },
);
expect(result).toMatchObject({
created: 0,
updated: 0,
matched: 0,
skipped: 1,
conflicts: [],
});
expect(saves).toEqual([]);
});
it('creates only the mapping without touching the student profile when updateProfile is false', async () => {
const occupied = { id: 5, name: '原名', phone: '13800000000' } as Student;
const { manager, saves } = managerFixture({ occupiedPhones: [occupied] });
const result = await syncDingTalkStudents(
manager,
[{ dingUserId: 'u2', name: '钉钉名', mobile: occupied.phone }],
{ createMissing: false, updateProfile: false },
);
expect(result).toMatchObject({
created: 0,
updated: 0,
matched: 1,
skipped: 0,
conflicts: [],
});
expect(occupied).toMatchObject({ name: '原名', phone: '13800000000' });
expect(saves.filter((save) => save.entity === Student)).toEqual([]);
expect(saves).toContainEqual({
entity: StudentDingMapping,
values: [expect.objectContaining({ dingUserId: 'u2', studentId: 5 })],
});
});
it('surfaces mapping persistence failure so the surrounding transaction can roll back', async () => {
const { manager } = managerFixture({ failMappingSave: true });

View File

@@ -16,6 +16,8 @@ export interface DingTalkStudentConflict {
export interface DingTalkStudentSyncResult {
created: number;
updated: number;
matched: number;
skipped: number;
studentIds: Map<string, number>;
conflicts: DingTalkStudentConflict[];
}
@@ -23,7 +25,10 @@ export interface DingTalkStudentSyncResult {
export async function syncDingTalkStudents(
manager: EntityManager,
inputs: DingTalkStudentInput[],
options: { createMissing?: boolean; updateProfile?: boolean } = {},
): Promise<DingTalkStudentSyncResult> {
const createMissing = options.createMissing !== false;
const updateProfile = options.updateProfile !== false;
const users = new Map<string, DingTalkStudentInput>();
const conflicts: DingTalkStudentConflict[] = [];
@@ -43,7 +48,7 @@ export async function syncDingTalkStudents(
}
if (users.size === 0) {
return { created: 0, updated: 0, studentIds: new Map(), conflicts };
return { created: 0, updated: 0, matched: 0, skipped: 0, studentIds: new Map(), conflicts };
}
const dingUserIds = [...users.keys()];
@@ -58,6 +63,7 @@ export async function syncDingTalkStudents(
const studentById = new Map(mappedStudents.map((student) => [student.id, student]));
const studentIds = new Map<string, number>();
const updates: Student[] = [];
let updatedCount = 0;
for (const mapping of mappings) {
const input = users.get(mapping.dingUserId);
@@ -71,9 +77,20 @@ export async function syncDingTalkStudents(
continue;
}
studentIds.set(mapping.dingUserId, student.id);
student.name = input.name;
if (input.mobile) student.phone = input.mobile;
updates.push(student);
if (!updateProfile) continue;
let changed = false;
if (student.name !== input.name) {
student.name = input.name;
changed = true;
}
if (input.mobile && student.phone !== input.mobile) {
student.phone = input.mobile;
changed = true;
}
if (changed) {
updates.push(student);
updatedCount++;
}
}
const newUsers = [...users.values()].filter((user) => !mappingByDingId.has(user.dingUserId));
@@ -81,19 +98,91 @@ export async function syncDingTalkStudents(
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 studentsByPhone = new Map<string, Student[]>();
for (const student of occupiedPhones) {
const list = studentsByPhone.get(student.phone) ?? [];
list.push(student);
studentsByPhone.set(student.phone, list);
}
// 新钉钉用户按手机号匹配:唯一命中 → 自动绑定;多人同号 → 冲突;无命中 → 新建
const boundStudentIds = new Set(mappings.map((mapping) => mapping.studentId));
const bindable: DingTalkStudentInput[] = [];
const creatable: DingTalkStudentInput[] = [];
const skipped: DingTalkStudentInput[] = [];
for (const user of newUsers) {
if (!user.mobile) {
(createMissing ? creatable : skipped).push(user);
continue;
}
const matches = studentsByPhone.get(user.mobile) ?? [];
if (matches.length === 0) {
(createMissing ? creatable : skipped).push(user);
continue;
}
if (matches.length > 1) {
conflicts.push({
dingUserId: user.dingUserId,
name: user.name,
reason: '手机号匹配到多名学生,请人工绑定',
});
continue;
}
const student = matches[0];
if (boundStudentIds.has(student.id)) {
conflicts.push({
dingUserId: user.dingUserId,
name: user.name,
reason: '手机号对应的学生已绑定其他钉钉账号',
});
continue;
}
boundStudentIds.add(student.id);
bindable.push(user);
}
const host = creatable.length
? await manager.findOne(Organization, { where: { isHost: true, status: 'active' } })
: null;
if (creatable.length && !host) throw new Error('尚未配置本机构');
const bindableStudent = new Map<string, Student>();
for (const user of bindable) {
const student = studentsByPhone.get(user.mobile!)![0];
bindableStudent.set(user.dingUserId, student);
if (!updateProfile) continue;
let changed = false;
if (student.name !== user.name) {
student.name = user.name;
changed = true;
}
if (user.mobile && student.phone !== user.mobile) {
student.phone = user.mobile;
changed = true;
}
if (changed) {
updates.push(student);
updatedCount++;
}
}
if (updates.length) await manager.save(Student, updates);
if (bindable.length) {
await manager.save(
StudentDingMapping,
bindable.map((user) =>
manager.create(StudentDingMapping, {
dingUserId: user.dingUserId,
studentId: bindableStudent.get(user.dingUserId)!.id,
}),
),
);
for (const user of bindable) {
studentIds.set(user.dingUserId, bindableStudent.get(user.dingUserId)!.id);
}
}
const createdStudents = creatable.length
? await manager.save(
Student,
@@ -120,5 +209,12 @@ export async function syncDingTalkStudents(
createdStudents.forEach((student, index) => studentIds.set(creatable[index].dingUserId, student.id));
}
return { created: createdStudents.length, updated: updates.length, studentIds, conflicts };
return {
created: createdStudents.length,
updated: updatedCount,
matched: bindable.length,
skipped: skipped.length,
studentIds,
conflicts,
};
}

View File

@@ -335,11 +335,16 @@ export class DingTalkService {
// Sync all — 主入口
// ═══════════════════════════════════════════
async syncAll(rootDeptId = 1): Promise<{
async syncAll(
rootDeptId = 1,
options: { createMissing?: boolean; updateProfile?: boolean } = {},
): Promise<{
deptCount: number;
userCount: number;
created: number;
updated: number;
matched: number;
skipped: number;
conflicts: Array<{ dingUserId: string; name: string; reason: string }>;
}> {
if (!(await this.isConfigured())) {
@@ -373,11 +378,12 @@ export class DingTalkService {
}
}
const result = await this.dataSource.transaction((manager) =>
syncDingTalkStudents(manager, [...users.values()]),
syncDingTalkStudents(manager, [...users.values()], options),
);
this.logger.log(
`钉钉同步完成: ${users.size} 个用户, ${allDeptIds.length} 个部门, ` +
`${result.created} 个新增, ${result.updated} 个更新, ${result.conflicts.length}冲突, ` +
`${result.created} 个新增, ${result.updated} 个更新, ${result.matched}手机号绑定, ` +
`${result.skipped} 个跳过, ${result.conflicts.length} 个冲突, ` +
`API 请求 ${this.apiRequestCount} 次, 耗时 ${Date.now() - t0}ms`,
);
return {
@@ -385,6 +391,8 @@ export class DingTalkService {
userCount: users.size,
created: result.created,
updated: result.updated,
matched: result.matched,
skipped: result.skipped,
conflicts: result.conflicts,
};
}

View File

@@ -638,6 +638,7 @@ export class StudentsService {
'student.gender',
'student.status',
'student.organizationId',
'student.createdAt',
'organization.name',
])
.leftJoin('student.organization', 'organization');
@@ -648,12 +649,12 @@ export class StudentsService {
// ---- Filters ----
if (query?.keyword) {
qb.andWhere(
'(student.name LIKE :keyword OR student.studentNo LIKE :keyword)',
'(student.name LIKE :keyword OR student.student_no LIKE :keyword)',
{ keyword: `%${query.keyword}%` },
);
}
if (query?.organizationId) {
qb.andWhere('student.organizationId = :orgId', { orgId: query.organizationId });
qb.andWhere('student.organization_id = :orgId', { orgId: query.organizationId });
}
qb.orderBy('student.createdAt', 'DESC').take(limit);
@@ -668,12 +669,12 @@ export class StudentsService {
const csQb = this.classStudentRepo
.createQueryBuilder('cs')
.select(['cs.studentId', 'cs.classId'])
.where('cs.studentId IN (:...ids)', { ids: studentIds })
.where('cs.student_id IN (:...ids)', { ids: studentIds })
.andWhere('cs.status = :status', { status: 'active' });
if (scope.type === 'teacher') {
csQb.andWhere(
'cs.classId IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)',
'cs.class_id IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)',
{ scopeTeacherUserId: scope.userId },
);
}
@@ -740,12 +741,12 @@ export class StudentsService {
const csQb = this.classStudentRepo
.createQueryBuilder('cs')
.select(['cs.classId'])
.where('cs.studentId = :studentId', { studentId })
.where('cs.student_id = :studentId', { studentId })
.andWhere('cs.status = :status', { status: 'active' });
if (scope.type === 'teacher') {
csQb.andWhere(
'cs.classId IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)',
'cs.class_id IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)',
{ scopeTeacherUserId: scope.userId },
);
}

View File

@@ -41,7 +41,7 @@ describe('SyncController — schedule sync options', () => {
await controller.triggerSync('dingtalk_students', '12');
expect(syncService.triggerSync).toHaveBeenCalledWith('dingtalk_students', 12);
expect(syncService.triggerSync).toHaveBeenCalledWith('dingtalk_students', 12, true, true);
});
it('returns Jinshuju form fields for the selector', async () => {

View File

@@ -16,9 +16,16 @@ export class SyncController {
async triggerSync(
@Query('platform') platform?: SyncPlatform,
@Query('rootDeptId') rootDeptId?: string,
@Query('createMissing') createMissing?: string,
@Query('updateProfile') updateProfile?: string,
) {
const rootId = rootDeptId ? this.parseRootDeptId(rootDeptId) : 1;
const logs = await this.syncService.triggerSync(platform, rootId);
const logs = await this.syncService.triggerSync(
platform,
rootId,
createMissing !== 'false',
updateProfile !== 'false',
);
return { synced: logs.length, logs };
}

View File

@@ -36,7 +36,13 @@ function createService(options?: {
find: jest.fn(),
};
const dingTalkService = {
syncAll: jest.fn().mockResolvedValue({ created: 1, updated: 2, conflicts: [] }),
syncAll: jest.fn().mockResolvedValue({
created: 1,
updated: 2,
matched: 0,
skipped: 0,
conflicts: [],
}),
};
const attendanceImportService = {
importFromDingTalk: jest.fn().mockResolvedValue(options?.attendanceResult ?? {

View File

@@ -34,13 +34,23 @@ export class SyncService {
private readonly dataSource: DataSource,
) {}
async syncDingTalkStudents(rootDeptId = 1): Promise<SyncLog> {
async syncDingTalkStudents(
rootDeptId = 1,
createMissing = true,
updateProfile = true,
): Promise<SyncLog> {
return this.runSync('dingtalk_students', async () => {
const result = await this.dingTalkService.syncAll(rootDeptId);
const result = await this.dingTalkService.syncAll(rootDeptId, { createMissing, updateProfile });
return {
recordsCount: result.created + result.updated,
recordsCount: result.created + result.updated + (result.matched ?? 0),
status: result.conflicts.length ? 'partial' : 'success',
message: result.conflicts.length ? JSON.stringify(result.conflicts.slice(0, 20)) : undefined,
message: result.conflicts.length
? JSON.stringify(result.conflicts.slice(0, 20))
: !createMissing && !updateProfile
? `手机号绑定 ${result.matched ?? 0} 人,跳过 ${result.skipped ?? 0}`
: createMissing
? `新增 ${result.created} 人,更新 ${result.updated} 人,手机号绑定 ${result.matched ?? 0}`
: `手机号绑定 ${result.matched ?? 0} 人,更新 ${result.updated} 人,跳过 ${result.skipped ?? 0}`,
};
});
}
@@ -220,12 +230,19 @@ export class SyncService {
});
}
async triggerSync(platform?: SyncPlatform, rootDeptId = 1): Promise<SyncLog[]> {
if (platform === 'dingtalk_students') return [await this.syncDingTalkStudents(rootDeptId)];
async triggerSync(
platform?: SyncPlatform,
rootDeptId = 1,
createMissing = true,
updateProfile = true,
): Promise<SyncLog[]> {
if (platform === 'dingtalk_students') {
return [await this.syncDingTalkStudents(rootDeptId, createMissing, updateProfile)];
}
if (platform === 'dingtalk_attendance') return [await this.syncDingTalkAttendance()];
if (platform === 'wecom') return [await this.syncWeCom()];
return [
await this.syncDingTalkStudents(rootDeptId),
await this.syncDingTalkStudents(rootDeptId, createMissing, updateProfile),
await this.syncDingTalkAttendance(),
await this.syncWeCom(),
];
@@ -274,6 +291,33 @@ export class SyncService {
return this.scheduleSyncService.getStatus(date || new Date().toISOString().slice(0, 10));
}
/**
* Agent tool: 汇总各平台最近一次同步状态和排课映射状态。
*/
async agentGetSyncStatus(): Promise<{
dingTalkStudents: { lastSyncAt: Date | null; status: string } | null;
dingTalkAttendance: { lastSyncAt: Date | null; status: string } | null;
weCom: { lastSyncAt: Date | null; status: string } | null;
schedule: { activeSchedules: number; mappedClasses: number; totalClasses: number };
}> {
const [students, attendance, weCom, schedule] = await Promise.all([
this.getLastSync('dingtalk_students'),
this.getLastSync('dingtalk_attendance'),
this.getLastSync('wecom'),
this.getScheduleSyncStatus(),
]);
return {
dingTalkStudents: students
? { lastSyncAt: students.finishedAt ?? null, status: students.status }
: null,
dingTalkAttendance: attendance
? { lastSyncAt: attendance.finishedAt ?? null, status: attendance.status }
: null,
weCom: weCom ? { lastSyncAt: weCom.finishedAt ?? null, status: weCom.status } : null,
schedule,
};
}
async getLogs(platform?: SyncPlatform, limit = 50): Promise<SyncLog[]> {
const where: Record<string, SyncPlatform> = {};
if (platform) where.platform = platform;