feat(core-edu): 完整实现 core-edu 教学核心服务

包含 classes/exams/homework/grades/attendance/scheduling 域、outbox、iam-consumer、redis 配置等完整实现
This commit is contained in:
SpecialX
2026-07-10 19:08:56 +08:00
parent 06a646ea4e
commit 58c0ba1bd9
55 changed files with 4204 additions and 305 deletions

View File

@@ -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 },
);
}
}
}