fix: harden DingTalk student synchronization

This commit is contained in:
2026-07-18 14:51:53 +08:00
parent d25e451b61
commit c11f6bb614
15 changed files with 758 additions and 356 deletions

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