From 0533c30ecec02ce7748dec714ea2ad3f18f115d9 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Mon, 13 Jul 2026 14:38:34 +0800 Subject: [PATCH] refactor(classes): preserve student membership history --- apps/admin/src/pages/Classes/detail.tsx | 13 ++- .../classes.batch-import-membership.spec.ts | 47 ++++++++ .../src/classes/classes.membership.spec.ts | 95 +++++++++++++++ apps/server/src/classes/classes.service.ts | 110 +++++++++++++----- .../database-migrations.class-student.spec.ts | 56 +++++++++ .../database/database-migrations.service.ts | 17 +++ .../src/entities/class-student.entity.ts | 7 +- 7 files changed, 303 insertions(+), 42 deletions(-) create mode 100644 apps/server/src/classes/classes.batch-import-membership.spec.ts create mode 100644 apps/server/src/classes/classes.membership.spec.ts create mode 100644 apps/server/src/database/database-migrations.class-student.spec.ts diff --git a/apps/admin/src/pages/Classes/detail.tsx b/apps/admin/src/pages/Classes/detail.tsx index 435b46b..d2fbaa7 100644 --- a/apps/admin/src/pages/Classes/detail.tsx +++ b/apps/admin/src/pages/Classes/detail.tsx @@ -19,6 +19,7 @@ interface ClassStudent { studentName: string; studentNo: string; joinDate: string; + leaveDate: string | null; status: string; } @@ -305,6 +306,7 @@ const ClassDetailPage: React.FC = () => { { title: '姓名', dataIndex: 'studentName' }, { title: '学号', dataIndex: 'studentNo' }, { title: '加入日期', dataIndex: 'joinDate' }, + { title: '离班日期', dataIndex: 'leaveDate', render: (v: string | null) => v || '-' }, { title: '状态', dataIndex: 'status', @@ -316,11 +318,12 @@ const ClassDetailPage: React.FC = () => { }, { title: '操作', - render: (_: unknown, r: ClassStudent) => ( - handleRemoveStudent(r.studentId)}> - 移除 - - ), + render: (_: unknown, r: ClassStudent) => + r.status === 'active' ? ( + handleRemoveStudent(r.studentId)}> + 移除 + + ) : null, }, ]; diff --git a/apps/server/src/classes/classes.batch-import-membership.spec.ts b/apps/server/src/classes/classes.batch-import-membership.spec.ts new file mode 100644 index 0000000..fea8627 --- /dev/null +++ b/apps/server/src/classes/classes.batch-import-membership.spec.ts @@ -0,0 +1,47 @@ +import { ClassesService } from './classes.service'; +import { ClassStudent } from '../entities'; + +describe('ClassesService — DingTalk class import membership lifecycle', () => { + it('reactivates left memberships and skips active memberships', async () => { + const left = { + classId: 3, + studentId: 8, + status: 'left', + joinDate: '2026-01-01', + 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) => value), + save: jest.fn().mockImplementation(async (value: ClassStudent[]) => 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, + ); + + const result = await service.batchImportStudents(3, [ + { dingUserId: 'd8', name: '学生8' }, + { dingUserId: 'd9', name: '学生9' }, + ]); + + expect(result).toEqual({ imported: 1, skipped: 1 }); + expect(left).toMatchObject({ status: 'active', leaveDate: null }); + expect(left.joinDate).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(classStudentRepo.save).toHaveBeenCalledWith([left]); + }); +}); diff --git a/apps/server/src/classes/classes.membership.spec.ts b/apps/server/src/classes/classes.membership.spec.ts new file mode 100644 index 0000000..9dc2167 --- /dev/null +++ b/apps/server/src/classes/classes.membership.spec.ts @@ -0,0 +1,95 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { ClassesService } from './classes.service'; +import { ClassStudent } from '../entities'; + +function createService( + classStudentRepo: Record, + classRepo: Record = { findOne: jest.fn().mockResolvedValue({ id: 3 }) }, + studentRepo: Record = { + find: jest.fn().mockResolvedValue([{ id: 8 }, { id: 9 }, { id: 10 }]), + }, +) { + return new ClassesService( + classRepo as never, + classStudentRepo as never, + {} as never, + {} as never, + {} as never, + {} as never, + studentRepo as never, + {} as never, + ); +} + +describe('ClassesService — student membership lifecycle', () => { + it('marks an active membership as left instead of deleting it', async () => { + const membership = { + classId: 3, + studentId: 8, + status: 'active', + leaveDate: null, + } as ClassStudent; + const repo = { + findOne: jest.fn().mockResolvedValue(membership), + save: jest.fn().mockImplementation(async (value: ClassStudent) => value), + }; + const service = createService(repo); + + await service.removeStudent(3, 8); + + expect(membership.status).toBe('left'); + expect(membership.leaveDate).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(repo.save).toHaveBeenCalledWith(membership); + }); + + it('rejects removing an already-left membership', async () => { + const repo = { + findOne: jest.fn().mockResolvedValue({ status: 'left' }), + save: jest.fn(), + }; + const service = createService(repo); + + await expect(service.removeStudent(3, 8)).rejects.toBeInstanceOf(BadRequestException); + expect(repo.save).not.toHaveBeenCalled(); + }); + + it('rejects removing a student without a membership', async () => { + const repo = { + findOne: jest.fn().mockResolvedValue(null), + save: jest.fn(), + }; + const service = createService(repo); + + await expect(service.removeStudent(3, 8)).rejects.toBeInstanceOf(NotFoundException); + }); + + it('reactivates left memberships, creates new ones, and skips active ones', async () => { + const left = { + id: 1, + classId: 3, + studentId: 8, + status: 'left', + joinDate: '2026-01-01', + leaveDate: '2026-02-01', + } as ClassStudent; + const active = { id: 2, classId: 3, studentId: 9, status: 'active' } as ClassStudent; + const repo = { + find: jest.fn().mockResolvedValue([left, active]), + create: jest.fn().mockImplementation((value: Partial) => value), + save: jest.fn().mockImplementation(async (value: ClassStudent[]) => value), + }; + const service = createService(repo); + + const result = await service.addStudents(3, [8, 9, 10, 10]); + + expect(result).toEqual({ added: 2, skipped: 1 }); + expect(left).toMatchObject({ status: 'active', leaveDate: null }); + expect(left.joinDate).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(repo.create).toHaveBeenCalledWith( + expect.objectContaining({ classId: 3, studentId: 10, status: 'active' }), + ); + expect(repo.save).toHaveBeenCalledWith( + expect.arrayContaining([left, expect.objectContaining({ studentId: 10 })]), + ); + }); +}); diff --git a/apps/server/src/classes/classes.service.ts b/apps/server/src/classes/classes.service.ts index d61a45f..36451a6 100644 --- a/apps/server/src/classes/classes.service.ts +++ b/apps/server/src/classes/classes.service.ts @@ -227,34 +227,45 @@ export class ClassesService { } // 3. Fetch existing class-student links in one query - const allStudentIds = Array.from(dingToStudentId.values()); - const alreadyInClass = new Set(); - if (allStudentIds.length > 0) { - const existingClassStudents = await this.classStudentRepo.find({ - where: { classId, studentId: In(allStudentIds) }, - }); - for (const cs of existingClassStudents) { - alreadyInClass.add(cs.studentId); + 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 []; } - } - - // 4. Batch insert new class-student records - const newClassStudents = allStudentIds - .filter((sid) => !alreadyInClass.has(sid)) - .map((studentId) => + if (existing) { + existing.status = 'active'; + existing.joinDate = today; + existing.leaveDate = null; + return [existing]; + } + return [ this.classStudentRepo.create({ classId, studentId, status: 'active', - joinDate: new Date().toISOString().slice(0, 10), + joinDate: today, }), - ); + ]; + }); - if (newClassStudents.length > 0) { - await this.classStudentRepo.save(newClassStudents); + if (memberships.length > 0) { + await this.classStudentRepo.save(memberships); } - return { imported: newClassStudents.length, skipped: alreadyInClass.size }; + return { imported: memberships.length, skipped }; } async update(id: number, dto: UpdateClassDto) { const cls = await this.classRepo.findOne({ where: { id } }); @@ -315,26 +326,61 @@ export class ClassesService { } async addStudents(classId: number, studentIds: number[]) { + const uniqueStudentIds = [...new Set(studentIds)]; + if (uniqueStudentIds.length === 0) return { added: 0, skipped: 0 }; + + const cls = await this.classRepo.findOne({ where: { id: classId } }); + if (!cls) throw new NotFoundException('班级不存在'); + + const students = await this.studentRepo.find({ where: { id: In(uniqueStudentIds) } }); + if (students.length !== uniqueStudentIds.length) { + throw new NotFoundException('部分学生不存在'); + } + const existing = await this.classStudentRepo.find({ - where: { classId, studentId: In(studentIds) }, + where: { classId, studentId: In(uniqueStudentIds) }, }); - const existingIds = new Set(existing.map((e) => e.studentId)); - const newIds = studentIds.filter((id) => !existingIds.has(id)); - - const entries = newIds.map((sid) => - this.classStudentRepo.create({ - classId, - studentId: sid, - joinDate: new Date().toISOString().split('T')[0], - }), + const existingByStudentId = new Map( + existing.map((classStudent) => [classStudent.studentId, classStudent]), ); - if (entries.length) await this.classStudentRepo.save(entries); + const today = new Date().toISOString().split('T')[0]; + let skipped = 0; + const memberships = uniqueStudentIds.flatMap((studentId) => { + const current = existingByStudentId.get(studentId); + if (current?.status === 'active') { + skipped++; + return []; + } + if (current) { + current.status = 'active'; + current.joinDate = today; + current.leaveDate = null; + return [current]; + } + return [ + this.classStudentRepo.create({ + classId, + studentId, + status: 'active', + joinDate: today, + }), + ]; + }); + if (memberships.length) await this.classStudentRepo.save(memberships); - return { added: entries.length, skipped: studentIds.length - entries.length }; + return { added: memberships.length, skipped }; } async removeStudent(classId: number, studentId: number) { - await this.classStudentRepo.delete({ classId, studentId }); + const membership = await this.classStudentRepo.findOne({ + where: { classId, studentId }, + }); + if (!membership) throw new NotFoundException('学生不在该班级'); + if (membership.status !== 'active') throw new BadRequestException('学生已离班'); + + membership.status = 'left'; + membership.leaveDate = new Date().toISOString().split('T')[0]; + await this.classStudentRepo.save(membership); return { success: true }; } diff --git a/apps/server/src/database/database-migrations.class-student.spec.ts b/apps/server/src/database/database-migrations.class-student.spec.ts new file mode 100644 index 0000000..6088762 --- /dev/null +++ b/apps/server/src/database/database-migrations.class-student.spec.ts @@ -0,0 +1,56 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getDataSourceToken } from '@nestjs/typeorm'; +import { DatabaseMigrationsService } from './database-migrations.service'; + +function createRunner(tableExists: boolean, columns: string[] = []) { + return { + connect: jest.fn(), + release: jest.fn(), + getTables: jest.fn().mockResolvedValue(tableExists ? [{ name: 'class_student' }] : []), + getTable: jest.fn().mockResolvedValue({ + name: 'class_student', + columns: columns.map((name) => ({ name })), + }), + dropColumn: jest.fn().mockResolvedValue(undefined), + }; +} + +async function createService(runner: ReturnType) { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DatabaseMigrationsService, + { + provide: getDataSourceToken(), + useValue: { + options: { type: 'better-sqlite3' }, + createQueryRunner: jest.fn().mockReturnValue(runner), + }, + }, + ], + }).compile(); + return module.get(DatabaseMigrationsService) as DatabaseMigrationsService & { + removeUnusedClassStudentColumns(): Promise; + }; +} + +describe('DatabaseMigrationsService — class student cleanup', () => { + it('drops the unused enrollment_id column', async () => { + const runner = createRunner(true, ['id', 'enrollment_id']); + const service = await createService(runner); + + await service.removeUnusedClassStudentColumns(); + + expect(runner.dropColumn).toHaveBeenCalledWith('class_student', 'enrollment_id'); + expect(runner.release).toHaveBeenCalled(); + }); + + it('does nothing when the table is absent', async () => { + const runner = createRunner(false); + const service = await createService(runner); + + await service.removeUnusedClassStudentColumns(); + + expect(runner.dropColumn).not.toHaveBeenCalled(); + expect(runner.release).toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/database/database-migrations.service.ts b/apps/server/src/database/database-migrations.service.ts index 0254339..d9c8587 100644 --- a/apps/server/src/database/database-migrations.service.ts +++ b/apps/server/src/database/database-migrations.service.ts @@ -16,6 +16,7 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap { await this.protectAttendanceHistory(); await this.removeUnusedClassroomColumns(); await this.cleanupDepositRefundColumns(); + await this.removeUnusedClassStudentColumns(); } private async removeUnusedClassroomColumns(): Promise { @@ -75,6 +76,22 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap { } } + private async removeUnusedClassStudentColumns(): Promise { + const runner = this.dataSource.createQueryRunner(); + await runner.connect(); + try { + const tables = await runner.getTables(['class_student']); + if (tables.length === 0) return; + + const table = await runner.getTable('class_student'); + if (table?.columns.some((column) => column.name === 'enrollment_id')) { + await runner.dropColumn('class_student', 'enrollment_id'); + } + } finally { + await runner.release(); + } + } + private async ensureAiConfigTable(): Promise { const runner = this.dataSource.createQueryRunner(); await runner.connect(); diff --git a/apps/server/src/entities/class-student.entity.ts b/apps/server/src/entities/class-student.entity.ts index 4b9906c..7a6ca73 100644 --- a/apps/server/src/entities/class-student.entity.ts +++ b/apps/server/src/entities/class-student.entity.ts @@ -30,14 +30,11 @@ export class ClassStudent { @JoinColumn({ name: 'student_id' }) student: Student; - @Column({ name: 'enrollment_id', type: 'integer', nullable: true }) - enrollmentId: number; - @Column({ name: 'join_date', type: 'date', nullable: true }) - joinDate: string; + joinDate: string | null; @Column({ name: 'leave_date', type: 'date', nullable: true }) - leaveDate: string; + leaveDate: string | null; @Column({ name: 'status', length: 10, default: 'active' }) status: string;