'use strict'; const Service = require('egg').Service; const path = require('path'); const fs = require('fs'); const crypto = require('crypto'); /** * 考试服务 - ExamService * * 封装考试相关业务逻辑,包括题目随机获取、答案校验、自动判分、 * 比赛状态检查、答题记录保存等功能。 */ class ExamService extends Service { /** * 根据用户的 id_number 获取聚合报名信息 * @param {string} idNumber - 身份证号 * @returns {Promise} 聚合报名记录 */ async getUserAggregation(idNumber) { const { ctx } = this; // 先通过 id_number 找到 Account,再通过 Account.uid 找到 Aggregation const account = await ctx.model.Account.findOne({ where: { id_number: idNumber }, attributes: ['uid'], }); if (!account) return null; return await ctx.model.Aggregation.findOne({ where: { account_uid: account.uid }, include: [ { model: ctx.model.Team, as: 'team', attributes: ['team_uid', 'group_name', 'team_id', 'team_number'] }, ], }); } /** * 根据题型获取对应的模型 * @param {string} questionType - 题型:choice / judge / word * @returns {Object} Sequelize 模型 */ _getQuestionModel(questionType) { const { ctx } = this; switch (questionType) { case 'choice': return ctx.model.ChoiceQuestion; case 'judge': return ctx.model.JudgeQuestion; case 'word': return ctx.model.WordQuestion; default: throw new Error(`未知题型: ${questionType}`); } } /** * 从可能带题型前缀的 question_uid 中提取原始自增 id。 * 兼容三种形态: * - 'choice_12' / 'judge_3'(新:题型前缀,保证跨题型全局唯一) * - '12'(旧:纯数字字符串) * - 12(number) * @param {string|number} uid * @returns {string|number} 原始自增 id */ _extractRawId(uid) { if (uid === undefined || uid === null) return uid; const s = String(uid); const idx = s.lastIndexOf('_'); if (idx >= 0) { const tail = s.slice(idx + 1); return /^\d+$/.test(tail) ? tail : s; } return s; } /** * 随机获取指定组别的题目(选择题 + 判断题,按各自数量抽取) * @param {string} group - 组别:zhongzhi / gaozhi * @param {Object} options - 抽题数量配置 * @param {number} options.choiceCount - 选择题数量,默认 50 * @param {number} options.judgeCount - 判断题数量,默认 20 * @returns {Promise} 题目列表(不含 answer 字段),顺序:选择题在前、判断题在后 */ async getRandomQuestions(group, options = {}) { const { ctx } = this; // 严格按题型数量分别抽取,确保分布符合「50 道选择题 + 20 道判断题」的赛制要求, // 而不是把单个总数对半切(否则会变成 35+35 等错误分布)。 const choiceCount = options.choiceCount != null ? options.choiceCount : 50; const judgeCount = options.judgeCount != null ? options.judgeCount : 20; const [choiceQuestions, judgeQuestions] = await Promise.all([ ctx.model.ChoiceQuestion.findAll({ where: { group }, order: ctx.model.Sequelize.literal('RAND()'), limit: choiceCount, attributes: { exclude: ['answer'] }, raw: true, }), ctx.model.JudgeQuestion.findAll({ where: { group }, order: ctx.model.Sequelize.literal('RAND()'), limit: judgeCount, attributes: { exclude: ['answer'] }, raw: true, }), ]); // 合并并标记题型,并规范化输出字段 // 顺序约定:选择题全部在前、判断题全部在后(各自内部仍由 RAND() 随机), // 前端按全局索引编号,选择题 1~N、判断题 N+1~M,Word 综合题接续 M+1,题号严格连续。 const questions = [ ...choiceQuestions.map(q => normalizeQuestion(q, 'choice')), ...judgeQuestions.map(q => normalizeQuestion(q, 'judge')), ]; return questions; } /** * 按指定的题目 UID 顺序取题(不随机),用于「重进复用同一套题目与顺序」。 * @param {string} group - 组别 * @param {Array} questionUids - 固化顺序的 question_uid 数组,如 ['choice_12','judge_3',...] * @returns {Promise} 严格按 questionUids 顺序的题目列表(缺失题目自动跳过) */ async getQuestionsByFixedOrder(group, questionUids) { const { ctx } = this; const buckets = { choice: [], judge: [] }; for (const uid of (questionUids || [])) { const s = String(uid); const idx = s.lastIndexOf('_'); if (idx < 0) continue; const type = s.slice(0, idx); const rawId = s.slice(idx + 1); if ((type === 'choice' || type === 'judge') && /^\d+$/.test(rawId)) { buckets[type].push(parseInt(rawId, 10)); } } const out = []; for (const type of ['choice', 'judge']) { const ids = buckets[type]; if (!ids.length) continue; const Model = type === 'choice' ? ctx.model.ChoiceQuestion : ctx.model.JudgeQuestion; const rows = await Model.findAll({ where: { id: { [ctx.model.Sequelize.Op.in]: ids }, group }, attributes: { exclude: ['answer'] }, raw: true, }); const map = new Map(rows.map(r => [r.id, r])); for (const id of ids) { const r = map.get(id); if (r) out.push(normalizeQuestion(r, type)); } } return out; } /** * 获取/固化该考生的题目顺序:首次进入随机生成并持久化到 exam_submissions.question_order; * 之后重进(或已提交后重进)一律复用同一顺序,彻底消除「重进题目顺序变乱」缺陷。 * 兼容历史已提交但无 question_order 的记录:从其 answers 推导顺序(答案顺序即作答顺序)。 * @param {number} aggregationUid - 聚合报名 ID(exam_submissions 以它为 contestant_id 存储) * @param {string} group - 组别 * @param {Object} options - 抽题数量配置 { choiceCount, judgeCount } * @returns {Promise<{questions: Array, questionUids: Array}>} */ async getAssignedQuestions(aggregationUid, group, options = {}) { const { ctx } = this; // 确保该考生有一条记录(占位,submit_time 保持 null,不影响 hasSubmitted 判定)。 const row = await this._ensureExamSubmissionSafe(aggregationUid, { contestant_id: aggregationUid, answers: [], submit_time: null, }); // 1) 已固化顺序 → 直接复用 const saved = this._readQuestionOrder(row); if (saved && saved.length) { return { questions: await this.getQuestionsByFixedOrder(group, saved), questionUids: saved, }; } // 2) 历史已提交但无固化顺序:从 answers 推导(答案顺序即作答顺序) const savedAnswers = row.answers; if (Array.isArray(savedAnswers) && savedAnswers.length) { const derived = savedAnswers .map(a => a.question_uid || (a.question_type && a.question_id ? `${a.question_type}_${a.question_id}` : null)) .filter(Boolean); if (derived.length) { await row.update({ question_order: JSON.stringify(derived) }); return { questions: await this.getQuestionsByFixedOrder(group, derived), questionUids: derived, }; } } // 3) 首次进入:随机生成并持久化(同一考生只发生一次) const questions = await this.getRandomQuestions(group, options); const uids = questions.map(q => q.question_uid); await row.update({ question_order: JSON.stringify(uids) }); return { questions, questionUids: uids }; } /** * 从 exam_submissions 记录中安全读取 question_order(JSON 字符串/对象 → 数组) * @param {Object} row - 记录实例或原始对象 * @returns {Array|null} */ _readQuestionOrder(row) { if (!row) return null; const v = row.question_order; if (!v) return null; try { const a = typeof v === 'string' ? JSON.parse(v) : v; return Array.isArray(a) ? a : null; } catch (e) { return null; } } /** * 核对单道题目的答案 * @param {number} questionId - 题目 ID * @param {string} questionType - 题型:choice / judge / word * @param {string} answerData - 选手提交的答案 * @returns {Promise} { isCorrect, score, question } */ async checkAnswer(questionId, questionType, answerData) { const QuestionModel = this._getQuestionModel(questionType); // question_uid 现在是「题型_自增id」的全局唯一形式(choice_12 / judge_3), // 判分时需还原为原始自增 id 再 findByPk;同时兼容历史纯数字形态。 const rawId = this._extractRawId(questionId); const question = await QuestionModel.findByPk(rawId); if (!question) { return { isCorrect: false, score: 0, question: null, error: '题目不存在' }; } // 比对答案(去除首尾空格,统一大小写) const correctAnswer = String(question.answer).trim().toLowerCase(); const userAnswer = String(answerData).trim().toLowerCase(); const isCorrect = correctAnswer === userAnswer; return { isCorrect, score: isCorrect ? question.score : 0, question: { id: question.id, question: question.question, question_type: questionType, correctAnswer: question.answer, maxScore: question.score, }, }; } /** * 批量自动判分 * * 🔴 批量化(2026-09-21 并发压测结论):原实现对每道答案 `await this.checkAnswer()`, * 而 checkAnswer 内部是 `findByPk` —— 单次交卷 59~70 题即 59~70 次**串行** DB 往返。 * 这是「单 worker accept 循环被重任务饿死 → 监听队列溢出 → Windows 回 RST」的主因之一 * (压测实测:单 worker 硬容量仅 15 交卷/秒,400 并发下 20% 考生连接被拒)。 * 改为按题型分组,用**最多 2 次** whereIn 取回全部题目,再在内存中判分。 * * 判分语义与逐题版**逐字等价**(不得漂移): * - 答案比对:`String(x).trim().toLowerCase()` 严格相等; * - 得分:答对取 question.score,答错取 0; * - 题目不存在:跳过该题、**不进 details**(与 checkAnswer 返回 question=null 时一致); * - details 的字段名与取值口径不变(question_id 保留调用方传入的原始 uid 形态)。 * * @param {Array} answers - 答案数组 [{ question_id, question_type, answer_data }] * @returns {Promise} { autoScore, total, details } */ async autoScore(answers) { const details = []; let autoScore = 0; let total = 0; // 第一步:按题型收集原始自增 id(去重后一次查出)。 const idsByType = { choice: [], judge: [] }; for (const item of answers) { const type = item.question_type; // 仅 choice/judge 有独立判分模型(word 走 WordQuestion,不在本链路)。 // 用 hasOwnProperty 白名单而非直接属性取值,避免 'constructor' 之类字符串 // 命中 Object 原型链而绕过题型判定。 if (!Object.prototype.hasOwnProperty.call(idsByType, type)) continue; const rawId = this._extractRawId(item.question_id || item.question_uid); if (/^\d+$/.test(String(rawId))) idsByType[type].push(String(rawId)); } const questionMap = new Map(); // `${type}:${id}` → question 实例 for (const type of [ 'choice', 'judge' ]) { const uniqueIds = Array.from(new Set(idsByType[type])); if (!uniqueIds.length) continue; const QuestionModel = this._getQuestionModel(type); const rows = await QuestionModel.findAll({ where: { id: uniqueIds } }); for (const q of rows) questionMap.set(`${type}:${q.id}`, q); } // 第二步:内存判分,保持与原实现相同的顺序(按 answers 入参顺序 push)。 for (const item of answers) { const type = item.question_type; const rawId = this._extractRawId(item.question_id || item.question_uid); const question = questionMap.get(`${type}:${rawId}`); if (!question) continue; // 题目不存在:与原 checkAnswer 行为一致 const correctAnswer = String(question.answer).trim().toLowerCase(); const userAnswer = String(item.answer_data).trim().toLowerCase(); const isCorrect = correctAnswer === userAnswer; const score = isCorrect ? question.score : 0; total += question.score; autoScore += score; details.push({ question_id: item.question_id || item.question_uid, question_type: type, answer_data: item.answer_data, is_correct: isCorrect, score, max_score: question.score, }); } return { autoScore, total, details }; } /** * 查询比赛时间状态 * @param {string} group - 组别 * @returns {Promise} { isActive, startTime, endTime, countdown, status } */ async getExamStatus(group) { const { ctx } = this; const timeConfig = await ctx.model.CompetitionTime.findOne({ where: { group: group }, }); // 未配置时间 if (!timeConfig || !timeConfig.start_time || !timeConfig.end_time) { return { isActive: false, startTime: null, endTime: null, countdown: 0, status: 'not_set', message: '考试时间尚未配置', }; } const now = new Date(); const startTime = parseLocal(timeConfig.start_time); const endTime = parseLocal(timeConfig.end_time); // 考试未开始 if (now < startTime) { return { isActive: false, startTime: startTime.toISOString(), endTime: endTime.toISOString(), countdown: 0, status: 'not_started', message: '考试尚未开始', }; } // 考试进行中 if (now >= startTime && now <= endTime) { const countdown = Math.max(0, Math.floor((endTime - now) / 1000)); return { isActive: true, startTime: startTime.toISOString(), endTime: endTime.toISOString(), countdown, status: 'in_progress', message: '考试正在进行中', }; } // 考试已结束 return { isActive: false, startTime: startTime.toISOString(), endTime: endTime.toISOString(), countdown: 0, status: 'ended', message: '考试已结束', }; } /** * 获取 Word 模板文件路径 * @param {string} group - 组别 * @returns {Promise} 模板文件路径 */ async getWordTemplatePath(group) { const { ctx } = this; const timeConfig = await ctx.model.CompetitionTime.findOne({ where: { group: group }, attributes: ['id', 'word_template'], }); return timeConfig ? timeConfig.word_template : null; } /** * 保存答题记录(逐题存储) * @param {number} aggregationUid - 聚合报名 ID * @param {Array} answers - 答案数组 [{ question_uid, answer_data, is_correct, score }] * @param {string} group - 组别 * @returns {Promise} 保存后的记录列表 */ /** * 交卷冻结守卫(R178·B①)——**唯一判据源** * * 缺陷背景:逐题保存此前只校验「考试窗口 isActive」,不校验是否已正式交卷; * `submit-all` 的 4006 只挡整卷重交。于是「提前交卷 → 窗口关闭前」这段时间内, * `POST /submit-answers` 仍可改写答案并重新判分 —— 即「已提交但分数还能变」。 * 典型触发并非恶意:考生开了两个标签页(或手机+电脑),交卷后另一页仍在自动保存。 * * 判据:`exam_submissions.submit_time` 非空即视为已正式交卷(与选手端 hasSubmitted 同源)。 * * 调用点(两处,共用本函数,⛔ 不得各写一套条件): * ① controller.exam.submitAnswers —— 前置快速失败,避免对已冻结答卷做无谓判分; * ② service.exam.saveAnswers —— 兜底,保证任何调用方(含未来新增)都无法绕过。 * ⚠️ 门禁 `verify_exam_freeze.py` 的变异体要求**两处同改**才允许变红,否则属前道抢答。 * * 为什么不缓存:逐题保存约 59 次/场,主键单列查询为微秒级; * 换来「交卷状态变更立即生效」,无需判断缓存失效时机。 * * @param {number} aggregationUid - 聚合报名 ID(中职/高职即 contestant.id) * @returns {Promise} 未交卷则静默返回 * @throws {Error} bizCode=4006 */ async assertNotSubmitted(aggregationUid) { const { ctx } = this; if (!aggregationUid) return; const submitted = await ctx.model.ExamSubmission.findOne({ where: { contestant_id: aggregationUid }, attributes: ['contestant_id', 'submit_time'], raw: true, }); if (submitted && submitted.submit_time) { const err = new Error('答卷已提交,不能再修改答案'); // bizCode 由 controller 翻译成用户可见的业务错误码(4006 与本项目既有「已提交」口径一致) err.bizCode = 4006; err.bizMessage = '您已交卷,答卷已冻结,不能再修改答案;如需重考请联系管理员'; throw err; } } /** * 保存答题记录(逐题存储) * @param {number} aggregationUid - 聚合报名 ID * @param {Array} answers - 答案数组 [{ question_uid, answer_data, is_correct, score }] * @param {string} group - 组别 * @returns {Promise} 保存后的记录列表 */ async saveAnswers(aggregationUid, answers, group) { const { ctx } = this; // 交卷冻结兜底:所有写答案的路径都经此函数,故此处是本守卫的最后一道闸。 // 不会挡住交卷自身 —— submitAll 是「先 saveAnswers、后 ensureExamSubmission 写 // submit_time」,执行到本函数时 submit_time 仍为 NULL;整卷重交在更早的 4006 已返回; // settleOnly 结算通道刻意不调用本函数。 await this.assertNotSubmitted(aggregationUid); // 根据组别选择对应的提交模型 const SubmissionModel = group === 'zhongzhi' ? ctx.model.ZhongzhiSubmission : ctx.model.GaozhiSubmission; // 死锁判定:并发 upsert 抢同一唯一索引(uk_zhongzhi_submission)时会瞬时死锁, // 属于可重试的瞬时冲突,整体重试事务即可(而非丢数据)。 const isDeadlock = (e) => { if (!e) return false; if (e.code === 'ER_LOCK_DEADLOCK') return true; if (e.parent && e.parent.code === 'ER_LOCK_DEADLOCK') return true; return /ER_LOCK_DEADLOCK/.test(String(e.message || '')); }; // 死锁重试(最多 3 次,指数退避)。事务整体提交/回滚是原子的,要么全落要么全不落。 const MAX_ATTEMPTS = 3; let lastErr = null; for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { try { await ctx.model.transaction(async (t) => { // 🔴 批量化(2026-09-21 并发压测结论):原实现在单事务内 for 循环**逐条** upsert, // 单次交卷 59~70 条即 59~70 次串行 INSERT —— 事务持锁时间被放大到百毫秒级, // 既直接拖慢交卷吞吐(实测单 worker 仅 15 交卷/秒),又显著放大不同考生 // 抢同一唯一索引 uk_*_submission 的概率。改为一次 bulkCreate + // updateOnDuplicate(单条 INSERT ... ON DUPLICATE KEY UPDATE)。 // // 语义保真:与原 upsert 的 SET 列表**逐列相同**(原实现未传 updateOnDuplicate, // Sequelize 取 values 的键集合作为更新列),故不新增/不遗漏任何列; // 特别是**不得**把 word_file_path / file_path 等未参与本链路的列写进更新列表, // 否则会用 NULL 覆盖考生已上传的作品文件信息。 // // ⚠️ question_uid 列是 INT:必须先把前缀形态('choice_683')还原成原始数字 id, // 否则 MySQL 会把字符串强转为 0,导致整卷答案坍缩成 choice/judge 各一行互相覆盖 // (B13 修复:uid 全局唯一化引入的落库回归)。 // // ⚠️ 注意:MariaDB/MySQL 不支持 RETURNING 子句,故不使用 returning, // 仅依赖事务提交 + 调用方 COUNT 复核(见 submitAll)。 const rows = answers.map((answer) => ({ aggregation_uid: aggregationUid, question_uid: this._extractRawId(answer.question_uid), question_type: answer.question_type, answer_data: answer.answer_data, is_correct: answer.is_correct, score: answer.score, })); // 空数组守卫:bulkCreate([]) 在部分 dialect 下会抛错,而原 for 循环是空转成功。 if (!rows.length) return; await SubmissionModel.bulkCreate(rows, { updateOnDuplicate: [ 'aggregation_uid', 'question_uid', 'question_type', 'answer_data', 'is_correct', 'score', ], transaction: t, }); }); return answers.length; } catch (err) { lastErr = err; ctx.logger.error(`[saveAnswers] attempt ${attempt}/${MAX_ATTEMPTS} failed agg=${aggregationUid}:`, err && err.message); if (isDeadlock(err) && attempt < MAX_ATTEMPTS) { await new Promise(r => setTimeout(r, 50 * attempt)); continue; } throw err; } } throw lastErr; } /** * 并发安全地获取/创建 exam_submissions 提交记录。 * 依赖 contestant_id 唯一约束:并发下若另一请求已插入,findOrCreate 的 create 会触发 * ER_DUP_ENTRY,此时直接取回已有记录,避免产生重复行(B12 修复)。 * @param {number} contestantId - 选手 ID * @param {Object} defaults - 新建时的默认值 * @returns {Promise} 提交记录实例 */ async _ensureExamSubmissionSafe(contestantId, defaults) { const { ctx } = this; try { const [rec] = await ctx.model.ExamSubmission.findOrCreate({ where: { contestant_id: contestantId }, defaults, }); return rec; } catch (e) { if (e.name === 'SequelizeUniqueConstraintError' || /ER_DUP_ENTRY/.test(String(e.message))) { return await ctx.model.ExamSubmission.findOne({ where: { contestant_id: contestantId } }); } throw e; } } /** * 获取/创建当前选手的 exam_submissions 提交记录(并发安全) * @param {number} contestantId - 选手 ID * @param {Array} answers - 答题内容 * @returns {Promise} 提交记录实例 */ async ensureExamSubmission(contestantId, answers) { const rec = await this._ensureExamSubmissionSafe(contestantId, { contestant_id: contestantId, answers: answers || [], submit_time: new Date(), }); // 记录可能是团队锁占位 / Word 上传时创建的(submit_time 为 null,表示未正式提交)。 // findOrCreate 命中已有行时不会更新字段,须显式补写 submit_time —— 否则 // 正式提交后 hasSubmitted / 管理后台仍显示「未提交」(提交状态不一致修复)。 if (rec && !rec.submit_time) { await rec.update({ submit_time: new Date(), answers: answers || [] }); } return rec; } /** * 检查用户是否已正式提交全部考试 * 仅当 exam_submissions 表中有 submit_time 记录时才判定为已提交 * @param {number} aggregationUid - 聚合报名 ID * @param {string} group - 组别 * @returns {Promise} 是否已正式提交 */ async hasSubmitted(aggregationUid, group) { const { ctx } = this; // 提交组(本科/教师)的提交记录落在 benke_submissions / jiaoshi_submissions, // 与考试组(中职/高职)的 exam_submissions 不同表。统一在此按组别判定, // 使 GET /api/exam/:group/status 返回的 submitted 对全部组别一致生效 // (前端 Dashboard「已提交」标签 + 进入拦截方可对提交组正确工作)。无新表列。 if (group === 'benke' || group === 'jiaoshi') { const SubmissionModel = group === 'benke' ? ctx.model.BenkeSubmission : ctx.model.JiaoshiSubmission; // 🔴 聚合键铁律:上传链路(service.upload.getUserAggregation)写入的 // benke/jiaoshi_submissions.aggregation_uid = aggregation 表的 aggregation_uid, // 而本方法收到的 aggregationUid 来自 exam 控制器 = contestant.id,真实选手 // 两者不相等(如郑佳怡 contestant.id=11 / aggregation_uid=1)。须按 // account_uid → aggregation_uid 双向映射后再查(与 getScores 口径一致), // 否则真实选手提交后 submitted 恒 false → 标签仍「进行中」+ 可重复进入。 const keys = [ aggregationUid ]; const agg = await ctx.model.Aggregation.findOne({ where: { account_uid: aggregationUid }, attributes: [ 'aggregation_uid' ], }); if (agg && agg.aggregation_uid && !keys.includes(agg.aggregation_uid)) { keys.push(agg.aggregation_uid); } const count = await SubmissionModel.count({ where: { aggregation_uid: keys }, }); return count > 0; } const record = await ctx.model.ExamSubmission.findOne({ where: { contestant_id: aggregationUid }, attributes: ['submit_time'], }); // 统一返回布尔(原为 record && ...,无记录时为 null),与提交组分支保持一致, // 避免调用方对 submitted 字段类型判断不一致。 return !!(record && record.submit_time !== null); } /** * 检查团队考试锁:同一小组是否已有人进入考试 * * 🔴 跨组 team_uid 复用防护(组内一人参赛锁的核心正确性前提): * team_uid / team_id 仅在「组内唯一」,不同组别可复用同一个整数团队号 * (实测:team_uid=1 同时存在于 zhongzhi 与 benke)。若不按组别限定队友, * benke 组选手的 exam_submissions 行会把 zhongzhi 组选手误判为「队友已进入考试」, * 导致 questions / submit-all 无故返回 4005「您的小组已进入考试」。 * 因此队友候选集必须用 contestants.group 复筛为同组(选手组别的权威来源与 * controller._getUserAggregation 的 team.group_name 一致,均取 contestant.group)。 * * @param {number} teamId - 团队 ID * @param {number} excludeContestantId - 排除的选手 ID(当前用户) * @param {string} [group] - 当前选手组别;传入时按组别限定队友(防跨组误锁) * @returns {Promise} { locked: boolean, lockedBy: string|null } */ async checkTeamLock(teamId, excludeContestantId, group) { const { ctx } = this; if (!teamId) return { locked: false, lockedBy: null }; // 队友识别兼容两种团队来源: // - contestants.team_id = teamId(导入流程填充) // - aggregation.team_uid = teamId(聚合报名表填充,兼容旧数据) const contestantWhere = { team_id: teamId }; if (group) contestantWhere.group = group; const contestants = await ctx.model.Contestant.findAll({ where: contestantWhere, attributes: ['id', 'name'], }); const contestantIds = contestants.map(c => c.id); // aggregation 表无组别列声明(Sequelize 模型未定义 group),故此来源先取全量 // 候选,再由下方 contestants.group 复筛统一过滤跨组污染。 const aggRows = await ctx.model.Aggregation.findAll({ where: { team_uid: teamId }, attributes: ['account_uid'], }); const aggIds = aggRows.map(a => a.account_uid); let allIds = Array.from(new Set([...contestantIds, ...aggIds])) .filter(id => id !== excludeContestantId); if (allIds.length === 0) return { locked: false, lockedBy: null }; // 🔴 同组复筛:剔除跨组复用同一 team_uid 的他组选手(见方法头注释) if (group) { const sameGroup = await ctx.model.Contestant.findAll({ where: { id: allIds, group }, attributes: ['id'], }); allIds = sameGroup.map(r => r.id); if (allIds.length === 0) return { locked: false, lockedBy: null }; } const existingSubmission = await ctx.model.ExamSubmission.findOne({ where: { contestant_id: allIds, }, include: [{ model: ctx.model.Contestant, as: 'contestant', attributes: ['name'], }], order: [['id', 'ASC']], }); if (existingSubmission) { return { locked: true, lockedBy: existingSubmission.contestant ? existingSubmission.contestant.name : '队友', }; } return { locked: false, lockedBy: null }; } /** * 设置团队考试锁(创建 exam_submission 占位记录) * @param {number} teamId - 团队 ID * @param {number} contestantId - 选手 ID * @returns {Promise} */ async setTeamLock(teamId, contestantId) { const { ctx } = this; // 使用并发安全方式获取/创建(唯一约束下避免重复行,B12 修复)。 // ⚠️ 占位记录 submit_time 必须为 null:占位 ≠ 正式提交,否则选手一进考试 // 就被 hasSubmitted 判为「已提交」(提交状态不一致 Bug 根源之一)。 const rec = await this._ensureExamSubmissionSafe(contestantId, { contestant_id: contestantId, answers: [], submit_time: null, }); return rec; } /** * 保存 Word 文件上传记录 * @param {number} aggregationUid - 聚合报名 ID * @param {number} questionUid - 题目 ID * @param {string} group - 组别 * @param {Object} fileInfo - 文件信息 { filePath, fileName, md5 } * @returns {Promise} 保存后的记录 */ async saveWordFile(aggregationUid, questionUid, group, fileInfo) { const { ctx } = this; // 查找该选手的答题记录 let record = await ctx.model.ExamSubmission.findOne({ where: { contestant_id: aggregationUid }, }); // 不存在则并发安全地创建(唯一约束下避免重复行,B12 修复)。 // ⚠️ 仅上传 Word ≠ 正式提交,submit_time 保持 null,正式提交由 // ensureExamSubmission 统一补写(提交状态不一致修复)。 if (!record) { record = await this._ensureExamSubmissionSafe(aggregationUid, { contestant_id: aggregationUid, answers: [], submit_time: null, }); } // 更新 Word 文件信息 await record.update({ word_file_path: fileInfo.filePath, word_file_name: fileInfo.fileName, }); return record; } /** * 获取某用户的所有答题记录 * @param {number} aggregationUid - 聚合报名 ID * @param {string} group - 组别 * @returns {Promise} 答题记录列表 */ async getUserAnswers(aggregationUid, group) { const { ctx } = this; const SubmissionModel = group === 'zhongzhi' ? ctx.model.ZhongzhiSubmission : ctx.model.GaozhiSubmission; return await SubmissionModel.findAll({ where: { aggregation_uid: aggregationUid }, }); } /** * 计算 Word 文件 MD5 * @param {string} filePath - 文件路径 * @returns {Promise} MD5 值 */ async calculateFileMd5(filePath) { return new Promise((resolve, reject) => { const hash = crypto.createHash('md5'); const stream = fs.createReadStream(filePath); stream.on('data', chunk => hash.update(chunk)); stream.on('end', () => resolve(hash.digest('hex'))); stream.on('error', reject); }); } } /** * 将数据库中的时间字符串(如 '2026-07-22 09:00:00',无时区标记)按本地时区解析。 * 注意:new Date('2026-07-22 09:00:00') 在 V8 中会按 UTC 解析,导致与业务库 * 本地时间产生约 8 小时的偏移;将空格替换为 'T' 后按本地时区解析可修正该偏差。 * @param {string|Date} ts - 时间字符串或 Date 对象 * @returns {Date} */ function parseLocal(ts) { if (!ts) return new Date(NaN); if (ts instanceof Date) return ts; const s = String(ts).trim().replace(' ', 'T'); return new Date(s); } /** * 规范化题目输出字段 * - 补齐 question_uid(铁律2:前端统一以 question_uid 作为题目稳定标识, * 库表主键为自增 id,question_uid 以其字符串形式暴露) * - options 在库中以 JSON 字符串存储,规范化为数组返回(judge 题型无 options) * @param {Object} q - 原始题目(raw:true 结果) * @param {string} questionType - choice / judge * @returns {Object} 规范化后的题目 */ function normalizeQuestion(q, questionType) { const out = { ...q }; out.question_type = questionType; if (out.question_uid === undefined || out.question_uid === null) { // 题号全局唯一(关键修复):选择题与判断题各自的自增 id 都从 1 开始、会重复, // 若仅用 id 作为 question_uid,choice#1 与 judge#1 会碰撞,导致: // ① 前端 findIndex 命中错误位置 → 序号重复(如判断题里出现「1」)、真实序号丢失(如「55」不见); // ② answers 以 question_uid 为键 → 两题相互覆盖 → 答完仍提示「未作答」/题目丢失。 // 因此以「题型_自增id」作为跨题型全局唯一的稳定标识。 out.question_uid = questionType + '_' + out.id; } if (questionType === 'choice') { if (typeof out.options === 'string') { try { const parsed = JSON.parse(out.options); out.options = Array.isArray(parsed) ? parsed : []; } catch (e) { out.options = []; } } else if (!Array.isArray(out.options)) { out.options = []; } } else { // 判断题无选项 out.options = []; } return out; } module.exports = ExamService;