Files
competition-website/app/service/exam.js
qubiaobiao 459f1adf6d feat(admin): R176-R180 公示表与新闻撤回 + 管理端多管理员权限(C 层)+ 交卷冻结
- 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)
2026-10-20 11:06:29 +08:00

852 lines
34 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 Service = require('egg').Service;
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
/**
* 考试服务 - ExamService
*
* 封装考试相关业务逻辑,包括题目随机获取、答案校验、自动判分、
* 比赛状态检查、答题记录保存等功能。
*/
class ExamService extends Service {
/**
* 根据用户的 id_number 获取聚合报名信息
* @param {string} idNumber - 身份证号
* @returns {Promise<Object|null>} 聚合报名记录
*/
async getUserAggregation(idNumber) {
const { ctx } = this;
// 先通过 id_number 找到 Account,再通过 Account.uid 找到 Aggregation
const account = await ctx.model.Account.findOne({
where: { id_number: idNumber },
attributes: ['uid'],
});
if (!account) return null;
return await ctx.model.Aggregation.findOne({
where: { account_uid: account.uid },
include: [
{ model: ctx.model.Team, as: 'team', attributes: ['team_uid', 'group_name', 'team_id', 'team_number'] },
],
});
}
/**
* 根据题型获取对应的模型
* @param {string} questionType - 题型:choice / judge / word
* @returns {Object} Sequelize 模型
*/
_getQuestionModel(questionType) {
const { ctx } = this;
switch (questionType) {
case 'choice': return ctx.model.ChoiceQuestion;
case 'judge': return ctx.model.JudgeQuestion;
case 'word': return ctx.model.WordQuestion;
default: throw new Error(`未知题型: ${questionType}`);
}
}
/**
* 从可能带题型前缀的 question_uid 中提取原始自增 id。
* 兼容三种形态:
* - 'choice_12' / 'judge_3'(新:题型前缀,保证跨题型全局唯一)
* - '12'(旧:纯数字字符串)
* - 12(number)
* @param {string|number} uid
* @returns {string|number} 原始自增 id
*/
_extractRawId(uid) {
if (uid === undefined || uid === null) return uid;
const s = String(uid);
const idx = s.lastIndexOf('_');
if (idx >= 0) {
const tail = s.slice(idx + 1);
return /^\d+$/.test(tail) ? tail : s;
}
return s;
}
/**
* 随机获取指定组别的题目(选择题 + 判断题,按各自数量抽取)
* @param {string} group - 组别:zhongzhi / gaozhi
* @param {Object} options - 抽题数量配置
* @param {number} options.choiceCount - 选择题数量,默认 50
* @param {number} options.judgeCount - 判断题数量,默认 20
* @returns {Promise<Array>} 题目列表(不含 answer 字段),顺序:选择题在前、判断题在后
*/
async getRandomQuestions(group, options = {}) {
const { ctx } = this;
// 严格按题型数量分别抽取,确保分布符合「50 道选择题 + 20 道判断题」的赛制要求,
// 而不是把单个总数对半切(否则会变成 35+35 等错误分布)。
const choiceCount = options.choiceCount != null ? options.choiceCount : 50;
const judgeCount = options.judgeCount != null ? options.judgeCount : 20;
const [choiceQuestions, judgeQuestions] = await Promise.all([
ctx.model.ChoiceQuestion.findAll({
where: { group },
order: ctx.model.Sequelize.literal('RAND()'),
limit: choiceCount,
attributes: { exclude: ['answer'] },
raw: true,
}),
ctx.model.JudgeQuestion.findAll({
where: { group },
order: ctx.model.Sequelize.literal('RAND()'),
limit: judgeCount,
attributes: { exclude: ['answer'] },
raw: true,
}),
]);
// 合并并标记题型,并规范化输出字段
// 顺序约定:选择题全部在前、判断题全部在后(各自内部仍由 RAND() 随机),
// 前端按全局索引编号,选择题 1~N、判断题 N+1~M,Word 综合题接续 M+1,题号严格连续。
const questions = [
...choiceQuestions.map(q => normalizeQuestion(q, 'choice')),
...judgeQuestions.map(q => normalizeQuestion(q, 'judge')),
];
return questions;
}
/**
* 按指定的题目 UID 顺序取题(不随机),用于「重进复用同一套题目与顺序」。
* @param {string} group - 组别
* @param {Array<string>} questionUids - 固化顺序的 question_uid 数组,如 ['choice_12','judge_3',...]
* @returns {Promise<Array>} 严格按 questionUids 顺序的题目列表(缺失题目自动跳过)
*/
async getQuestionsByFixedOrder(group, questionUids) {
const { ctx } = this;
const buckets = { choice: [], judge: [] };
for (const uid of (questionUids || [])) {
const s = String(uid);
const idx = s.lastIndexOf('_');
if (idx < 0) continue;
const type = s.slice(0, idx);
const rawId = s.slice(idx + 1);
if ((type === 'choice' || type === 'judge') && /^\d+$/.test(rawId)) {
buckets[type].push(parseInt(rawId, 10));
}
}
const out = [];
for (const type of ['choice', 'judge']) {
const ids = buckets[type];
if (!ids.length) continue;
const Model = type === 'choice' ? ctx.model.ChoiceQuestion : ctx.model.JudgeQuestion;
const rows = await Model.findAll({
where: { id: { [ctx.model.Sequelize.Op.in]: ids }, group },
attributes: { exclude: ['answer'] },
raw: true,
});
const map = new Map(rows.map(r => [r.id, r]));
for (const id of ids) {
const r = map.get(id);
if (r) out.push(normalizeQuestion(r, type));
}
}
return out;
}
/**
* 获取/固化该考生的题目顺序:首次进入随机生成并持久化到 exam_submissions.question_order;
* 之后重进(或已提交后重进)一律复用同一顺序,彻底消除「重进题目顺序变乱」缺陷。
* 兼容历史已提交但无 question_order 的记录:从其 answers 推导顺序(答案顺序即作答顺序)。
* @param {number} aggregationUid - 聚合报名 ID(exam_submissions 以它为 contestant_id 存储)
* @param {string} group - 组别
* @param {Object} options - 抽题数量配置 { choiceCount, judgeCount }
* @returns {Promise<{questions: Array, questionUids: Array<string>}>}
*/
async getAssignedQuestions(aggregationUid, group, options = {}) {
const { ctx } = this;
// 确保该考生有一条记录(占位,submit_time 保持 null,不影响 hasSubmitted 判定)。
const row = await this._ensureExamSubmissionSafe(aggregationUid, {
contestant_id: aggregationUid,
answers: [],
submit_time: null,
});
// 1) 已固化顺序 → 直接复用
const saved = this._readQuestionOrder(row);
if (saved && saved.length) {
return {
questions: await this.getQuestionsByFixedOrder(group, saved),
questionUids: saved,
};
}
// 2) 历史已提交但无固化顺序:从 answers 推导(答案顺序即作答顺序)
const savedAnswers = row.answers;
if (Array.isArray(savedAnswers) && savedAnswers.length) {
const derived = savedAnswers
.map(a => a.question_uid || (a.question_type && a.question_id ? `${a.question_type}_${a.question_id}` : null))
.filter(Boolean);
if (derived.length) {
await row.update({ question_order: JSON.stringify(derived) });
return {
questions: await this.getQuestionsByFixedOrder(group, derived),
questionUids: derived,
};
}
}
// 3) 首次进入:随机生成并持久化(同一考生只发生一次)
const questions = await this.getRandomQuestions(group, options);
const uids = questions.map(q => q.question_uid);
await row.update({ question_order: JSON.stringify(uids) });
return { questions, questionUids: uids };
}
/**
* 从 exam_submissions 记录中安全读取 question_order(JSON 字符串/对象 → 数组)
* @param {Object} row - 记录实例或原始对象
* @returns {Array<string>|null}
*/
_readQuestionOrder(row) {
if (!row) return null;
const v = row.question_order;
if (!v) return null;
try {
const a = typeof v === 'string' ? JSON.parse(v) : v;
return Array.isArray(a) ? a : null;
} catch (e) {
return null;
}
}
/**
* 核对单道题目的答案
* @param {number} questionId - 题目 ID
* @param {string} questionType - 题型:choice / judge / word
* @param {string} answerData - 选手提交的答案
* @returns {Promise<Object>} { isCorrect, score, question }
*/
async checkAnswer(questionId, questionType, answerData) {
const QuestionModel = this._getQuestionModel(questionType);
// question_uid 现在是「题型_自增id」的全局唯一形式(choice_12 / judge_3),
// 判分时需还原为原始自增 id 再 findByPk;同时兼容历史纯数字形态。
const rawId = this._extractRawId(questionId);
const question = await QuestionModel.findByPk(rawId);
if (!question) {
return { isCorrect: false, score: 0, question: null, error: '题目不存在' };
}
// 比对答案(去除首尾空格,统一大小写)
const correctAnswer = String(question.answer).trim().toLowerCase();
const userAnswer = String(answerData).trim().toLowerCase();
const isCorrect = correctAnswer === userAnswer;
return {
isCorrect,
score: isCorrect ? question.score : 0,
question: {
id: question.id,
question: question.question,
question_type: questionType,
correctAnswer: question.answer,
maxScore: question.score,
},
};
}
/**
* 批量自动判分
*
* 🔴 批量化(2026-09-21 并发压测结论):原实现对每道答案 `await this.checkAnswer()`,
* 而 checkAnswer 内部是 `findByPk` —— 单次交卷 59~70 题即 59~70 次**串行** DB 往返。
* 这是「单 worker accept 循环被重任务饿死 → 监听队列溢出 → Windows 回 RST」的主因之一
* (压测实测:单 worker 硬容量仅 15 交卷/秒,400 并发下 20% 考生连接被拒)。
* 改为按题型分组,用**最多 2 次** whereIn 取回全部题目,再在内存中判分。
*
* 判分语义与逐题版**逐字等价**(不得漂移):
* - 答案比对:`String(x).trim().toLowerCase()` 严格相等;
* - 得分:答对取 question.score,答错取 0;
* - 题目不存在:跳过该题、**不进 details**(与 checkAnswer 返回 question=null 时一致);
* - details 的字段名与取值口径不变(question_id 保留调用方传入的原始 uid 形态)。
*
* @param {Array} answers - 答案数组 [{ question_id, question_type, answer_data }]
* @returns {Promise<Object>} { autoScore, total, details }
*/
async autoScore(answers) {
const details = [];
let autoScore = 0;
let total = 0;
// 第一步:按题型收集原始自增 id(去重后一次查出)。
const idsByType = { choice: [], judge: [] };
for (const item of answers) {
const type = item.question_type;
// 仅 choice/judge 有独立判分模型(word 走 WordQuestion,不在本链路)。
// 用 hasOwnProperty 白名单而非直接属性取值,避免 'constructor' 之类字符串
// 命中 Object 原型链而绕过题型判定。
if (!Object.prototype.hasOwnProperty.call(idsByType, type)) continue;
const rawId = this._extractRawId(item.question_id || item.question_uid);
if (/^\d+$/.test(String(rawId))) idsByType[type].push(String(rawId));
}
const questionMap = new Map(); // `${type}:${id}` → question 实例
for (const type of [ 'choice', 'judge' ]) {
const uniqueIds = Array.from(new Set(idsByType[type]));
if (!uniqueIds.length) continue;
const QuestionModel = this._getQuestionModel(type);
const rows = await QuestionModel.findAll({ where: { id: uniqueIds } });
for (const q of rows) questionMap.set(`${type}:${q.id}`, q);
}
// 第二步:内存判分,保持与原实现相同的顺序(按 answers 入参顺序 push)。
for (const item of answers) {
const type = item.question_type;
const rawId = this._extractRawId(item.question_id || item.question_uid);
const question = questionMap.get(`${type}:${rawId}`);
if (!question) continue; // 题目不存在:与原 checkAnswer 行为一致
const correctAnswer = String(question.answer).trim().toLowerCase();
const userAnswer = String(item.answer_data).trim().toLowerCase();
const isCorrect = correctAnswer === userAnswer;
const score = isCorrect ? question.score : 0;
total += question.score;
autoScore += score;
details.push({
question_id: item.question_id || item.question_uid,
question_type: type,
answer_data: item.answer_data,
is_correct: isCorrect,
score,
max_score: question.score,
});
}
return { autoScore, total, details };
}
/**
* 查询比赛时间状态
* @param {string} group - 组别
* @returns {Promise<Object>} { isActive, startTime, endTime, countdown, status }
*/
async getExamStatus(group) {
const { ctx } = this;
const timeConfig = await ctx.model.CompetitionTime.findOne({
where: { group: group },
});
// 未配置时间
if (!timeConfig || !timeConfig.start_time || !timeConfig.end_time) {
return {
isActive: false,
startTime: null,
endTime: null,
countdown: 0,
status: 'not_set',
message: '考试时间尚未配置',
};
}
const now = new Date();
const startTime = parseLocal(timeConfig.start_time);
const endTime = parseLocal(timeConfig.end_time);
// 考试未开始
if (now < startTime) {
return {
isActive: false,
startTime: startTime.toISOString(),
endTime: endTime.toISOString(),
countdown: 0,
status: 'not_started',
message: '考试尚未开始',
};
}
// 考试进行中
if (now >= startTime && now <= endTime) {
const countdown = Math.max(0, Math.floor((endTime - now) / 1000));
return {
isActive: true,
startTime: startTime.toISOString(),
endTime: endTime.toISOString(),
countdown,
status: 'in_progress',
message: '考试正在进行中',
};
}
// 考试已结束
return {
isActive: false,
startTime: startTime.toISOString(),
endTime: endTime.toISOString(),
countdown: 0,
status: 'ended',
message: '考试已结束',
};
}
/**
* 获取 Word 模板文件路径
* @param {string} group - 组别
* @returns {Promise<string|null>} 模板文件路径
*/
async getWordTemplatePath(group) {
const { ctx } = this;
const timeConfig = await ctx.model.CompetitionTime.findOne({
where: { group: group },
attributes: ['id', 'word_template'],
});
return timeConfig ? timeConfig.word_template : null;
}
/**
* 保存答题记录(逐题存储)
* @param {number} aggregationUid - 聚合报名 ID
* @param {Array} answers - 答案数组 [{ question_uid, answer_data, is_correct, score }]
* @param {string} group - 组别
* @returns {Promise<Array>} 保存后的记录列表
*/
/**
* 交卷冻结守卫(R178·B①)——**唯一判据源**
*
* 缺陷背景:逐题保存此前只校验「考试窗口 isActive」,不校验是否已正式交卷;
* `submit-all` 的 4006 只挡整卷重交。于是「提前交卷 → 窗口关闭前」这段时间内,
* `POST /submit-answers` 仍可改写答案并重新判分 —— 即「已提交但分数还能变」。
* 典型触发并非恶意:考生开了两个标签页(或手机+电脑),交卷后另一页仍在自动保存。
*
* 判据:`exam_submissions.submit_time` 非空即视为已正式交卷(与选手端 hasSubmitted 同源)。
*
* 调用点(两处,共用本函数,⛔ 不得各写一套条件):
* ① controller.exam.submitAnswers —— 前置快速失败,避免对已冻结答卷做无谓判分;
* ② service.exam.saveAnswers —— 兜底,保证任何调用方(含未来新增)都无法绕过。
* ⚠️ 门禁 `verify_exam_freeze.py` 的变异体要求**两处同改**才允许变红,否则属前道抢答。
*
* 为什么不缓存:逐题保存约 59 次/场,主键单列查询为微秒级;
* 换来「交卷状态变更立即生效」,无需判断缓存失效时机。
*
* @param {number} aggregationUid - 聚合报名 ID(中职/高职即 contestant.id)
* @returns {Promise<void>} 未交卷则静默返回
* @throws {Error} bizCode=4006
*/
async assertNotSubmitted(aggregationUid) {
const { ctx } = this;
if (!aggregationUid) return;
const submitted = await ctx.model.ExamSubmission.findOne({
where: { contestant_id: aggregationUid },
attributes: ['contestant_id', 'submit_time'],
raw: true,
});
if (submitted && submitted.submit_time) {
const err = new Error('答卷已提交,不能再修改答案');
// bizCode 由 controller 翻译成用户可见的业务错误码(4006 与本项目既有「已提交」口径一致)
err.bizCode = 4006;
err.bizMessage = '您已交卷,答卷已冻结,不能再修改答案;如需重考请联系管理员';
throw err;
}
}
/**
* 保存答题记录(逐题存储)
* @param {number} aggregationUid - 聚合报名 ID
* @param {Array} answers - 答案数组 [{ question_uid, answer_data, is_correct, score }]
* @param {string} group - 组别
* @returns {Promise<Array>} 保存后的记录列表
*/
async saveAnswers(aggregationUid, answers, group) {
const { ctx } = this;
// 交卷冻结兜底:所有写答案的路径都经此函数,故此处是本守卫的最后一道闸。
// 不会挡住交卷自身 —— submitAll 是「先 saveAnswers、后 ensureExamSubmission 写
// submit_time」,执行到本函数时 submit_time 仍为 NULL;整卷重交在更早的 4006 已返回;
// settleOnly 结算通道刻意不调用本函数。
await this.assertNotSubmitted(aggregationUid);
// 根据组别选择对应的提交模型
const SubmissionModel = group === 'zhongzhi'
? ctx.model.ZhongzhiSubmission
: ctx.model.GaozhiSubmission;
// 死锁判定:并发 upsert 抢同一唯一索引(uk_zhongzhi_submission)时会瞬时死锁,
// 属于可重试的瞬时冲突,整体重试事务即可(而非丢数据)。
const isDeadlock = (e) => {
if (!e) return false;
if (e.code === 'ER_LOCK_DEADLOCK') return true;
if (e.parent && e.parent.code === 'ER_LOCK_DEADLOCK') return true;
return /ER_LOCK_DEADLOCK/.test(String(e.message || ''));
};
// 死锁重试(最多 3 次,指数退避)。事务整体提交/回滚是原子的,要么全落要么全不落。
const MAX_ATTEMPTS = 3;
let lastErr = null;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
await ctx.model.transaction(async (t) => {
// 🔴 批量化(2026-09-21 并发压测结论):原实现在单事务内 for 循环**逐条** upsert,
// 单次交卷 59~70 条即 59~70 次串行 INSERT —— 事务持锁时间被放大到百毫秒级,
// 既直接拖慢交卷吞吐(实测单 worker 仅 15 交卷/秒),又显著放大不同考生
// 抢同一唯一索引 uk_*_submission 的概率。改为一次 bulkCreate +
// updateOnDuplicate(单条 INSERT ... ON DUPLICATE KEY UPDATE)。
//
// 语义保真:与原 upsert 的 SET 列表**逐列相同**(原实现未传 updateOnDuplicate,
// Sequelize 取 values 的键集合作为更新列),故不新增/不遗漏任何列;
// 特别是**不得**把 word_file_path / file_path 等未参与本链路的列写进更新列表,
// 否则会用 NULL 覆盖考生已上传的作品文件信息。
//
// ⚠️ question_uid 列是 INT:必须先把前缀形态('choice_683')还原成原始数字 id,
// 否则 MySQL 会把字符串强转为 0,导致整卷答案坍缩成 choice/judge 各一行互相覆盖
// (B13 修复:uid 全局唯一化引入的落库回归)。
//
// ⚠️ 注意:MariaDB/MySQL 不支持 RETURNING 子句,故不使用 returning,
// 仅依赖事务提交 + 调用方 COUNT 复核(见 submitAll)。
const rows = answers.map((answer) => ({
aggregation_uid: aggregationUid,
question_uid: this._extractRawId(answer.question_uid),
question_type: answer.question_type,
answer_data: answer.answer_data,
is_correct: answer.is_correct,
score: answer.score,
}));
// 空数组守卫:bulkCreate([]) 在部分 dialect 下会抛错,而原 for 循环是空转成功。
if (!rows.length) return;
await SubmissionModel.bulkCreate(rows, {
updateOnDuplicate: [
'aggregation_uid',
'question_uid',
'question_type',
'answer_data',
'is_correct',
'score',
],
transaction: t,
});
});
return answers.length;
} catch (err) {
lastErr = err;
ctx.logger.error(`[saveAnswers] attempt ${attempt}/${MAX_ATTEMPTS} failed agg=${aggregationUid}:`, err && err.message);
if (isDeadlock(err) && attempt < MAX_ATTEMPTS) {
await new Promise(r => setTimeout(r, 50 * attempt));
continue;
}
throw err;
}
}
throw lastErr;
}
/**
* 并发安全地获取/创建 exam_submissions 提交记录。
* 依赖 contestant_id 唯一约束:并发下若另一请求已插入,findOrCreate 的 create 会触发
* ER_DUP_ENTRY,此时直接取回已有记录,避免产生重复行(B12 修复)。
* @param {number} contestantId - 选手 ID
* @param {Object} defaults - 新建时的默认值
* @returns {Promise<Object>} 提交记录实例
*/
async _ensureExamSubmissionSafe(contestantId, defaults) {
const { ctx } = this;
try {
const [rec] = await ctx.model.ExamSubmission.findOrCreate({
where: { contestant_id: contestantId },
defaults,
});
return rec;
} catch (e) {
if (e.name === 'SequelizeUniqueConstraintError' ||
/ER_DUP_ENTRY/.test(String(e.message))) {
return await ctx.model.ExamSubmission.findOne({ where: { contestant_id: contestantId } });
}
throw e;
}
}
/**
* 获取/创建当前选手的 exam_submissions 提交记录(并发安全)
* @param {number} contestantId - 选手 ID
* @param {Array} answers - 答题内容
* @returns {Promise<Object>} 提交记录实例
*/
async ensureExamSubmission(contestantId, answers) {
const rec = await this._ensureExamSubmissionSafe(contestantId, {
contestant_id: contestantId,
answers: answers || [],
submit_time: new Date(),
});
// 记录可能是团队锁占位 / Word 上传时创建的(submit_time 为 null,表示未正式提交)。
// findOrCreate 命中已有行时不会更新字段,须显式补写 submit_time —— 否则
// 正式提交后 hasSubmitted / 管理后台仍显示「未提交」(提交状态不一致修复)。
if (rec && !rec.submit_time) {
await rec.update({ submit_time: new Date(), answers: answers || [] });
}
return rec;
}
/**
* 检查用户是否已正式提交全部考试
* 仅当 exam_submissions 表中有 submit_time 记录时才判定为已提交
* @param {number} aggregationUid - 聚合报名 ID
* @param {string} group - 组别
* @returns {Promise<boolean>} 是否已正式提交
*/
async hasSubmitted(aggregationUid, group) {
const { ctx } = this;
// 提交组(本科/教师)的提交记录落在 benke_submissions / jiaoshi_submissions,
// 与考试组(中职/高职)的 exam_submissions 不同表。统一在此按组别判定,
// 使 GET /api/exam/:group/status 返回的 submitted 对全部组别一致生效
// (前端 Dashboard「已提交」标签 + 进入拦截方可对提交组正确工作)。无新表列。
if (group === 'benke' || group === 'jiaoshi') {
const SubmissionModel = group === 'benke'
? ctx.model.BenkeSubmission
: ctx.model.JiaoshiSubmission;
// 🔴 聚合键铁律:上传链路(service.upload.getUserAggregation)写入的
// benke/jiaoshi_submissions.aggregation_uid = aggregation 表的 aggregation_uid,
// 而本方法收到的 aggregationUid 来自 exam 控制器 = contestant.id,真实选手
// 两者不相等(如郑佳怡 contestant.id=11 / aggregation_uid=1)。须按
// account_uid → aggregation_uid 双向映射后再查(与 getScores 口径一致),
// 否则真实选手提交后 submitted 恒 false → 标签仍「进行中」+ 可重复进入。
const keys = [ aggregationUid ];
const agg = await ctx.model.Aggregation.findOne({
where: { account_uid: aggregationUid },
attributes: [ 'aggregation_uid' ],
});
if (agg && agg.aggregation_uid && !keys.includes(agg.aggregation_uid)) {
keys.push(agg.aggregation_uid);
}
const count = await SubmissionModel.count({
where: { aggregation_uid: keys },
});
return count > 0;
}
const record = await ctx.model.ExamSubmission.findOne({
where: { contestant_id: aggregationUid },
attributes: ['submit_time'],
});
// 统一返回布尔(原为 record && ...,无记录时为 null),与提交组分支保持一致,
// 避免调用方对 submitted 字段类型判断不一致。
return !!(record && record.submit_time !== null);
}
/**
* 检查团队考试锁:同一小组是否已有人进入考试
*
* 🔴 跨组 team_uid 复用防护(组内一人参赛锁的核心正确性前提):
* team_uid / team_id 仅在「组内唯一」,不同组别可复用同一个整数团队号
* (实测:team_uid=1 同时存在于 zhongzhi 与 benke)。若不按组别限定队友,
* benke 组选手的 exam_submissions 行会把 zhongzhi 组选手误判为「队友已进入考试」,
* 导致 questions / submit-all 无故返回 4005「您的小组已进入考试」。
* 因此队友候选集必须用 contestants.group 复筛为同组(选手组别的权威来源与
* controller._getUserAggregation 的 team.group_name 一致,均取 contestant.group)。
*
* @param {number} teamId - 团队 ID
* @param {number} excludeContestantId - 排除的选手 ID(当前用户)
* @param {string} [group] - 当前选手组别;传入时按组别限定队友(防跨组误锁)
* @returns {Promise<Object>} { locked: boolean, lockedBy: string|null }
*/
async checkTeamLock(teamId, excludeContestantId, group) {
const { ctx } = this;
if (!teamId) return { locked: false, lockedBy: null };
// 队友识别兼容两种团队来源:
// - contestants.team_id = teamId(导入流程填充)
// - aggregation.team_uid = teamId(聚合报名表填充,兼容旧数据)
const contestantWhere = { team_id: teamId };
if (group) contestantWhere.group = group;
const contestants = await ctx.model.Contestant.findAll({
where: contestantWhere,
attributes: ['id', 'name'],
});
const contestantIds = contestants.map(c => c.id);
// aggregation 表无组别列声明(Sequelize 模型未定义 group),故此来源先取全量
// 候选,再由下方 contestants.group 复筛统一过滤跨组污染。
const aggRows = await ctx.model.Aggregation.findAll({
where: { team_uid: teamId },
attributes: ['account_uid'],
});
const aggIds = aggRows.map(a => a.account_uid);
let allIds = Array.from(new Set([...contestantIds, ...aggIds]))
.filter(id => id !== excludeContestantId);
if (allIds.length === 0) return { locked: false, lockedBy: null };
// 🔴 同组复筛:剔除跨组复用同一 team_uid 的他组选手(见方法头注释)
if (group) {
const sameGroup = await ctx.model.Contestant.findAll({
where: { id: allIds, group },
attributes: ['id'],
});
allIds = sameGroup.map(r => r.id);
if (allIds.length === 0) return { locked: false, lockedBy: null };
}
const existingSubmission = await ctx.model.ExamSubmission.findOne({
where: {
contestant_id: allIds,
},
include: [{
model: ctx.model.Contestant,
as: 'contestant',
attributes: ['name'],
}],
order: [['id', 'ASC']],
});
if (existingSubmission) {
return {
locked: true,
lockedBy: existingSubmission.contestant ? existingSubmission.contestant.name : '队友',
};
}
return { locked: false, lockedBy: null };
}
/**
* 设置团队考试锁(创建 exam_submission 占位记录)
* @param {number} teamId - 团队 ID
* @param {number} contestantId - 选手 ID
* @returns {Promise<void>}
*/
async setTeamLock(teamId, contestantId) {
const { ctx } = this;
// 使用并发安全方式获取/创建(唯一约束下避免重复行,B12 修复)。
// ⚠️ 占位记录 submit_time 必须为 null:占位 ≠ 正式提交,否则选手一进考试
// 就被 hasSubmitted 判为「已提交」(提交状态不一致 Bug 根源之一)。
const rec = await this._ensureExamSubmissionSafe(contestantId, {
contestant_id: contestantId,
answers: [],
submit_time: null,
});
return rec;
}
/**
* 保存 Word 文件上传记录
* @param {number} aggregationUid - 聚合报名 ID
* @param {number} questionUid - 题目 ID
* @param {string} group - 组别
* @param {Object} fileInfo - 文件信息 { filePath, fileName, md5 }
* @returns {Promise<Object>} 保存后的记录
*/
async saveWordFile(aggregationUid, questionUid, group, fileInfo) {
const { ctx } = this;
// 查找该选手的答题记录
let record = await ctx.model.ExamSubmission.findOne({
where: { contestant_id: aggregationUid },
});
// 不存在则并发安全地创建(唯一约束下避免重复行,B12 修复)。
// ⚠️ 仅上传 Word ≠ 正式提交,submit_time 保持 null,正式提交由
// ensureExamSubmission 统一补写(提交状态不一致修复)。
if (!record) {
record = await this._ensureExamSubmissionSafe(aggregationUid, {
contestant_id: aggregationUid,
answers: [],
submit_time: null,
});
}
// 更新 Word 文件信息
await record.update({
word_file_path: fileInfo.filePath,
word_file_name: fileInfo.fileName,
});
return record;
}
/**
* 获取某用户的所有答题记录
* @param {number} aggregationUid - 聚合报名 ID
* @param {string} group - 组别
* @returns {Promise<Array>} 答题记录列表
*/
async getUserAnswers(aggregationUid, group) {
const { ctx } = this;
const SubmissionModel = group === 'zhongzhi'
? ctx.model.ZhongzhiSubmission
: ctx.model.GaozhiSubmission;
return await SubmissionModel.findAll({
where: { aggregation_uid: aggregationUid },
});
}
/**
* 计算 Word 文件 MD5
* @param {string} filePath - 文件路径
* @returns {Promise<string>} MD5 值
*/
async calculateFileMd5(filePath) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('md5');
const stream = fs.createReadStream(filePath);
stream.on('data', chunk => hash.update(chunk));
stream.on('end', () => resolve(hash.digest('hex')));
stream.on('error', reject);
});
}
}
/**
* 将数据库中的时间字符串(如 '2026-07-22 09:00:00',无时区标记)按本地时区解析。
* 注意:new Date('2026-07-22 09:00:00') 在 V8 中会按 UTC 解析,导致与业务库
* 本地时间产生约 8 小时的偏移;将空格替换为 'T' 后按本地时区解析可修正该偏差。
* @param {string|Date} ts - 时间字符串或 Date 对象
* @returns {Date}
*/
function parseLocal(ts) {
if (!ts) return new Date(NaN);
if (ts instanceof Date) return ts;
const s = String(ts).trim().replace(' ', 'T');
return new Date(s);
}
/**
* 规范化题目输出字段
* - 补齐 question_uid(铁律2:前端统一以 question_uid 作为题目稳定标识,
* 库表主键为自增 id,question_uid 以其字符串形式暴露)
* - options 在库中以 JSON 字符串存储,规范化为数组返回(judge 题型无 options)
* @param {Object} q - 原始题目(raw:true 结果)
* @param {string} questionType - choice / judge
* @returns {Object} 规范化后的题目
*/
function normalizeQuestion(q, questionType) {
const out = { ...q };
out.question_type = questionType;
if (out.question_uid === undefined || out.question_uid === null) {
// 题号全局唯一(关键修复):选择题与判断题各自的自增 id 都从 1 开始、会重复,
// 若仅用 id 作为 question_uid,choice#1 与 judge#1 会碰撞,导致:
// ① 前端 findIndex 命中错误位置 → 序号重复(如判断题里出现「1」)、真实序号丢失(如「55」不见);
// ② answers 以 question_uid 为键 → 两题相互覆盖 → 答完仍提示「未作答」/题目丢失。
// 因此以「题型_自增id」作为跨题型全局唯一的稳定标识。
out.question_uid = questionType + '_' + out.id;
}
if (questionType === 'choice') {
if (typeof out.options === 'string') {
try {
const parsed = JSON.parse(out.options);
out.options = Array.isArray(parsed) ? parsed : [];
} catch (e) {
out.options = [];
}
} else if (!Array.isArray(out.options)) {
out.options = [];
}
} else {
// 判断题无选项
out.options = [];
}
return out;
}
module.exports = ExamService;