Files
Edu/services/core-edu/src/exams/exams.service.ts
SpecialX 58c0ba1bd9 feat(core-edu): 完整实现 core-edu 教学核心服务
包含 classes/exams/homework/grades/attendance/scheduling 域、outbox、iam-consumer、redis 配置等完整实现
2026-07-10 19:08:56 +08:00

405 lines
10 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { Injectable } from "@nestjs/common";
import { db } from "../config/database.js";
import { exams } from "./exams.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));
});
}
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 },
);
}
}
}