feat(server): 报读记录关联班级 classId 与花名册单向同步

- student_enrollments 新增 class_id 列(迁移幂等),报读/换班/归档/删除时同步 class_student 成员状态
- 班级名以班级表为准快照,防止前后端漂移;班级不存在直接 400
- 迁移 runner 改 glob 自动发现(ts-node/dist 均可),新增迁移文件免手改清单
This commit is contained in:
2026-08-11 15:17:58 +08:00
parent 2e5e3fb7e5
commit 2499f90231
10 changed files with 428 additions and 37 deletions

View File

@@ -12,6 +12,8 @@ function createService(repos: Partial<Record<string, Record<string, jest.Mock>>>
(repos.attachment ?? {}) as never,
(repos.attendance ?? {}) as never,
{} as never,
{} as never,
{} as never,
);
}

View File

@@ -0,0 +1,255 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { ArchiveService } from './archive.service';
import { ClassStudent, StudentEnrollment } from '../entities';
/** 内存花名册 + manager mockfindOne/save 读写 roster 数组count 由测试控制。 */
function makeManager(roster: Array<Record<string, unknown>> = [], otherActiveCount = 0) {
const memberSet = new WeakSet<object>();
const manager = {
roster,
findOne: jest.fn(async (Entity: unknown, opts: { where?: Record<string, unknown> }) => {
if (Entity !== ClassStudent) return null;
const w = opts?.where ?? {};
return (
roster.find(
(r) =>
(w.classId === undefined || r.classId === w.classId) &&
(w.studentId === undefined || r.studentId === w.studentId) &&
(w.status === undefined || r.status === w.status),
) ?? null
);
}),
create: jest.fn((Entity: unknown, data: Record<string, unknown>) => {
const row = { ...data };
if (Entity === ClassStudent) memberSet.add(row);
return row;
}),
save: jest.fn(async (row: Record<string, unknown>) => {
if (memberSet.has(row)) {
const idx = roster.findIndex(
(r) => r.classId === row.classId && r.studentId === row.studentId,
);
if (idx >= 0) roster[idx] = { ...roster[idx], ...row };
else roster.push({ ...row });
}
return row;
}),
update: jest.fn(),
delete: jest.fn(),
getRepository: jest.fn().mockReturnValue({
count: jest.fn().mockResolvedValue(otherActiveCount),
}),
};
return manager;
}
function createService(opts: {
student?: unknown;
classRepo?: unknown;
enrollment?: unknown;
examScore?: unknown;
manager?: ReturnType<typeof makeManager>;
}) {
const manager = opts.manager ?? makeManager();
const dataSource = {
transaction: jest.fn(async (fn: (m: unknown) => Promise<unknown>) => fn(manager)),
};
const service = new ArchiveService(
{ findOne: jest.fn().mockResolvedValue(opts.student ?? { id: 7 }) } as never,
{} as never,
(opts.enrollment ?? { findOne: jest.fn() }) as never,
(opts.examScore ?? { count: jest.fn().mockResolvedValue(0) }) as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
(opts.classRepo ?? { findOne: jest.fn().mockResolvedValue({ id: 5, name: '冲刺班' }) }) as never,
{} as never,
dataSource as never,
);
return { service, manager, dataSource };
}
const baseDto = { courseCategory: '文化', classType: '冲刺' };
describe('ArchiveService enrollment → roster sync', () => {
it('adds an active enrollment with classId: class name snapshotted, member joined with joinDate', async () => {
const { service, manager } = createService({});
const saved = await service.addEnrollment(7, { ...baseDto, classId: 5, startDate: '2026-08-01', className: '旧名字' });
expect(saved).toMatchObject({ classId: 5, className: '冲刺班', status: 'active' });
const memberCreates = manager.create.mock.calls.filter(([E]) => E === ClassStudent);
expect(memberCreates).toHaveLength(1);
expect(memberCreates[0][1]).toEqual({
classId: 5,
studentId: 7,
status: 'active',
joinDate: '2026-08-01',
});
expect(manager.roster).toEqual([
expect.objectContaining({ classId: 5, studentId: 7, status: 'active', joinDate: '2026-08-01' }),
]);
});
it('adds without classId: no roster write at all', async () => {
const { service, manager } = createService({});
await service.addEnrollment(7, { ...baseDto, className: '自由文本' });
expect(
manager.create.mock.calls.filter(([E]) => E === ClassStudent),
).toHaveLength(0);
expect(manager.roster).toHaveLength(0);
});
it('adds with classId but status completed: no roster join', async () => {
const { service, manager } = createService({});
const saved = await service.addEnrollment(7, { ...baseDto, classId: 5, status: 'completed' });
expect(saved.className).toBe('冲刺班');
expect(
manager.create.mock.calls.filter(([E]) => E === ClassStudent),
).toHaveLength(0);
expect(manager.roster).toHaveLength(0);
});
it('rejects a classId pointing to a missing class before any transaction', async () => {
const { service, dataSource } = createService({
classRepo: { findOne: jest.fn().mockResolvedValue(null) },
});
await expect(service.addEnrollment(7, { ...baseDto, classId: 999 })).rejects.toEqual(
new BadRequestException('班级不存在'),
);
expect(dataSource.transaction).not.toHaveBeenCalled();
});
it('marks the member left when the same class flips active → completed', async () => {
const roster = [
{ id: 50, classId: 5, studentId: 7, status: 'active', joinDate: '2026-08-01', leaveDate: null },
];
const { service, manager } = createService({
manager: makeManager(roster),
enrollment: {
findOne: jest.fn().mockResolvedValue({
id: 1,
studentId: 7,
classId: 5,
className: '冲刺班',
status: 'active',
startDate: '2026-08-01',
}),
},
});
await service.updateEnrollment(1, { status: 'completed' });
expect(manager.roster[0]).toMatchObject({ classId: 5, studentId: 7, status: 'left' });
expect(manager.roster[0].leaveDate).toBeTruthy();
});
it('marks old class left and joins the new class when classId changes', async () => {
const roster = [
{ id: 50, classId: 5, studentId: 7, status: 'active', joinDate: '2026-08-01', leaveDate: null },
];
const { service, manager } = createService({
manager: makeManager(roster),
classRepo: { findOne: jest.fn().mockResolvedValue({ id: 6, name: '强化班' }) },
enrollment: {
findOne: jest.fn().mockResolvedValue({
id: 1,
studentId: 7,
classId: 5,
className: '冲刺班',
status: 'active',
startDate: '2026-08-01',
}),
},
});
const saved = await service.updateEnrollment(1, { classId: 6 });
expect(saved).toMatchObject({ classId: 6, className: '强化班', status: 'active' });
expect(manager.roster.find((r) => r.classId === 5)).toMatchObject({ status: 'left' });
expect(manager.roster.find((r) => r.classId === 6)).toMatchObject({
studentId: 7,
status: 'active',
joinDate: '2026-08-01',
});
});
it('archive (soft delete) marks the member left', async () => {
const roster = [
{ id: 50, classId: 5, studentId: 7, status: 'active', joinDate: '2026-08-01', leaveDate: null },
];
const { service, manager } = createService({
manager: makeManager(roster),
enrollment: {
findOne: jest.fn().mockResolvedValue({
id: 1,
studentId: 7,
classId: 5,
status: 'active',
}),
},
});
await expect(service.deleteEnrollment(1)).resolves.toEqual({ message: '已归档' });
expect(manager.update).toHaveBeenCalledWith(StudentEnrollment, 1, { status: 'archived' });
expect(manager.roster[0].status).toBe('left');
});
it('permanent delete marks the member left', async () => {
const roster = [
{ id: 50, classId: 5, studentId: 7, status: 'active', joinDate: '2026-08-01', leaveDate: null },
];
const { service, manager } = createService({
manager: makeManager(roster),
enrollment: {
findOne: jest.fn().mockResolvedValue({
id: 1,
studentId: 7,
classId: 5,
status: 'archived',
}),
},
});
await expect(service.purgeEnrollment(1)).resolves.toEqual({
message: '已永久删除报名记录(不可恢复)',
});
expect(manager.delete).toHaveBeenCalledWith(StudentEnrollment, 1);
expect(manager.roster[0].status).toBe('left');
});
it('keeps the member active while another active enrollment still references the class', async () => {
const roster = [
{ id: 50, classId: 5, studentId: 7, status: 'active', joinDate: '2026-08-01', leaveDate: null },
];
const { service, manager } = createService({
manager: makeManager(roster, /* otherActiveCount */ 1),
enrollment: {
findOne: jest.fn().mockResolvedValue({
id: 1,
studentId: 7,
classId: 5,
className: '冲刺班',
status: 'active',
startDate: '2026-08-01',
}),
},
});
await service.updateEnrollment(1, { status: 'completed' });
expect(manager.roster[0].status).toBe('active');
expect(manager.roster[0].leaveDate).toBeNull();
});
it('propagates NotFoundException for missing enrollment on update', async () => {
const { service } = createService({ enrollment: { findOne: jest.fn().mockResolvedValue(null) } });
await expect(service.updateEnrollment(1, {})).rejects.toBeInstanceOf(NotFoundException);
});
});

