'use strict'; const Controller = require('egg').Controller; const path = require('path'); const fs = require('fs'); const crypto = require('crypto'); const jwt = require('jsonwebtoken'); const { acquire: acquireFileIo, release: releaseFileIo } = require('../lib/file_io_guard'); // 🔴 同源 collapse(缺口9 修复):文件发送统一走 sendFile util const { sendFile } = require('../utils/sendFile'); /** * 团队考试锁的进程内互斥(单实例部署适用)。 * 防止同一小组两名成员"同一瞬间"点击进入考试时,都越过 checkTeamLock * 各自写入占位 submission 而导致两人同时进入正式考试的竞态(G1 修复)。 * 进入临界区的胜者写入占位记录并提交;落后者进入临界区时复检到该记录, * 返回 4005。多实例部署需替换为 Redis 分布式锁。 */ const teamLockChain = new Map(); async function withTeamLock(teamId, fn) { const prev = teamLockChain.get(teamId) || Promise.resolve(); let release; const next = new Promise((resolve) => { release = resolve; }); teamLockChain.set(teamId, prev.then(() => next)); try { await prev; return await fn(); } finally { teamLockChain.delete(teamId); release(); } } /** * 考试控制器 - ExamController * * 负责在线答题相关功能,包括获取题目、提交答案、上传 Word 文件、 * 一体提交等。适用于中职组和高职组的在线考试模式。 * * 主要接口: * - GET /api/exam/:group/status : 获取考试时间状态 * - GET /api/exam/:group/questions : 获取随机题目列表 * - GET /api/exam/:group/word-template: 下载 Word 模板 * - POST /api/exam/:group/submit-answers : 提交答题答案 * - POST /api/exam/:group/upload-word : 上传 Word 操作文件 * - POST /api/exam/:group/submit-all : 一体提交(答案 + 文件) */ class ExamController extends Controller { /** * 校验组别参数是否有效 * @param {string} group - 组别 * @returns {boolean} 是否有效 */ _validateGroup(group) { const validGroups = ['zhongzhi', 'gaozhi', 'benke', 'jiaoshi']; if (!validGroups.includes(group)) { this.ctx.body = this.ctx.helper.error(400, '无效的组别'); return false; } return true; } /** * 获取用户的报名信息及组别 * 直接从 Contestant 表获取(简化版数据库无 accounts/aggregation 表) * @returns {Promise} */ async _getUserAggregation() { const user = this.ctx.state.currentUser; if (!user || !user.id) return null; const contestant = await this.ctx.model.Contestant.findOne({ where: { id: user.id }, attributes: ['id', 'id_number', 'group', 'name', 'team_id'], }); if (!contestant) return null; // 团队归属解析(兼容两种来源): // 1) 首选 contestant.team_id(导入流程填充的整数团队 uid,G1 锁主依据); // 2) 回退到聚合报名表 aggregation.team_uid(避免真实种子仅建了聚合、未回填 contestant.team_id 时锁失效)。 let teamId = contestant.team_id; if (!teamId) { const agg = await this.ctx.model.Aggregation.findOne({ where: { account_uid: contestant.id }, attributes: ['team_uid'], }); if (agg && agg.team_uid) teamId = agg.team_uid; } return { aggregation_uid: contestant.id, team: { group_name: contestant.group, team_id: teamId }, contestant, }; } /** * 团队考试锁统一入口(原子:检查 + 占位):同一小组仅允许一人进入正式考试。 * 用进程内按 team_id 互斥锁包裹「检查他人是否已进 + 为自己写占位记录」, * 消除并发竞态与绕过 questions 直接提交的旁路(G1 修复)。 * @returns {Promise<{allowed:boolean, lockedBy?:string}>} */ async _enterExamWithTeamLock(aggregation) { if (!aggregation.team || !aggregation.team.team_id) { return { allowed: true }; } const teamId = aggregation.team.team_id; // 🔴 team_uid 仅组内唯一:互斥锁与队友判定均须带组别,否则不同组的同号队伍 // 会被互相串行化 + 误判为队友(4005 误锁)。 const group = aggregation.team.group_name; return withTeamLock(`${group}:${teamId}`, async () => { const lockInfo = await this.ctx.service.exam.checkTeamLock(teamId, aggregation.aggregation_uid, group); if (lockInfo.locked) return { allowed: false, lockedBy: lockInfo.lockedBy }; // 抢占成功:为自己写占位 submission(同组他人此后将被 4005 拦截) const rec = await this.ctx.service.exam.setTeamLock(teamId, aggregation.aggregation_uid); return { allowed: true }; }); } /** * 获取考试时间状态 * GET /api/exam/:group/status * * 返回比赛是否激活、开始/结束时间、剩余秒数 */ async status() { const { ctx, service } = this; try { const { group } = ctx.params; if (!this._validateGroup(group)) return; // 获取考试时间状态 const status = await service.exam.getExamStatus(group); // 获取用户的聚合信息,确认组别匹配 const aggregation = await this._getUserAggregation(); if (aggregation && aggregation.team) { const userGroup = aggregation.team.group_name; if (userGroup !== group) { ctx.body = ctx.helper.error(403, '您不属于该组别'); return; } } // 如果已提交过,附加已提交状态 let submitted = false; if (aggregation) { try { submitted = await service.exam.hasSubmitted(aggregation.aggregation_uid, group); } catch (e) { ctx.logger.warn('[Exam] 查询提交状态失败:', e.message); } } // 检查团队锁:同一小组只能有一人进入考试 let teamLocked = false; let teamLockedBy = null; if (aggregation && aggregation.team && aggregation.team.team_id) { try { const lockInfo = await service.exam.checkTeamLock( aggregation.team.team_id, aggregation.aggregation_uid, aggregation.team.group_name ); teamLocked = lockInfo.locked; teamLockedBy = lockInfo.lockedBy || null; } catch (e) { ctx.logger.warn('[Exam] 查询团队锁状态失败:', e.message); } } ctx.body = ctx.helper.success({ isActive: status.isActive, startTime: status.startTime, endTime: status.endTime, countdown: status.countdown, status: status.status, submitted, teamLocked, teamLockedBy, }, '获取考试状态成功'); } catch (err) { ctx.logger.error('获取考试状态失败:', err); ctx.body = ctx.helper.error(500, '获取考试状态失败'); } } /** * 获取随机题目列表 * GET /api/exam/:group/questions * * 从题库中随机抽取指定数量的题目,排除答案字段保证安全 */ async questions() { const { ctx, service } = this; try { const { group } = ctx.params; if (!this._validateGroup(group)) return; // 验证考试是否活跃 const examStatus = await service.exam.getExamStatus(group); if (!examStatus.isActive) { ctx.body = ctx.helper.error(4001, examStatus.message || '考试未在进行中'); return; } // 获取用户聚合信息 const aggregation = await this._getUserAggregation(); if (!aggregation) { ctx.body = ctx.helper.error(403, '未找到您的报名信息'); return; } // 校验组别 if (aggregation.team && aggregation.team.group_name !== group) { ctx.body = ctx.helper.error(403, '您不属于该组别'); return; } // 🔴 已正式交卷禁止再次进入(考试组一次性答题,测试反馈 #3)。 // 这里是「进入考试」的真实门控:题目经此下发,拒绝即等于无法进入答题界面, // 前端按钮置灰只是 UX 层,绕过前端直调本接口同样被拦。 // hasSubmitted 仅在 exam_submissions.submit_time 落库后为 true ⇒ // 未交卷的意外退出(断网/断电)不受影响,仍可重进恢复作答。 // 仅约束考试组;本科/教师走作品提交,不在此限制。 if (group === 'zhongzhi' || group === 'gaozhi') { const alreadySubmitted = await service.exam.hasSubmitted(aggregation.aggregation_uid, group); if (alreadySubmitted) { ctx.body = ctx.helper.error(4006, '您已提交试卷,无法再次进入考试'); return; } } // 团队锁:同一小组只能一人进入正式考试(原子检查+占位,G1 修复) const teamGate = await this._enterExamWithTeamLock(aggregation); if (!teamGate.allowed) { ctx.body = ctx.helper.error(4005, '您的小组已进入考试'); return; } // 抽题数量以 competition_times 配置为准(题目数量不足时 LIMIT 自动按题库实际封顶) let choiceCount = 50; let judgeCount = 20; try { const ct = await ctx.model.CompetitionTime.findOne({ where: { group }, attributes: ['choice_count', 'judge_count'], raw: true, }); if (ct) { choiceCount = ct.choice_count != null ? ct.choice_count : 50; judgeCount = ct.judge_count != null ? ct.judge_count : 20; } } catch (e) { /* 配置缺失时使用默认 50/20 */ } // 取题:按考生固化题目顺序(首次进入随机生成并持久化,之后重进复用同一顺序, // 避免「重进考试题目与顺序变化」缺陷)。顺序固化在 exam_submissions.question_order。 const { questions } = await service.exam.getAssignedQuestions( aggregation.aggregation_uid, group, { choiceCount, judgeCount } ); // 题库实际数量:用于前端提示「配置抽取 N / 题库实际 M」,体现自适应 const [availableChoice, availableJudge] = await Promise.all([ ctx.model.ChoiceQuestion.count({ where: { group } }), ctx.model.JudgeQuestion.count({ where: { group } }), ]); ctx.body = ctx.helper.success({ group, count: questions.length, questions, configuredChoice: choiceCount, configuredJudge: judgeCount, availableChoice, availableJudge, }, '获取题目成功'); } catch (err) { ctx.logger.error('获取题目失败:', err); ctx.body = ctx.helper.error(500, '获取题目失败'); } } /** * 下载 Word 模板文件 * GET /api/exam/:group/word-template * * 以流式方式下载该组别的 Word 操作模板 */ async wordTemplate() { const { ctx, service, app } = this; try { const { group } = ctx.params; if (!this._validateGroup(group)) return; // 鉴权:Word 模板是文件下载(前端通过 fetch 触发,可能只在 URL 上带 ?token= 查询参数, // 也可能带 Authorization 请求头)。这里自行校验 token,兼容两种方式, // 避免被「只认 Authorization 头的 auth 中间件」误判为 401「未提供认证令牌」。 const authHeader = ctx.get('Authorization'); const tokenFromQuery = ctx.query.token; const token = (authHeader && authHeader.startsWith('Bearer ')) ? authHeader.slice(7) : tokenFromQuery; if (!token) { ctx.status = 401; ctx.body = ctx.helper.error(401, '未提供认证令牌,请先登录'); return; } try { const decoded = jwt.verify(token, app.config.jwt.secret); ctx.state.currentUser = { id: decoded.id || decoded.uid || 0, uid: decoded.uid || decoded.id || 0, id_number: decoded.id_number || '', phone: decoded.phone || '', role: decoded.role, }; } catch (e) { ctx.status = 401; ctx.body = ctx.helper.error(401, '认证令牌无效或已过期,请重新登录'); return; } // 获取模板路径 const templatePath = await service.exam.getWordTemplatePath(group); if (!templatePath) { ctx.body = ctx.helper.error(4002, 'Word 模板尚未上传'); return; } // 构建完整文件路径(保留「模板未上传」早返回:service 可能返回不存在路径) const fullPath = path.isAbsolute(templatePath) ? templatePath : path.join(app.baseDir, 'app/public', templatePath); // 检查文件是否存在(早返回,沿用原错误码 4003) if (!fs.existsSync(fullPath)) { ctx.body = ctx.helper.error(4003, '模板文件不存在'); return; } // 🔴 同源 collapse(缺口9 修复):统一走 sendFile(磁盘校验 + X-File-Size 哨兵 + X-Accel 卸载) const fileName = path.basename(fullPath); return sendFile(ctx, app, { relativePath: path.isAbsolute(templatePath) ? undefined : templatePath, absolutePath: path.isAbsolute(templatePath) ? fullPath : undefined, fileName, contentType: 'application/octet-stream', }); } catch (err) { ctx.logger.error('下载模板失败:', err); ctx.body = ctx.helper.error(500, '下载模板失败'); } } /** * 提交答题答案 * POST /api/exam/:group/submit-answers * * 接收答案数组,自动判分并保存记录 * Body: { answers: [{ question_uid, answer_data }] } */ async submitAnswers() { const { ctx, service } = this; try { const { group } = ctx.params; if (!this._validateGroup(group)) return; // 验证考试是否活跃 const examStatus = await service.exam.getExamStatus(group); if (!examStatus.isActive) { ctx.body = ctx.helper.error(4001, examStatus.message || '考试未在进行中'); return; } // 获取用户聚合信息 const aggregation = await this._getUserAggregation(); if (!aggregation) { ctx.body = ctx.helper.error(403, '未找到您的报名信息'); return; } // 团队锁:同一小组只能一人进入正式考试(G1 修复,防绕过 questions 直接提交) const teamGate = await this._enterExamWithTeamLock(aggregation); if (!teamGate.allowed) { ctx.body = ctx.helper.error(4005, '您的小组已进入考试'); return; } // 🔴 交卷冻结(R178·B①):已正式交卷者不得再改写答案。 // 位置刻意在判分之前 —— 已冻结的答卷不该再做一次无意义的 autoScore。 // 与 service.saveAnswers 内的兜底调用是**同一个函数**(单一判据源), // 不是两套条件;异常由下方 catch 按 bizCode 翻译成 4006。 await service.exam.assertNotSubmitted(aggregation.aggregation_uid); // 参数校验 const rule = { answers: { type: 'array', required: true, min: 1 }, }; try { ctx.validate(rule); } catch (validateErr) { ctx.body = ctx.helper.error(400, '参数校验失败:' + validateErr.errors[0].message); return; } const { answers } = ctx.request.body; // 验证每个答案的数据格式(需要 question_id 和 question_type) // 容错:question_type 缺失时按 uid 前缀(choice_x/judge_x)推导, // 避免旧版前端缓存逐题保存时被「答案格式错误」拦截。 for (const ans of answers) { if (!ans.question_id && !ans.question_uid) { ctx.body = ctx.helper.error(400, '答案格式错误,需要 question_id 或 question_uid'); return; } if (!ans.question_type) { const uid = String(ans.question_uid || ans.question_id || ''); if (uid.startsWith('choice')) ans.question_type = 'choice'; else if (uid.startsWith('judge')) ans.question_type = 'judge'; } if (!ans.question_type) { ctx.body = ctx.helper.error(400, '答案格式错误,需要 question_type(choice/judge/word)'); return; } } // 自动判分 const scoreResult = await service.exam.autoScore(answers); // 构建保存数据 const saveData = scoreResult.details.map(d => ({ question_uid: d.question_id, question_type: d.question_type, answer_data: d.answer_data, is_correct: d.is_correct, score: d.score, })); // 保存答题记录 const savedRecords = await service.exam.saveAnswers( aggregation.aggregation_uid, saveData, group ); // ⚠️ 此处【不】调用 ensureExamSubmission:逐题保存只落库客观题答案(saveAnswers), // 绝不写 exam_submissions.submit_time。占位记录由 _enterExamWithTeamLock→setTeamLock // 以 submit_time=null 创建。若在此写入 submit_time,选手答一题即被 hasSubmitted 判为 // 「已提交」,导致意外退出后无法重新进入考试(考试组重进需求的直接根因)。 // 成绩门控:自动评分结果只落库供管理后台使用,绝不回传给考生。 // 历史实现直接返回 autoScore/total/details,考生在浏览器网络面板即可看到客观题得分, // 与「管理后台评分并发布后考生才能看到成绩」的业务约束冲突。 ctx.body = ctx.helper.success({ savedCount: savedRecords.length, }, '答案提交成功'); } catch (err) { // 业务性拒绝(如「已交卷,答卷已冻结」)不该被伪装成 500: // service 层用 bizCode 标记期望的错误码,这里原样翻译给前端。 if (err && err.bizCode) { ctx.logger.warn( `[submitAnswers] 业务拒绝 code=${err.bizCode} msg=${err.bizMessage || err.message}` ); ctx.body = ctx.helper.error(err.bizCode, err.bizMessage || err.message); return; } ctx.logger.error('提交答案失败:', err); ctx.body = ctx.helper.error(500, '提交答案失败'); } } /** * 上传 Word 操作文件 * POST /api/exam/:group/upload-word * * 处理 Word 文件上传,保存到对应组别的目录中 */ async uploadWord() { const { ctx, service, app } = this; try { 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) { ctx.body = ctx.helper.error(403, '未找到您的报名信息'); return; } // 获取上传的文件流 const stream = await ctx.getFileStream(); if (!stream) { ctx.body = ctx.helper.error(400, '未接收到文件'); return; } // 验证文件扩展名(仅允许 .doc / .docx) const ext = path.extname(stream.filename).toLowerCase(); if (!['.doc', '.docx'].includes(ext)) { ctx.body = ctx.helper.error(400, '仅支持 .doc / .docx 格式的 Word 文件'); return; } // 验证文件大小(最大 50MB) const maxSize = 50 * 1024 * 1024; // 50MB // 从请求头获取文件大小 const contentLength = parseInt(ctx.get('content-length')) || 0; if (contentLength > maxSize) { ctx.body = ctx.helper.error(400, '文件大小超过限制(最大 50MB)'); return; } // 🔴 文件 IO 并发护栏:与分片上传/合并共享配额,极端并发下统一限流, // 超出返回 429 由前端退避重试,避免单 worker fd/事件循环耗尽。 try { await acquireFileIo(); } catch (e) { ctx.status = 429; ctx.body = ctx.helper.error(429, '服务器繁忙,上传通道暂满,请稍后重试'); return; } try { // 构建存储目录:app/public/answers/{group}/ const uploadDir = path.join(app.baseDir, 'app/public/answers', group); if (!fs.existsSync(uploadDir)) { fs.mkdirSync(uploadDir, { recursive: true }); } // 构建文件名:{aggregation_uid}_{timestamp}_{原始文件名} const timestamp = Date.now(); const safeFileName = `exam_${aggregation.aggregation_uid}_${timestamp}${ext}`; const filePath = path.join(uploadDir, safeFileName); // 将文件流写入磁盘 const writeStream = fs.createWriteStream(filePath); await new Promise((resolve, reject) => { stream.pipe(writeStream); writeStream.on('finish', resolve); writeStream.on('error', reject); stream.on('error', reject); }); // 计算文件 MD5 const md5 = await service.exam.calculateFileMd5(filePath); // 构建相对路径(相对于 app/public/) const relativePath = path.join('answers', group, safeFileName).replace(/\\/g, '/'); // 从 query 或 body 获取关联的题目 ID const questionUid = ctx.query.question_uid || (ctx.request.body && ctx.request.body.question_uid); // 保存文件信息到数据库 const fileInfo = { filePath: relativePath, fileName: stream.filename, md5, }; const record = await service.exam.saveWordFile( aggregation.aggregation_uid, questionUid || 0, group, fileInfo ); ctx.body = ctx.helper.success({ id: record.id, fileName: stream.filename, filePath: relativePath, fileUrl: ctx.helper.fileUrl(relativePath), fileSize: contentLength, md5, }, 'Word 文件上传成功'); } finally { releaseFileIo(); } } catch (err) { ctx.logger.error('上传 Word 文件失败:', err); ctx.body = ctx.helper.error(500, '上传 Word 文件失败'); } } /** * 删除已上传的 Word 答题文件(中职/高职组) * DELETE /api/exam/:group/word * * 仅允许删除当前登录用户本人的 Word 文件:清空 exam_submissions 中的 * word_file_path / word_file_name,并尽力删除磁盘物理文件(文件不存在时静默忽略)。 * 注意:删除 Word 文件不影响「正式提交」状态(submit_time 不变),仅移除附件。 */ async deleteWord() { const { ctx, app } = this; try { const { group } = ctx.params; if (!this._validateGroup(group)) return; if (!['zhongzhi', 'gaozhi'].includes(group)) { ctx.body = this.ctx.helper.error(400, '仅中职组/高职组支持删除 Word 答题文件'); return; } const aggregation = await this._getUserAggregation(); if (!aggregation) { ctx.body = ctx.helper.error(403, '未找到您的报名信息'); return; } const record = await ctx.model.ExamSubmission.findOne({ where: { contestant_id: aggregation.aggregation_uid }, }); if (!record || !record.word_file_path) { ctx.body = ctx.helper.error(404, '未找到已上传的 Word 文件'); return; } // 尽力删除磁盘物理文件(沙箱安全钩子可能拦截 unlink,文件不存在时忽略) const absPath = path.join(app.baseDir, 'app/public', record.word_file_path); try { if (fs.existsSync(absPath)) fs.unlinkSync(absPath); } catch (e) { ctx.logger.warn('删除 Word 物理文件失败(忽略):', e.message); } // 清空数据库中的文件信息 await record.update({ word_file_path: null, word_file_name: null }); ctx.body = ctx.helper.success({ deleted: true }, 'Word 文件已删除'); } catch (err) { ctx.logger.error('删除 Word 文件失败:', err); ctx.body = ctx.helper.error(500, '删除 Word 文件失败'); } } /** * 获取本次抽中的综合大题(仅中职组 / 高职组) * 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 || {}; // 🔴 2026-09-21 同源加固(与 submitAll 的结算通道同构): // 原实现为 `if (!examStatus.isActive && !body.forceSubmit)` —— 该布尔完全来自请求体, // 任何考生都能在截止后自行携带 forceSubmit 继续改写综合大题作答; // 而综合大题由人工阅卷计分,等同「截止后继续答题」,公平性受损。 // 现改为服务端判定:窗口关闭时只允许把**库内已有作答**标记为已提交(markSubmitted), // 绝不接受请求体内容;库内无作答行则明确拒绝。 const examStatus = await service.exam.getExamStatus(group); const windowOpen = examStatus.isActive === true; const aggregation = await this._getUserAggregation(); if (!aggregation) { ctx.body = ctx.helper.error(403, '未找到您的报名信息'); return; } if (!windowOpen) { // 结算语义:不写内容,只补 submit_time(库内无该考生作答行 → 返回 null → 拒绝) const settled = await service.bigQuestion.markSubmitted(aggregation.aggregation_uid); if (!settled) { ctx.body = ctx.helper.error(4001, examStatus.message || '考试未在进行中'); return; } ctx.body = ctx.helper.success({ filled: [ settled.answer_1, settled.answer_2, settled.answer_3 ] .filter((v) => v && String(v).trim().length > 0).length, subCount: service.bigQuestion.subQuestionCount(), maxChars: service.bigQuestion.answerMaxChars(), savedAt: new Date().toISOString(), settled: true, }, '考试已结束,已按已保存内容结算'); 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 * * 验证考试活跃状态,处理答案提交并确保 Word 文件已上传 * Body: { answers: [{ question_uid, answer_data }] } */ async submitAll() { const { ctx, service } = this; try { const { group } = ctx.params; if (!this._validateGroup(group)) return; // forceSubmit 由「倒计时结束的自动提交」传入,其作用已被收敛为**仅**豁免下方 // 「未答满全部题目」闸门(倒计时归零即合法交卷,不应因少答几题而交不上)。 // // 🔴 2026-09-21 加固:forceSubmit **不再**授予「绕过考试窗口」的权力。 // 原实现为 `if (!examStatus.isActive && !forceSubmit)` —— 该布尔完全来自请求体, // 任何考生都可在截止后自行携带 forceSubmit 重新提交并改写答案。 // 窗口是否开放改由服务端判定(见下方结算通道),不接受客户端的自我声明。 const forceSubmit = ctx.request.body && ctx.request.body.forceSubmit === true; // 验证考试是否活跃 const examStatus = await service.exam.getExamStatus(group); // 获取用户聚合信息(须先于窗口判定:结算通道需要按考生查已落库作答) const aggregation = await this._getUserAggregation(); if (!aggregation) { ctx.body = ctx.helper.error(403, '未找到您的报名信息'); return; } // 🔴 服务端窗口判定 + 截止后结算通道(2026-09-21 并发压测结论) // // 缺陷背景:400 并发同刻交卷时,约 20% 考生的连接在 TCP accept 层即被拒 // (Windows 在 accept 队列满时回 RST,客户端得到 10061),请求**从未到达业务逻辑**; // 前端遇到该情况只能提示「请手动点击提交全部」,而手动交卷不带 forceSubmit, // 窗口关闭后必被 4001 拦下 —— 这批考生的 exam_submissions.submit_time 永久为空, // 管理后台显示「未提交」、成绩无法归集。即「答案明明已在库里,却交不上卷」。 // // 因此窗口关闭后开放**服务端结算通道**(settleOnly): // - 只补写 exam_submissions.submit_time + answers 快照,把库内作答正式标记为已提交; // - 严格以数据库已落库作答为准,**忽略请求体答案** —— 否则考生可借该通道在截止后 // 继续作答刷分,公平性受损; // - 一题未答(库内无作答行)→ 仍按 4001 拒绝(无内容可结算)。 const windowOpen = examStatus.isActive === true; let settleOnly = false; let storedAnswers = null; if (!windowOpen) { const existing = await service.exam.getUserAnswers(aggregation.aggregation_uid, group); storedAnswers = (existing || []).filter( (r) => r.answer_data !== null && r.answer_data !== undefined && String(r.answer_data).trim() !== '' ); if (!storedAnswers.length) { ctx.body = ctx.helper.error(4001, examStatus.message || '考试未在进行中'); return; } settleOnly = true; // 幂等:此前已正式提交过(自动交卷其实成功了,只是响应在连接层丢失)→ // 直接返回成功并回吐真实 submit_time,不重复写库。 // 这解决了「考生不确定自己交上没有、反复点提交」的焦虑式重试。 const alreadySubmitted = await service.exam.hasSubmitted(aggregation.aggregation_uid, group); if (alreadySubmitted) { const rec = await ctx.model.ExamSubmission.findOne({ where: { contestant_id: aggregation.aggregation_uid }, attributes: [ 'submit_time' ], }); ctx.body = ctx.helper.success({ answeredCount: storedAnswers.length, totalQuestions: storedAnswers.length, hasWordFile: storedAnswers.some((s) => s.word_file_path), savedCount: storedAnswers.length, submitted: true, settled: true, submitTime: (rec && rec.submit_time) ? new Date(rec.submit_time).toISOString() : new Date().toISOString(), }, '提交成功(此前已提交)'); return; } } // 🔴 窗口内重复交卷拦截(考试组,测试反馈 #3)。 // 背景:下方窗口内分支会重新校验请求体答案并覆盖写库 ⇒ 已交卷者若可重进, // 即可「改答案 → 再交卷」无损刷分。入口 questions() 已拦重进,此处再堵住 // 绕过前端直接重放 submit-all 的路径。 // ⚠️ 只作用于窗口打开时;窗口关闭后走上方结算通道(那是补写标记,不是重新作答)。 if (windowOpen && (group === 'zhongzhi' || group === 'gaozhi')) { const alreadyDone = await service.exam.hasSubmitted(aggregation.aggregation_uid, group); if (alreadyDone) { ctx.body = ctx.helper.error(4006, '您已提交试卷,不能重复提交'); return; } } // 团队锁:同一小组只能一人进入正式考试(G1 修复,防绕过 questions 直接提交) // ⚠️ 结算通道跳过:一是窗口已关闭不应再产生 setTeamLock 写副作用, // 二是结算绝不能被队友残留锁拦下(否则又回到「答案在库却交不上」)。 if (!settleOnly) { const teamGate = await this._enterExamWithTeamLock(aggregation); if (!teamGate.allowed) { ctx.body = ctx.helper.error(4005, '您的小组已进入考试'); return; } } // 参数校验(结算通道不接收请求体答案,故放宽为可空) const rule = { answers: settleOnly ? { type: 'array', required: false } : { type: 'array', required: true, min: 1 }, }; try { ctx.validate(rule); } catch (validateErr) { ctx.body = ctx.helper.error(400, '参数校验失败:' + validateErr.errors[0].message); return; } // ---------- 结算通道:只补 submit_time,绝不改动任何答题行 ---------- // 刻意不做 autoScore / saveAnswers:库内 is_correct / score 已由考生逐题保存时 // 判分落库(submitAnswers 链路),此处若重新判分并回写,等于在截止后修改作答与判分, // 既有公平性风险,也可能因题目变更而覆盖考生既得的判分结果。 if (settleOnly) { const snapshot = storedAnswers.map((r) => ({ question_uid: r.question_uid, question_type: r.question_type, answer_data: r.answer_data, })); await service.exam.ensureExamSubmission(aggregation.aggregation_uid, snapshot); // 大题作答同样补 submit_time(结算通道只补标记、不改内容) await service.bigQuestion.markSubmitted(aggregation.aggregation_uid); ctx.logger.warn( `[submitAll][settle] 窗口已关闭,按库内作答结算 agg=${aggregation.aggregation_uid} questions=${snapshot.length}` ); ctx.body = ctx.helper.success({ answeredCount: snapshot.length, totalQuestions: snapshot.length, hasWordFile: storedAnswers.some((s) => s.word_file_path), savedCount: snapshot.length, submitted: true, settled: true, submitTime: new Date().toISOString(), }, '提交成功(考试已结束,按已保存答案结算)'); return; } const { answers } = ctx.request.body; // 验证每个答案的数据格式(必须包含 question_type,否则 autoScore 无法判分) // 容错:question_type 缺失时按 uid 前缀(choice_x/judge_x)推导, // 避免恢复会话等场景 questions 未加载导致整卷被「答案格式错误」拦截。 for (const ans of answers) { if (!ans.question_uid || ans.answer_data === undefined) { ctx.body = ctx.helper.error(400, '答案格式错误,需要 question_uid 和 answer_data'); return; } if (!ans.question_type) { const uid = String(ans.question_uid); if (uid.startsWith('choice')) ans.question_type = 'choice'; else if (uid.startsWith('judge')) ans.question_type = 'judge'; } if (!ans.question_type || !['choice', 'judge'].includes(ans.question_type)) { ctx.body = ctx.helper.error(400, '答案格式错误,需要合法的 question_type(choice/judge)'); return; } } // 校验是否所有题目都已作答:以【出题数量】为基准(前端回传 totalQuestions), // 避免与随机抽题基准不一致。原先使用全库 999 比对,会导致只抽到 N 题却要求答满全库的悖论。 const expectedCount = parseInt(ctx.request.body.totalQuestions) || answers.length; if (answers.length < expectedCount && !forceSubmit) { ctx.body = ctx.helper.error(4004, `还有 ${expectedCount - answers.length} 道题目未作答`); return; } // 检查是否已上传 Word 文件 const existingSubmission = await service.exam.getUserAnswers( aggregation.aggregation_uid, group ); const hasWordFile = existingSubmission.some(s => s.word_file_path); // 自动判分 const scoreResult = await service.exam.autoScore(answers); // 构建保存数据 const saveData = scoreResult.details.map(d => ({ question_uid: d.question_id, question_type: d.question_type, answer_data: d.answer_data, is_correct: d.is_correct, score: d.score, })); // 保存答题记录 const savedRecords = await service.exam.saveAnswers( aggregation.aggregation_uid, saveData, group ); // 服务端三重断言:事务提交 ≠ 一定落库(MariaDB 不支持 RETURNING / 死锁回滚等 // 都会导致「内存返回成功实例、磁盘 0 行」的静默丢数据)。故提交后实查持久化条数, // 与实际应保存条数比对,缺失即视为提交失败,绝不明文返回「提交成功」。 const SubmissionModel = group === 'zhongzhi' ? ctx.model.ZhongzhiSubmission : ctx.model.GaozhiSubmission; const persisted = await SubmissionModel.count({ where: { aggregation_uid: aggregation.aggregation_uid }, }); if (!persisted || persisted < answers.length) { ctx.logger.error(`[submitAll] 落库不完整 agg=${aggregation.aggregation_uid} persisted=${persisted} expected=${answers.length}`); ctx.body = ctx.helper.error(500, `提交失败:答案保存不完整(已保存 ${persisted || 0}/${answers.length} 题),请重试`); return; } // 同步写入 exam_submissions 汇总行(answers JSON + submit_time), // 供选手端 hasSubmitted / 管理后台「考生答题记录」提交状态 / 试卷回看使用。 // 聚合键取 aggregation.aggregation_uid(中职/高职即 contestant.id),与 hasSubmitted 一致。 await service.exam.ensureExamSubmission(aggregation.aggregation_uid, saveData); // 综合大题(中职/高职)作答随整卷正式提交补写 submit_time。 // 🔴 2026-09-21 修复:service.bigQuestion.markSubmitted 此前**无任何调用点**(死代码), // 而 big_question_answers.submit_time 的列注释明确写着「随整卷正式提交写入(未提交为 NULL)」 // → 中职/高职考生的大题 submit_time 恒为 NULL,管理端据此列区分「已作答未提交」时 // 会把真实作答误判为未提交。markSubmitted 内部有 if (!submit_time) 保护, // 重复提交不会覆盖首次提交时间,天然幂等;无大题作答行时返回 null(如本科/教师组),无副作用。 await service.bigQuestion.markSubmitted(aggregation.aggregation_uid); // 成绩门控:不回传 autoScore/total/details(自动评分仅落库供管理后台阅卷与合分使用)。 // 考生端提交后只应得到「提交成功 + 已保存题数」的确认,成绩须待管理后台发布。 ctx.body = ctx.helper.success({ answeredCount: answers.length, totalQuestions: expectedCount, hasWordFile, savedCount: persisted, submitted: true, submitTime: new Date().toISOString(), }, '提交成功'); } catch (err) { ctx.logger.error('一体提交失败:', err); ctx.body = ctx.helper.error(500, '提交失败'); } } /** * 获取已有答案 * GET /api/exam/:group/answers * * 获取用户已提交的答案,用于考试重新进入时恢复状态 */ async getAnswers() { const { ctx, service } = this; try { const { group } = ctx.params; if (!this._validateGroup(group)) return; // 获取用户聚合信息 const aggregation = await this._getUserAggregation(); if (!aggregation) { ctx.body = ctx.helper.error(403, '未找到您的报名信息'); return; } // 获取已有答案 const answers = await service.exam.getUserAnswers( aggregation.aggregation_uid, group ); // 成绩门控:未发布前,答案回看不得携带逐题得分与对错标记。 // 否则考生把 score 累加即可得出客观题总分,完全绕过「管理后台发布后才可见」的约束。 // 答案内容本身保留(前端用于恢复/回看作答)。 const isPublished = await service.admin.isScorePublished(group); const safeAnswers = answers.map(row => { const item = typeof row.toJSON === 'function' ? row.toJSON() : { ...row }; if (!isPublished) { delete item.score; delete item.is_correct; } // 还原 question_uid 为前端键格式(choice_x / judge_x):DB 中 zhongzhi/gaozhi_submissions // .question_uid 经 _extractRawId 存为数字 id(int 列),而前端 answers 以 'choice_x' 字符串为键, // 若直接回传数字 id,重进恢复答案时键对不上 → 已答题目全部丢失。补回题型前缀保证可恢复; // 同时保留 raw_question_uid 供后台阅卷/回看按数字 id 定位题目。 if (item.question_type && item.question_uid != null) { const prefix = item.question_type === 'judge' ? 'judge_' : 'choice_'; item.raw_question_uid = item.question_uid; item.question_uid = prefix + item.question_uid; } return item; }); ctx.body = ctx.helper.success({ answers: safeAnswers, scoreVisible: isPublished, }, '获取已有答案成功'); } catch (err) { ctx.logger.error('获取已有答案失败:', err); ctx.body = ctx.helper.error(500, '获取已有答案失败'); } } } module.exports = ExamController;