feat: 支持考试批量归档与恢复
All checks were successful
CI / check (pull_request) Successful in 2m4s

This commit is contained in:
2026-07-25 09:45:58 +08:00
parent 0ef86e65ce
commit 095eccea76
8 changed files with 393 additions and 6 deletions

View File

@@ -0,0 +1,63 @@
import 'reflect-metadata';
import { ValidationPipe } from '@nestjs/common';
import { PIPES_METADATA } from '@nestjs/common/constants';
import { PERMISSION_KEY } from '../auth/decorators/permission.decorator';
import { BatchIdsDto } from '../common/batch-ids.dto';
import { ExamsController } from './exams.controller';
describe('ExamsController batch archive and restore', () => {
const req = {
user: {
id: 7,
username: 'admin',
isSuperAdmin: true,
permissions: ['exam:view'],
},
ip: '127.0.0.1',
headers: {},
};
it.each(['batchArchive', 'batchRestore'] as const)(
'%s uses the existing exam permission',
(method) => {
const handler = ExamsController.prototype[method] as (...args: never[]) => unknown;
expect(Reflect.getMetadata(PERMISSION_KEY, handler)).toEqual(['exam:view']);
},
);
it('class-level validation rejects invalid and non-whitelisted batch bodies', async () => {
const pipes = Reflect.getMetadata(PIPES_METADATA, ExamsController) as ValidationPipe[];
expect(pipes).toHaveLength(1);
const metadata = { type: 'body' as const, metatype: BatchIdsDto, data: undefined };
await expect(pipes[0].transform({ ids: [] }, metadata)).rejects.toBeDefined();
await expect(pipes[0].transform({ ids: [0] }, metadata)).rejects.toBeDefined();
await expect(
pipes[0].transform({ ids: [1], unexpected: true }, metadata),
).rejects.toBeDefined();
});
it('passes ids and access context to services and writes batch audit logs', async () => {
const service = {
batchArchive: jest.fn().mockResolvedValue({ archived: 1, skipped: 1 }),
batchRestore: jest.fn().mockResolvedValue({ restored: 1, skipped: 1 }),
};
const log = jest.fn().mockResolvedValue(undefined);
const controller = new ExamsController(service as never, { log } as never);
await expect(controller.batchArchive({ ids: [1, 2] }, req)).resolves.toEqual({
archived: 1,
skipped: 1,
});
await expect(controller.batchRestore({ ids: [3, 4] }, req)).resolves.toEqual({
restored: 1,
skipped: 1,
});
expect(service.batchArchive).toHaveBeenCalledWith([1, 2], 7, true);
expect(service.batchRestore).toHaveBeenCalledWith([3, 4], 7, true);
expect(log.mock.calls.map(([entry]) => [entry.action, entry.detail])).toEqual([
['批量归档考试', 'IDs: 1,2'],
['批量恢复考试', 'IDs: 3,4'],
]);
});
});

View File

