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。
This commit is contained in:
2026-09-22 09:16:55 +08:00
parent 8ef18da395
commit 23604f2718
12 changed files with 535 additions and 91 deletions

View File

@@ -144,7 +144,8 @@ class AuthService extends Service {
* 将指定 id 的选手设置为已激活状态,并更新密码。
*
* @param {number} id - 选手 id
* @param {string} password - SHA256 哈希后的密码
* @param {string} password - bcrypt 加盐哈希后的密码(由 helper.hashPassword 生成,
* BCRYPT_ROUNDS=10;调用方 controller/auth.js 的 activate() 已保证此口径)
* @returns {Promise<Model>} 更新后的 Contestant 模型对象
*/
async activateAccount(id, password) {

View File

@@ -255,6 +255,19 @@ class ExamService extends Service {
/**
* 批量自动判分
*
* 🔴 批量化(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 }
*/
@@ -263,24 +276,49 @@ class ExamService extends Service {
let autoScore = 0;
let total = 0;
// 第一步:按题型收集原始自增 id(去重后一次查出)。
const idsByType = { choice: [], judge: [] };
for (const item of answers) {
const result = await this.checkAnswer(
item.question_id || item.question_uid,
item.question_type,
item.answer_data
);
if (result.question) {
total += result.question.maxScore;
autoScore += result.score;
details.push({
question_id: item.question_id || item.question_uid,
question_type: item.question_type,
answer_data: item.answer_data,
is_correct: result.isCorrect,
score: result.score,
max_score: result.question.maxScore,
});
}
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 };
@@ -394,31 +432,46 @@ class ExamService extends Service {
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
await ctx.model.transaction(async (t) => {
for (const answer of answers) {
// 原子 upsert:依靠 (aggregation_uid, question_uid, question_type) 唯一约束,
// 并发作答时 MySQL 的 INSERT ... ON DUPLICATE KEY UPDATE 会将竞争请求
// 折叠为对同一行的更新,彻底杜绝重复落库(B12 修复,覆盖 B11 的联合键)。
//
// ⚠️ question_uid 列是 INT:必须先把前缀形态('choice_683')还原成原始数字 id,
// 否则 MySQL 会把字符串强转为 0,导致整卷 70 条答案坍缩成 choice/judge 各一行
// 互相覆盖(B13 修复:uid 全局唯一化引入的落库回归)。
//
// ⚠️ 注意:MariaDB/MySQL 不支持 RETURNING 子句,带 returning:true 时 Sequelize
// 会生成不被支持的 SQL,导致 upsert 静默不落库却返回「成功实例」。故此处去掉
// returning,仅依赖事务提交 + 调用方 COUNT 复核(见 submitAll)。
const rawQuestionId = this._extractRawId(answer.question_uid);
await SubmissionModel.upsert({
aggregation_uid: aggregationUid,
question_uid: rawQuestionId,
question_type: answer.question_type,
answer_data: answer.answer_data,
is_correct: answer.is_correct,
score: answer.score,
}, {
conflictFields: ['aggregation_uid', 'question_uid', 'question_type'],
transaction: 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) {