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

@@ -0,0 +1,60 @@
/**
* 考试状态机(纯函数)
*
* 状态流转ISSUE-003 仲裁 scheme A
* draft → published → in_progress → grading → graded → archived
* └→ cancelled终态
* cancelled 可从 published/in_progress/grading 流入(异常终止)
*
* archived 为软删除终态ISSUE-006 决策 #7archived 仅做软删除,不物理删除)
*/
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;

View File

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

View File

@@ -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();

View File

@@ -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;

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