@@ -15,6 +15,7 @@ import {
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequirePermission } from '../auth/decorators/permission.decorator';
import { extractRequestInfo } from '../common/request-utils';
import { BatchIdsDto } from '../common/batch-ids.dto';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import type { AuthenticatedUser } from '../authorization';
import { CreateExamDto, QueryExamDto, UpdateExamScoreValueDto } from './dto/exam.dto';
@@ -46,6 +47,48 @@ export class ExamsController {
return this.service.findAll(query, classIds);
}
@Put('batch-archive')
@RequirePermission('exam:view')
async batchArchive(@Body() dto: BatchIdsDto, @Request() req: AuthenticatedRequest) {
const result = await this.service.batchArchive(
dto.ids,
req.user.id,
this.canManageAll(req),
);
const { ipAddress, userAgent } = extractRequestInfo(req);
await this.logService.log({
userId: req.user.id,
username: req.user.username,
module: '考试管理',
action: '批量归档考试',
detail: `IDs: ${dto.ids.join(',')}`,
ipAddress,
userAgent,
});
return result;
}
@Put('batch-restore')
@RequirePermission('exam:view')
async batchRestore(@Body() dto: BatchIdsDto, @Request() req: AuthenticatedRequest) {
const result = await this.service.batchRestore(
dto.ids,
req.user.id,
this.canManageAll(req),
);
const { ipAddress, userAgent } = extractRequestInfo(req);
await this.logService.log({
userId: req.user.id,
username: req.user.username,
module: '考试管理',
action: '批量恢复考试',
detail: `IDs: ${dto.ids.join(',')}`,
ipAddress,
userAgent,
});
return result;
}
@Get(':id')
@RequirePermission('exam:view')
findOne(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {

View File

@@ -1,4 +1,4 @@
import { BadRequestException, ForbiddenException, ValidationPipe } from '@nestjs/common';
import { BadRequestException, ForbiddenException, NotFoundException, ValidationPipe } from '@nestjs/common';
import { ExamScore } from '../entities';
import { QueryExamDto } from './dto/exam.dto';
import { ExamsService } from './exams.service';
@@ -23,6 +23,19 @@ function createService(
);
}
function updateQb(affected = 1) {
const qb = {
update: jest.fn(),
set: jest.fn(),
where: jest.fn(),
execute: jest.fn().mockResolvedValue({ affected }),
};
qb.update.mockReturnValue(qb);
qb.set.mockReturnValue(qb);
qb.where.mockReturnValue(qb);
return qb;
}
describe('ExamsService', () => {
it('creates score rows from the active class roster snapshot', async () => {
const members = [
@@ -224,6 +237,102 @@ describe('ExamsService', () => {
expect(examRepo.update).not.toHaveBeenCalled();
});
it('rejects empty and invalid ids for batch archive and restore', async () => {
const service = createService(async () => undefined, { examRepo: { find: jest.fn() } });
for (const call of [
(ids: number[]) => service.batchArchive(ids, 1, true),
(ids: number[]) => service.batchRestore(ids, 1, true),
]) {
await expect(call([])).rejects.toBeInstanceOf(BadRequestException);
await expect(call([0])).rejects.toBeInstanceOf(BadRequestException);
await expect(call([1.5])).rejects.toBeInstanceOf(BadRequestException);
}
});
it('deduplicates ids, archives active exams, skips archived exams, and leaves scores unchanged', async () => {
const qb = updateQb();
const examRepo = {
find: jest.fn().mockResolvedValue([
{ id: 8, classId: 3, status: 'active' },
{ id: 9, classId: 4, status: 'archived' },
]),
createQueryBuilder: jest.fn(() => qb),
};
const scoreRepo = { update: jest.fn(), save: jest.fn() };
const service = createService(async () => undefined, { examRepo, scoreRepo });
await expect(service.batchArchive([8, 8, 9], 1, true)).resolves.toEqual({
message: '已批量归档 1 场考试',
archived: 1,
skipped: 1,
});
expect(examRepo.find).toHaveBeenCalledWith({ where: { id: expect.anything() } });
expect(qb.set).toHaveBeenCalledWith({ status: 'archived' });
expect(qb.where).toHaveBeenCalledWith('id IN (:...ids)', { ids: [8] });
expect(scoreRepo.update).not.toHaveBeenCalled();
expect(scoreRepo.save).not.toHaveBeenCalled();
});
it('restores archived exams and skips active exams', async () => {
const qb = updateQb();
const examRepo = {
find: jest.fn().mockResolvedValue([
{ id: 8, classId: 3, status: 'archived' },
{ id: 9, classId: 4, status: 'active' },
]),
createQueryBuilder: jest.fn(() => qb),
};
const service = createService(async () => undefined, { examRepo });
await expect(service.batchRestore([8, 9], 1, true)).resolves.toEqual({
message: '已批量恢复 1 场考试',
restored: 1,
skipped: 1,
});
expect(qb.set).toHaveBeenCalledWith({ status: 'active' });
expect(qb.where).toHaveBeenCalledWith('id IN (:...ids)', { ids: [8] });
});
it('rejects a batch when any exam is missing before updating', async () => {
const examRepo = {
find: jest.fn().mockResolvedValue([{ id: 8, classId: 3, status: 'active' }]),
createQueryBuilder: jest.fn(),
};
const service = createService(async () => undefined, { examRepo });
await expect(service.batchArchive([8, 9], 1, true)).rejects.toBeInstanceOf(NotFoundException);
expect(examRepo.createQueryBuilder).not.toHaveBeenCalled();
});
it('checks access for every selected exam before a batch update', async () => {
const examRepo = {
find: jest.fn().mockResolvedValue([
{ id: 8, classId: 3, status: 'active' },
{ id: 9, classId: 4, status: 'active' },
]),
createQueryBuilder: jest.fn(),
};
const classTeacherRepo = {
findOne: jest
.fn()
.mockResolvedValueOnce({ id: 1 })
.mockResolvedValueOnce(null),
};
const service = createService(async () => undefined, { examRepo, classTeacherRepo });
await expect(service.batchArchive([8, 9], 21, false)).rejects.toBeInstanceOf(
ForbiddenException,
);
expect(classTeacherRepo.findOne).toHaveBeenNthCalledWith(1, {
where: { userId: 21, classId: 3 },
});
expect(classTeacherRepo.findOne).toHaveBeenNthCalledWith(2, {
where: { userId: 21, classId: 4 },
});
expect(examRepo.createQueryBuilder).not.toHaveBeenCalled();
});
it('rejects score updates for archived exams', async () => {
const manager = {
findOne: jest.fn().mockResolvedValue({ id: 8, classId: 3, status: 'archived' }),

View File

@@ -149,6 +149,58 @@ export class ExamsService {
return { success: true };
}
async batchArchive(ids: number[], userId: number, canManageAll: boolean) {
const exams = await this.findBatchExams(ids, userId, canManageAll, '归档');
const targetIds = exams.filter((exam) => exam.status === 'active').map((exam) => exam.id);
const archived = await this.updateBatchStatus(targetIds, 'archived');
return {
message: `已批量归档 ${archived} 场考试`,
archived,
skipped: exams.length - targetIds.length,
};
}
async batchRestore(ids: number[], userId: number, canManageAll: boolean) {
const exams = await this.findBatchExams(ids, userId, canManageAll, '恢复');
const targetIds = exams.filter((exam) => exam.status === 'archived').map((exam) => exam.id);
const restored = await this.updateBatchStatus(targetIds, 'active');
return {
message: `已批量恢复 ${restored} 场考试`,
restored,
skipped: exams.length - targetIds.length,
};
}
private async findBatchExams(
ids: number[],
userId: number,
canManageAll: boolean,
action: '归档' | '恢复',
) {
const uniqueIds = [...new Set(ids || [])];
if (uniqueIds.length === 0) throw new BadRequestException(`请选择要${action}的考试`);
if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) {
throw new BadRequestException('考试 ID 无效');
}
const exams = await this.examRepo.find({ where: { id: In(uniqueIds) } });
if (exams.length !== uniqueIds.length) throw new NotFoundException('部分考试不存在');
for (const exam of exams) {
await this.assertClassAccess(userId, exam.classId, canManageAll);
}
return exams;
}
private async updateBatchStatus(ids: number[], status: 'active' | 'archived') {
if (ids.length === 0) return 0;
const result = await this.examRepo
.createQueryBuilder()
.update()
.set({ status })
.where('id IN (:...ids)', { ids })
.execute();
return result.affected || 0;
}
private async createScoreRows(manager: EntityManager, exam: Exam, members: ClassStudent[]) {
const rows = members.map((member) =>
manager.create(ExamScore, {