View File

@@ -3,6 +3,8 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { Student } from '../entities/student.entity';
import { StudentProfile } from '../entities/student-profile.entity';
import { StudentEnrollment } from '../entities/student-enrollment.entity';
import { Class } from '../entities/class.entity';
import { ClassStudent } from '../entities/class-student.entity';
import { ExamScore } from '../entities/exam-score.entity';
import { LearningRecord } from '../entities/learning-record.entity';
import { ResultArchive } from '../entities/result-archive.entity';
@@ -25,6 +27,8 @@ import { ArchiveController } from './archive.controller';
ResultArchive,
ArchiveAttachment,
AttendanceRecord,
Class,
ClassStudent,
]),
NotificationsModule,
StudentsModule,

View File

@@ -3,6 +3,7 @@ import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { ArchiveService } from './archive.service';
import { StudentEnrollment } from '../entities';
describe('ArchiveService purge sub-records', () => {
const createService = (overrides?: {
@@ -46,6 +47,19 @@ describe('ArchiveService purge sub-records', () => {
}),
delete: jest.fn().mockResolvedValue({ affected: 1 }),
};
const managerMock = {
delete: jest.fn().mockResolvedValue({ affected: 1 }),
update: jest.fn().mockResolvedValue({ affected: 1 }),
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
getRepository: jest.fn().mockReturnValue({
count: jest.fn().mockResolvedValue(0),
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
}),
};
const service = new ArchiveService(
{} as never,
{} as never,
@@ -56,8 +70,11 @@ describe('ArchiveService purge sub-records', () => {
attachmentRepo as never,
{} as never,
{} as never,
{} as never,
{} as never,
{ transaction: jest.fn(async (fn: (m: unknown) => Promise<unknown>) => fn(managerMock)) } as never,
);
return { service, enrollmentRepo, examScoreRepo, learningRecordRepo, attachmentRepo };
return { service, managerMock, enrollmentRepo, examScoreRepo, learningRecordRepo, attachmentRepo };
};
it('rejects non-archived sub-records', async () => {
@@ -77,7 +94,7 @@ describe('ArchiveService purge sub-records', () => {
});
it('deletes archived enrollment, exam score, and learning record', async () => {
const { service, enrollmentRepo, examScoreRepo, learningRecordRepo } = createService();
const { service, managerMock, examScoreRepo, learningRecordRepo } = createService();
await expect(service.purgeEnrollment(1)).resolves.toEqual({
message: '已永久删除报名记录(不可恢复)',
});
@@ -87,7 +104,7 @@ describe('ArchiveService purge sub-records', () => {
await expect(service.purgeLearningRecord(3)).resolves.toEqual({
message: '已永久删除学习记录(不可恢复)',
});
expect(enrollmentRepo.delete).toHaveBeenCalledWith(1);
expect(managerMock.delete).toHaveBeenCalledWith(StudentEnrollment, 1);
expect(examScoreRepo.delete).toHaveBeenCalledWith(2);
expect(learningRecordRepo.delete).toHaveBeenCalledWith(3);
});

View File

@@ -29,6 +29,8 @@ describe('ArchiveService.getProfile', () => {
attachmentRepo as never,
attendanceRepo as never,
{} as never,
{} as never,
{} as never,
);
const response = await service.getProfile(7);
@@ -69,6 +71,8 @@ describe('ArchiveService.getProfile', () => {
emptyRepos.attachment as never,
emptyRepos.attendance as never,
{} as never,
{} as never,
{} as never,
);
const response = await service.getProfile(7);

View File

@@ -1,6 +1,6 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { DataSource, EntityManager, Not, Repository } from 'typeorm';
import * as fs from 'fs';
import * as path from 'path';
@@ -8,6 +8,8 @@ import { NotificationsService } from '../notifications/notifications.service';
import { Student } from '../entities/student.entity';
import { StudentProfile } from '../entities/student-profile.entity';
import { StudentEnrollment } from '../entities/student-enrollment.entity';
import { Class, ClassStudent } from '../entities';
import dayjs from '../common/dayjs';
import { ExamScore } from '../entities/exam-score.entity';
import { LearningRecord } from '../entities/learning-record.entity';
import { ResultArchive } from '../entities/result-archive.entity';
@@ -36,6 +38,9 @@ export class ArchiveService {
@InjectRepository(ArchiveAttachment) private attachmentRepo: Repository<ArchiveAttachment>,
@InjectRepository(AttendanceRecord) private attendanceRepo: Repository<AttendanceRecord>,
private readonly notificationsService: NotificationsService,
@InjectRepository(Class) private classRepo: Repository<Class>,
@InjectRepository(ClassStudent) private classStudentRepo: Repository<ClassStudent>,
private dataSource: DataSource,
) {}
get uploadDir(): string {
@@ -158,26 +163,113 @@ export class ArchiveService {
return this.profileRepo.save(profile);
}
/**
* 花名册成员关系唯一写入口(报读→班级单向全同步)。
* wantActive=true 时确保成员 activefalse 时若该生无其他「报读中」记录引用此班则标离。
*/
private async syncRosterMembership(
manager: EntityManager,
studentId: number,
classId: number,
wantActive: boolean,
joinDate: string,
excludeEnrollmentId?: number,
): Promise<void> {
if (wantActive) {
let row = await manager.findOne(ClassStudent, { where: { classId, studentId } });
if (!row) {
row = manager.create(ClassStudent, { classId, studentId, status: 'active', joinDate });
} else if (row.status !== 'active') {
row.status = 'active';
row.joinDate = joinDate;
row.leaveDate = null;
}
await manager.save(row);
return;
}
// 离班守卫:该生其他「报读中」报读记录仍引用此班时不移出
const otherActive = await manager.getRepository(StudentEnrollment).count({
where: {
studentId,
classId,
status: 'active',
...(excludeEnrollmentId ? { id: Not(excludeEnrollmentId) } : {}),
},
});
if (otherActive > 0) return;
const row = await manager.findOne(ClassStudent, { where: { classId, studentId, status: 'active' } });
if (row) {
row.status = 'left';
row.leaveDate = dayjs().utcOffset(8).format('YYYY-MM-DD');
await manager.save(row);
}
}
async addEnrollment(studentId: number, dto: CreateEnrollmentDto) {
const student = await this.studentRepo.findOne({ where: { id: studentId } });
if (!student) throw new NotFoundException('学生不存在');
const entity = this.enrollmentRepo.create({ ...dto, studentId });
return this.enrollmentRepo.save(entity);
const classId = dto.classId ?? null;
let className = dto.className;
if (classId != null) {
const cls = await this.classRepo.findOne({ where: { id: classId } });
if (!cls) throw new BadRequestException('班级不存在');
// 快照以班级为准,防止前后端漂移
className = cls.name;
}
const status = dto.status ?? 'active';
const joinDate = dto.startDate || dayjs().utcOffset(8).format('YYYY-MM-DD');
return this.dataSource.transaction(async (manager) => {
const entity = manager.create(StudentEnrollment, { ...dto, studentId, className, classId, status });
const saved = await manager.save(entity);
if (classId != null && status === 'active') {
await this.syncRosterMembership(manager, studentId, classId, true, joinDate);
}
return saved;
});
}
async updateEnrollment(id: number, dto: UpdateEnrollmentDto) {
const entity = await this.enrollmentRepo.findOne({ where: { id } });
if (!entity) throw new NotFoundException('报名记录不存在');
Object.assign(entity, dto);
return this.enrollmentRepo.save(entity);
const oldClassId = entity.classId ?? null;
const oldStatus = entity.status;
const classId = dto.classId !== undefined ? (dto.classId ?? null) : oldClassId;
let className = dto.className ?? entity.className;
if (classId != null) {
const cls = await this.classRepo.findOne({ where: { id: classId } });
if (!cls) throw new BadRequestException('班级不存在');
className = cls.name;
}
const status = dto.status ?? oldStatus;
const joinDate = dto.startDate || entity.startDate || dayjs().utcOffset(8).format('YYYY-MM-DD');
return this.dataSource.transaction(async (manager) => {
Object.assign(entity, { ...dto, className, classId, status });
await manager.save(entity);
if (oldClassId != null && oldClassId !== classId) {
await this.syncRosterMembership(manager, entity.studentId, oldClassId, false, '', id);
}
if (classId != null) {
await this.syncRosterMembership(manager, entity.studentId, classId, status === 'active', joinDate, id);
}
return entity;
});
}
async deleteEnrollment(id: number) {
const entity = await this.enrollmentRepo.findOne({ where: { id } });
if (!entity) throw new NotFoundException('报名记录不存在');
if (entity.status === 'archived') throw new BadRequestException('报名记录已归档');
await this.enrollmentRepo.update(id, { status: 'archived' });
await this.dataSource.transaction(async (manager) => {
await manager.update(StudentEnrollment, id, { status: 'archived' });
if (entity.classId != null) {
await this.syncRosterMembership(manager, entity.studentId, entity.classId, false, '', id);
}
});
return { message: '已归档' };
}
@@ -191,7 +283,14 @@ export class ArchiveService {
if (scoreCount > 0) {
throw new BadRequestException('该报名记录已被考试成绩引用,无法永久删除');
}
await this.enrollmentRepo.delete(id);
await this.dataSource.transaction(async (manager) => {
await manager.delete(StudentEnrollment, id);
if (entity.classId != null) {
// 已删记录本身被排除,守卫对其他 active 记录生效
await this.syncRosterMembership(manager, entity.studentId, entity.classId, false, '', id);
}
});
return { message: '已永久删除报名记录(不可恢复)' };
}

View File

@@ -1,5 +1,13 @@
import { PartialType } from '@nestjs/mapped-types';
import { IsOptional, IsString, IsNumber, IsDateString, IsNotEmpty, Min } from 'class-validator';
import {
IsOptional,
IsString,
IsNumber,
IsDateString,
IsNotEmpty,
Min,
IsInt,
} from 'class-validator';
export class UpsertProfileDto {
@IsOptional() @IsString() targetCollege?: string;
@@ -16,6 +24,7 @@ export class CreateEnrollmentDto {
@IsString() @IsNotEmpty() courseCategory: string;
@IsString() @IsNotEmpty() classType: string;
@IsOptional() @IsString() className?: string;
@IsOptional() @IsInt() classId?: number;
@IsOptional() @IsString() headTeacher?: string;
@IsOptional() @IsString() subjectTeacher?: string;
@IsOptional() @IsDateString() startDate?: string;

View File

@@ -30,6 +30,9 @@ export class StudentEnrollment {
@Column({ name: 'class_name', length: 100, nullable: true })
className: string;
@Column({ name: 'class_id', type: 'integer', nullable: true })
classId: number | null;
@Column({ name: 'head_teacher', length: 50, nullable: true })
headTeacher: string;

View File

@@ -1,16 +1,5 @@
import { DataSource } from 'typeorm';
import { InitialSchema1784520727860 } from './migrations/1784520727860-InitialSchema';
import { AddExamManagement1784600000000 } from './migrations/1784600000000-AddExamManagement';
import { AddRoomInspections1784680000000 } from './migrations/1784680000000-AddRoomInspections';
import { AddJinshujuMatchRules1784700000000 } from './migrations/1784700000000-AddJinshujuMatchRules';
import { AddAiChat1784780000000 } from './migrations/1784780000000-AddAiChat';
import { EnhanceAiChatForAntDesignX1784860000000 } from './migrations/1784860000000-EnhanceAiChatForAntDesignX';
import { AddA2UiForms1784870000000 } from './migrations/1784870000000-AddA2UiForms';
import { AddA2UiReviews1784880000000 } from './migrations/1784880000000-AddA2UiReviews';
import { EnlargeAiReviewSections1784900000000 } from './migrations/1784900000000-EnlargeAiReviewSections';
import { AddImportRuns1784910000000 } from './migrations/1784910000000-AddImportRuns';
import { DropAiMessageFeedback1784920000000 } from './migrations/1784920000000-DropAiMessageFeedback';
import { AddImportRunSettings1784930000000 } from './migrations/1784930000000-AddImportRunSettings';
import { join } from 'path';
import { config } from 'dotenv';
config();
@@ -24,20 +13,9 @@ export async function runMigrationsOnStartup(): Promise<void> {
password: process.env.DB_PASSWORD || '',
database: process.env.DB_DATABASE || 'dorm_billing',
charset: 'utf8mb4',
migrations: [
InitialSchema1784520727860,
AddExamManagement1784600000000,
AddRoomInspections1784680000000,
AddJinshujuMatchRules1784700000000,
AddAiChat1784780000000,
EnhanceAiChatForAntDesignX1784860000000,
AddA2UiForms1784870000000,
AddA2UiReviews1784880000000,
EnlargeAiReviewSections1784900000000,
AddImportRuns1784910000000,
DropAiMessageFeedback1784920000000,
AddImportRunSettings1784930000000,
],
// 自动发现ts-node 下匹配 src/migrations/*.ts编译产物下匹配 dist/migrations/*.js。
// 新增迁移文件无需再手改清单(此前手写数组已漏跑 2 个迁移)。
migrations: [join(__dirname, 'migrations/*{.ts,.js}')],
});
await ds.initialize();

View File

@@ -0,0 +1,20 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* student_enrollments 增加 class_id报读班型关联真实班级实体花名册同步用
* 纯 int 列不加外键enrollments 是归档数据,班级永久删除时允许悬空 classId
* 前端对查不到班级的 classId 回退纯文本展示。
*/
export class AddClassIdToStudentEnrollments1786406400000 implements MigrationInterface {
async up(queryRunner: QueryRunner): Promise<void> {
if (!(await queryRunner.hasColumn('student_enrollments', 'class_id'))) {
await queryRunner.query('ALTER TABLE `student_enrollments` ADD COLUMN `class_id` int NULL');
}
}
async down(queryRunner: QueryRunner): Promise<void> {
if (await queryRunner.hasColumn('student_enrollments', 'class_id')) {
await queryRunner.query('ALTER TABLE `student_enrollments` DROP COLUMN `class_id`');
}
}
}