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 2604a97..0000000 Binary files a/app/public/templates/gaozhi/word_template_1785117066172.docx and /dev/null differ diff --git a/app/public/templates/gaozhi/word_template_1785207535307.docx b/app/public/templates/gaozhi/word_template_1785207535307.docx deleted file mode 100644 index 0fcc266..0000000 Binary files a/app/public/templates/gaozhi/word_template_1785207535307.docx and /dev/null differ 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 2bb8928..0000000 Binary files a/app/public/templates/gaozhi/word_template_1785207663577.docx and /dev/null differ diff --git a/app/public/templates/gaozhi/word_template_1785207668867.docx b/app/public/templates/gaozhi/word_template_1785207668867.docx deleted file mode 100644 index 0fcc266..0000000 Binary files a/app/public/templates/gaozhi/word_template_1785207668867.docx and /dev/null differ 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 0f105d7..0000000 Binary files a/app/public/templates/zhongzhi/word_template_1785117066098.docx and /dev/null differ diff --git a/app/public/templates/zhongzhi/word_template_1785139347982.docx b/app/public/templates/zhongzhi/word_template_1785139347982.docx deleted file mode 100644 index 0821507..0000000 Binary files a/app/public/templates/zhongzhi/word_template_1785139347982.docx and /dev/null differ 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'); + }, +};