feat(core-edu): 完整实现 core-edu 教学核心服务
包含 classes/exams/homework/grades/attendance/scheduling 域、outbox、iam-consumer、redis 配置等完整实现
This commit is contained in:
@@ -5,19 +5,31 @@ 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: string;
|
||||
duration: number;
|
||||
totalScore: string;
|
||||
schoolId: string;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
@@ -25,26 +37,45 @@ export interface UpdateExamInput {
|
||||
title?: string;
|
||||
description?: string;
|
||||
examDate?: Date;
|
||||
duration?: string;
|
||||
duration?: number;
|
||||
totalScore?: string;
|
||||
status?: 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): Promise<{ id: string }> {
|
||||
if (!input.classId || !input.title || !input.createdBy) {
|
||||
throw new ValidationError("classId, title, createdBy are required");
|
||||
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,
|
||||
// Drizzle datetime 列需要 Date 对象(调用 toISOString)。HTTP 请求体里的
|
||||
// examDate 是 ISO 字符串,这里统一转成 Date,避免 "toISOString is not a function"。
|
||||
examDate:
|
||||
input.examDate instanceof Date
|
||||
? input.examDate
|
||||
@@ -52,22 +83,34 @@ export class ExamsService {
|
||||
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",
|
||||
payload: JSON.stringify({
|
||||
id,
|
||||
classId: input.classId,
|
||||
title: input.title,
|
||||
}),
|
||||
occurredAt: new Date(event.occurred_at),
|
||||
payload: serializeEvent(event),
|
||||
status: "pending",
|
||||
},
|
||||
tx,
|
||||
@@ -94,16 +137,28 @@ export class ExamsService {
|
||||
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",
|
||||
payload: JSON.stringify({ id, changes: data }),
|
||||
occurredAt: new Date(event.occurred_at),
|
||||
payload: serializeEvent(event),
|
||||
status: "pending",
|
||||
},
|
||||
tx,
|
||||
@@ -119,17 +174,231 @@ export class ExamsService {
|
||||
|
||||
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",
|
||||
payload: JSON.stringify({ id }),
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user