Files
competition-website/database/migrations/024-create-big-question-answers.js
qubiaobiao 8ef18da395 feat(big-question): 中职/高职组「综合大题」在线作答 + 封堵 auth 成绩泄露三通道
## 一、需求变更:中职/高职组 Word 文件上传题 → 在线「综合大题」

按 2026-09-21 需求变更,中职组 / 高职组取消 Word 文件上传题,改为在线作答
综合大题(1 道大题 × 3 个小问 × 每小问 10 分 = 大题 30 分)。

- 新增表(迁移 023/024)
  · `big_questions`        —— 题库(题干富文本含表格 + 三小问提示语 + 评分口径)
  · `big_question_answers` —— 作答(抽题快照 + 三小问答案 + 按小问得分)
  两份迁移同时提供 `.sql`(生产直跑)与 `.js`(本地/CI 幂等执行)。
- 新增 model:`app/model/bigQuestion.js`、`app/model/bigQuestionAnswer.js`
- 新增 service:`app/service/bigQuestion.js`(抽题 / 保存答案 / 评分核心逻辑)
- 考生端接口(`app/controller/exam.js`)
  · `GET  /api/exam/:group/big-question` —— 首次抽题并落库题干快照,
    后续一律返回同一道题(**幂等**,防止同一考生拿到不同题干引发纠纷)
  · `POST /api/exam/:group/big-answer`   —— 保存三小问答案,带考试窗口门控
- 管理端接口(`app/controller/admin.js`)
  · 题库 CRUD:`/api/admin/big-questions/:group[/:id]`
  · 作答查证:`GET /api/admin/big-answers/:group`(考生 → 抽到的题 → 三小问 → 提交时间)
  · 按小问评分:`PUT /api/admin/big-answers/:group/:uid/scores`
- 服务端封堵越界数据:`app/controller/exam.js` 对中职/高职组的 Word 上传接口
  一并拒绝(前端入口已移除,但直调 API 仍可绕过)。

**所有新增读写仅落在新增的两张表,不触碰任何既有表结构。**

## 二、安全修复(铁律 15 成绩泄露 —— auth 通道缺口)

原有成绩发布门控只覆盖 4 条通道,遗漏了 auth 的**三条同源通道**:三处都直接
回传 `toJSON()`,在 `score_published_<group> = false`(未发布)时照样下发成绩,
而同期 `GET /api/score` 正确返回 `null`。

- `POST /api/auth/login`         —— 实测未发布态回传 `score`
- `GET  /api/auth/current-user`  —— 全字段 `toJSON`,连带下发分项
- `POST /api/auth/refresh`       —— 全字段 `toJSON`,且前端 `doSilentRefresh()`
  会把响应 `user` 写入 `localStorage` 与 Pinia,等效于登录通道;
  实测泄漏 `score` + `exam_score` + `word_score` + `ppt_score` 共 4 个字段

修复方式:新增 `AuthService.toClientUser()` 作为**唯一出口**,三处全部收敛,
禁止各自直接 `toJSON`。口径:
- 该组已发布 → 保留 `score`;未发布 / 查询异常 → 剔除 `score`(异常按未发布,安全默认)
- `exam_score` / `word_score` / `ppt_score` **任何情况下都不下发**
  (前端不使用;保留会形成"总分虽隐藏、分项可加总"的旁路)

门禁 `scripts/verify_score_leak_all_channels.py` 相应从 14 项扩到 20 项,
新增通道⑤/⑥/⑦断言(含"发布后 score 必须恢复"的**正向对照**,防止
"永不回传"式过度封堵造成假绿),并经变异注入自检证明断言可证伪。

## 三、移除已弃用的 Word 模板

中职 / 高职各 3 个历史 Word 模板(`app/public/templates/{zhongzhi,gaozhi}/*.docx`)
随需求一并移除;目录保留 `.gitkeep` 以维持路径存在。

---
验证:G10 铁律15 门禁 20/20 PASS(含变异自检,EXIT=0)、G9 铁律14 门禁 22/22 PASS、
窗口哨兵残留自检 6/6 PASS。前端配套改动见前端仓库提交 1bfe007。
2026-09-21 11:57:58 +08:00

