Files
competition-website/app/controller/exam.js
qubiaobiao 8ef18da395 feat(big-question): 中职/高职组「综合大题」在线作答 + 封堵 auth 成绩泄露三通道
## 一、需求变更:中职/高职组 Word 文件上传题 → 在线「综合大题」

按 2026-09-21 需求变更,中职组 / 高职组取消 Word 文件上传题,改为在线作答
综合大题(1 道大题 × 3 个小问 × 每小问 10 分 = 大题 30 分)。

- 新增表(迁移 023/024)
  · `big_questions`        —— 题库(题干富文本含表格 + 三小问提示语 + 评分口径)
  · `big_question_answers` —— 作答(抽题快照 + 三小问答案 + 按小问得分)
  两份迁移同时提供 `.sql`(生产直跑)与 `.js`(本地/CI 幂等执行)。
- 新增 model:`app/model/bigQuestion.js`、`app/model/bigQuestionAnswer.js`
- 新增 service:`app/service/bigQuestion.js`(抽题 / 保存答案 / 评分核心逻辑)
- 考生端接口(`app/controller/exam.js`)
  · `GET  /api/exam/:group/big-question` —— 首次抽题并落库题干快照,
    后续一律返回同一道题(**幂等**,防止同一考生拿到不同题干引发纠纷)
  · `POST /api/exam/:group/big-answer`   —— 保存三小问答案,带考试窗口门控
- 管理端接口(`app/controller/admin.js`)
  · 题库 CRUD:`/api/admin/big-questions/:group[/:id]`
  · 作答查证:`GET /api/admin/big-answers/:group`(考生 → 抽到的题 → 三小问 → 提交时间)
  · 按小问评分:`PUT /api/admin/big-answers/:group/:uid/scores`
- 服务端封堵越界数据:`app/controller/exam.js` 对中职/高职组的 Word 上传接口
  一并拒绝(前端入口已移除,但直调 API 仍可绕过)。

**所有新增读写仅落在新增的两张表,不触碰任何既有表结构。**

## 二、安全修复(铁律 15 成绩泄露 —— auth 通道缺口)

原有成绩发布门控只覆盖 4 条通道,遗漏了 auth 的**三条同源通道**:三处都直接
回传 `toJSON()`,在 `score_published_<group> = false`(未发布)时照样下发成绩,
而同期 `GET /api/score` 正确返回 `null`。

- `POST /api/auth/login`         —— 实测未发布态回传 `score`
- `GET  /api/auth/current-user`  —— 全字段 `toJSON`,连带下发分项
- `POST /api/auth/refresh`       —— 全字段 `toJSON`,且前端 `doSilentRefresh()`
  会把响应 `user` 写入 `localStorage` 与 Pinia,等效于登录通道;
  实测泄漏 `score` + `exam_score` + `word_score` + `ppt_score` 共 4 个字段

修复方式:新增 `AuthService.toClientUser()` 作为**唯一出口**,三处全部收敛,
禁止各自直接 `toJSON`。口径:
- 该组已发布 → 保留 `score`;未发布 / 查询异常 → 剔除 `score`(异常按未发布,安全默认)
- `exam_score` / `word_score` / `ppt_score` **任何情况下都不下发**
  (前端不使用;保留会形成"总分虽隐藏、分项可加总"的旁路)

门禁 `scripts/verify_score_leak_all_channels.py` 相应从 14 项扩到 20 项,
新增通道⑤/⑥/⑦断言(含"发布后 score 必须恢复"的**正向对照**,防止
"永不回传"式过度封堵造成假绿),并经变异注入自检证明断言可证伪。

## 三、移除已弃用的 Word 模板

中职 / 高职各 3 个历史 Word 模板(`app/public/templates/{zhongzhi,gaozhi}/*.docx`)
随需求一并移除;目录保留 `.gitkeep` 以维持路径存在。

---
验证:G10 铁律15 门禁 20/20 PASS(含变异自检,EXIT=0)、G9 铁律14 门禁 22/22 PASS、
窗口哨兵残留自检 6/6 PASS。前端配套改动见前端仓库提交 1bfe007。
2026-09-21 11:57:58 +08:00

953 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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<Object|null>}
*/
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;