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*、构建/缓存产物。
This commit is contained in:
813
app/controller/exam.js
Normal file
813
app/controller/exam.js
Normal file
@@ -0,0 +1,813 @@
|
||||
'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;
|
||||
Reference in New Issue
Block a user