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:
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user