Files
competition-website/app/model/bigQuestion.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

100 lines
2.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';
/**
* 综合大题模型 - BigQuestion
*
* 对应 big_questions 表(2026-09-21 新增)。
* 中职组(zhongzhi) / 高职组(gaozhi) 取消 Word 文件上传题后,改为从本表随机抽取一道大题:
* 题干 = 文字描述 + 富文本表格(HTML),下设三小问,每小问 score_per_sub 分(默认 10),
* 大题总分 total_score(默认 30)。
*
* 说明:本模型为**新增隔离模型**,与既有 word_questions(已弃用)互不影响,
* 也不参与 choice_questions / judge_questions 客观题链路。
*/
module.exports = app => {
const { STRING, INTEGER, DATE, TEXT } = app.Sequelize;
return app.model.define('BigQuestion', {
id: {
type: INTEGER,
primaryKey: true,
autoIncrement: true,
comment: '大题ID',
},
question_uid: {
type: STRING(64),
allowNull: false,
comment: '大题稳定标识(全局唯一,形如 big_12)',
},
group: {
type: STRING(20),
allowNull: false,
comment: '组别:zhongzhi/gaozhi',
},
title: {
type: STRING(200),
allowNull: false,
comment: '大题标题',
},
description: {
type: TEXT('medium'),
allowNull: true,
comment: '题干富文本 HTML(含表格)',
},
sub_prompt_1: {
type: TEXT,
allowNull: true,
comment: '第1小问题干',
},
sub_prompt_2: {
type: TEXT,
allowNull: true,
comment: '第2小问题干',
},
sub_prompt_3: {
type: TEXT,
allowNull: true,
comment: '第3小问题干',
},
score_per_sub: {
type: INTEGER,
allowNull: false,
defaultValue: 10,
comment: '每小题分值',
},
total_score: {
type: INTEGER,
allowNull: false,
defaultValue: 30,
comment: '大题总分',
},
sort_order: {
type: INTEGER,
allowNull: false,
defaultValue: 0,
comment: '排序',
},
status: {
type: INTEGER,
allowNull: false,
defaultValue: 1,
comment: '状态:1=启用(参与抽题),0=停用',
},
created_at: {
type: DATE,
defaultValue: app.Sequelize.NOW,
comment: '创建时间',
},
updated_at: {
type: DATE,
defaultValue: app.Sequelize.NOW,
comment: '更新时间',
},
}, {
tableName: 'big_questions',
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at',
});
};