From 8ef18da39513566aa00d85cea42f9bdd5768c389 Mon Sep 17 00:00:00 2001 From: qubiaobiao <3294694717@qq.com> Date: Mon, 21 Sep 2026 11:57:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(big-question):=20=E4=B8=AD=E8=81=8C/?= =?UTF-8?q?=E9=AB=98=E8=81=8C=E7=BB=84=E3=80=8C=E7=BB=BC=E5=90=88=E5=A4=A7?= =?UTF-8?q?=E9=A2=98=E3=80=8D=E5=9C=A8=E7=BA=BF=E4=BD=9C=E7=AD=94=20+=20?= =?UTF-8?q?=E5=B0=81=E5=A0=B5=20auth=20=E6=88=90=E7=BB=A9=E6=B3=84?= =?UTF-8?q?=E9=9C=B2=E4=B8=89=E9=80=9A=E9=81=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 一、需求变更:中职/高职组 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_ = 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。 --- app/controller/admin.js | 208 +++++++ app/controller/auth.js | 20 +- app/controller/exam.js | 139 +++++ app/model/bigQuestion.js | 99 +++ app/model/bigQuestionAnswer.js | 110 ++++ app/public/templates/gaozhi/.gitkeep | 0 .../gaozhi/word_template_1785117066172.docx | Bin 921 -> 0 bytes .../gaozhi/word_template_1785207535307.docx | Bin 19009 -> 0 bytes .../gaozhi/word_template_1785207663577.docx | Bin 17747 -> 0 bytes .../gaozhi/word_template_1785207668867.docx | Bin 19009 -> 0 bytes app/public/templates/zhongzhi/.gitkeep | 0 .../zhongzhi/word_template_1785117066098.docx | Bin 922 -> 0 bytes .../zhongzhi/word_template_1785139347982.docx | Bin 17656 -> 0 bytes app/router.js | 13 + app/service/auth.js | 52 ++ app/service/bigQuestion.js | 578 ++++++++++++++++++ .../023-024-big-question-tables.sql | 80 +++ .../migrations/023-create-big-questions.js | 110 ++++ .../024-create-big-question-answers.js | 127 ++++ 19 files changed, 1527 insertions(+), 9 deletions(-) create mode 100644 app/model/bigQuestion.js create mode 100644 app/model/bigQuestionAnswer.js create mode 100644 app/public/templates/gaozhi/.gitkeep delete mode 100644 app/public/templates/gaozhi/word_template_1785117066172.docx delete mode 100644 app/public/templates/gaozhi/word_template_1785207535307.docx delete mode 100644 app/public/templates/gaozhi/word_template_1785207663577.docx delete mode 100644 app/public/templates/gaozhi/word_template_1785207668867.docx create mode 100644 app/public/templates/zhongzhi/.gitkeep delete mode 100644 app/public/templates/zhongzhi/word_template_1785117066098.docx delete mode 100644 app/public/templates/zhongzhi/word_template_1785139347982.docx create mode 100644 app/service/bigQuestion.js create mode 100644 database/migrations/023-024-big-question-tables.sql create mode 100644 database/migrations/023-create-big-questions.js create mode 100644 database/migrations/024-create-big-question-answers.js diff --git a/app/controller/admin.js b/app/controller/admin.js index 3792997..327a8c6 100644 --- a/app/controller/admin.js +++ b/app/controller/admin.js @@ -1659,6 +1659,214 @@ class AdminController extends Controller { ctx.body = ctx.helper.error(500, '重置测试数据失败'); } } + + // ==================== 综合大题(中职组 / 高职组) ==================== + // + // 2026-09-21 需求变更配套管理端能力: + // · 题库 CRUD(题干富文本含表格 + 三小问提示语 + 每小问 10 分 / 大题 30 分) + // · 作答查证(考生 → 抽到的题目快照 → 三小问答案 → 提交时间),用于避免考生纠纷 + // · 按小问评分(合计分经既有唯一评分路径写入 contestants.word_score) + // + // 全部读写仅落在新增的 big_questions / big_question_answers,不触碰任何既有表。 + + /** + * 大题题库列表 + * GET /api/admin/big-questions/:group + */ + async listBigQuestions() { + const { ctx, service } = this; + if (!this._ensureAdmin()) return; + + try { + const { group } = ctx.params; + if (!this._validateGroup(group)) return; + + if (!service.bigQuestion.isEnabledGroup(group)) { + ctx.body = ctx.helper.error(400, '该组别未启用综合大题'); + return; + } + + const list = await service.bigQuestion.listQuestions(group); + ctx.body = ctx.helper.success({ + list, + total: list.length, + scorePerSub: 10, + subCount: service.bigQuestion.subQuestionCount(), + maxChars: service.bigQuestion.answerMaxChars(), + }, '获取大题题库成功'); + } catch (err) { + ctx.logger.error('获取大题题库失败:', err); + ctx.body = ctx.helper.error(500, '获取大题题库失败'); + } + } + + /** + * 新增大题 + * POST /api/admin/big-questions/:group + * Body: { title, description, sub_prompt_1..3, score_per_sub, sort_order, status } + */ + async createBigQuestion() { + const { ctx, service } = this; + if (!this._ensureAdmin()) return; + + try { + const { group } = ctx.params; + if (!this._validateGroup(group)) return; + + const result = await service.bigQuestion.createQuestion(group, ctx.request.body || {}); + if (!result.ok) { + ctx.body = ctx.helper.error(400, result.message); + return; + } + + ctx.body = ctx.helper.success({ + id: result.record.id, + questionUid: result.record.question_uid, + }, '大题已新增'); + } catch (err) { + ctx.logger.error('新增大题失败:', err); + ctx.body = ctx.helper.error(500, '新增大题失败'); + } + } + + /** + * 更新大题 + * PUT /api/admin/big-questions/:group/:id + */ + async updateBigQuestion() { + const { ctx, service } = this; + if (!this._ensureAdmin()) return; + + try { + const { group, id } = ctx.params; + if (!this._validateGroup(group)) return; + + const qid = parseInt(id, 10); + if (isNaN(qid)) { + ctx.body = ctx.helper.error(400, '大题 ID 非法'); + return; + } + + const result = await service.bigQuestion.updateQuestion(group, qid, ctx.request.body || {}); + if (!result.ok) { + ctx.body = ctx.helper.error(result.code === 'NOT_FOUND' ? 404 : 400, result.message); + return; + } + + ctx.body = ctx.helper.success({ id: result.record.id }, '大题已更新'); + } catch (err) { + ctx.logger.error('更新大题失败:', err); + ctx.body = ctx.helper.error(500, '更新大题失败'); + } + } + + /** + * 删除大题(已被考生抽取过的大题拒绝删除,引导改为「停用」) + * DELETE /api/admin/big-questions/:group/:id + */ + async deleteBigQuestion() { + const { ctx, service } = this; + if (!this._ensureAdmin()) return; + + try { + const { group, id } = ctx.params; + if (!this._validateGroup(group)) return; + + const qid = parseInt(id, 10); + if (isNaN(qid)) { + ctx.body = ctx.helper.error(400, '大题 ID 非法'); + return; + } + + const result = await service.bigQuestion.deleteQuestion(group, qid); + if (!result.ok) { + ctx.body = ctx.helper.error(result.code === 'NOT_FOUND' ? 404 : 409, result.message); + return; + } + + ctx.body = ctx.helper.success({ deleted: 1 }, '大题已删除'); + } catch (err) { + ctx.logger.error('删除大题失败:', err); + ctx.body = ctx.helper.error(500, '删除大题失败'); + } + } + + /** + * 大题作答查证:按组返回「考生 → 抽到的题目(快照)→ 三小问答案 → 提交时间 → 得分」 + * GET /api/admin/big-answers/:group + * + * 含该组全部考生(未抽题者 drawn=false),便于核对缺考/未作答。 + * 题目取自**题干快照**而非当前题库,确保考生当时所见可举证。 + */ + async getBigAnswers() { + const { ctx, service } = this; + if (!this._ensureAdmin()) return; + + try { + const { group } = ctx.params; + if (!this._validateGroup(group)) return; + + if (!service.bigQuestion.isEnabledGroup(group)) { + ctx.body = ctx.helper.error(400, '该组别未启用综合大题'); + return; + } + + const list = await service.bigQuestion.listAnswersForAdmin(group); + ctx.body = ctx.helper.success({ + list, + total: list.length, + drawnCount: list.filter(x => x.drawn).length, + }, '获取大题作答成功'); + } catch (err) { + ctx.logger.error('获取大题作答失败:', err); + ctx.body = ctx.helper.error(500, '获取大题作答失败'); + } + } + + /** + * 按小问评分(三小问各 0-10,合计 0-30) + * PUT /api/admin/big-answers/:group/:uid/scores + * Body: { score_1, score_2, score_3 } + * + * 合计分经 service.admin.updateScores(既有唯一评分路径)同步 contestants.word_score / + * contestants.score / accounts,保证与「成绩发布门控」及总分公式完全一致, + * 不引入第二条写成绩的代码通路。 + */ + async setBigAnswerScores() { + const { ctx, service } = this; + if (!this._ensureAdmin()) return; + + try { + const { group, uid } = ctx.params; + if (!this._validateGroup(group)) return; + + const aggregationUid = parseInt(uid, 10); + if (isNaN(aggregationUid)) { + ctx.body = ctx.helper.error(400, '考生聚合 ID 非法'); + return; + } + + const body = ctx.request.body || {}; + const result = await service.bigQuestion.setSubScores(group, aggregationUid, { + score_1: body.score_1, + score_2: body.score_2, + score_3: body.score_3, + }); + + if (!result.ok) { + ctx.body = ctx.helper.error(result.code === 'NOT_FOUND' ? 404 : 400, result.message); + return; + } + + ctx.body = ctx.helper.success({ + total: result.total, + perSubScore: result.perSubScore, + }, `大题评分已保存(合计 ${result.total} 分)`); + } catch (err) { + ctx.logger.error('大题评分失败:', err); + ctx.body = ctx.helper.error(500, '大题评分失败'); + } + } } module.exports = AdminController; diff --git a/app/controller/auth.js b/app/controller/auth.js index 3f52c29..15bf8de 100644 --- a/app/controller/auth.js +++ b/app/controller/auth.js @@ -309,9 +309,9 @@ class AuthController extends Controller { role: 'contestant', }, this.app.config.jwt.secret, this.app.config.jwt.expiresIn); - // 返回用户信息(排除密码) - const userData = contestant.toJSON(); - delete userData.password; + // 返回用户信息 —— 统一走 AuthService.toClientUser 出口: + // 剔除密码 + 按「成绩发布门控」决定是否携带 score(铁律15 全通道封堵,2026-09-21) + const userData = await service.auth.toClientUser(contestant); ctx.body = ctx.helper.success({ token, @@ -392,7 +392,7 @@ class AuthController extends Controller { * 获取当前登录用户信息(需认证) */ async currentUser() { - const { ctx } = this; + const { ctx, service } = this; // 从 JWT token 中获取用户信息 const token = ctx.headers.authorization @@ -426,8 +426,9 @@ class AuthController extends Controller { return; } - const userData = contestant.toJSON(); - delete userData.password; + // 与 login 共用同一脱敏出口(同源族:两条 auth 通道口径必须一致, + // 且此处原为全字段 toJSON —— 会连带下发 exam_score/word_score/ppt_score) + const userData = await service.auth.toClientUser(contestant); ctx.body = ctx.helper.success(userData, '获取用户信息成功'); } @@ -544,7 +545,7 @@ class AuthController extends Controller { * - 该接口刻意不挂 auth 中间件(因为携带的 token 可能已过期)。 */ async refresh() { - const { ctx } = this; + const { ctx, service } = this; const authorization = ctx.headers.authorization || ''; const token = authorization.replace(/^Bearer\s+/i, '') || null; @@ -597,8 +598,9 @@ class AuthController extends Controller { : { id: account.id, phone: account.phone, role: 'contestant' }; const newToken = ctx.helper.generateToken(payload, this.app.config.jwt.secret, this.app.config.jwt.expiresIn); - const userData = account.toJSON(); - delete userData.password; + // 返回用户信息 —— 统一走 AuthService.toClientUser 出口 + // (与 login / currentUser 同源:禁止各自直接 toJSON,否则成绩未发布时 score 会从本通道漏出) + const userData = await service.auth.toClientUser(account); ctx.body = ctx.helper.success({ token: newToken, diff --git a/app/controller/exam.js b/app/controller/exam.js index ea677fb..a86be65 100644 --- a/app/controller/exam.js +++ b/app/controller/exam.js @@ -456,6 +456,17 @@ class ExamController extends Controller { const { group } = ctx.params; if (!this._validateGroup(group)) return; + // 🔴 中职组/高职组已取消 Word 文件上传题(改为在线作答「综合大题」)。 + // 前端入口已移除,但直调 API 仍可绕过,故在服务端一并封堵,避免出现 + // 「界面上没这道题、后台却收到 Word 附件」的越界数据(2026-09-21)。 + if (['zhongzhi', 'gaozhi'].includes(group)) { + ctx.body = ctx.helper.error( + 400, + '中职组/高职组已取消 Word 文件上传题,请在「综合大题」中在线作答' + ); + return; + } + // 获取用户聚合信息 const aggregation = await this._getUserAggregation(); if (!aggregation) { @@ -609,6 +620,134 @@ class ExamController extends Controller { } } + /** + * 获取本次抽中的综合大题(仅中职组 / 高职组) + * GET /api/exam/:group/big-question + * + * 2026-09-21 需求变更:中职/高职取消 Word 文件上传题,改为随机抽取一道大题在线作答。 + * 语义:**首次调用抽题并落库题干快照,之后一律返回同一道题**(幂等,防考生纠纷), + * 同时回传已保存的三小问作答,供重新进入考试时恢复。 + * + * ⚠️ 不回传任何分数:成绩仅经 GET /api/score 单通道(发布门控后)下发,铁律15。 + */ + async bigQuestion() { + const { ctx, service } = this; + + try { + const { group } = ctx.params; + if (!this._validateGroup(group)) return; + + if (!service.bigQuestion.isEnabledGroup(group)) { + ctx.body = ctx.helper.error(400, '该组别未启用综合大题'); + return; + } + + const aggregation = await this._getUserAggregation(); + if (!aggregation) { + ctx.body = ctx.helper.error(403, '未找到您的报名信息'); + return; + } + + // 组别归属校验(与 status / questions 同口径,防止跨组越权取题) + if (aggregation.team && aggregation.team.group_name + && aggregation.team.group_name !== group) { + ctx.body = ctx.helper.error(403, '您不属于该组别'); + return; + } + + const result = await service.bigQuestion.getForCandidate( + aggregation.aggregation_uid, + group, + (ctx.state.currentUser && ctx.state.currentUser.id) || aggregation.aggregation_uid + ); + + if (!result.ok) { + // 组别未启用 → 400;无题可抽 → 4004(与「题目未配置」类业务码口径一致) + const code = result.code === 'NO_QUESTION' ? 4004 : 400; + ctx.body = ctx.helper.error(code, result.message); + return; + } + + ctx.body = ctx.helper.success({ + question: result.question, + answers: result.answers, + submitTime: result.submitTime, + maxChars: result.maxChars, + subCount: result.subCount, + reused: result.reused, + }, '获取综合大题成功'); + } catch (err) { + ctx.logger.error('获取综合大题失败:', err); + ctx.body = ctx.helper.error(500, '获取综合大题失败'); + } + } + + /** + * 保存综合大题三小问作答(仅中职组 / 高职组) + * POST /api/exam/:group/big-answer + * Body: { answer_1, answer_2, answer_3, forceSubmit? } + * + * 每小问上限 2000 字,**后端同步校验**(前端 maxlength 只是体验优化,不可作为唯一防线)。 + * forceSubmit 与 submit-all 同语义:仅「倒计时归零的自动提交」传入,允许在考试窗口 + * 临界/已关闭时仍落库考生已写内容,避免整卷提交时丢失大题答案。 + */ + async saveBigAnswer() { + const { ctx, service } = this; + + try { + const { group } = ctx.params; + if (!this._validateGroup(group)) return; + + if (!service.bigQuestion.isEnabledGroup(group)) { + ctx.body = ctx.helper.error(400, '该组别未启用综合大题'); + return; + } + + const body = ctx.request.body || {}; + const forceSubmit = body.forceSubmit === true; + + // 与逐题保存(submitAnswers)同口径的考试窗口门控;forceSubmit 为自动交卷兜底通道 + const examStatus = await service.exam.getExamStatus(group); + if (!examStatus.isActive && !forceSubmit) { + ctx.body = ctx.helper.error(4001, examStatus.message || '考试未在进行中'); + return; + } + + const aggregation = await this._getUserAggregation(); + if (!aggregation) { + ctx.body = ctx.helper.error(403, '未找到您的报名信息'); + return; + } + + const result = await service.bigQuestion.saveAnswers( + aggregation.aggregation_uid, + group, + { + answer_1: body.answer_1, + answer_2: body.answer_2, + answer_3: body.answer_3, + } + ); + + if (!result.ok) { + // 超长 → 400(参数非法);未抽题 → 4004(业务前置条件未满足) + const code = result.code === 'NOT_DRAWN' ? 4004 : 400; + ctx.body = ctx.helper.error(code, result.message); + return; + } + + ctx.body = ctx.helper.success({ + filled: result.filled, + subCount: service.bigQuestion.subQuestionCount(), + maxChars: service.bigQuestion.answerMaxChars(), + savedAt: new Date().toISOString(), + }, '综合大题答案已保存'); + } catch (err) { + ctx.logger.error('保存综合大题答案失败:', err); + ctx.body = ctx.helper.error(500, '保存综合大题答案失败'); + } + } + /** * 一体提交:答案 + Word 文件 * POST /api/exam/:group/submit-all diff --git a/app/model/bigQuestion.js b/app/model/bigQuestion.js new file mode 100644 index 0000000..6f29cc6 --- /dev/null +++ b/app/model/bigQuestion.js @@ -0,0 +1,99 @@ +'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', + }); +}; diff --git a/app/model/bigQuestionAnswer.js b/app/model/bigQuestionAnswer.js new file mode 100644 index 0000000..bb60284 --- /dev/null +++ b/app/model/bigQuestionAnswer.js @@ -0,0 +1,110 @@ +'use strict'; + +/** + * 综合大题作答模型 - BigQuestionAnswer + * + * 对应 big_question_answers 表(2026-09-21 新增)。 + * + * 一对一行:UNIQUE(aggregation_uid) —— 一个考生一行数据(用户明确要求的存储形态), + * 同时与「逐题一行」的客观题表 zhongzhi_submissions / gaozhi_submissions 完全隔离。 + * + * question_snapshot:抽题那一刻的题干快照(JSON 文本)。因 MariaDB 5.5 无 JSON 列类型, + * 以 MEDIUMTEXT 存储,由应用层 JSON.stringify / JSON.parse 处理。 + */ +module.exports = app => { + const { STRING, INTEGER, DATE, TEXT } = app.Sequelize; + + return app.model.define('BigQuestionAnswer', { + id: { + type: INTEGER, + primaryKey: true, + autoIncrement: true, + comment: '主键ID', + }, + aggregation_uid: { + type: INTEGER, + allowNull: false, + comment: '聚合报名ID(中职/高职组 == contestants.id),唯一 → 一考生一行', + }, + contestant_id: { + type: INTEGER, + allowNull: true, + comment: '选手ID(冗余,便于管理端查证与排障)', + }, + group: { + type: STRING(20), + allowNull: false, + comment: '组别:zhongzhi/gaozhi', + }, + big_question_id: { + type: INTEGER, + allowNull: true, + comment: '抽中的大题ID', + }, + big_question_uid: { + type: STRING(64), + allowNull: true, + comment: '抽中的大题稳定标识', + }, + question_snapshot: { + type: TEXT('medium'), + allowNull: true, + comment: '抽题时刻的题干快照 JSON(防纠纷存证)', + }, + answer_1: { + type: TEXT, + allowNull: true, + comment: '第1小问作答(上限 2000 字)', + }, + answer_2: { + type: TEXT, + allowNull: true, + comment: '第2小问作答(上限 2000 字)', + }, + answer_3: { + type: TEXT, + allowNull: true, + comment: '第3小问作答(上限 2000 字)', + }, + score_1: { + type: INTEGER, + allowNull: true, + comment: '第1小问得分(0-10)', + }, + score_2: { + type: INTEGER, + allowNull: true, + comment: '第2小问得分(0-10)', + }, + score_3: { + type: INTEGER, + allowNull: true, + comment: '第3小问得分(0-10)', + }, + total_score: { + type: INTEGER, + allowNull: true, + comment: '三小问得分合计', + }, + submit_time: { + type: DATE, + allowNull: true, + comment: '随整卷正式提交写入(未提交为 NULL)', + }, + created_at: { + type: DATE, + defaultValue: app.Sequelize.NOW, + comment: '创建时间', + }, + updated_at: { + type: DATE, + defaultValue: app.Sequelize.NOW, + comment: '更新时间', + }, + }, { + tableName: 'big_question_answers', + timestamps: true, + createdAt: 'created_at', + updatedAt: 'updated_at', + }); +}; diff --git a/app/public/templates/gaozhi/.gitkeep b/app/public/templates/gaozhi/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app/public/templates/gaozhi/word_template_1785117066172.docx b/app/public/templates/gaozhi/word_template_1785117066172.docx deleted file mode 100644 index 2604a97bad6d361bcf9437ee629b3328e40ad2fe..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 921 zcmWIWW@Zs#U|`^2NcQ_3Q<-Oc{RNP>mXU!$7)VDu=jWBA=9R>UR2HNb$Ldw&=F|q9 z^gC=I@K-c@okrn<27#{2%%=q{rtnMLoP1=JdF7o;|7`uDs~lP%pNanZ>*_u0jf>Bi zv9WH?Wp8TgJJ#lT@cFN;l?(@2n;pfOH7x`VXT7-I?xM5GY(vw|DPJ^HU(d8Dk``|5 z>M^X=`Fduu&kA#kiF;n}s$Q9_A>tJ*#xL`EuwtI|2~>lbmd5l`1f^t736Y%ywvY6=D#0+5-_v>n!j2L3=mCVz;FZU z_@dODVtqXj*?Zoa?~nlx+k@3E_cEi{S^_TXajewVV&5QSzBWV7>(Juu>defpJU!d$ z>!-c`mYG(zTtGdYHN=&(XH7}grlK6%Ng%SW2! zh!`w;!av8|a?-&F3x6|zHgTS1F>GgTPibd8y1nVn#}AnYZl%l>60izod@b((|3;pK zny0&Zep}A3!bjQ@Q+Z}hYG~)~T3x>TgwBU$&sK}i7Dx3}SfxGV8lZ1p1F;|wm**Fy z=%?f-m*xWF92D8EeY-(^bFEiBPNs;ZFyC?44?#UvZrct)SPU_(vMK5kI-Iy{@Zi1^=Le?}t zzcUL1wPtee@e^e|FvnJ->TG52;wX(<0w*23ZoW@1UGaMHr&|kkPFdgFdL}@Cdx!ZG z<_(Wy{O^3)o$w*?8c&kCoA3OSTUXr?J=UA}`25m;w!ifQycwB97;vX$p#Q+20T_i? yQ#QH*=y49w&cM*XCsysM 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 diff --git a/app/public/templates/gaozhi/word_template_1785207663577.docx b/app/public/templates/gaozhi/word_template_1785207663577.docx deleted file mode 100644 index 2bb8928d445c0feff63f23cbab762721afa71ecf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17747 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 diff --git a/app/public/templates/zhongzhi/.gitkeep b/app/public/templates/zhongzhi/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app/public/templates/zhongzhi/word_template_1785117066098.docx b/app/public/templates/zhongzhi/word_template_1785117066098.docx deleted file mode 100644 index 0f105d7bfef46098e34f22a10f903f729af272e1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 922 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 diff --git a/app/router.js b/app/router.js index 7015268..14832df 100644 --- a/app/router.js +++ b/app/router.js @@ -74,6 +74,11 @@ module.exports = app => { router.delete('/api/exam/:group/word', auth, controller.exam.deleteWord); router.post('/api/exam/:group/submit-all', auth, controller.exam.submitAll); + // 综合大题(仅中职/高职组;2026-09-21 起替代原 Word 文件上传题) + // 首次 GET 抽题并落库题干快照,后续 GET 一律返回同一道题(幂等防纠纷)。 + router.get('/api/exam/:group/big-question', auth, controller.exam.bigQuestion); + router.post('/api/exam/:group/big-answer', auth, controller.exam.saveBigAnswer); + // ==================== 文件上传(需认证) ==================== router.post('/api/upload/:group', auth, controller.upload.upload); @@ -119,6 +124,14 @@ module.exports = app => { router.get('/api/admin/exam/:group/word-template', adminAuth, controller.admin.getWordTemplate); router.delete('/api/admin/exam/:group/word-template', adminAuth, controller.admin.deleteWordTemplate); + // 综合大题:题库管理(CRUD)+ 作答查证(防考生纠纷)+ 按小问评分 + router.get('/api/admin/big-questions/:group', adminAuth, controller.admin.listBigQuestions); + router.post('/api/admin/big-questions/:group', adminAuth, controller.admin.createBigQuestion); + router.put('/api/admin/big-questions/:group/:id', adminAuth, controller.admin.updateBigQuestion); + router.delete('/api/admin/big-questions/:group/:id', adminAuth, controller.admin.deleteBigQuestion); + router.get('/api/admin/big-answers/:group', adminAuth, controller.admin.getBigAnswers); + router.put('/api/admin/big-answers/:group/:uid/scores', adminAuth, controller.admin.setBigAnswerScores); + // 备赛资料管理 router.get('/api/admin/materials/:group', adminAuth, controller.admin.getMaterials); router.post('/api/admin/materials/:group', adminAuth, controller.admin.uploadMaterial); diff --git a/app/service/auth.js b/app/service/auth.js index 5135cde..d3fa4f3 100644 --- a/app/service/auth.js +++ b/app/service/auth.js @@ -37,6 +37,58 @@ class AuthService extends Service { }); } + /** + * 构造「可下发给考生端」的用户信息(**唯一出口**)。 + * + * 2026-09-21 修复(铁律15 成绩泄露全通道封堵 —— 登录通道缺口): + * 此前 `POST /api/auth/login` 与 `GET /api/auth/current-user` 直接回传 + * `contestant.toJSON()`,其中 `score` 在成绩**未发布**状态下依然被下发 + * (实测未发布态 `score=27`),而同期 `GET /api/score` 正确返回 `null`。 + * 即门控只覆盖了 4 条已知通道,遗漏了 auth 这两条同源通道。 + * + * 同源族(**三处**必须共用本出口,禁止各自直接 toJSON): + * · controller/auth.js login() + * · controller/auth.js currentUser() + * · controller/auth.js refresh() (`POST /api/auth/refresh`; 前端 doSilentRefresh 会把它写入 localStorage 与 Pinia) + * + * 口径(与 GET /api/score 一致,复用 service.admin.isScorePublished 单一判定): + * · 该组成绩**已发布** → 保留 score; + * · 该组成绩**未发布 / 查询异常** → 剔除 score(安全默认:宁可不显示,不可泄露); + * · 分项字段(exam_score / word_score / ppt_score)**任何情况下都不下发** + * —— 它们只是总分的组成部分,前端从不使用,留在响应里等于给出第二条推算通道。 + * + * @param {Model|Object} contestant - Contestant 模型实例或其普通对象 + * @returns {Promise} 已脱敏的用户信息 + */ + async toClientUser(contestant) { + const { service } = this; + if (!contestant) return null; + + const userData = typeof contestant.toJSON === 'function' + ? contestant.toJSON() + : Object.assign({}, contestant); + + delete userData.password; + + // 成绩发布门控:查询失败按「未发布」处理,避免异常路径把分数漏出去 + let published = false; + try { + published = await service.admin.isScorePublished(userData.group); + } catch (e) { + published = false; + } + if (!published) { + delete userData.score; + } + + // 分项成绩一律不下发(前端不使用;保留会造成「总分虽隐藏、分项可加总」的旁路) + delete userData.exam_score; + delete userData.word_score; + delete userData.ppt_score; + + return userData; + } + /** * 根据用户名查找管理员 * diff --git a/app/service/bigQuestion.js b/app/service/bigQuestion.js new file mode 100644 index 0000000..de25d57 --- /dev/null +++ b/app/service/bigQuestion.js @@ -0,0 +1,578 @@ +'use strict'; + +const Service = require('egg').Service; + +/** + * 综合大题服务 - BigQuestionService + * + * 2026-09-21 需求变更:中职组(zhongzhi) / 高职组(gaozhi) 取消「Word 文件上传题」, + * 改为从题库随机抽取一道大题在线作答。大题 = 文字描述 + 富文本表格(HTML) + 三小问, + * 每小问 10 分,大题总分 30 分。 + * + * ── 隔离边界(硬约束:除本次功能外不得影响其他功能)───────────────────────── + * · 本服务**只读写新增的 big_questions / big_question_answers**; + * · 不触碰 zhongzhi_submissions / gaozhi_submissions(客观题逐题表); + * · 不触碰 benke_submissions / jiaoshi_submissions(本科/教师组作品提交); + * · 不触碰 choice_questions / judge_questions(客观题题库); + * · 评分**复用既有唯一写路径** service.admin.updateScores(见 setSubScores), + * 保证 contestants.word_score / score 的写入口径与「成绩发布门控」不出现第二条通路。 + * + * ── 关键存储事实 ───────────────────────────────────────────────────────── + * controller/exam.js:90 `_getUserAggregation()` 返回 `aggregation_uid: contestant.id`, + * 故中职/高职组本表 aggregation_uid 即 contestants.id(与 zhongzhi/gaozhi_submissions + * 的 aggregation_uid 同口径),管理端查证可按 contestants.id 直接映射。 + * + * MariaDB 5.5 无 JSON 列类型,题干快照以 JSON 文本存 MEDIUMTEXT。 + */ + +/** 启用「综合大题」的组别(其余组别走原有作品/Word 提交通道,不受影响) */ +const BIG_QUESTION_GROUPS = ['zhongzhi', 'gaozhi']; + +/** 每小问作答上限(字符数,与前端 textarea maxlength 同口径,前后端一致) */ +const MAX_ANSWER_CHARS = 2000; + +/** 大题固定含 3 小问 */ +const SUB_QUESTION_COUNT = 3; + +const ANSWER_FIELDS = ['answer_1', 'answer_2', 'answer_3']; +const SCORE_FIELDS = ['score_1', 'score_2', 'score_3']; + +class BigQuestionService extends Service { + /** 该组别是否启用综合大题 */ + isEnabledGroup(group) { + return BIG_QUESTION_GROUPS.includes(group); + } + + /** 每小问字数上限(供 controller 回传前端) */ + answerMaxChars() { + return MAX_ANSWER_CHARS; + } + + /** 小问数量 */ + subQuestionCount() { + return SUB_QUESTION_COUNT; + } + + // ==================== 考生端 ==================== + + /** + * 取回本次抽中的大题 + 已保存的作答(供考生端渲染)。 + * + * 幂等语义:同一考生重复进入只会得到**同一道题**(首次进入时抽题并落库快照, + * 之后一律复用该行),这是「避免考生纠纷」的基础保证。 + * + * ⚠️ 返回值**不含任何分数**:成绩经发布门控后仅由 GET /api/score 单通道下发 + * (铁律15 成绩泄露四通道封堵)。 + * + * @param {number} aggregationUid - 聚合报名ID(中职/高职组 == contestants.id) + * @param {string} group - 组别 + * @param {number} [contestantId] - 选手ID(冗余存证用,缺省同 aggregationUid) + * @returns {Promise} { ok, code, message, question, answers, submitTime, ... } + */ + async getForCandidate(aggregationUid, group, contestantId) { + const drawn = await this.drawOrGet(aggregationUid, group, contestantId); + if (!drawn.ok) return drawn; + + const record = drawn.record; + return { + ok: true, + reused: drawn.reused, + question: this.parseSnapshot(record.question_snapshot), + answers: { + answer_1: record.answer_1 || '', + answer_2: record.answer_2 || '', + answer_3: record.answer_3 || '', + }, + submitTime: record.submit_time || null, + maxChars: MAX_ANSWER_CHARS, + subCount: SUB_QUESTION_COUNT, + }; + } + + /** + * 抽题或取回已抽中的题(并发安全,一考生一行)。 + * @param {number} aggregationUid + * @param {string} group + * @param {number} [contestantId] + * @returns {Promise} { ok, record, reused } 或 { ok:false, code, message } + */ + async drawOrGet(aggregationUid, group, contestantId) { + const { ctx } = this; + + if (!this.isEnabledGroup(group)) { + return { ok: false, code: 'GROUP_NOT_ENABLED', message: '该组别未启用综合大题' }; + } + if (!aggregationUid) { + return { ok: false, code: 'NO_AGGREGATION', message: '未找到您的报名信息' }; + } + + const Answer = ctx.model.BigQuestionAnswer; + + // 已抽过 → 直接复用(题目确定性 = 防纠纷) + const existed = await Answer.findOne({ where: { aggregation_uid: aggregationUid } }); + if (existed) return { ok: true, record: existed, reused: true }; + + // 首次进入:从该组启用中的题库随机抽 1 道 + const Question = ctx.model.BigQuestion; + const where = { group, status: 1 }; + const total = await Question.count({ where }); + if (!total) { + return { ok: false, code: 'NO_QUESTION', message: '该组别尚未配置综合大题,请联系管理员' }; + } + + // 以 count + 随机 offset 抽样(不依赖特定方言的 RAND(),MariaDB 5.5 亦安全) + const offset = Math.floor(Math.random() * total); + const picked = await Question.findOne({ + where, + order: [['sort_order', 'ASC'], ['id', 'ASC']], + offset, + limit: 1, + }); + if (!picked) { + return { ok: false, code: 'NO_QUESTION', message: '该组别尚未配置综合大题,请联系管理员' }; + } + + const snapshot = this.buildSnapshot(picked); + const defaults = { + aggregation_uid: aggregationUid, + contestant_id: contestantId || aggregationUid, + group, + big_question_id: picked.id, + big_question_uid: picked.question_uid, + question_snapshot: JSON.stringify(snapshot), + answer_1: '', + answer_2: '', + answer_3: '', + submit_time: null, + }; + + try { + const created = await Answer.create(defaults); + return { ok: true, record: created, reused: false }; + } catch (e) { + // 唯一键竞争(同一考生并发首抽):回读已落库的那一行, + // 保证「一考生一行」且题目只有一个权威版本(另一并发请求已写入快照)。 + if (this.isDupKey(e)) { + const winner = await Answer.findOne({ where: { aggregation_uid: aggregationUid } }); + if (winner) return { ok: true, record: winner, reused: true }; + } + throw e; + } + } + + /** + * 保存三小问作答(后端同步做长度校验,防前端绕过 maxlength)。 + * @param {number} aggregationUid + * @param {string} group + * @param {Object} answers - { answer_1, answer_2, answer_3 } + * @returns {Promise} { ok, filled, record } 或 { ok:false, code, message } + */ + async saveAnswers(aggregationUid, group, answers) { + const { ctx } = this; + + if (!this.isEnabledGroup(group)) { + return { ok: false, code: 'GROUP_NOT_ENABLED', message: '该组别未启用综合大题' }; + } + + // 长度校验:逐问超限即整批拒绝(不静默截断——截断会造成考生答案与页面所见不一致) + const normalized = []; + for (let i = 0; i < SUB_QUESTION_COUNT; i++) { + const raw = answers ? answers[ANSWER_FIELDS[i]] : undefined; + const val = raw === undefined || raw === null ? '' : String(raw); + if (val.length > MAX_ANSWER_CHARS) { + return { + ok: false, + code: 'ANSWER_TOO_LONG', + message: `第 ${i + 1} 小问作答超过 ${MAX_ANSWER_CHARS} 字上限(当前 ${val.length} 字)`, + }; + } + normalized.push(val); + } + + const Answer = ctx.model.BigQuestionAnswer; + const record = await Answer.findOne({ where: { aggregation_uid: aggregationUid } }); + if (!record) { + // 未抽题就作答 → 明确拒绝,避免产生「无题目来源」的孤儿作答 + return { ok: false, code: 'NOT_DRAWN', message: '尚未抽取题目,请先进入考试页面抽取题目' }; + } + + await record.update({ + answer_1: normalized[0], + answer_2: normalized[1], + answer_3: normalized[2], + }); + + const filled = normalized.filter(v => v.trim().length > 0).length; + return { ok: true, filled, record }; + } + + /** + * 随整卷正式提交时补写 submit_time(已写则不覆盖,保留首次提交时间)。 + * @param {number} aggregationUid + * @returns {Promise} 更新后的记录(无大题作答行则返回 null) + */ + async markSubmitted(aggregationUid) { + const { ctx } = this; + if (!aggregationUid) return null; + + const record = await ctx.model.BigQuestionAnswer.findOne({ + where: { aggregation_uid: aggregationUid }, + }); + if (!record) return null; + + if (!record.submit_time) { + await record.update({ submit_time: new Date() }); + } + return record; + } + + /** + * 判断大题是否已答满(三小问均有非空内容)。 + * @param {Object} record + * @returns {boolean} + */ + isCompleted(record) { + if (!record) return false; + return ANSWER_FIELDS.every(f => String(record[f] || '').trim().length > 0); + } + + // ==================== 管理端 ==================== + + /** + * 管理端查证视图:按组返回「每位考生 → 抽到的题目(快照)→ 三小问作答 → 提交时间」。 + * + * 包含该组**全部考生**(未抽题者 drawn=false),便于管理员核对缺考/未作答情况。 + * 题目取**题干快照**而非当前题库内容 —— 考生看到的即当时那版,防止题目被改后无法举证。 + * + * @param {string} group + * @returns {Promise} 查证列表 + */ + async listAnswersForAdmin(group) { + const { ctx } = this; + + const answers = await ctx.model.BigQuestionAnswer.findAll({ + where: { group }, + order: [['id', 'ASC']], + }); + + // aggregation_uid 与 contestant_id 双键索引(铁律14:跨键须双向映射防单键错配) + const byAgg = {}; + const byContestant = {}; + for (const a of answers) { + byAgg[String(a.aggregation_uid)] = a; + if (a.contestant_id != null) byContestant[String(a.contestant_id)] = a; + } + + const contestants = await ctx.model.Contestant.findAll({ + where: { group }, + attributes: ['id', 'name', 'id_number', 'phone', 'team_id', 'word_score'], + order: [['id', 'ASC']], + }); + + return contestants.map(c => { + const rec = byContestant[String(c.id)] || byAgg[String(c.id)] || null; + return { + contestantId: c.id, + aggregationUid: rec ? rec.aggregation_uid : c.id, + name: c.name, + idNumber: c.id_number, + phone: c.phone, + teamId: c.team_id, + drawn: !!rec, + bigQuestionUid: rec ? rec.big_question_uid : null, + questionSnapshot: rec ? this.parseSnapshot(rec.question_snapshot) : null, + answer1: rec ? (rec.answer_1 || '') : '', + answer2: rec ? (rec.answer_2 || '') : '', + answer3: rec ? (rec.answer_3 || '') : '', + answeredCount: rec ? ANSWER_FIELDS.filter(f => String(rec[f] || '').trim().length > 0).length : 0, + score1: rec ? rec.score_1 : null, + score2: rec ? rec.score_2 : null, + score3: rec ? rec.score_3 : null, + bigScore: rec ? rec.total_score : null, + wordScore: c.word_score != null ? Number(c.word_score) : null, + submitTime: rec ? rec.submit_time : null, + }; + }); + } + + /** + * 管理端按三小问给分。 + * + * 双写但**不新增成绩通路**: + * 1) 三小问得分 + 合计落 big_question_answers(存证 / 复核依据); + * 2) 合计分通过既有唯一评分路径 service.admin.updateScores 同步 contestants.word_score + * → 与「成绩发布门控」「总分公式(中职/高职 = exam_score + word_score)」完全一致。 + * + * @param {string} group + * @param {number} aggregationUid + * @param {Object} scores - { score_1, score_2, score_3 } + * @returns {Promise} { ok, total } 或 { ok:false, code, message } + */ + async setSubScores(group, aggregationUid, scores) { + const { ctx, service } = this; + + if (!this.isEnabledGroup(group)) { + return { ok: false, code: 'GROUP_NOT_ENABLED', message: '该组别未启用综合大题' }; + } + + const record = await ctx.model.BigQuestionAnswer.findOne({ + where: { aggregation_uid: aggregationUid }, + }); + if (!record) { + return { ok: false, code: 'NOT_FOUND', message: '该考生尚未抽取大题,无法评分' }; + } + + // 逐问校验 0–10 整数(分值口径来自题库 score_per_sub,默认 10) + const per = await this.resolveScorePerSub(group, record); + const parsed = []; + for (let i = 0; i < SUB_QUESTION_COUNT; i++) { + const raw = scores ? scores[SCORE_FIELDS[i]] : undefined; + if (raw === undefined || raw === null || raw === '') { + return { ok: false, code: 'INVALID_SCORE', message: `第 ${i + 1} 小问必须填写得分(0-${per})` }; + } + const n = Number(raw); + if (!Number.isFinite(n) || n < 0 || n > per) { + return { ok: false, code: 'INVALID_SCORE', message: `第 ${i + 1} 小问得分须为 0-${per} 的数值` }; + } + parsed.push(Math.round(n)); + } + const total = parsed.reduce((a, b) => a + b, 0); + + await record.update({ + score_1: parsed[0], + score_2: parsed[1], + score_3: parsed[2], + total_score: total, + }); + + // 同步到既有成绩路径(contests.word_score / score + accounts + 作品分项), + // 复用 AdminService.updateScores,避免出现第二条写成绩的代码路径。 + const contestantId = record.contestant_id || record.aggregation_uid; + await service.admin.updateScores([{ contestantId, wordScore: total }], group); + + return { ok: true, total, perSubScore: per }; + } + + /** 取该大题每小题分值(优先题库当前配置,回退快照,再回退 10) */ + async resolveScorePerSub(group, record) { + const { ctx } = this; + if (record && record.big_question_id) { + const q = await ctx.model.BigQuestion.findByPk(record.big_question_id, { + attributes: ['score_per_sub'], + }); + if (q && q.score_per_sub) return Number(q.score_per_sub); + } + const snap = record ? this.parseSnapshot(record.question_snapshot) : null; + if (snap && snap.score_per_sub) return Number(snap.score_per_sub); + return 10; + } + + // ==================== 题库管理(管理端) ==================== + + /** + * 题库列表(按组) + * @param {string} group + * @returns {Promise} + */ + async listQuestions(group) { + const { ctx } = this; + const rows = await ctx.model.BigQuestion.findAll({ + where: { group }, + order: [['sort_order', 'ASC'], ['id', 'ASC']], + }); + return rows.map(r => ({ + id: r.id, + questionUid: r.question_uid, + group: r.group, + title: r.title, + description: r.description || '', + subPrompt1: r.sub_prompt_1 || '', + subPrompt2: r.sub_prompt_2 || '', + subPrompt3: r.sub_prompt_3 || '', + scorePerSub: r.score_per_sub, + totalScore: r.total_score, + sortOrder: r.sort_order, + status: r.status, + createdAt: r.created_at, + updatedAt: r.updated_at, + })); + } + + /** + * 新建大题 + * @param {string} group + * @param {Object} data + * @returns {Promise} { ok, record } 或 { ok:false, code, message } + */ + async createQuestion(group, data) { + const { ctx } = this; + const checked = this.validateQuestionPayload(data); + if (!checked.ok) return checked; + + const q = await ctx.model.BigQuestion.create({ + // 唯一键占位:question_uid 期望形态为 big_<自增id>,需插入拿到 id 后回填; + // 占位值加时间戳+随机数,避免并发新建时占位互相冲突。 + question_uid: `tmp_${Date.now()}_${Math.floor(Math.random() * 1000000)}`, + group, + title: checked.title, + description: checked.description, + sub_prompt_1: checked.subPrompts[0], + sub_prompt_2: checked.subPrompts[1], + sub_prompt_3: checked.subPrompts[2], + score_per_sub: checked.scorePerSub, + total_score: checked.scorePerSub * SUB_QUESTION_COUNT, + sort_order: checked.sortOrder, + status: checked.status, + }); + await q.update({ question_uid: `big_${q.id}` }); + return { ok: true, record: q }; + } + + /** + * 更新大题 + * @param {string} group + * @param {number} id + * @param {Object} data + * @returns {Promise} + */ + async updateQuestion(group, id, data) { + const { ctx } = this; + const q = await ctx.model.BigQuestion.findOne({ where: { id, group } }); + if (!q) return { ok: false, code: 'NOT_FOUND', message: '大题不存在' }; + + const checked = this.validateQuestionPayload(data, q); + if (!checked.ok) return checked; + + await q.update({ + title: checked.title, + description: checked.description, + sub_prompt_1: checked.subPrompts[0], + sub_prompt_2: checked.subPrompts[1], + sub_prompt_3: checked.subPrompts[2], + score_per_sub: checked.scorePerSub, + total_score: checked.scorePerSub * SUB_QUESTION_COUNT, + sort_order: checked.sortOrder, + status: checked.status, + }); + return { ok: true, record: q }; + } + + /** + * 删除大题。 + * 已被考生抽取过的大题**拒绝硬删除**(否则管理端查证只剩快照、题库无法回溯), + * 引导改为「停用」(status=0,不再参与抽题,已有考生不受影响)。 + * @param {string} group + * @param {number} id + * @returns {Promise} + */ + async deleteQuestion(group, id) { + const { ctx } = this; + const q = await ctx.model.BigQuestion.findOne({ where: { id, group } }); + if (!q) return { ok: false, code: 'NOT_FOUND', message: '大题不存在' }; + + const used = await ctx.model.BigQuestionAnswer.count({ where: { big_question_id: id } }); + if (used > 0) { + return { + ok: false, + code: 'IN_USE', + message: `该大题已被 ${used} 名考生抽取,不可删除;如需下线请改为「停用」(不影响已抽到该题的考生)`, + }; + } + + await q.destroy(); + return { ok: true }; + } + + /** + * 校验大题录入数据(新增 / 更新共用) + * @param {Object} data + * @param {Object} [existing] - 更新时用于字段缺省回退 + * @returns {Object} { ok, title, description, subPrompts, scorePerSub, sortOrder, status } + */ + validateQuestionPayload(data, existing) { + const src = data || {}; + const title = String(src.title !== undefined ? src.title : (existing ? existing.title : '')).trim(); + if (!title) return { ok: false, code: 'TITLE_REQUIRED', message: '大题标题不能为空' }; + if (title.length > 200) return { ok: false, code: 'TITLE_TOO_LONG', message: '大题标题不得超过 200 字' }; + + const description = String( + src.description !== undefined ? src.description : (existing ? existing.description : '') || '' + ); + + const subPrompts = []; + for (let i = 0; i < SUB_QUESTION_COUNT; i++) { + const key = `sub_prompt_${i + 1}`; + const camel = `subPrompt${i + 1}`; + let v; + if (src[key] !== undefined) v = src[key]; + else if (src[camel] !== undefined) v = src[camel]; + else v = existing ? existing[key] : ''; + subPrompts.push(String(v == null ? '' : v)); + } + + const perRaw = src.score_per_sub !== undefined ? src.score_per_sub + : (src.scorePerSub !== undefined ? src.scorePerSub + : (existing ? existing.score_per_sub : 10)); + const scorePerSub = Number(perRaw); + if (!Number.isFinite(scorePerSub) || scorePerSub <= 0) { + return { ok: false, code: 'INVALID_SCORE', message: '每小题分值必须为大于 0 的数值' }; + } + + const sortRaw = src.sort_order !== undefined ? src.sort_order + : (src.sortOrder !== undefined ? src.sortOrder + : (existing ? existing.sort_order : 0)); + const sortOrder = Number.isFinite(Number(sortRaw)) ? Math.round(Number(sortRaw)) : 0; + + let status = existing ? Number(existing.status) : 1; + if (src.status !== undefined && src.status !== null && src.status !== '') { + status = Number(src.status) ? 1 : 0; + } + + return { + ok: true, + title, + description, + subPrompts, + scorePerSub: Math.round(scorePerSub), + sortOrder, + status, + }; + } + + // ==================== 内部工具 ==================== + + /** 构造题干快照(抽题时刻固化,题目后续被改不影响存证) */ + buildSnapshot(q) { + return { + big_question_uid: q.question_uid, + title: q.title, + description: q.description || '', + sub_prompts: [q.sub_prompt_1 || '', q.sub_prompt_2 || '', q.sub_prompt_3 || ''], + score_per_sub: q.score_per_sub, + total_score: q.total_score, + snapshot_at: new Date().toISOString(), + }; + } + + /** 解析题干快照(容错:脏数据不抛异常,返回 null) */ + parseSnapshot(text) { + if (!text) return null; + if (typeof text === 'object') return text; + try { + return JSON.parse(text); + } catch (e) { + return null; + } + } + + /** 唯一键冲突判定(并发首抽竞争) */ + isDupKey(e) { + if (!e) return false; + if (e.name === 'SequelizeUniqueConstraintError') return true; + if (e.code === 'ER_DUP_ENTRY') return true; + if (e.parent && e.parent.code === 'ER_DUP_ENTRY') return true; + return /ER_DUP_ENTRY/.test(String(e.message || '')); + } +} + +module.exports = BigQuestionService; diff --git a/database/migrations/023-024-big-question-tables.sql b/database/migrations/023-024-big-question-tables.sql new file mode 100644 index 0000000..149dc74 --- /dev/null +++ b/database/migrations/023-024-big-question-tables.sql @@ -0,0 +1,80 @@ +-- ============================================================================ +-- 023 / 024 · 综合大题改造(中职组 / 高职组取消 Word 上传题 → 随机抽取大题在线作答) +-- 日期:2026-09-21 +-- 目标库:MariaDB 5.5.68 competition_db (字符集 utf8mb4 / utf8mb4_general_ci) +-- +-- 本文件是**服务器同步用**的可直接执行 DDL(与 023-*.js / 024-*.js 迁移等价)。 +-- 本改造为纯 ADD TABLE: +-- · 不改动任何现有表结构 +-- · 不动 zhongzhi_submissions / gaozhi_submissions(客观题链路) +-- · 不动 benke_submissions / jiaoshi_submissions(本科/教师组) +-- · 不动成绩发布门控与总分公式(中职/高职总分仍 = exam_score + word_score) +-- +-- 注意:MariaDB 5.5 无 JSON 列类型(JSON 一律用 TEXT / MEDIUMTEXT 存文本); +-- DATETIME 不支持 DEFAULT CURRENT_TIMESTAMP,时间戳由应用层写入。 +-- ============================================================================ + +-- --------------------------------------------------------------------------- +-- 023 · 综合大题题库 +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `big_questions` ( + `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '大题ID', + `question_uid` varchar(64) NOT NULL COMMENT '大题稳定标识(全局唯一,形如 big_12)', + `group` varchar(20) NOT NULL COMMENT '组别:zhongzhi/gaozhi', + `title` varchar(200) NOT NULL COMMENT '大题标题', + `description` mediumtext DEFAULT NULL COMMENT '题干富文本 HTML(含表格)', + `sub_prompt_1` text DEFAULT NULL COMMENT '第1小问题干', + `sub_prompt_2` text DEFAULT NULL COMMENT '第2小问题干', + `sub_prompt_3` text DEFAULT NULL COMMENT '第3小问题干', + `score_per_sub` int(11) NOT NULL DEFAULT '10' COMMENT '每小题分值', + `total_score` int(11) NOT NULL DEFAULT '30' COMMENT '大题总分', + `sort_order` int(11) NOT NULL DEFAULT '0' COMMENT '排序', + `status` int(11) NOT NULL DEFAULT '1' COMMENT '状态:1=启用(参与抽题),0=停用', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_at` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_big_question_uid` (`question_uid`), + KEY `idx_big_q_group_status` (`group`,`status`,`sort_order`) +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + COMMENT='综合大题题库(中职/高职组,替代原 Word 上传题)'; + +-- --------------------------------------------------------------------------- +-- 024 · 综合大题作答(一考生一行) +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `big_question_answers` ( + `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `aggregation_uid` int(11) NOT NULL COMMENT '聚合报名ID(中职/高职组 == contestants.id),唯一 → 一考生一行', + `contestant_id` int(11) DEFAULT NULL COMMENT '选手ID(冗余,便于管理端查证与排障)', + `group` varchar(20) NOT NULL COMMENT '组别:zhongzhi/gaozhi', + `big_question_id` int(11) DEFAULT NULL COMMENT '抽中的大题ID', + `big_question_uid` varchar(64) DEFAULT NULL COMMENT '抽中的大题稳定标识', + `question_snapshot` mediumtext DEFAULT NULL COMMENT '抽题时刻的题干快照 JSON(防纠纷存证)', + `answer_1` text DEFAULT NULL COMMENT '第1小问作答(上限2000字,后端同步校验)', + `answer_2` text DEFAULT NULL COMMENT '第2小问作答(上限2000字)', + `answer_3` text DEFAULT NULL COMMENT '第3小问作答(上限2000字)', + `score_1` int(11) DEFAULT NULL COMMENT '第1小问得分(0-10,人工阅卷)', + `score_2` int(11) DEFAULT NULL COMMENT '第2小问得分(0-10,人工阅卷)', + `score_3` int(11) DEFAULT NULL COMMENT '第3小问得分(0-10,人工阅卷)', + `total_score` int(11) DEFAULT NULL COMMENT '三小问得分合计(回写 contestants.word_score 的依据)', + `submit_time` datetime DEFAULT NULL COMMENT '随整卷正式提交写入(未提交为 NULL)', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_at` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_big_answer_agg` (`aggregation_uid`), + KEY `idx_big_answer_group` (`group`), + KEY `idx_big_answer_q` (`big_question_uid`) +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + COMMENT='综合大题作答(一考生一行,含题干快照)'; + +-- ============================================================================ +-- 回滚(仅在确认弃用时执行) +-- DROP TABLE IF EXISTS `big_question_answers`; +-- DROP TABLE IF EXISTS `big_questions`; +-- +-- 历史数据清理(2026-09-21 用户确认:已弃用,可全部删除) +-- DELETE FROM zhongzhi_submissions; -- 84 行(孤儿遗留,aggregation_uid 与现有选手不匹配) +-- DELETE FROM gaozhi_submissions; -- 480 行 +-- UPDATE competition_times SET word_template = NULL WHERE `group` IN ('zhongzhi','gaozhi'); +-- -- 并删除磁盘旧文件:app/public/answers/{zhongzhi,gaozhi}/、app/public/templates/{zhongzhi,gaozhi}/ +-- -- ⚠️ 仅限中职/高职;本科/教师组的 word_template 与作品文件一律不得触碰。 +-- ============================================================================ diff --git a/database/migrations/023-create-big-questions.js b/database/migrations/023-create-big-questions.js new file mode 100644 index 0000000..dbcf354 --- /dev/null +++ b/database/migrations/023-create-big-questions.js @@ -0,0 +1,110 @@ +'use strict'; + +/** + * 023 - 综合大题题库表 big_questions + * + * 背景(2026-09-21 需求变更) + * 中职组 / 高职组取消「Word 文件上传题」,改为从题库随机抽取一道大题在线作答; + * 大题含「文字描述 + 富文本表格」,下设三小问,每小问 10 分,大题总分 30 分。 + * + * 隔离原则(硬约束:除本次功能外不得影响其他功能) + * 本表为**新增隔离表**,不改动任何现有表结构与既有客观题链路 + * (choice_questions / judge_questions / zhongzhi_submissions / gaozhi_submissions + * 以及本科/教师组 benke_submissions / jiaoshi_submissions 全部零改动)。 + * + * 环境说明 + * 目标库为 MariaDB 5.5.68,**不支持 JSON 列类型**,故富文本 / 序列化数据一律用 + * TEXT / MEDIUMTEXT 存储;亦不支持 DATETIME 的 DEFAULT CURRENT_TIMESTAMP, + * 时间戳列交由应用层(Sequelize timestamps)写入,与既有表(word_questions 等)一致。 + */ +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.createTable('big_questions', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false, + comment: '大题ID', + }, + question_uid: { + type: Sequelize.STRING(64), + allowNull: false, + comment: '大题稳定标识(全局唯一,形如 big_12)', + }, + group: { + type: Sequelize.STRING(20), + allowNull: false, + comment: '组别:zhongzhi/gaozhi', + }, + title: { + type: Sequelize.STRING(200), + allowNull: false, + comment: '大题标题', + }, + description: { + type: Sequelize.TEXT('medium'), + allowNull: true, + comment: '题干富文本 HTML(含表格)', + }, + sub_prompt_1: { + type: Sequelize.TEXT, + allowNull: true, + comment: '第1小问题干', + }, + sub_prompt_2: { + type: Sequelize.TEXT, + allowNull: true, + comment: '第2小问题干', + }, + sub_prompt_3: { + type: Sequelize.TEXT, + allowNull: true, + comment: '第3小问题干', + }, + score_per_sub: { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 10, + comment: '每小题分值', + }, + total_score: { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 30, + comment: '大题总分', + }, + sort_order: { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 0, + comment: '排序', + }, + status: { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 1, + comment: '状态:1=启用(参与抽题),0=停用', + }, + created_at: { + type: Sequelize.DATE, + allowNull: true, + comment: '创建时间', + }, + updated_at: { + type: Sequelize.DATE, + allowNull: true, + comment: '更新时间', + }, + }).then(() => { + return Promise.all([ + queryInterface.addIndex('big_questions', ['question_uid'], { name: 'uk_big_question_uid', unique: true }), + queryInterface.addIndex('big_questions', ['group', 'status', 'sort_order'], { name: 'idx_big_q_group_status' }), + ]); + }); + }, + + down: async (queryInterface) => { + await queryInterface.dropTable('big_questions'); + }, +}; diff --git a/database/migrations/024-create-big-question-answers.js b/database/migrations/024-create-big-question-answers.js new file mode 100644 index 0000000..e4f55ec --- /dev/null +++ b/database/migrations/024-create-big-question-answers.js @@ -0,0 +1,127 @@ +'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'); + }, +};