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;