feat(big-question): 录入侧防呆——题干不得含「可直抄的答案」(R27)

背景(2026-09-28 测试人员实测反馈):
  「答题时大题部分有参考答案。」初判怀疑前端未区分 / 评分系统未隔离,
  实测两条均被证伪:
    · 前端 Competition.vue 仅渲染 description / sub_prompts,无答案区块;
    · 后端 buildSnapshot() / getForCandidate() 返回字段无 answer;
    · 建表 DDL 023-024 的 big_questions 无答案列;
    · 真实联调 question 字段 = [big_question_uid, description, score_per_sub,
      snapshot_at, sub_prompts, title, total_score]。
  ⇒ 泄露 100% 来自**题库录入内容**:中职组启用题 id=38 的题干内嵌
    「指令输入示例」(>OFFMSG,COM1*4A 等三条),而第 3 小问正是问这些指令。

为什么守点是录入入口:
  抽题快照 question_snapshot **抽题即固化**(防纠纷设计)⇒ 事后改题库
  **撤不回**已抽考生看到的版本;下发链路本身无泄漏点 ⇒ 只能在录入时拦。

改动:
  · 新增 detectAnswerLeak():扫 title / description / sub_prompt_1..3
      ① 「参考答案 / 标准答案 / 评分标准 / 评分细则 / 答案要点」字样;
      ② 带**字面参数**的指令示例(>CMD,值*两位十六进制校验码)。
  · validateQuestionPayload() 内调用 ⇒ create 与 update **同源**拦截;
    命中返回 code=SUSPECT_ANSWER_LEAK,message 直接教会管理员怎么改。
  · 兼容 > 与裸 > 两种存储形态。

边界(刻意为之,勿收紧):
  格式定义(参数用方括号占位,如 >OFFMSG[,Port[,Message]]*CS)**不拦** ——
  校验码限定「两位十六进制」天然排除占位符 *CS(S 非 hex)。
  向考生提供指令**手册**是允许的;等同于答案的**成品**才不允许。
  —— 与同日数据修复口径一致(只删示例、保留格式定义)。

实测(后端已重启加载新码,5/5 PASS):
  ① 合法格式定义题 → 放行(code=0,新增 1 行)
  ② 含指令示例 → 拒绝 HTTP200 code=400,**未落库**
  ③ 含「参考答案」字样 → 拒绝,未落库
  ④ PUT 更新路径 → 同样拒绝,且原内容未被改写
  ⑤ 清理复原:行数与 MAX(id) 回到基线
This commit is contained in:
2026-09-28 11:58:14 +08:00
parent cbc05ed4e7
commit 37d07907a1

View File

@@ -641,6 +641,13 @@ class BigQuestionService extends Service {
subPrompts.push(String(v == null ? '' : v));
}
// ── 防呆:题干不得混入「可被直接抄袭的答案性内容」(2026-09-28 R27 缺陷加固)──
// 背景:中职组某启用题的题干里给了三条指令**输入示例**(>OFFMSG,COM1*4A 等),
// 而该题第 3 小问恰是「列出配置指令」⇒ 答案被题干直接给出;又因抽题快照固化
// (question_snapshot 抽题即冻结)无法事后撤回,只能从**录入入口**前置拦截。
const leak = this.detectAnswerLeak({ title, description, subPrompts });
if (leak) return { ok: false, code: 'SUSPECT_ANSWER_LEAK', message: leak };
const perRaw = src.score_per_sub !== undefined ? src.score_per_sub
: (src.scorePerSub !== undefined ? src.scorePerSub
: (existing ? existing.score_per_sub : 10));
@@ -672,6 +679,62 @@ class BigQuestionService extends Service {
};
}
/**
* 防呆扫描:题干 / 小问里是否混入了「考生可直接抄袭的答案性内容」。
*
* 背景(2026-09-28 实测缺陷):中职组某启用题的 `description` 内嵌
* 「答题可能用到的指令集」,除格式定义外还给了**可直接使用的输入示例**:
* >OFFMSG,COM1*4A / >OUTMSG,COM1,GGA,1*3B / >SAVECONFIG*0B
* 而该题第 3 小问正是「请列出对该接收机做串口输出配置需下发的指令」⇒ 答案白送。
* 前端只渲染 description、后端按快照原样下发 ⇒ 唯一守点是**录入入口**。
*
* 判定规则(命中即拒绝保存):
* 1) 出现「参考答案 / 标准答案 / 评分标准 / 评分细则 / 答案要点」等字样;
* 2) 出现**带具体字面参数**的指令示例(形如 >CMD,字面值*两位十六进制校验码)。
*
* ⚠️ 刻意**不拦**「指令格式定义」(参数用方括号占位,如 >OFFMSG[,Port[,Message]]*CS):
* 向考生提供指令**手册**是允许的,构成泄露的是**能照抄的成品**。
* —— 本规则与 2026-09-28 那次修复(只删示例、保留格式定义)同口径。
*
* @param {Object} parts - { title, description, subPrompts }
* @returns {string|null} 命中说明(直接展示给管理员);未命中返回 null
*/
detectAnswerLeak(parts) {
const targets = [
['大题标题', parts.title],
['题干', parts.description],
...((parts.subPrompts) || []).map((p, i) => [`第 ${i + 1} 小问`, p]),
];
const KEYWORDS = ['参考答案', '标准答案', '评分标准', '评分细则', '答案要点'];
// 带字面参数的指令示例:>OFFMSG,COM1*4A
// 校验码限定「两位十六进制」⇒ 天然排除占位符 *CS(S 非十六进制字符)
// 兼容两种存储形态:富文本存 >(实测库中形态)或裸 >
const CMD_SAMPLE = /(?:&gt;|>)\s*[A-Z][A-Z0-9]*(?:,[^<&\n]*)?\*[0-9A-Fa-f]{2}(?![0-9A-Fa-f])/g;
for (const [where, raw] of targets) {
const text = String(raw == null ? '' : raw);
if (!text) continue;
for (const kw of KEYWORDS) {
if (text.includes(kw)) {
return `${where}中出现「${kw}」字样,疑似把参考答案写进了题目,已拒绝保存。`;
}
}
CMD_SAMPLE.lastIndex = 0;
let m;
while ((m = CMD_SAMPLE.exec(text)) !== null) {
const hit = m[0];
// 方括号占位(如 [,Port])属「格式定义」,不算示例
if (hit.includes('[')) continue;
return `${where}中出现可直接抄袭的指令示例「${hit}」,已拒绝保存。`
+ '如确需向考生提供指令手册,请只写**格式定义**(参数用方括号占位,例如:'
+ '&gt;OUTMSG[,Port],Message,Period*CS);带具体参数值的示例等同于给出答案。';
}
}
return null;
}
// ==================== 内部工具 ====================
/** 构造题干快照(抽题时刻固化,题目后续被改不影响存证) */