Files
competition-website/app/controller/exam.js
WorkBuddy a303cf2d53 chore: 后端首次基线提交
运行必须源码:app/(controller/model/service/middleware/lib/utils/validate/router)、config/、database/(migrations/seeders/sql)、启动脚本、app/public/templates 模板。
已忽略运行非必须:node_modules/、logs/、run/、app/public 运行时上传数据、.env*、构建/缓存产物。
2026-08-17 12:25:42 +08:00

814 lines
30 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;
// 获取用户聚合信息
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 文件失败');
}
}
/**
* 一体提交:答案 + 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;