fix: harden DingTalk student synchronization
This commit is contained in:
@@ -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 } });
|
||||
|
||||
Reference in New Issue
Block a user