feat: add exam score management
Some checks failed
CI 检查 / lint (pull_request) Has been cancelled
CI 检查 / typecheck (pull_request) Has been cancelled
CI 检查 / test (pull_request) Has been cancelled

This commit is contained in:
2026-07-21 16:04:17 +08:00
parent 37ef6f9dd7
commit cbc04fea4f
27 changed files with 1061 additions and 16 deletions

View File

@@ -0,0 +1,29 @@
import { Type } from 'class-transformer';
import {
IsDateString,
IsInt,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
Max,
Min,
} from 'class-validator';
export class CreateExamDto {
@IsString() @IsNotEmpty() examType: string;
@IsString() @IsNotEmpty() examName: string;
@IsString() @IsNotEmpty() subject: string;
@IsDateString() examDate: string;
@IsInt() @Min(1) classId: number;
}
export class QueryExamDto {
@IsOptional() @IsString() keyword?: string;
@IsOptional() @IsString() examType?: string;
@IsOptional() @Type(() => Number) @IsInt() @Min(1) classId?: number;
}
export class UpdateExamScoreValueDto {
@IsOptional() @IsNumber() @Min(0) @Max(999.99) score?: number | null;
}

View File

@@ -0,0 +1,104 @@
import {
Body,
Controller,
Get,
Param,
ParseIntPipe,
Post,
Put,
Query,
Request,
UseGuards,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequirePermission } from '../auth/decorators/permission.decorator';
import { extractRequestInfo } from '../common/request-utils';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import type { AuthenticatedUser } from '../authorization';
import { CreateExamDto, QueryExamDto, UpdateExamScoreValueDto } from './dto/exam.dto';
import { ExamsService } from './exams.service';
interface AuthenticatedRequest {
user: AuthenticatedUser;
ip?: string;
headers?: Record<string, string | string[] | undefined>;
}
@UseGuards(JwtAuthGuard)
@UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }))
@Controller('exams')
export class ExamsController {
constructor(
private readonly service: ExamsService,
private readonly logService: OperationLogsService,
) {}
private canManageAll(req: AuthenticatedRequest) {
return req.user.isSuperAdmin || req.user.permissions.includes('exam:edit');
}
@Get()
@RequirePermission('exam:view')
async findAll(@Query() query: QueryExamDto, @Request() req: AuthenticatedRequest) {
const classIds = await this.service.getAccessibleClassIds(req.user.id, this.canManageAll(req));
return this.service.findAll(query, classIds);
}
@Get(':id')
@RequirePermission('exam:view')
findOne(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
return this.service.findOne(id, req.user.id, this.canManageAll(req));
}
@Post()
@RequirePermission('exam:view')
async create(@Body() dto: CreateExamDto, @Request() req: AuthenticatedRequest) {
const result = await this.service.create(dto, 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: '创建考试',
targetId: result.id,
targetType: 'exam',
detail: `${dto.examName} - ${dto.subject}`,
ipAddress,
userAgent,
});
return result;
}
@Put(':examId/scores/:scoreId')
@RequirePermission('exam:view')
async updateScore(
@Param('examId', ParseIntPipe) examId: number,
@Param('scoreId', ParseIntPipe) scoreId: number,
@Body() dto: UpdateExamScoreValueDto,
@Request() req: AuthenticatedRequest,
) {
const result = await this.service.updateScore(
examId,
scoreId,
dto.score,
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: dto.score === null || dto.score === undefined ? '清空成绩' : '录入成绩',
targetId: scoreId,
targetType: 'exam_score',
detail: dto.score === null || dto.score === undefined ? '成绩已清空' : `成绩:${dto.score}`,
ipAddress,
userAgent,
});
return result;
}
}

View File

@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Class, ClassStudent, ClassTeacher, Exam, ExamScore, Student } from '../entities';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
import { ExamsController } from './exams.controller';
import { ExamsService } from './exams.service';
@Module({
imports: [
TypeOrmModule.forFeature([Exam, ExamScore, Class, ClassStudent, ClassTeacher, Student]),
OperationLogsModule,
],
controllers: [ExamsController],
providers: [ExamsService],
})
export class ExamsModule {}

View File

