- R176 成绩公示表引擎(lib/publicitySheet.js + lib/assets/publicity-template.xlsx 官方模板基底) 与指导教师回填(lib/teacherBackfill.js);赛项名称/编号改为配置项 - R177 新闻「发布 ↔ 撤回」互斥:撤回只置 is_draft=1,不删除数据 - R178 C 层 RBAC:lib/adminPermissions.js(13 权限点 / 70 条路由规则,唯一 SoT) + middleware/auth.js 三段判定(默认拒绝 · is_super 单点 · token_version) + controller/service/adminUser.js + lib/auditLog.js + model/adminAuditLog.js - R178 B1 交卷冻结:service.exam.assertNotSubmitted(bizCode 4006 单一判据源) - R178 B④ 提交口径拆分:formalSubmitted / submitted / answeredCount 三字段 - R178 成绩总览补小组编号:resolveTeamLabels(与选手管理同源,含兜底与三态) - R181 生产迁移配套:admins +7 列、admin_audit_logs(scripts/migrate_r178_admin_rbac.py)
1108 lines
45 KiB
JavaScript
1108 lines
45 KiB
JavaScript
'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;
|
||
}
|
||
|
||
// 🔴 交卷冻结(R178·B①):已正式交卷者不得再改写答案。
|
||
// 位置刻意在判分之前 —— 已冻结的答卷不该再做一次无意义的 autoScore。
|
||
// 与 service.saveAnswers 内的兜底调用是**同一个函数**(单一判据源),
|
||
// 不是两套条件;异常由下方 catch 按 bizCode 翻译成 4006。
|
||
await service.exam.assertNotSubmitted(aggregation.aggregation_uid);
|
||
|
||
// 参数校验
|
||
const rule = {
|
||
answers: { type: 'array', required: true, min: 1 },
|
||
};
|
||
try {
|
||
ctx.validate(rule);
|
||
} catch (validateErr) {
|
||
ctx.body = ctx.helper.error(400, '参数校验失败:' + validateErr.errors[0].message);
|
||
return;
|
||
}
|
||
|
||
const { answers } = ctx.request.body;
|
||
|
||
// 验证每个答案的数据格式(需要 question_id 和 question_type)
|
||
// 容错:question_type 缺失时按 uid 前缀(choice_x/judge_x)推导,
|
||
// 避免旧版前端缓存逐题保存时被「答案格式错误」拦截。
|
||
for (const ans of answers) {
|
||
if (!ans.question_id && !ans.question_uid) {
|
||
ctx.body = ctx.helper.error(400, '答案格式错误,需要 question_id 或 question_uid');
|
||
return;
|
||
}
|
||
if (!ans.question_type) {
|
||
const uid = String(ans.question_uid || ans.question_id || '');
|
||
if (uid.startsWith('choice')) ans.question_type = 'choice';
|
||
else if (uid.startsWith('judge')) ans.question_type = 'judge';
|
||
}
|
||
if (!ans.question_type) {
|
||
ctx.body = ctx.helper.error(400, '答案格式错误,需要 question_type(choice/judge/word)');
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 自动判分
|
||
const scoreResult = await service.exam.autoScore(answers);
|
||
|
||
// 构建保存数据
|
||
const saveData = scoreResult.details.map(d => ({
|
||
question_uid: d.question_id,
|
||
question_type: d.question_type,
|
||
answer_data: d.answer_data,
|
||
is_correct: d.is_correct,
|
||
score: d.score,
|
||
}));
|
||
|
||
// 保存答题记录
|
||
const savedRecords = await service.exam.saveAnswers(
|
||
aggregation.aggregation_uid,
|
||
saveData,
|
||
group
|
||
);
|
||
|
||
// ⚠️ 此处【不】调用 ensureExamSubmission:逐题保存只落库客观题答案(saveAnswers),
|
||
// 绝不写 exam_submissions.submit_time。占位记录由 _enterExamWithTeamLock→setTeamLock
|
||
// 以 submit_time=null 创建。若在此写入 submit_time,选手答一题即被 hasSubmitted 判为
|
||
// 「已提交」,导致意外退出后无法重新进入考试(考试组重进需求的直接根因)。
|
||
|
||
// 成绩门控:自动评分结果只落库供管理后台使用,绝不回传给考生。
|
||
// 历史实现直接返回 autoScore/total/details,考生在浏览器网络面板即可看到客观题得分,
|
||
// 与「管理后台评分并发布后考生才能看到成绩」的业务约束冲突。
|
||
ctx.body = ctx.helper.success({
|
||
savedCount: savedRecords.length,
|
||
}, '答案提交成功');
|
||
} catch (err) {
|
||
// 业务性拒绝(如「已交卷,答卷已冻结」)不该被伪装成 500:
|
||
// service 层用 bizCode 标记期望的错误码,这里原样翻译给前端。
|
||
if (err && err.bizCode) {
|
||
ctx.logger.warn(
|
||
`[submitAnswers] 业务拒绝 code=${err.bizCode} msg=${err.bizMessage || err.message}`
|
||
);
|
||
ctx.body = ctx.helper.error(err.bizCode, err.bizMessage || err.message);
|
||
return;
|
||
}
|
||
ctx.logger.error('提交答案失败:', err);
|
||
ctx.body = ctx.helper.error(500, '提交答案失败');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 上传 Word 操作文件
|
||
* POST /api/exam/:group/upload-word
|
||
*
|
||
* 处理 Word 文件上传,保存到对应组别的目录中
|
||
*/
|
||
async uploadWord() {
|
||
const { ctx, service, app } = this;
|
||
|
||
try {
|
||
const { group } = ctx.params;
|
||
if (!this._validateGroup(group)) return;
|
||
|
||
// 🔴 中职组/高职组已取消 Word 文件上传题(改为在线作答「综合大题」)。
|
||
// 前端入口已移除,但直调 API 仍可绕过,故在服务端一并封堵,避免出现
|
||
// 「界面上没这道题、后台却收到 Word 附件」的越界数据(2026-09-21)。
|
||
if (['zhongzhi', 'gaozhi'].includes(group)) {
|
||
ctx.body = ctx.helper.error(
|
||
400,
|
||
'中职组/高职组已取消 Word 文件上传题,请在「综合大题」中在线作答'
|
||
);
|
||
return;
|
||
}
|
||
|
||
// 获取用户聚合信息
|
||
const aggregation = await this._getUserAggregation();
|
||
if (!aggregation) {
|
||
ctx.body = ctx.helper.error(403, '未找到您的报名信息');
|
||
return;
|
||
}
|
||
|
||
// 获取上传的文件流
|
||
const stream = await ctx.getFileStream();
|
||
if (!stream) {
|
||
ctx.body = ctx.helper.error(400, '未接收到文件');
|
||
return;
|
||
}
|
||
|
||
// 验证文件扩展名(仅允许 .doc / .docx)
|
||
const ext = path.extname(stream.filename).toLowerCase();
|
||
if (!['.doc', '.docx'].includes(ext)) {
|
||
ctx.body = ctx.helper.error(400, '仅支持 .doc / .docx 格式的 Word 文件');
|
||
return;
|
||
}
|
||
|
||
// 验证文件大小(最大 50MB)
|
||
const maxSize = 50 * 1024 * 1024; // 50MB
|
||
// 从请求头获取文件大小
|
||
const contentLength = parseInt(ctx.get('content-length')) || 0;
|
||
if (contentLength > maxSize) {
|
||
ctx.body = ctx.helper.error(400, '文件大小超过限制(最大 50MB)');
|
||
return;
|
||
}
|
||
|
||
// 🔴 文件 IO 并发护栏:与分片上传/合并共享配额,极端并发下统一限流,
|
||
// 超出返回 429 由前端退避重试,避免单 worker fd/事件循环耗尽。
|
||
try {
|
||
await acquireFileIo();
|
||
} catch (e) {
|
||
ctx.status = 429;
|
||
ctx.body = ctx.helper.error(429, '服务器繁忙,上传通道暂满,请稍后重试');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// 构建存储目录:app/public/answers/{group}/
|
||
const uploadDir = path.join(app.baseDir, 'app/public/answers', group);
|
||
if (!fs.existsSync(uploadDir)) {
|
||
fs.mkdirSync(uploadDir, { recursive: true });
|
||
}
|
||
|
||
// 构建文件名:{aggregation_uid}_{timestamp}_{原始文件名}
|
||
const timestamp = Date.now();
|
||
const safeFileName = `exam_${aggregation.aggregation_uid}_${timestamp}${ext}`;
|
||
const filePath = path.join(uploadDir, safeFileName);
|
||
|
||
// 将文件流写入磁盘
|
||
const writeStream = fs.createWriteStream(filePath);
|
||
await new Promise((resolve, reject) => {
|
||
stream.pipe(writeStream);
|
||
writeStream.on('finish', resolve);
|
||
writeStream.on('error', reject);
|
||
stream.on('error', reject);
|
||
});
|
||
|
||
// 计算文件 MD5
|
||
const md5 = await service.exam.calculateFileMd5(filePath);
|
||
|
||
// 构建相对路径(相对于 app/public/)
|
||
const relativePath = path.join('answers', group, safeFileName).replace(/\\/g, '/');
|
||
|
||
// 从 query 或 body 获取关联的题目 ID
|
||
const questionUid = ctx.query.question_uid
|
||
|| (ctx.request.body && ctx.request.body.question_uid);
|
||
|
||
// 保存文件信息到数据库
|
||
const fileInfo = {
|
||
filePath: relativePath,
|
||
fileName: stream.filename,
|
||
md5,
|
||
};
|
||
const record = await service.exam.saveWordFile(
|
||
aggregation.aggregation_uid,
|
||
questionUid || 0,
|
||
group,
|
||
fileInfo
|
||
);
|
||
|
||
ctx.body = ctx.helper.success({
|
||
id: record.id,
|
||
fileName: stream.filename,
|
||
filePath: relativePath,
|
||
fileUrl: ctx.helper.fileUrl(relativePath),
|
||
fileSize: contentLength,
|
||
md5,
|
||
}, 'Word 文件上传成功');
|
||
} finally {
|
||
releaseFileIo();
|
||
}
|
||
} catch (err) {
|
||
ctx.logger.error('上传 Word 文件失败:', err);
|
||
ctx.body = ctx.helper.error(500, '上传 Word 文件失败');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 删除已上传的 Word 答题文件(中职/高职组)
|
||
* DELETE /api/exam/:group/word
|
||
*
|
||
* 仅允许删除当前登录用户本人的 Word 文件:清空 exam_submissions 中的
|
||
* word_file_path / word_file_name,并尽力删除磁盘物理文件(文件不存在时静默忽略)。
|
||
* 注意:删除 Word 文件不影响「正式提交」状态(submit_time 不变),仅移除附件。
|
||
*/
|
||
async deleteWord() {
|
||
const { ctx, app } = this;
|
||
|
||
try {
|
||
const { group } = ctx.params;
|
||
if (!this._validateGroup(group)) return;
|
||
if (!['zhongzhi', 'gaozhi'].includes(group)) {
|
||
ctx.body = this.ctx.helper.error(400, '仅中职组/高职组支持删除 Word 答题文件');
|
||
return;
|
||
}
|
||
|
||
const aggregation = await this._getUserAggregation();
|
||
if (!aggregation) {
|
||
ctx.body = ctx.helper.error(403, '未找到您的报名信息');
|
||
return;
|
||
}
|
||
|
||
const record = await ctx.model.ExamSubmission.findOne({
|
||
where: { contestant_id: aggregation.aggregation_uid },
|
||
});
|
||
|
||
if (!record || !record.word_file_path) {
|
||
ctx.body = ctx.helper.error(404, '未找到已上传的 Word 文件');
|
||
return;
|
||
}
|
||
|
||
// 尽力删除磁盘物理文件(沙箱安全钩子可能拦截 unlink,文件不存在时忽略)
|
||
const absPath = path.join(app.baseDir, 'app/public', record.word_file_path);
|
||
try {
|
||
if (fs.existsSync(absPath)) fs.unlinkSync(absPath);
|
||
} catch (e) {
|
||
ctx.logger.warn('删除 Word 物理文件失败(忽略):', e.message);
|
||
}
|
||
|
||
// 清空数据库中的文件信息
|
||
await record.update({ word_file_path: null, word_file_name: null });
|
||
|
||
ctx.body = ctx.helper.success({ deleted: true }, 'Word 文件已删除');
|
||
} catch (err) {
|
||
ctx.logger.error('删除 Word 文件失败:', err);
|
||
ctx.body = ctx.helper.error(500, '删除 Word 文件失败');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取本次抽中的综合大题(仅中职组 / 高职组)
|
||
* GET /api/exam/:group/big-question
|
||
*
|
||
* 2026-09-21 需求变更:中职/高职取消 Word 文件上传题,改为随机抽取一道大题在线作答。
|
||
* 语义:**首次调用抽题并落库题干快照,之后一律返回同一道题**(幂等,防考生纠纷),
|
||
* 同时回传已保存的三小问作答,供重新进入考试时恢复。
|
||
*
|
||
* ⚠️ 不回传任何分数:成绩仅经 GET /api/score 单通道(发布门控后)下发,铁律15。
|
||
*/
|
||
async bigQuestion() {
|
||
const { ctx, service } = this;
|
||
|
||
try {
|
||
const { group } = ctx.params;
|
||
if (!this._validateGroup(group)) return;
|
||
|
||
if (!service.bigQuestion.isEnabledGroup(group)) {
|
||
ctx.body = ctx.helper.error(400, '该组别未启用综合大题');
|
||
return;
|
||
}
|
||
|
||
const aggregation = await this._getUserAggregation();
|
||
if (!aggregation) {
|
||
ctx.body = ctx.helper.error(403, '未找到您的报名信息');
|
||
return;
|
||
}
|
||
|
||
// 组别归属校验(与 status / questions 同口径,防止跨组越权取题)
|
||
if (aggregation.team && aggregation.team.group_name
|
||
&& aggregation.team.group_name !== group) {
|
||
ctx.body = ctx.helper.error(403, '您不属于该组别');
|
||
return;
|
||
}
|
||
|
||
const result = await service.bigQuestion.getForCandidate(
|
||
aggregation.aggregation_uid,
|
||
group,
|
||
(ctx.state.currentUser && ctx.state.currentUser.id) || aggregation.aggregation_uid
|
||
);
|
||
|
||
if (!result.ok) {
|
||
// 组别未启用 → 400;无题可抽 → 4004(与「题目未配置」类业务码口径一致)
|
||
const code = result.code === 'NO_QUESTION' ? 4004 : 400;
|
||
ctx.body = ctx.helper.error(code, result.message);
|
||
return;
|
||
}
|
||
|
||
ctx.body = ctx.helper.success({
|
||
question: result.question,
|
||
answers: result.answers,
|
||
submitTime: result.submitTime,
|
||
maxChars: result.maxChars,
|
||
subCount: result.subCount,
|
||
reused: result.reused,
|
||
}, '获取综合大题成功');
|
||
} catch (err) {
|
||
ctx.logger.error('获取综合大题失败:', err);
|
||
ctx.body = ctx.helper.error(500, '获取综合大题失败');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 保存综合大题三小问作答(仅中职组 / 高职组)
|
||
* POST /api/exam/:group/big-answer
|
||
* Body: { answer_1, answer_2, answer_3, forceSubmit? }
|
||
*
|
||
* 每小问上限 2000 字,**后端同步校验**(前端 maxlength 只是体验优化,不可作为唯一防线)。
|
||
* forceSubmit 与 submit-all 同语义:仅「倒计时归零的自动提交」传入,允许在考试窗口
|
||
* 临界/已关闭时仍落库考生已写内容,避免整卷提交时丢失大题答案。
|
||
*/
|
||
async saveBigAnswer() {
|
||
const { ctx, service } = this;
|
||
|
||
try {
|
||
const { group } = ctx.params;
|
||
if (!this._validateGroup(group)) return;
|
||
|
||
if (!service.bigQuestion.isEnabledGroup(group)) {
|
||
ctx.body = ctx.helper.error(400, '该组别未启用综合大题');
|
||
return;
|
||
}
|
||
|
||
const body = ctx.request.body || {};
|
||
|
||
// 🔴 2026-09-21 同源加固(与 submitAll 的结算通道同构):
|
||
// 原实现为 `if (!examStatus.isActive && !body.forceSubmit)` —— 该布尔完全来自请求体,
|
||
// 任何考生都能在截止后自行携带 forceSubmit 继续改写综合大题作答;
|
||
// 而综合大题由人工阅卷计分,等同「截止后继续答题」,公平性受损。
|
||
// 现改为服务端判定:窗口关闭时只允许把**库内已有作答**标记为已提交(markSubmitted),
|
||
// 绝不接受请求体内容;库内无作答行则明确拒绝。
|
||
const examStatus = await service.exam.getExamStatus(group);
|
||
const windowOpen = examStatus.isActive === true;
|
||
|
||
const aggregation = await this._getUserAggregation();
|
||
if (!aggregation) {
|
||
ctx.body = ctx.helper.error(403, '未找到您的报名信息');
|
||
return;
|
||
}
|
||
|
||
if (!windowOpen) {
|
||
// 结算语义:不写内容,只补 submit_time(库内无该考生作答行 → 返回 null → 拒绝)
|
||
const settled = await service.bigQuestion.markSubmitted(aggregation.aggregation_uid);
|
||
if (!settled) {
|
||
ctx.body = ctx.helper.error(4001, examStatus.message || '考试未在进行中');
|
||
return;
|
||
}
|
||
ctx.body = ctx.helper.success({
|
||
filled: [ settled.answer_1, settled.answer_2, settled.answer_3 ]
|
||
.filter((v) => v && String(v).trim().length > 0).length,
|
||
subCount: service.bigQuestion.subQuestionCount(),
|
||
maxChars: service.bigQuestion.answerMaxChars(),
|
||
savedAt: new Date().toISOString(),
|
||
settled: true,
|
||
}, '考试已结束,已按已保存内容结算');
|
||
return;
|
||
}
|
||
|
||
const result = await service.bigQuestion.saveAnswers(
|
||
aggregation.aggregation_uid,
|
||
group,
|
||
{
|
||
answer_1: body.answer_1,
|
||
answer_2: body.answer_2,
|
||
answer_3: body.answer_3,
|
||
}
|
||
);
|
||
|
||
if (!result.ok) {
|
||
// 超长 → 400(参数非法);未抽题 → 4004(业务前置条件未满足)
|
||
const code = result.code === 'NOT_DRAWN' ? 4004 : 400;
|
||
ctx.body = ctx.helper.error(code, result.message);
|
||
return;
|
||
}
|
||
|
||
ctx.body = ctx.helper.success({
|
||
filled: result.filled,
|
||
subCount: service.bigQuestion.subQuestionCount(),
|
||
maxChars: service.bigQuestion.answerMaxChars(),
|
||
savedAt: new Date().toISOString(),
|
||
}, '综合大题答案已保存');
|
||
} catch (err) {
|
||
ctx.logger.error('保存综合大题答案失败:', err);
|
||
ctx.body = ctx.helper.error(500, '保存综合大题答案失败');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 一体提交:答案 + Word 文件
|
||
* POST /api/exam/:group/submit-all
|
||
*
|
||
* 验证考试活跃状态,处理答案提交并确保 Word 文件已上传
|
||
* Body: { answers: [{ question_uid, answer_data }] }
|
||
*/
|
||
async submitAll() {
|
||
const { ctx, service } = this;
|
||
|
||
try {
|
||
const { group } = ctx.params;
|
||
if (!this._validateGroup(group)) return;
|
||
|
||
// forceSubmit 由「倒计时结束的自动提交」传入,其作用已被收敛为**仅**豁免下方
|
||
// 「未答满全部题目」闸门(倒计时归零即合法交卷,不应因少答几题而交不上)。
|
||
//
|
||
// 🔴 2026-09-21 加固:forceSubmit **不再**授予「绕过考试窗口」的权力。
|
||
// 原实现为 `if (!examStatus.isActive && !forceSubmit)` —— 该布尔完全来自请求体,
|
||
// 任何考生都可在截止后自行携带 forceSubmit 重新提交并改写答案。
|
||
// 窗口是否开放改由服务端判定(见下方结算通道),不接受客户端的自我声明。
|
||
const forceSubmit = ctx.request.body && ctx.request.body.forceSubmit === true;
|
||
|
||
// 验证考试是否活跃
|
||
const examStatus = await service.exam.getExamStatus(group);
|
||
|
||
// 获取用户聚合信息(须先于窗口判定:结算通道需要按考生查已落库作答)
|
||
const aggregation = await this._getUserAggregation();
|
||
if (!aggregation) {
|
||
ctx.body = ctx.helper.error(403, '未找到您的报名信息');
|
||
return;
|
||
}
|
||
|
||
// 🔴 服务端窗口判定 + 截止后结算通道(2026-09-21 并发压测结论)
|
||
//
|
||
// 缺陷背景:400 并发同刻交卷时,约 20% 考生的连接在 TCP accept 层即被拒
|
||
// (Windows 在 accept 队列满时回 RST,客户端得到 10061),请求**从未到达业务逻辑**;
|
||
// 前端遇到该情况只能提示「请手动点击提交全部」,而手动交卷不带 forceSubmit,
|
||
// 窗口关闭后必被 4001 拦下 —— 这批考生的 exam_submissions.submit_time 永久为空,
|
||
// 管理后台显示「未提交」、成绩无法归集。即「答案明明已在库里,却交不上卷」。
|
||
//
|
||
// 因此窗口关闭后开放**服务端结算通道**(settleOnly):
|
||
// - 只补写 exam_submissions.submit_time + answers 快照,把库内作答正式标记为已提交;
|
||
// - 严格以数据库已落库作答为准,**忽略请求体答案** —— 否则考生可借该通道在截止后
|
||
// 继续作答刷分,公平性受损;
|
||
// - 一题未答(库内无作答行)→ 仍按 4001 拒绝(无内容可结算)。
|
||
const windowOpen = examStatus.isActive === true;
|
||
let settleOnly = false;
|
||
let storedAnswers = null;
|
||
|
||
if (!windowOpen) {
|
||
const existing = await service.exam.getUserAnswers(aggregation.aggregation_uid, group);
|
||
storedAnswers = (existing || []).filter(
|
||
(r) => r.answer_data !== null && r.answer_data !== undefined && String(r.answer_data).trim() !== ''
|
||
);
|
||
if (!storedAnswers.length) {
|
||
ctx.body = ctx.helper.error(4001, examStatus.message || '考试未在进行中');
|
||
return;
|
||
}
|
||
settleOnly = true;
|
||
|
||
// 幂等:此前已正式提交过(自动交卷其实成功了,只是响应在连接层丢失)→
|
||
// 直接返回成功并回吐真实 submit_time,不重复写库。
|
||
// 这解决了「考生不确定自己交上没有、反复点提交」的焦虑式重试。
|
||
const alreadySubmitted = await service.exam.hasSubmitted(aggregation.aggregation_uid, group);
|
||
if (alreadySubmitted) {
|
||
const rec = await ctx.model.ExamSubmission.findOne({
|
||
where: { contestant_id: aggregation.aggregation_uid },
|
||
attributes: [ 'submit_time' ],
|
||
});
|
||
ctx.body = ctx.helper.success({
|
||
answeredCount: storedAnswers.length,
|
||
totalQuestions: storedAnswers.length,
|
||
hasWordFile: storedAnswers.some((s) => s.word_file_path),
|
||
savedCount: storedAnswers.length,
|
||
submitted: true,
|
||
settled: true,
|
||
submitTime: (rec && rec.submit_time)
|
||
? new Date(rec.submit_time).toISOString()
|
||
: new Date().toISOString(),
|
||
}, '提交成功(此前已提交)');
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 🔴 窗口内重复交卷拦截(考试组,测试反馈 #3)。
|
||
// 背景:下方窗口内分支会重新校验请求体答案并覆盖写库 ⇒ 已交卷者若可重进,
|
||
// 即可「改答案 → 再交卷」无损刷分。入口 questions() 已拦重进,此处再堵住
|
||
// 绕过前端直接重放 submit-all 的路径。
|
||
// ⚠️ 只作用于窗口打开时;窗口关闭后走上方结算通道(那是补写标记,不是重新作答)。
|
||
if (windowOpen && (group === 'zhongzhi' || group === 'gaozhi')) {
|
||
const alreadyDone = await service.exam.hasSubmitted(aggregation.aggregation_uid, group);
|
||
if (alreadyDone) {
|
||
ctx.body = ctx.helper.error(4006, '您已提交试卷,不能重复提交');
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 团队锁:同一小组只能一人进入正式考试(G1 修复,防绕过 questions 直接提交)
|
||
// ⚠️ 结算通道跳过:一是窗口已关闭不应再产生 setTeamLock 写副作用,
|
||
// 二是结算绝不能被队友残留锁拦下(否则又回到「答案在库却交不上」)。
|
||
if (!settleOnly) {
|
||
const teamGate = await this._enterExamWithTeamLock(aggregation);
|
||
if (!teamGate.allowed) {
|
||
ctx.body = ctx.helper.error(4005, '您的小组已进入考试');
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 参数校验(结算通道不接收请求体答案,故放宽为可空)
|
||
const rule = {
|
||
answers: settleOnly
|
||
? { type: 'array', required: false }
|
||
: { type: 'array', required: true, min: 1 },
|
||
};
|
||
try {
|
||
ctx.validate(rule);
|
||
} catch (validateErr) {
|
||
ctx.body = ctx.helper.error(400, '参数校验失败:' + validateErr.errors[0].message);
|
||
return;
|
||
}
|
||
|
||
// ---------- 结算通道:只补 submit_time,绝不改动任何答题行 ----------
|
||
// 刻意不做 autoScore / saveAnswers:库内 is_correct / score 已由考生逐题保存时
|
||
// 判分落库(submitAnswers 链路),此处若重新判分并回写,等于在截止后修改作答与判分,
|
||
// 既有公平性风险,也可能因题目变更而覆盖考生既得的判分结果。
|
||
if (settleOnly) {
|
||
const snapshot = storedAnswers.map((r) => ({
|
||
question_uid: r.question_uid,
|
||
question_type: r.question_type,
|
||
answer_data: r.answer_data,
|
||
}));
|
||
await service.exam.ensureExamSubmission(aggregation.aggregation_uid, snapshot);
|
||
// 大题作答同样补 submit_time(结算通道只补标记、不改内容)
|
||
await service.bigQuestion.markSubmitted(aggregation.aggregation_uid);
|
||
ctx.logger.warn(
|
||
`[submitAll][settle] 窗口已关闭,按库内作答结算 agg=${aggregation.aggregation_uid} questions=${snapshot.length}`
|
||
);
|
||
ctx.body = ctx.helper.success({
|
||
answeredCount: snapshot.length,
|
||
totalQuestions: snapshot.length,
|
||
hasWordFile: storedAnswers.some((s) => s.word_file_path),
|
||
savedCount: snapshot.length,
|
||
submitted: true,
|
||
settled: true,
|
||
submitTime: new Date().toISOString(),
|
||
}, '提交成功(考试已结束,按已保存答案结算)');
|
||
return;
|
||
}
|
||
|
||
const { answers } = ctx.request.body;
|
||
|
||
// 验证每个答案的数据格式(必须包含 question_type,否则 autoScore 无法判分)
|
||
// 容错:question_type 缺失时按 uid 前缀(choice_x/judge_x)推导,
|
||
// 避免恢复会话等场景 questions 未加载导致整卷被「答案格式错误」拦截。
|
||
for (const ans of answers) {
|
||
if (!ans.question_uid || ans.answer_data === undefined) {
|
||
ctx.body = ctx.helper.error(400, '答案格式错误,需要 question_uid 和 answer_data');
|
||
return;
|
||
}
|
||
if (!ans.question_type) {
|
||
const uid = String(ans.question_uid);
|
||
if (uid.startsWith('choice')) ans.question_type = 'choice';
|
||
else if (uid.startsWith('judge')) ans.question_type = 'judge';
|
||
}
|
||
if (!ans.question_type || !['choice', 'judge'].includes(ans.question_type)) {
|
||
ctx.body = ctx.helper.error(400, '答案格式错误,需要合法的 question_type(choice/judge)');
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 校验是否所有题目都已作答:以【出题数量】为基准(前端回传 totalQuestions),
|
||
// 避免与随机抽题基准不一致。原先使用全库 999 比对,会导致只抽到 N 题却要求答满全库的悖论。
|
||
const expectedCount = parseInt(ctx.request.body.totalQuestions) || answers.length;
|
||
if (answers.length < expectedCount && !forceSubmit) {
|
||
ctx.body = ctx.helper.error(4004, `还有 ${expectedCount - answers.length} 道题目未作答`);
|
||
return;
|
||
}
|
||
|
||
// 检查是否已上传 Word 文件
|
||
const existingSubmission = await service.exam.getUserAnswers(
|
||
aggregation.aggregation_uid,
|
||
group
|
||
);
|
||
const hasWordFile = existingSubmission.some(s => s.word_file_path);
|
||
|
||
// 自动判分
|
||
const scoreResult = await service.exam.autoScore(answers);
|
||
|
||
// 构建保存数据
|
||
const saveData = scoreResult.details.map(d => ({
|
||
question_uid: d.question_id,
|
||
question_type: d.question_type,
|
||
answer_data: d.answer_data,
|
||
is_correct: d.is_correct,
|
||
score: d.score,
|
||
}));
|
||
|
||
// 保存答题记录
|
||
const savedRecords = await service.exam.saveAnswers(
|
||
aggregation.aggregation_uid,
|
||
saveData,
|
||
group
|
||
);
|
||
|
||
// 服务端三重断言:事务提交 ≠ 一定落库(MariaDB 不支持 RETURNING / 死锁回滚等
|
||
// 都会导致「内存返回成功实例、磁盘 0 行」的静默丢数据)。故提交后实查持久化条数,
|
||
// 与实际应保存条数比对,缺失即视为提交失败,绝不明文返回「提交成功」。
|
||
const SubmissionModel = group === 'zhongzhi'
|
||
? ctx.model.ZhongzhiSubmission
|
||
: ctx.model.GaozhiSubmission;
|
||
const persisted = await SubmissionModel.count({
|
||
where: { aggregation_uid: aggregation.aggregation_uid },
|
||
});
|
||
if (!persisted || persisted < answers.length) {
|
||
ctx.logger.error(`[submitAll] 落库不完整 agg=${aggregation.aggregation_uid} persisted=${persisted} expected=${answers.length}`);
|
||
ctx.body = ctx.helper.error(500, `提交失败:答案保存不完整(已保存 ${persisted || 0}/${answers.length} 题),请重试`);
|
||
return;
|
||
}
|
||
|
||
// 同步写入 exam_submissions 汇总行(answers JSON + submit_time),
|
||
// 供选手端 hasSubmitted / 管理后台「考生答题记录」提交状态 / 试卷回看使用。
|
||
// 聚合键取 aggregation.aggregation_uid(中职/高职即 contestant.id),与 hasSubmitted 一致。
|
||
await service.exam.ensureExamSubmission(aggregation.aggregation_uid, saveData);
|
||
|
||
// 综合大题(中职/高职)作答随整卷正式提交补写 submit_time。
|
||
// 🔴 2026-09-21 修复:service.bigQuestion.markSubmitted 此前**无任何调用点**(死代码),
|
||
// 而 big_question_answers.submit_time 的列注释明确写着「随整卷正式提交写入(未提交为 NULL)」
|
||
// → 中职/高职考生的大题 submit_time 恒为 NULL,管理端据此列区分「已作答未提交」时
|
||
// 会把真实作答误判为未提交。markSubmitted 内部有 if (!submit_time) 保护,
|
||
// 重复提交不会覆盖首次提交时间,天然幂等;无大题作答行时返回 null(如本科/教师组),无副作用。
|
||
await service.bigQuestion.markSubmitted(aggregation.aggregation_uid);
|
||
|
||
// 成绩门控:不回传 autoScore/total/details(自动评分仅落库供管理后台阅卷与合分使用)。
|
||
// 考生端提交后只应得到「提交成功 + 已保存题数」的确认,成绩须待管理后台发布。
|
||
ctx.body = ctx.helper.success({
|
||
answeredCount: answers.length,
|
||
totalQuestions: expectedCount,
|
||
hasWordFile,
|
||
savedCount: persisted,
|
||
submitted: true,
|
||
submitTime: new Date().toISOString(),
|
||
}, '提交成功');
|
||
} catch (err) {
|
||
ctx.logger.error('一体提交失败:', err);
|
||
ctx.body = ctx.helper.error(500, '提交失败');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取已有答案
|
||
* GET /api/exam/:group/answers
|
||
*
|
||
* 获取用户已提交的答案,用于考试重新进入时恢复状态
|
||
*/
|
||
async getAnswers() {
|
||
const { ctx, service } = this;
|
||
|
||
try {
|
||
const { group } = ctx.params;
|
||
if (!this._validateGroup(group)) return;
|
||
|
||
// 获取用户聚合信息
|
||
const aggregation = await this._getUserAggregation();
|
||
if (!aggregation) {
|
||
ctx.body = ctx.helper.error(403, '未找到您的报名信息');
|
||
return;
|
||
}
|
||
|
||
// 获取已有答案
|
||
const answers = await service.exam.getUserAnswers(
|
||
aggregation.aggregation_uid,
|
||
group
|
||
);
|
||
|
||
// 成绩门控:未发布前,答案回看不得携带逐题得分与对错标记。
|
||
// 否则考生把 score 累加即可得出客观题总分,完全绕过「管理后台发布后才可见」的约束。
|
||
// 答案内容本身保留(前端用于恢复/回看作答)。
|
||
const isPublished = await service.admin.isScorePublished(group);
|
||
const safeAnswers = answers.map(row => {
|
||
const item = typeof row.toJSON === 'function' ? row.toJSON() : { ...row };
|
||
if (!isPublished) {
|
||
delete item.score;
|
||
delete item.is_correct;
|
||
}
|
||
// 还原 question_uid 为前端键格式(choice_x / judge_x):DB 中 zhongzhi/gaozhi_submissions
|
||
// .question_uid 经 _extractRawId 存为数字 id(int 列),而前端 answers 以 'choice_x' 字符串为键,
|
||
// 若直接回传数字 id,重进恢复答案时键对不上 → 已答题目全部丢失。补回题型前缀保证可恢复;
|
||
// 同时保留 raw_question_uid 供后台阅卷/回看按数字 id 定位题目。
|
||
if (item.question_type && item.question_uid != null) {
|
||
const prefix = item.question_type === 'judge' ? 'judge_' : 'choice_';
|
||
item.raw_question_uid = item.question_uid;
|
||
item.question_uid = prefix + item.question_uid;
|
||
}
|
||
return item;
|
||
});
|
||
|
||
ctx.body = ctx.helper.success({
|
||
answers: safeAnswers,
|
||
scoreVisible: isPublished,
|
||
}, '获取已有答案成功');
|
||
} catch (err) {
|
||
ctx.logger.error('获取已有答案失败:', err);
|
||
ctx.body = ctx.helper.error(500, '获取已有答案失败');
|
||
}
|
||
}
|
||
}
|
||
|
||
module.exports = ExamController;
|