feat(core-edu): admin/dashboard/leave-requests 模块 + gRPC + 状态机测试 + nextstep 文档

This commit is contained in:
SpecialX
2026-07-14 15:56:10 +08:00
parent d260df864c
commit 7dd5c44406
23 changed files with 3344 additions and 2 deletions

View File

@@ -0,0 +1,59 @@
import {
mysqlTable,
varchar,
text,
timestamp,
char,
int,
json,
index,
uniqueIndex,
} from "drizzle-orm/mysql-core";
// 考试草稿表P3.13 新增)
export const examDrafts = mysqlTable(
"core_edu_exam_drafts",
{
id: char("id", { length: 36 }).notNull().primaryKey(),
examId: char("exam_id", { length: 36 }).notNull(),
studentId: char("student_id", { length: 36 }).notNull(),
answers: json("answers"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
},
(table) => ({
uniqExamStudentDraft: uniqueIndex("uniq_exam_student_draft").on(
table.examId,
table.studentId,
),
idxExamDraftsStudent: index("idx_exam_drafts_student").on(table.studentId),
}),
);
// 考试违规表P3.13 新增)
export const examViolations = mysqlTable(
"core_edu_exam_violations",
{
id: char("id", { length: 36 }).notNull().primaryKey(),
examId: char("exam_id", { length: 36 }).notNull(),
studentId: char("student_id", { length: 36 }).notNull(),
violationType: varchar("violation_type", { length: 40 }).notNull(),
detail: text("detail"),
severity: int("severity").notNull().default(1),
createdAt: timestamp("created_at").notNull().defaultNow(),
},
(table) => ({
idxExamViolationsExam: index("idx_exam_violations_exam").on(table.examId),
idxExamViolationsStudent: index("idx_exam_violations_student").on(
table.studentId,
),
idxExamViolationsType: index("idx_exam_violations_type").on(
table.violationType,
),
}),
);
export type ExamDraft = typeof examDrafts.$inferSelect;
export type NewExamDraft = typeof examDrafts.$inferInsert;
export type ExamViolation = typeof examViolations.$inferSelect;
export type NewExamViolation = typeof examViolations.$inferInsert;

View File

@@ -0,0 +1,171 @@
import { describe, it, expect } from "vitest";
import {
canTransition,
transition,
isTerminal,
EXAM_STATUSES,
} from "./exam-state-machine.js";
import type { ExamStatus, ExamAction } from "./exam-state-machine.js";
describe("exam-state-machine", () => {
describe("合法状态转换", () => {
it("draft --publish--> published", () => {
expect(transition("draft", "publish")).toBe("published");
});
it("published --start--> in_progress", () => {
expect(transition("published", "start")).toBe("in_progress");
});
it("in_progress --submit--> grading", () => {
expect(transition("in_progress", "submit")).toBe("grading");
});
it("grading --grade--> graded", () => {
expect(transition("grading", "grade")).toBe("graded");
});
it("graded --archive--> archived", () => {
expect(transition("graded", "archive")).toBe("archived");
});
it("cancelled 可从 draft/published/in_progress/grading 流入cancel", () => {
expect(transition("draft", "cancel")).toBe("cancelled");
expect(transition("published", "cancel")).toBe("cancelled");
expect(transition("in_progress", "cancel")).toBe("cancelled");
expect(transition("grading", "cancel")).toBe("cancelled");
});
});
describe("非法状态转换", () => {
it("draft --grade--> 抛错(不能从 draft 直接 grade", () => {
expect(() => transition("draft", "grade")).toThrow();
});
it("draft --start--> 抛错(必须先 publish", () => {
expect(() => transition("draft", "start")).toThrow();
});
it("published --submit--> 抛错(必须先 start", () => {
expect(() => transition("published", "submit")).toThrow();
});
it("graded --publish--> 抛错(只能 archive", () => {
expect(() => transition("graded", "publish")).toThrow();
});
it("graded --cancel--> 抛错graded 只能 archive", () => {
expect(() => transition("graded", "cancel")).toThrow();
});
it("archived 是终态,任何动作都抛错", () => {
const actions: ExamAction[] = [
"publish",
"start",
"submit",
"grade",
"archive",
"cancel",
];
for (const action of actions) {
expect(() => transition("archived", action)).toThrow();
}
});
it("cancelled 是终态,任何动作都抛错", () => {
const actions: ExamAction[] = [
"publish",
"start",
"submit",
"grade",
"archive",
"cancel",
];
for (const action of actions) {
expect(() => transition("cancelled", action)).toThrow();
}
});
it("抛错信息包含非法转换描述", () => {
expect(() => transition("draft", "grade")).toThrow(
/Invalid exam state transition/,
);
});
});
describe("canTransition", () => {
it("合法转换返回 true", () => {
expect(canTransition("draft", "publish")).toBe(true);
expect(canTransition("published", "start")).toBe(true);
expect(canTransition("in_progress", "submit")).toBe(true);
expect(canTransition("grading", "grade")).toBe(true);
expect(canTransition("graded", "archive")).toBe(true);
});
it("cancel 动作在 draft/published/in_progress/grading 下返回 true", () => {
expect(canTransition("draft", "cancel")).toBe(true);
expect(canTransition("published", "cancel")).toBe(true);
expect(canTransition("in_progress", "cancel")).toBe(true);
expect(canTransition("grading", "cancel")).toBe(true);
});
it("非法转换返回 false", () => {
expect(canTransition("draft", "grade")).toBe(false);
expect(canTransition("draft", "start")).toBe(false);
expect(canTransition("published", "submit")).toBe(false);
expect(canTransition("graded", "publish")).toBe(false);
expect(canTransition("graded", "cancel")).toBe(false);
});
it("终态对所有动作返回 false", () => {
const actions: ExamAction[] = [
"publish",
"start",
"submit",
"grade",
"archive",
"cancel",
];
for (const action of actions) {
expect(canTransition("archived", action)).toBe(false);
expect(canTransition("cancelled", action)).toBe(false);
}
});
});
describe("isTerminal", () => {
it("archived 是终态", () => {
expect(isTerminal("archived")).toBe(true);
});
it("cancelled 是终态", () => {
expect(isTerminal("cancelled")).toBe(true);
});
it("非终态状态返回 false", () => {
const nonTerminal: ExamStatus[] = [
"draft",
"published",
"in_progress",
"grading",
"graded",
];
for (const status of nonTerminal) {
expect(isTerminal(status)).toBe(false);
}
});
});
describe("EXAM_STATUSES 常量", () => {
it("包含全部 7 种状态", () => {
expect(EXAM_STATUSES).toHaveLength(7);
expect([...EXAM_STATUSES]).toContain("draft");
expect([...EXAM_STATUSES]).toContain("published");
expect([...EXAM_STATUSES]).toContain("in_progress");
expect([...EXAM_STATUSES]).toContain("grading");
expect([...EXAM_STATUSES]).toContain("graded");
expect([...EXAM_STATUSES]).toContain("archived");
expect([...EXAM_STATUSES]).toContain("cancelled");
});
});
});

View File

@@ -1,8 +1,9 @@
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { eq, and } from "drizzle-orm";
import { Injectable } from "@nestjs/common";
import { db } from "../config/database.js";
import { exams } from "./exams.schema.js";
import { examDrafts, examViolations } from "./exam-extensions.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";
@@ -391,6 +392,92 @@ export class ExamsService {
});
}
// --------------------------------------------------------------------------
// P3.13 新增考试草稿自动保存upsert by exam_id + student_id
// --------------------------------------------------------------------------
async saveExamDraft(
examId: string,
studentId: string,
answers: AnswerInput[],
): Promise<{ draftId: string }> {
if (!examId || !studentId) {
throw new ValidationError("examId and studentId are required");
}
// 校验考试存在
const exam = await examsRepository.findById(examId);
if (!exam) {
throw new NotFoundError(`Exam ${examId} not found`);
}
const answersPayload = answers.map((a) => ({
questionId: a.questionId,
answer: a.answer,
}));
// 查找已有草稿unique key: exam_id + student_id
const existing = await db
.select()
.from(examDrafts)
.where(
and(eq(examDrafts.examId, examId), eq(examDrafts.studentId, studentId)),
)
.limit(1);
const draftRow = existing[0];
if (draftRow) {
const draftId = draftRow.id;
await db
.update(examDrafts)
.set({ answers: answersPayload })
.where(eq(examDrafts.id, draftId));
return { draftId };
}
const draftId = randomUUID();
await db.insert(examDrafts).values({
id: draftId,
examId,
studentId,
answers: answersPayload,
});
return { draftId };
}
// --------------------------------------------------------------------------
// P3.13 新增:考试违规事件记录(防作弊)
// --------------------------------------------------------------------------
async recordExamViolation(
examId: string,
studentId: string,
violationType: string,
detail: string,
severity: number,
): Promise<{ violationId: string }> {
if (!examId || !studentId || !violationType) {
throw new ValidationError(
"examId, studentId, violationType are required",
);
}
// 校验考试存在
const exam = await examsRepository.findById(examId);
if (!exam) {
throw new NotFoundError(`Exam ${examId} not found`);
}
const violationId = randomUUID();
await db.insert(examViolations).values({
id: violationId,
examId,
studentId,
violationType,
detail: detail || null,
severity: severity || 1,
});
return { violationId };
}
private assertTransition(from: ExamStatus, action: ExamAction): void {
if (!canTransition(from, action)) {
throw new ApplicationError(