题目校验抽取为可单测 lib;扩展 solve judgeMode(answer/rubric)分支校验

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
jacobxu666
2026-08-20 13:59:51 +08:00
co-authored by Claude Haiku 4.5
parent bb8bb25096
commit e896b2be51
3 changed files with 129 additions and 35 deletions
+2 -35
View File
@@ -11,6 +11,7 @@ import { todayStr } from "@/lib/dates";
import { ensureDay } from "@/lib/tasks";
import { getSessionUser } from "@/lib/auth";
import { DEFAULT_RULE_POINTS, upsertPointRule } from "@/lib/points";
import { validateQuestions } from "@/lib/question-validation";
function hashCode(code: string) {
return crypto.createHash("sha256").update(code).digest("hex");
@@ -325,40 +326,6 @@ export async function deleteExamRecord(id: number): Promise<void> {
revalidatePath("/parent");
}
// xuchao: 试卷题目 JSON 形状校验:数组 + 每题必需字段 + 题型条件字段
const EXAM_TYPES = ["choice", "fill", "judge", "order", "match", "solve"];
function validateQuestionsJson(raw: string): { error?: string; count?: number } {
let arr: unknown;
try {
arr = JSON.parse(raw);
} catch {
return { error: "题目 JSON 解析失败,请检查语法" };
}
if (!Array.isArray(arr)) return { error: "题目必须是 JSON 数组" };
if (arr.length === 0) return { error: "试卷至少需要 1 道题" };
for (let i = 0; i < arr.length; i++) {
const q = arr[i] as Record<string, unknown>;
const no = `第 ${i + 1} 题`;
if (!q || typeof q !== "object") return { error: `${no}不是对象` };
if (typeof q.stem !== "string" || !q.stem.trim()) return { error: `${no}缺少题干(stem)` };
if (typeof q.explain !== "string" || !q.explain.trim()) return { error: `${no}缺少解析(explain)` };
const t = q.type;
if (!EXAM_TYPES.includes(t as string)) return { error: `${no}的 type 非法:${String(t)}` };
if ((t === "choice" || t === "judge") && typeof q.answer !== "number") return { error: `${no}(${t})缺少答案(answer 数字)` };
if (t === "choice" && (!Array.isArray(q.options) || q.options.length === 0)) return { error: `${no}(choice)缺少选项(options)` };
if (t === "order" && (!Array.isArray(q.words) || q.words.length === 0)) return { error: `${no}(order)缺少词块(words)` };
if (t === "fill" && !Array.isArray(q.answers)) return { error: `${no}(fill)缺少参考答案(answers 数组)` };
if (t === "order" && !Array.isArray(q.answer)) return { error: `${no}(order)缺少答案(answer 数组)` };
if (t === "match" && !Array.isArray(q.pairs)) return { error: `${no}(match)缺少配对(pairs 数组)` };
if (t === "solve") {
if (typeof q.reference !== "string" || !q.reference.trim()) return { error: `${no}(solve)缺少参考解答(reference)` };
if (typeof q.points !== "number" || q.points <= 0) return { error: `${no}(solve)缺少满分(points>0)` };
}
}
return { count: arr.length };
}
// xuchao: 保存试卷(paperId 重复则全量更新,孩子端 /exam 立即生效)
export async function saveExamPaper(formData: FormData): Promise<{ ok?: boolean; error?: string }> {
const parent = await getSessionUser();
@@ -373,7 +340,7 @@ export async function saveExamPaper(formData: FormData): Promise<{ ok?: boolean;
if (!Number.isInteger(month) || month < 0 || month > 12) return { error: "月份需为 0-12 的整数" };
if (!Number.isInteger(seq) || seq < 0) return { error: "展示序号需为不小于 0 的整数" };
const questions = f("questions");
const chk = validateQuestionsJson(questions);
const chk = validateQuestions(questions);
if (chk.error) return { error: chk.error };
await db