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

852 lines
34 KiB
JavaScript
Raw Normal View History

'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,
},
};
}
/**
* 批量自动判分
feat(exam/auth): 登录闸门 + 交卷稳定性加固(R4-R8) R4 登录/交卷背压闸门 - app/middleware/concurrency_guard.js: 新增。超限即 429 + Retry-After(立即拒绝式, 不排队;实测 429 P50=34ms)。RETRY_AFTER 收敛为单一有依据常量 = '2' (≈1.8 个满载窗口;实测 bcryptjs 1129ms / 原生 265ms 每窗口)。 配额:LOGIN_MAX=16 / SUBMIT_MAX=24 / HEAVY_REQ_MAX=20,各 *REQ_MAX 覆盖。 - config/config.default.js: 闸门配额与连接池容量配置;DB dialect 锁 mysql。 R7 顺序红线 + R8 Retry-After 同源 + 原生 bcrypt - 3 处硬编码 '2' 收敛为单一常量;补"勿因换实现而删闸门"红线注释。 - bcryptjs -> bcrypt@6(N-API,Windows/Linux 均免编译;drop-in,存量哈希 $2b$10$ 兼容)。 - app/model/account.js / app/service/auth.js: 同步哈希实现注释(D4 全族 5 处陈旧 SHA256 注释修正)。 答题链路与综合大题 - app/controller/exam.js: 中职/高职综合大题接口(GET big-question / POST big-answer, 逐问 2000 字上限校验);uploadWord 对中职/高职封堵(400)。 - app/service/exam.js: 抽题快照 question_snapshot 落库、答案归集键修正。 - app/controller/auth.js / app/service/auth.js: toClientUser() 唯一出口, 未发布态按组剔除 score,exam_score/word_score/ppt_score 任何情况下不下发。 - app/router.js: 新增大题与管理端路由。 - app/extend/helper.js: 下载文件名 RFC5987 编码(禁双重编码)。 - app.js: 池容量断言按 env 区分单/多 worker(防 dev 假日志)。 验证:ci-gates.sh 9/9 全绿;verify_login_guard.py 28/28(含 D 组变异 9/9); 真实登录/抽题/交卷端到端 code=0;前端构建 BUILD_RC=0。
2026-09-22 09:16:55 +08:00
*
* 🔴 批量化(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;
feat(exam/auth): 登录闸门 + 交卷稳定性加固(R4-R8) R4 登录/交卷背压闸门 - app/middleware/concurrency_guard.js: 新增。超限即 429 + Retry-After(立即拒绝式, 不排队;实测 429 P50=34ms)。RETRY_AFTER 收敛为单一有依据常量 = '2' (≈1.8 个满载窗口;实测 bcryptjs 1129ms / 原生 265ms 每窗口)。 配额:LOGIN_MAX=16 / SUBMIT_MAX=24 / HEAVY_REQ_MAX=20,各 *REQ_MAX 覆盖。 - config/config.default.js: 闸门配额与连接池容量配置;DB dialect 锁 mysql。 R7 顺序红线 + R8 Retry-After 同源 + 原生 bcrypt - 3 处硬编码 '2' 收敛为单一常量;补"勿因换实现而删闸门"红线注释。 - bcryptjs -> bcrypt@6(N-API,Windows/Linux 均免编译;drop-in,存量哈希 $2b$10$ 兼容)。 - app/model/account.js / app/service/auth.js: 同步哈希实现注释(D4 全族 5 处陈旧 SHA256 注释修正)。 答题链路与综合大题 - app/controller/exam.js: 中职/高职综合大题接口(GET big-question / POST big-answer, 逐问 2000 字上限校验);uploadWord 对中职/高职封堵(400)。 - app/service/exam.js: 抽题快照 question_snapshot 落库、答案归集键修正。 - app/controller/auth.js / app/service/auth.js: toClientUser() 唯一出口, 未发布态按组剔除 score,exam_score/word_score/ppt_score 任何情况下不下发。 - app/router.js: 新增大题与管理端路由。 - app/extend/helper.js: 下载文件名 RFC5987 编码(禁双重编码)。 - app.js: 池容量断言按 env 区分单/多 worker(防 dev 假日志)。 验证:ci-gates.sh 9/9 全绿;verify_login_guard.py 28/28(含 D 组变异 9/9); 真实登录/抽题/交卷端到端 code=0;前端构建 BUILD_RC=0。
2026-09-22 09:16:55 +08:00
// 第一步:按题型收集原始自增 id(去重后一次查出)。
const idsByType = { choice: [], judge: [] };
for (const item of answers) {
feat(exam/auth): 登录闸门 + 交卷稳定性加固(R4-R8) R4 登录/交卷背压闸门 - app/middleware/concurrency_guard.js: 新增。超限即 429 + Retry-After(立即拒绝式, 不排队;实测 429 P50=34ms)。RETRY_AFTER 收敛为单一有依据常量 = '2' (≈1.8 个满载窗口;实测 bcryptjs 1129ms / 原生 265ms 每窗口)。 配额:LOGIN_MAX=16 / SUBMIT_MAX=24 / HEAVY_REQ_MAX=20,各 *REQ_MAX 覆盖。 - config/config.default.js: 闸门配额与连接池容量配置;DB dialect 锁 mysql。 R7 顺序红线 + R8 Retry-After 同源 + 原生 bcrypt - 3 处硬编码 '2' 收敛为单一常量;补"勿因换实现而删闸门"红线注释。 - bcryptjs -> bcrypt@6(N-API,Windows/Linux 均免编译;drop-in,存量哈希 $2b$10$ 兼容)。 - app/model/account.js / app/service/auth.js: 同步哈希实现注释(D4 全族 5 处陈旧 SHA256 注释修正)。 答题链路与综合大题 - app/controller/exam.js: 中职/高职综合大题接口(GET big-question / POST big-answer, 逐问 2000 字上限校验);uploadWord 对中职/高职封堵(400)。 - app/service/exam.js: 抽题快照 question_snapshot 落库、答案归集键修正。 - app/controller/auth.js / app/service/auth.js: toClientUser() 唯一出口, 未发布态按组剔除 score,exam_score/word_score/ppt_score 任何情况下不下发。 - app/router.js: 新增大题与管理端路由。 - app/extend/helper.js: 下载文件名 RFC5987 编码(禁双重编码)。 - app.js: 池容量断言按 env 区分单/多 worker(防 dev 假日志)。 验证:ci-gates.sh 9/9 全绿;verify_login_guard.py 28/28(含 D 组变异 9/9); 真实登录/抽题/交卷端到端 code=0;前端构建 BUILD_RC=0。
2026-09-22 09:16:55 +08:00
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) => {
feat(exam/auth): 登录闸门 + 交卷稳定性加固(R4-R8) R4 登录/交卷背压闸门 - app/middleware/concurrency_guard.js: 新增。超限即 429 + Retry-After(立即拒绝式, 不排队;实测 429 P50=34ms)。RETRY_AFTER 收敛为单一有依据常量 = '2' (≈1.8 个满载窗口;实测 bcryptjs 1129ms / 原生 265ms 每窗口)。 配额:LOGIN_MAX=16 / SUBMIT_MAX=24 / HEAVY_REQ_MAX=20,各 *REQ_MAX 覆盖。 - config/config.default.js: 闸门配额与连接池容量配置;DB dialect 锁 mysql。 R7 顺序红线 + R8 Retry-After 同源 + 原生 bcrypt - 3 处硬编码 '2' 收敛为单一常量;补"勿因换实现而删闸门"红线注释。 - bcryptjs -> bcrypt@6(N-API,Windows/Linux 均免编译;drop-in,存量哈希 $2b$10$ 兼容)。 - app/model/account.js / app/service/auth.js: 同步哈希实现注释(D4 全族 5 处陈旧 SHA256 注释修正)。 答题链路与综合大题 - app/controller/exam.js: 中职/高职综合大题接口(GET big-question / POST big-answer, 逐问 2000 字上限校验);uploadWord 对中职/高职封堵(400)。 - app/service/exam.js: 抽题快照 question_snapshot 落库、答案归集键修正。 - app/controller/auth.js / app/service/auth.js: toClientUser() 唯一出口, 未发布态按组剔除 score,exam_score/word_score/ppt_score 任何情况下不下发。 - app/router.js: 新增大题与管理端路由。 - app/extend/helper.js: 下载文件名 RFC5987 编码(禁双重编码)。 - app.js: 池容量断言按 env 区分单/多 worker(防 dev 假日志)。 验证:ci-gates.sh 9/9 全绿;verify_login_guard.py 28/28(含 D 组变异 9/9); 真实登录/抽题/交卷端到端 code=0;前端构建 BUILD_RC=0。
2026-09-22 09:16:55 +08:00
// 🔴 批量化(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;