Files
competition-website/app/controller/exam.js

1093 lines
44 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;
}
// 🔴 已正式交卷禁止再次进入(考试组一次性答题,测试反馈 #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;
}
// 参数校验
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 || {};
// 🔴 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;