包含 classes/exams/homework/grades/attendance/scheduling 域、outbox、iam-consumer、redis 配置等完整实现
405 lines
10 KiB
TypeScript
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 },
|
|
);
|
|
}
|
|
}
|
|
}
|