feat(core-edu): 完整实现 core-edu 教学核心服务
包含 classes/exams/homework/grades/attendance/scheduling 域、outbox、iam-consumer、redis 配置等完整实现
This commit is contained in:
60
services/core-edu/src/exams/exam-state-machine.ts
Normal file
60
services/core-edu/src/exams/exam-state-machine.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 考试状态机(纯函数)
|
||||
*
|
||||
* 状态流转(ISSUE-003 仲裁 scheme A):
|
||||
* draft → published → in_progress → grading → graded → archived
|
||||
* └→ cancelled(终态)
|
||||
* cancelled 可从 published/in_progress/grading 流入(异常终止)
|
||||
*
|
||||
* archived 为软删除终态(ISSUE-006 决策 #7:archived 仅做软删除,不物理删除)
|
||||
*/
|
||||
export type ExamStatus =
|
||||
| "draft"
|
||||
| "published"
|
||||
| "in_progress"
|
||||
| "grading"
|
||||
| "graded"
|
||||
| "archived"
|
||||
| "cancelled";
|
||||
|
||||
export type ExamAction =
|
||||
"publish" | "start" | "submit" | "grade" | "archive" | "cancel";
|
||||
|
||||
const TRANSITIONS: Record<
|
||||
ExamStatus,
|
||||
Partial<Record<ExamAction, ExamStatus>>
|
||||
> = {
|
||||
draft: { publish: "published", cancel: "cancelled" },
|
||||
published: { start: "in_progress", cancel: "cancelled" },
|
||||
in_progress: { submit: "grading", cancel: "cancelled" },
|
||||
grading: { grade: "graded", cancel: "cancelled" },
|
||||
graded: { archive: "archived" },
|
||||
archived: {},
|
||||
cancelled: {},
|
||||
};
|
||||
|
||||
export function canTransition(from: ExamStatus, action: ExamAction): boolean {
|
||||
return TRANSITIONS[from]?.[action] !== undefined;
|
||||
}
|
||||
|
||||
export function transition(from: ExamStatus, action: ExamAction): ExamStatus {
|
||||
const next = TRANSITIONS[from]?.[action];
|
||||
if (!next) {
|
||||
throw new Error(`Invalid exam state transition: ${from} --${action}-->`);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function isTerminal(status: ExamStatus): boolean {
|
||||
return status === "archived" || status === "cancelled";
|
||||
}
|
||||
|
||||
export const EXAM_STATUSES: readonly ExamStatus[] = [
|
||||
"draft",
|
||||
"published",
|
||||
"in_progress",
|
||||
"grading",
|
||||
"graded",
|
||||
"archived",
|
||||
"cancelled",
|
||||
] as const;
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Put,
|
||||
Req,
|
||||
} from "@nestjs/common";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
ExamsService,
|
||||
type CreateExamInput,
|
||||
@@ -18,26 +19,75 @@ import {
|
||||
RequirePermission,
|
||||
} from "../middleware/permission.guard.js";
|
||||
import type { AuthenticatedRequest } from "../middleware/auth.middleware.js";
|
||||
import { UnauthorizedError } from "../shared/errors/application-error.js";
|
||||
import {
|
||||
UnauthorizedError,
|
||||
ValidationError,
|
||||
} from "../shared/errors/application-error.js";
|
||||
|
||||
@Controller("exams")
|
||||
const createExamSchema = z.object({
|
||||
classId: z.string().min(1),
|
||||
subjectId: z.string().min(1),
|
||||
title: z.string().min(1).max(200),
|
||||
description: z.string().optional(),
|
||||
examDate: z.string().min(1),
|
||||
duration: z.number().int().positive(),
|
||||
totalScore: z.string().min(1),
|
||||
schoolId: z.string().min(1),
|
||||
});
|
||||
|
||||
const updateExamSchema = z.object({
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
description: z.string().optional(),
|
||||
examDate: z.string().min(1).optional(),
|
||||
duration: z.number().int().positive().optional(),
|
||||
totalScore: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
const answerInputSchema = z.object({
|
||||
questionId: z.string().min(1),
|
||||
answer: z.string(),
|
||||
});
|
||||
|
||||
const scoreInputSchema = z.object({
|
||||
questionId: z.string().min(1),
|
||||
score: z.string().min(1),
|
||||
teacherComment: z.string().optional(),
|
||||
});
|
||||
|
||||
const submitExamSchema = z.object({
|
||||
studentId: z.string().min(1),
|
||||
answers: z.array(answerInputSchema).default([]),
|
||||
});
|
||||
|
||||
const gradeExamSchema = z.object({
|
||||
submissionId: z.string().min(1),
|
||||
scores: z.array(scoreInputSchema).min(1),
|
||||
});
|
||||
|
||||
@Controller("v1/exams")
|
||||
export class ExamsController {
|
||||
constructor(private readonly examsService: ExamsService) {}
|
||||
|
||||
@Post()
|
||||
@RequirePermission(Permissions.EXAM_CREATE)
|
||||
async create(
|
||||
@Body() body: CreateExamInput,
|
||||
@Body() body: unknown,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<{ success: true; data: { id: string } }> {
|
||||
const userId = req.userId;
|
||||
if (!userId) {
|
||||
throw new UnauthorizedError("Missing x-user-id header");
|
||||
}
|
||||
const result = await this.examsService.createExam({
|
||||
...body,
|
||||
const parsed = createExamSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
throw new ValidationError("Invalid exam input", parsed.error.flatten());
|
||||
}
|
||||
const input: CreateExamInput = {
|
||||
...parsed.data,
|
||||
examDate: new Date(parsed.data.examDate),
|
||||
createdBy: userId,
|
||||
});
|
||||
};
|
||||
const result = await this.examsService.createExam(input, userId);
|
||||
return { success: true, data: result };
|
||||
}
|
||||
|
||||
@@ -65,9 +115,22 @@ export class ExamsController {
|
||||
@RequirePermission(Permissions.EXAM_UPDATE)
|
||||
async update(
|
||||
@Param("id") id: string,
|
||||
@Body() body: UpdateExamInput,
|
||||
@Body() body: unknown,
|
||||
): Promise<{ success: true; data: { success: true } }> {
|
||||
await this.examsService.updateExam(id, body);
|
||||
const parsed = updateExamSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
throw new ValidationError(
|
||||
"Invalid exam update input",
|
||||
parsed.error.flatten(),
|
||||
);
|
||||
}
|
||||
const data: UpdateExamInput = {
|
||||
...parsed.data,
|
||||
examDate: parsed.data.examDate
|
||||
? new Date(parsed.data.examDate)
|
||||
: undefined,
|
||||
};
|
||||
await this.examsService.updateExam(id, data);
|
||||
return { success: true, data: { success: true } };
|
||||
}
|
||||
|
||||
@@ -79,4 +142,74 @@ export class ExamsController {
|
||||
await this.examsService.deleteExam(id);
|
||||
return { success: true, data: { success: true } };
|
||||
}
|
||||
|
||||
@Post(":id/publish")
|
||||
@RequirePermission(Permissions.EXAM_PUBLISH)
|
||||
async publish(
|
||||
@Param("id") id: string,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<{ success: true; data: { success: true } }> {
|
||||
const userId = req.userId;
|
||||
if (!userId) {
|
||||
throw new UnauthorizedError("Missing x-user-id header");
|
||||
}
|
||||
await this.examsService.publishExam(id, userId);
|
||||
return { success: true, data: { success: true } };
|
||||
}
|
||||
|
||||
@Post(":id/submit")
|
||||
@RequirePermission(Permissions.EXAM_SUBMIT)
|
||||
async submit(
|
||||
@Param("id") id: string,
|
||||
@Body() body: unknown,
|
||||
): Promise<{ success: true; data: { submissionId: string } }> {
|
||||
const parsed = submitExamSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
throw new ValidationError("Invalid submit input", parsed.error.flatten());
|
||||
}
|
||||
const result = await this.examsService.submitExam(
|
||||
id,
|
||||
parsed.data.studentId,
|
||||
parsed.data.answers,
|
||||
);
|
||||
return { success: true, data: result };
|
||||
}
|
||||
|
||||
@Post(":id/grade")
|
||||
@RequirePermission(Permissions.EXAM_GRADE)
|
||||
async grade(
|
||||
@Param("id") id: string,
|
||||
@Body() body: unknown,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<{ success: true; data: { totalScore: string } }> {
|
||||
const userId = req.userId;
|
||||
if (!userId) {
|
||||
throw new UnauthorizedError("Missing x-user-id header");
|
||||
}
|
||||
const parsed = gradeExamSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
throw new ValidationError("Invalid grade input", parsed.error.flatten());
|
||||
}
|
||||
const result = await this.examsService.gradeExam(
|
||||
id,
|
||||
parsed.data.submissionId,
|
||||
parsed.data.scores,
|
||||
userId,
|
||||
);
|
||||
return { success: true, data: result };
|
||||
}
|
||||
|
||||
@Post(":id/archive")
|
||||
@RequirePermission(Permissions.EXAM_UPDATE)
|
||||
async archive(
|
||||
@Param("id") id: string,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<{ success: true; data: { success: true } }> {
|
||||
const userId = req.userId;
|
||||
if (!userId) {
|
||||
throw new UnauthorizedError("Missing x-user-id header");
|
||||
}
|
||||
await this.examsService.archiveExam(id, userId);
|
||||
return { success: true, data: { success: true } };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { db } from "../config/database.js";
|
||||
import { exams, type Exam, type NewExam } from "./exams.schema.js";
|
||||
import {
|
||||
exams,
|
||||
examQuestions,
|
||||
examSubmissions,
|
||||
type Exam,
|
||||
type NewExam,
|
||||
type ExamQuestion,
|
||||
type NewExamQuestion,
|
||||
type ExamSubmission,
|
||||
type NewExamSubmission,
|
||||
} from "./exams.schema.js";
|
||||
|
||||
export class ExamsRepository {
|
||||
async findById(id: string): Promise<Exam | undefined> {
|
||||
@@ -23,6 +33,63 @@ export class ExamsRepository {
|
||||
async delete(id: string): Promise<void> {
|
||||
await db.delete(exams).where(eq(exams.id, id));
|
||||
}
|
||||
|
||||
// Exam questions
|
||||
async addQuestions(questions: NewExamQuestion[]): Promise<void> {
|
||||
if (questions.length === 0) return;
|
||||
await db.insert(examQuestions).values(questions);
|
||||
}
|
||||
|
||||
async listQuestions(examId: string): Promise<ExamQuestion[]> {
|
||||
return db
|
||||
.select()
|
||||
.from(examQuestions)
|
||||
.where(eq(examQuestions.examId, examId));
|
||||
}
|
||||
|
||||
// Exam submissions
|
||||
async findSubmission(
|
||||
examId: string,
|
||||
studentId: string,
|
||||
): Promise<ExamSubmission | undefined> {
|
||||
const [result] = await db
|
||||
.select()
|
||||
.from(examSubmissions)
|
||||
.where(
|
||||
and(
|
||||
eq(examSubmissions.examId, examId),
|
||||
eq(examSubmissions.studentId, studentId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return result;
|
||||
}
|
||||
|
||||
async findSubmissionById(id: string): Promise<ExamSubmission | undefined> {
|
||||
const [result] = await db
|
||||
.select()
|
||||
.from(examSubmissions)
|
||||
.where(eq(examSubmissions.id, id))
|
||||
.limit(1);
|
||||
return result;
|
||||
}
|
||||
|
||||
async createSubmission(
|
||||
submission: NewExamSubmission,
|
||||
tx: typeof db = db,
|
||||
): Promise<void> {
|
||||
await tx.insert(examSubmissions).values(submission);
|
||||
}
|
||||
|
||||
async updateSubmission(
|
||||
id: string,
|
||||
data: Partial<NewExamSubmission>,
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(examSubmissions)
|
||||
.set(data)
|
||||
.where(eq(examSubmissions.id, id));
|
||||
}
|
||||
}
|
||||
|
||||
export const examsRepository = new ExamsRepository();
|
||||
|
||||
@@ -5,21 +5,102 @@ import {
|
||||
timestamp,
|
||||
char,
|
||||
datetime,
|
||||
} from 'drizzle-orm/mysql-core';
|
||||
int,
|
||||
decimal,
|
||||
index,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/mysql-core";
|
||||
|
||||
export const exams = mysqlTable('core_edu_exams', {
|
||||
id: char('id', { length: 36 }).notNull().primaryKey(),
|
||||
classId: char('class_id', { length: 36 }).notNull(),
|
||||
title: varchar('title', { length: 200 }).notNull(),
|
||||
description: text('description'),
|
||||
examDate: datetime('exam_date').notNull(),
|
||||
duration: varchar('duration', { length: 50 }).notNull(),
|
||||
totalScore: varchar('total_score', { length: 10 }).notNull(),
|
||||
status: varchar('status', { length: 20 }).notNull().default('draft'),
|
||||
createdBy: char('created_by', { length: 36 }).notNull(),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow(),
|
||||
});
|
||||
// 考试主表
|
||||
export const exams = mysqlTable(
|
||||
"core_edu_exams",
|
||||
{
|
||||
id: char("id", { length: 36 }).notNull().primaryKey(),
|
||||
classId: char("class_id", { length: 36 }).notNull(),
|
||||
subjectId: char("subject_id", { length: 36 }).notNull(),
|
||||
title: varchar("title", { length: 200 }).notNull(),
|
||||
description: text("description"),
|
||||
examDate: datetime("exam_date").notNull(),
|
||||
duration: int("duration").notNull(), // 秒
|
||||
totalScore: decimal("total_score", { precision: 6, scale: 2 }).notNull(),
|
||||
status: varchar("status", { length: 20 }).notNull().default("draft"),
|
||||
statusChangedAt: timestamp("status_changed_at").notNull().defaultNow(),
|
||||
statusChangedBy: char("status_changed_by", { length: 36 }),
|
||||
schoolId: char("school_id", { length: 36 }).notNull(),
|
||||
createdBy: char("created_by", { length: 36 }).notNull(),
|
||||
archivedAt: datetime("archived_at"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
|
||||
},
|
||||
(table) => ({
|
||||
idxExamsClassStatus: index("idx_exams_class_status").on(
|
||||
table.classId,
|
||||
table.status,
|
||||
),
|
||||
idxExamsSchoolDate: index("idx_exams_school_date").on(
|
||||
table.schoolId,
|
||||
table.examDate,
|
||||
),
|
||||
idxExamsCreatedBy: index("idx_exams_created_by").on(table.createdBy),
|
||||
}),
|
||||
);
|
||||
|
||||
// 考试题目关联表
|
||||
export const examQuestions = mysqlTable(
|
||||
"core_edu_exam_questions",
|
||||
{
|
||||
id: char("id", { length: 36 }).notNull().primaryKey(),
|
||||
examId: char("exam_id", { length: 36 }).notNull(),
|
||||
questionId: char("question_id", { length: 36 }).notNull(),
|
||||
order: int("order").notNull(),
|
||||
score: decimal("score", { precision: 6, scale: 2 }).notNull(),
|
||||
questionType: varchar("question_type", { length: 30 }).notNull(),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
idxExamQuestionsExamId: index("idx_exam_questions_exam_id").on(
|
||||
table.examId,
|
||||
),
|
||||
idxExamQuestionsQuestionId: index("idx_exam_questions_question_id").on(
|
||||
table.questionId,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
// 考试提交表
|
||||
export const examSubmissions = mysqlTable(
|
||||
"core_edu_exam_submissions",
|
||||
{
|
||||
id: char("id", { length: 36 }).notNull().primaryKey(),
|
||||
examId: char("exam_id", { length: 36 }).notNull(),
|
||||
studentId: char("student_id", { length: 36 }).notNull(),
|
||||
status: varchar("status", { length: 20 })
|
||||
.notNull()
|
||||
.default("not_submitted"),
|
||||
submittedAt: datetime("submitted_at"),
|
||||
gradedAt: datetime("graded_at"),
|
||||
gradedBy: char("graded_by", { length: 36 }),
|
||||
totalScore: decimal("total_score", { precision: 6, scale: 2 }),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
|
||||
},
|
||||
(table) => ({
|
||||
uniqExamStudent: uniqueIndex("uniq_exam_student").on(
|
||||
table.examId,
|
||||
table.studentId,
|
||||
),
|
||||
idxExamSubmissionsExamId: index("idx_exam_submissions_exam_id").on(
|
||||
table.examId,
|
||||
),
|
||||
idxExamSubmissionsStudentId: index("idx_exam_submissions_student_id").on(
|
||||
table.studentId,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
export type Exam = typeof exams.$inferSelect;
|
||||
export type NewExam = typeof exams.$inferInsert;
|
||||
export type ExamQuestion = typeof examQuestions.$inferSelect;
|
||||
export type NewExamQuestion = typeof examQuestions.$inferInsert;
|
||||
export type ExamSubmission = typeof examSubmissions.$inferSelect;
|
||||
export type NewExamSubmission = typeof examSubmissions.$inferInsert;
|
||||
|
||||
@@ -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