492 lines
13 KiB
TypeScript
492 lines
13 KiB
TypeScript
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 },
|
||
);
|
||
}
|
||
}
|
||
}
|