feat(core-edu): admin/dashboard/leave-requests 模块 + gRPC + 状态机测试 + nextstep 文档
This commit is contained in:
59
services/core-edu/src/exams/exam-extensions.schema.ts
Normal file
59
services/core-edu/src/exams/exam-extensions.schema.ts
Normal 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;
|
||||
171
services/core-edu/src/exams/exam-state-machine.test.ts
Normal file
171
services/core-edu/src/exams/exam-state-machine.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user