Files
Edu/services/core-edu/src/exams/exams.service.ts

492 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { randomUUID } from "node:crypto";
import { eq, and } from "drizzle-orm";
import { Injectable } from "@nestjs/common";
import { db } from "../config/database.js";
import { exams } from "./exams.schema.js";
import { examDrafts, examViolations } from "./exam-extensions.schema.js";
import { examsRepository } from "./exams.repository.js";
import { outboxRepository } from "../shared/outbox/outbox.repository.js";
import { buildEvent, serializeEvent } from "../shared/outbox/event-builder.js";
import {
canTransition,
transition,
type ExamStatus,
type ExamAction,
} from "./exam-state-machine.js";
import type { Exam, NewExam } from "./exams.schema.js";
import {
NotFoundError,
ValidationError,
ConflictError,
ApplicationError,
CoreEduErrorCode,
} from "../shared/errors/application-error.js";
export interface CreateExamInput {
classId: string;
subjectId: string;
title: string;
description?: string;
examDate: Date | string;
duration: number;
totalScore: string;
schoolId: string;
createdBy: string;
}
export interface UpdateExamInput {
title?: string;
description?: string;
examDate?: Date;
duration?: number;
totalScore?: string;
}
export interface AnswerInput {
questionId: string;
answer: string;
}
export interface ScoreInput {
questionId: string;
score: string;
teacherComment?: string;
}
@Injectable()
export class ExamsService {
async createExam(
input: CreateExamInput,
userId?: string,
): Promise<{ id: string }> {
if (
!input.classId ||
!input.title ||
!input.createdBy ||
!input.subjectId
) {
throw new ValidationError(
"classId, subjectId, title, createdBy are required",
);
}
const id = randomUUID();
const exam: NewExam = {
id,
classId: input.classId,
subjectId: input.subjectId,
title: input.title,
description: input.description,
examDate:
input.examDate instanceof Date
? input.examDate
: new Date(input.examDate),
duration: input.duration,
totalScore: input.totalScore,
status: "draft",
statusChangedAt: new Date(),
schoolId: input.schoolId,
createdBy: input.createdBy,
};
const event = buildEvent({
aggregateId: id,
eventType: "exam.created",
payload: {
examId: id,
classId: input.classId,
subjectId: input.subjectId,
title: input.title,
},
userId,
});
await db.transaction(async (tx) => {
await tx.insert(exams).values(exam);
await outboxRepository.create(
{
id: randomUUID(),
eventId: event.event_id,
aggregateId: id,
aggregateType: "exam",
eventType: "exam.created",
occurredAt: new Date(event.occurred_at),
payload: serializeEvent(event),
status: "pending",
},
tx,
);
});
return { id };
}
async getExam(id: string): Promise<Exam> {
const exam = await examsRepository.findById(id);
if (!exam) {
throw new NotFoundError(`Exam ${id} not found`);
}
return exam;
}
async listExamsByClass(classId: string): Promise<Exam[]> {
return examsRepository.findByClassId(classId);
}
async updateExam(id: string, data: UpdateExamInput): Promise<void> {
const existing = await examsRepository.findById(id);
if (!existing) {
throw new NotFoundError(`Exam ${id} not found`);
}
if (existing.status !== "draft") {
throw new ConflictError(
`Cannot update exam in status ${existing.status} (only draft allows edits)`,
);
}
await db.transaction(async (tx) => {
await tx.update(exams).set(data).where(eq(exams.id, id));
const event = buildEvent({
aggregateId: id,
eventType: "exam.updated",
payload: { examId: id, changes: data },
});
await outboxRepository.create(
{
id: randomUUID(),
eventId: event.event_id,
aggregateId: id,
aggregateType: "exam",
eventType: "exam.updated",
occurredAt: new Date(event.occurred_at),
payload: serializeEvent(event),
status: "pending",
},
tx,
);
});
}
async deleteExam(id: string): Promise<void> {
const existing = await examsRepository.findById(id);
if (!existing) {
throw new NotFoundError(`Exam ${id} not found`);
}
await db.transaction(async (tx) => {
await tx.delete(exams).where(eq(exams.id, id));
const event = buildEvent({
aggregateId: id,
eventType: "exam.deleted",
payload: { examId: id },
});
await outboxRepository.create(
{
id: randomUUID(),
eventId: event.event_id,
aggregateId: id,
aggregateType: "exam",
eventType: "exam.deleted",
occurredAt: new Date(event.occurred_at),
payload: serializeEvent(event),
status: "pending",
},
tx,
);
});
}
async publishExam(id: string, publishedBy: string): Promise<void> {
const exam = await examsRepository.findById(id);
if (!exam) {
throw new NotFoundError(`Exam ${id} not found`);
}
const currentStatus = exam.status as ExamStatus;
this.assertTransition(currentStatus, "publish");
const newStatus = transition(currentStatus, "publish");
await db.transaction(async (tx) => {
await tx
.update(exams)
.set({
status: newStatus,
statusChangedAt: new Date(),
statusChangedBy: publishedBy,
})
.where(eq(exams.id, id));
const event = buildEvent({
aggregateId: id,
eventType: "exam.published",
payload: {
examId: id,
classId: exam.classId,
subjectId: exam.subjectId,
},
userId: publishedBy,
});
await outboxRepository.create(
{
id: randomUUID(),
eventId: event.event_id,
aggregateId: id,
aggregateType: "exam",
eventType: "exam.published",
occurredAt: new Date(event.occurred_at),
payload: serializeEvent(event),
status: "pending",
},
tx,
);
});
}
async submitExam(
examId: string,
studentId: string,
_answers: AnswerInput[],
): Promise<{ submissionId: string }> {
const exam = await examsRepository.findById(examId);
if (!exam) {
throw new NotFoundError(`Exam ${examId} not found`);
}
// Check existing submission (idempotency via unique index)
const existing = await examsRepository.findSubmission(examId, studentId);
if (
existing &&
(existing.status === "submitted" || existing.status === "graded")
) {
throw new ConflictError(
`Student ${studentId} already submitted exam ${examId}`,
);
}
const submissionId = randomUUID();
await db.transaction(async (tx) => {
await examsRepository.createSubmission(
{
id: submissionId,
examId,
studentId,
status: "submitted",
submittedAt: new Date(),
},
tx,
);
// ISSUE-006 决策 #6: exam.submitted 事件只带 submission_id
const event = buildEvent({
aggregateId: examId,
eventType: "exam.submitted",
payload: {
examId,
submissionId,
studentId,
},
userId: studentId,
});
await outboxRepository.create(
{
id: randomUUID(),
eventId: event.event_id,
aggregateId: examId,
aggregateType: "exam",
eventType: "exam.submitted",
occurredAt: new Date(event.occurred_at),
payload: serializeEvent(event),
status: "pending",
},
tx,
);
});
return { submissionId };
}
async gradeExam(
examId: string,
submissionId: string,
scores: ScoreInput[],
gradedBy: string,
): Promise<{ totalScore: string }> {
const exam = await examsRepository.findById(examId);
if (!exam) {
throw new NotFoundError(`Exam ${examId} not found`);
}
const submission = await examsRepository.findSubmissionById(submissionId);
if (!submission || submission.examId !== examId) {
throw new NotFoundError(
`Submission ${submissionId} not found for exam ${examId}`,
);
}
if (submission.status === "graded") {
throw new ConflictError(`Submission ${submissionId} already graded`);
}
// Calculate total score
const totalScore = scores.reduce((sum, s) => sum + Number(s.score), 0);
const totalScoreStr = totalScore.toFixed(2);
await db.transaction(async (tx) => {
await examsRepository.updateSubmission(submissionId, {
status: "graded",
gradedAt: new Date(),
gradedBy,
totalScore: totalScore.toFixed(2),
});
const event = buildEvent({
aggregateId: examId,
eventType: "exam.graded",
payload: {
examId,
submissionId,
studentId: submission.studentId,
totalScore: totalScoreStr,
},
userId: gradedBy,
});
await outboxRepository.create(
{
id: randomUUID(),
eventId: event.event_id,
aggregateId: examId,
aggregateType: "exam",
eventType: "exam.graded",
occurredAt: new Date(event.occurred_at),
payload: serializeEvent(event),
status: "pending",
},
tx,
);
});
return { totalScore: totalScoreStr };
}
async archiveExam(id: string, archivedBy: string): Promise<void> {
const exam = await examsRepository.findById(id);
if (!exam) {
throw new NotFoundError(`Exam ${id} not found`);
}
// ISSUE-006 决策 #7: archived 仅做软删除
if (exam.status === "archived") {
return; // Already archived
}
if (exam.status !== "graded") {
throw new ConflictError(
`Cannot archive exam in status ${exam.status} (only graded allows archive)`,
);
}
await db.transaction(async (tx) => {
await tx
.update(exams)
.set({
status: "archived",
statusChangedAt: new Date(),
statusChangedBy: archivedBy,
archivedAt: new Date(),
})
.where(eq(exams.id, id));
});
}
// --------------------------------------------------------------------------
// P3.13 新增考试草稿自动保存upsert by exam_id + student_id
// --------------------------------------------------------------------------
async saveExamDraft(
examId: string,
studentId: string,
answers: AnswerInput[],
): Promise<{ draftId: string }> {
if (!examId || !studentId) {
throw new ValidationError("examId and studentId are required");
}
// 校验考试存在
const exam = await examsRepository.findById(examId);
if (!exam) {
throw new NotFoundError(`Exam ${examId} not found`);
}
const answersPayload = answers.map((a) => ({
questionId: a.questionId,
answer: a.answer,
}));
// 查找已有草稿unique key: exam_id + student_id
const existing = await db
.select()
.from(examDrafts)
.where(
and(eq(examDrafts.examId, examId), eq(examDrafts.studentId, studentId)),
)
.limit(1);
const draftRow = existing[0];
if (draftRow) {
const draftId = draftRow.id;
await db
.update(examDrafts)
.set({ answers: answersPayload })
.where(eq(examDrafts.id, draftId));
return { draftId };
}
const draftId = randomUUID();
await db.insert(examDrafts).values({
id: draftId,
examId,
studentId,
answers: answersPayload,
});
return { draftId };
}
// --------------------------------------------------------------------------
// P3.13 新增:考试违规事件记录(防作弊)
// --------------------------------------------------------------------------
async recordExamViolation(
examId: string,
studentId: string,
violationType: string,
detail: string,
severity: number,
): Promise<{ violationId: string }> {
if (!examId || !studentId || !violationType) {
throw new ValidationError(
"examId, studentId, violationType are required",
);
}
// 校验考试存在
const exam = await examsRepository.findById(examId);
if (!exam) {
throw new NotFoundError(`Exam ${examId} not found`);
}
const violationId = randomUUID();
await db.insert(examViolations).values({
id: violationId,
examId,
studentId,
violationType,
detail: detail || null,
severity: severity || 1,
});
return { violationId };
}
private assertTransition(from: ExamStatus, action: ExamAction): void {
if (!canTransition(from, action)) {
throw new ApplicationError(
CoreEduErrorCode.EXAM_INVALID_TRANSITION,
`Invalid exam state transition: ${from} --${action}-->`,
409,
{ from, action },
);
}
}
}