@@ -0,0 +1,127 @@
import { BadRequestException } from '@nestjs/common';
import { ExamScore } from '../entities';
import { ExamsService } from './exams.service';
function createService(transaction: (run: (manager: any) => Promise<unknown>) => Promise<unknown>) {
return new ExamsService(
{} as never,
{} as never,
{} as never,
{} as never,
{ findOne: jest.fn().mockResolvedValue({ id: 1 }) } as never,
{ transaction } as never,
);
}
describe('ExamsService', () => {
it('creates score rows from the active class roster snapshot', async () => {
const members = [
{ studentId: 11, status: 'active' },
{ studentId: 12, status: 'active' },
];
const manager = {
findOne: jest.fn().mockResolvedValue({ id: 3, isArchived: false }),
find: jest.fn().mockResolvedValue(members),
create: jest.fn((_entity, value) => value),
save: jest.fn(async (entity, value) =>
entity.name === 'Exam' ? { ...value, id: 9 } : value,
),
};
const service = createService(async (run) => run(manager));
await service.create(
{
examType: '月考',
examName: '七月月考',
subject: '数学',
examDate: '2026-07-21',
classId: 3,
},
1,
true,
);
expect(manager.find).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ where: { classId: 3, status: 'active' } }),
);
expect(manager.save).toHaveBeenCalledWith(
ExamScore,
expect.arrayContaining([
expect.objectContaining({ examId: 9, studentId: 11, score: null }),
expect.objectContaining({ examId: 9, studentId: 12, score: null }),
]),
);
});
it('rejects creating an exam for an empty class', async () => {
const manager = {
findOne: jest.fn().mockResolvedValue({ id: 3, isArchived: false }),
find: jest.fn().mockResolvedValue([]),
};
const service = createService(async (run) => run(manager));
await expect(
service.create(
{
examType: '月考',
examName: '七月月考',
subject: '数学',
examDate: '2026-07-21',
classId: 3,
},
1,
true,
),
).rejects.toBeInstanceOf(BadRequestException);
});
it('counts zero, ignores empty scores, and uses competition ranking', async () => {
const rows = [
{ id: 1, score: 90, classAvg: null, rank: null },
{ id: 2, score: 90, classAvg: null, rank: null },
{ id: 3, score: 60, classAvg: null, rank: null },
{ id: 4, score: 0, classAvg: null, rank: null },
{ id: 5, score: null, classAvg: null, rank: null },
];
const manager = {
findOne: jest
.fn()
.mockResolvedValueOnce({ id: 8, classId: 3, status: 'active' })
.mockResolvedValueOnce(rows[0])
.mockResolvedValueOnce(rows[0]),
find: jest.fn().mockResolvedValue(rows),
save: jest.fn(async (_entity, value) => value),
};
const service = createService(async (run) => run(manager));
await service.updateScore(8, 1, 90, 1, true);
expect(rows.map((row) => row.rank)).toEqual([1, 1, 3, 4, null]);
expect(rows.map((row) => row.classAvg)).toEqual([60, 60, 60, 60, 60]);
});
it('clears a score and recalculates the remaining rows', async () => {
const rows = [
{ id: 1, score: 90, classAvg: 80, rank: 1 },
{ id: 2, score: 70, classAvg: 80, rank: 2 },
];
const manager = {
findOne: jest
.fn()
.mockResolvedValueOnce({ id: 8, classId: 3, status: 'active' })
.mockResolvedValueOnce(rows[0])
.mockResolvedValueOnce(rows[0]),
find: jest.fn().mockResolvedValue(rows),
save: jest.fn(async (_entity, value) => value),
};
const service = createService(async (run) => run(manager));
await service.updateScore(8, 1, null, 1, true);
expect(rows).toEqual([
expect.objectContaining({ score: null, classAvg: 70, rank: null }),
expect.objectContaining({ score: 70, classAvg: 70, rank: 1 }),
]);
});
});

View File

