'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; } // 团队锁:同一小组只能一人进入正式考试(原子检查+占位,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; } // 参数校验 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) { 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 || {}; 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 * * 验证考试活跃状态,处理答案提交并确保 Word 文件已上传 * Body: { answers: [{ question_uid, answer_data }] } */ async submitAll() { const { ctx, service } = this; try { const { group } = ctx.params; if (!this._validateGroup(group)) return; // forceSubmit 仅由「倒计时结束的自动提交」传入:允许在考试窗口临界/已关闭、 // 且未答满全部题目时仍记录提交(倒计时归零即合法交卷),确保 submit_time 落库, // 否则会被下方两道闸门拦截 → submit_time 永不写入 → 管理后台回退显示进入考试时间。 const forceSubmit = ctx.request.body && ctx.request.body.forceSubmit === true; // 验证考试是否活跃 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; } // 团队锁:同一小组只能一人进入正式考试(G1 修复,防绕过 questions 直接提交) const teamGate = await this._enterExamWithTeamLock(aggregation); if (!teamGate.allowed) { ctx.body = ctx.helper.error(4005, '您的小组已进入考试'); return; } // 参数校验 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_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); // 成绩门控:不回传 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;