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

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