@@ -0,0 +1,182 @@
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, EntityManager, In, Like, Repository } from 'typeorm';
import { Class, ClassStudent, ClassTeacher, Exam, ExamScore } from '../entities';
import { CreateExamDto, QueryExamDto } from './dto/exam.dto';
@Injectable()
export class ExamsService {
constructor(
@InjectRepository(Exam) private examRepo: Repository<Exam>,
@InjectRepository(ExamScore) private scoreRepo: Repository<ExamScore>,
@InjectRepository(Class) private classRepo: Repository<Class>,
@InjectRepository(ClassStudent) private classStudentRepo: Repository<ClassStudent>,
@InjectRepository(ClassTeacher) private classTeacherRepo: Repository<ClassTeacher>,
private dataSource: DataSource,
) {}
async getAccessibleClassIds(userId: number, canManageAll: boolean) {
if (canManageAll) return undefined;
const rows = await this.classTeacherRepo.find({ where: { userId } });
return [...new Set(rows.map((row) => row.classId))];
}
async assertClassAccess(userId: number, classId: number, canManageAll: boolean) {
if (canManageAll) return;
const assignment = await this.classTeacherRepo.findOne({ where: { userId, classId } });
if (!assignment) throw new ForbiddenException('只能访问自己被分配的班级');
}
async findAll(query: QueryExamDto, accessibleClassIds?: number[]) {
const where: Record<string, unknown> = {
status: 'active',
};
if (query.keyword) where.examName = Like(`%${query.keyword}%`);
if (query.examType) where.examType = query.examType;
if (query.classId) where.classId = query.classId;
if (accessibleClassIds) {
if (accessibleClassIds.length === 0) return [];
where.classId = query.classId
? accessibleClassIds.includes(query.classId)
? query.classId
: -1
: In(accessibleClassIds);
}
const exams = await this.examRepo.find({
where,
relations: ['class'],
order: { examDate: 'DESC', createdAt: 'DESC' },
});
if (exams.length === 0) return [];
const scoreRows = await this.scoreRepo.find({
where: { examId: In(exams.map((exam) => exam.id)), status: 'active' },
});
const progress = new Map<number, { total: number; entered: number }>();
for (const row of scoreRows) {
const item = progress.get(row.examId!) ?? { total: 0, entered: 0 };
item.total++;
if (row.score !== null && row.score !== undefined) item.entered++;
progress.set(row.examId!, item);
}
return exams.map((exam) => ({
...exam,
className: exam.class?.name,
totalStudents: progress.get(exam.id)?.total ?? 0,
enteredScores: progress.get(exam.id)?.entered ?? 0,
}));
}
async findOne(id: number, userId: number, canManageAll: boolean) {
const exam = await this.examRepo.findOne({ where: { id }, relations: ['class'] });
if (!exam) throw new NotFoundException('考试不存在');
await this.assertClassAccess(userId, exam.classId, canManageAll);
const scores = await this.scoreRepo.find({
where: { examId: id, status: 'active' },
relations: ['student'],
order: { id: 'ASC' },
});
return {
...exam,
className: exam.class?.name,
totalStudents: scores.length,
enteredScores: scores.filter((row) => row.score !== null && row.score !== undefined).length,
scores: scores.map((row) => ({
id: row.id,
studentId: row.studentId,
phone: row.student?.phone,
name: row.student?.name,
score: row.score === null ? null : Number(row.score),
classAvg: row.classAvg === null ? null : Number(row.classAvg),
rank: row.rank,
})),
};
}
async create(dto: CreateExamDto, userId: number, canManageAll: boolean) {
await this.assertClassAccess(userId, dto.classId, canManageAll);
return this.dataSource.transaction(async (manager) => {
const cls = await manager.findOne(Class, { where: { id: dto.classId } });
if (!cls) throw new NotFoundException('班级不存在');
if (cls.isArchived) throw new BadRequestException('归档班级不能创建考试');
const members = await manager.find(ClassStudent, {
where: { classId: dto.classId, status: 'active' },
order: { createdAt: 'ASC' },
});
if (members.length === 0) throw new BadRequestException('班级暂无在读学员,不能创建考试');
const exam = await manager.save(Exam, manager.create(Exam, { ...dto, status: 'active' }));
await this.createScoreRows(manager, exam, members);
return exam;
});
}
async updateScore(
examId: number,
scoreId: number,
score: number | null | undefined,
userId: number,
canManageAll: boolean,
) {
return this.dataSource.transaction(async (manager) => {
const exam = await manager.findOne(Exam, { where: { id: examId } });
if (!exam) throw new NotFoundException('考试不存在');
await this.assertClassAccess(userId, exam.classId, canManageAll);
if (exam.status === 'archived') throw new BadRequestException('已归档考试不能录入成绩');
const row = await manager.findOne(ExamScore, { where: { id: scoreId, examId } });
if (!row) throw new NotFoundException('成绩记录不存在');
row.score = score === undefined ? null : score;
await manager.save(ExamScore, row);
await this.recalculate(manager, examId);
return manager.findOne(ExamScore, { where: { id: scoreId } });
});
}
private async createScoreRows(manager: EntityManager, exam: Exam, members: ClassStudent[]) {
const rows = members.map((member) =>
manager.create(ExamScore, {
examId: exam.id,
studentId: member.studentId,
examType: exam.examType,
examName: exam.examName,
subject: exam.subject,
score: null,
classAvg: null,
rank: null,
examDate: exam.examDate,
status: 'active',
}),
);
await manager.save(ExamScore, rows);
}
private async recalculate(manager: EntityManager, examId: number) {
const rows = await manager.find(ExamScore, { where: { examId, status: 'active' } });
const entered = rows
.filter((row) => row.score !== null && row.score !== undefined)
.sort((a, b) => Number(b.score) - Number(a.score));
const average =
entered.length === 0
? null
: Math.round((entered.reduce((sum, row) => sum + Number(row.score), 0) / entered.length) * 100) /
100;
let previousScore: number | null = null;
let previousRank = 0;
for (let index = 0; index < entered.length; index++) {
const row = entered[index];
const value = Number(row.score);
const rank = previousScore !== null && value === previousScore ? previousRank : index + 1;
row.classAvg = average;
row.rank = rank;
previousScore = value;
previousRank = rank;
}
const enteredIds = new Set(entered.map((row) => row.id));
for (const row of rows) {
if (!enteredIds.has(row.id)) {
row.classAvg = average;
row.rank = null;
}
}
await manager.save(ExamScore, rows);
}
}