128 lines
4.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/**
* 024 - 综合大题作答表 big_question_answers
*
* 背景(2026-09-21 需求变更)
* 中职组 / 高职组取消 Word 上传题,改为随机抽取一道大题、在页面内直接作答三小问。
*
* 设计要点
* 1. **一考生一行**:UNIQUE(aggregation_uid) —— 满足「一个考生一行数据保存」的目标,
* 且与题量配置解耦(客观题题量由 competition_times.choice_count/judge_count 配置,
* 属逐题表 zhongzhi/gaozhi_submissions,与本表互不干扰)。
* 2. **题干快照 question_snapshot**:抽题那一刻把题干(标题/描述/三小问/分值)序列化落库,
* 题目后续被管理员修改也不影响存证 → 防考生纠纷。
* 3. **不存储每题一列**:客观题 50+20=70 题且题量可配,逐题建列会导致配置每变更一次
* 就要 ALTER TABLE(反扩展性);大题固定 3 小问,故 answer_1/2/3 列式存储是安全的。
*
* 隔离原则
* 新增隔离表,zhongzhi_submissions / gaozhi_submissions 零改动、零迁移。
*
* 环境说明
* MariaDB 5.5.68:无 JSON 列类型(快照用 MEDIUMTEXT 存 JSON 文本);
* DATETIME 不支持 DEFAULT CURRENT_TIMESTAMP,时间戳交给应用层写入。
*/
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('big_question_answers', {
id: {
type: Sequelize.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
comment: '主键ID',
},
aggregation_uid: {
type: Sequelize.INTEGER,
allowNull: false,
comment: '聚合报名ID(中职/高职组 == contestants.id),唯一 → 一考生一行',
},
contestant_id: {
type: Sequelize.INTEGER,
allowNull: true,
comment: '选手ID(冗余,便于管理端查证与排障)',
},
group: {
type: Sequelize.STRING(20),
allowNull: false,
comment: '组别:zhongzhi/gaozhi',
},
big_question_id: {
type: Sequelize.INTEGER,
allowNull: true,
comment: '抽中的大题ID',
},
big_question_uid: {
type: Sequelize.STRING(64),
allowNull: true,
comment: '抽中的大题稳定标识',
},
question_snapshot: {
type: Sequelize.TEXT('medium'),
allowNull: true,
comment: '抽题时刻的题干快照 JSON(防纠纷存证)',
},
answer_1: {
type: Sequelize.TEXT,
allowNull: true,
comment: '第1小问作答(上限 2000 字,后端同步校验)',
},
answer_2: {
type: Sequelize.TEXT,
allowNull: true,
comment: '第2小问作答(上限 2000 字)',
},
answer_3: {
type: Sequelize.TEXT,
allowNull: true,
comment: '第3小问作答(上限 2000 字)',
},
score_1: {
type: Sequelize.INTEGER,
allowNull: true,
comment: '第1小问得分(0-10,人工阅卷)',
},
score_2: {
type: Sequelize.INTEGER,
allowNull: true,
comment: '第2小问得分(0-10,人工阅卷)',
},
score_3: {
type: Sequelize.INTEGER,
allowNull: true,
comment: '第3小问得分(0-10,人工阅卷)',
},
total_score: {
type: Sequelize.INTEGER,
allowNull: true,
comment: '三小问得分合计(阅卷后回写 contestants.word_score 取数依据)',
},
submit_time: {
type: Sequelize.DATE,
allowNull: true,
comment: '随整卷正式提交写入(占位/未提交为 NULL)',
},
created_at: {
type: Sequelize.DATE,
allowNull: true,
comment: '创建时间',
},
updated_at: {
type: Sequelize.DATE,
allowNull: true,
comment: '更新时间',
},
}).then(() => {
return Promise.all([
queryInterface.addIndex('big_question_answers', ['aggregation_uid'], { name: 'uk_big_answer_agg', unique: true }),
queryInterface.addIndex('big_question_answers', ['group'], { name: 'idx_big_answer_group' }),
queryInterface.addIndex('big_question_answers', ['big_question_uid'], { name: 'idx_big_answer_q' }),
]);
});
},
down: async (queryInterface) => {
await queryInterface.dropTable('big_question_answers');
},
};