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:
44
app.js
44
app.js
@@ -61,23 +61,53 @@ class AppBootHook {
|
|||||||
`[AppBoot] 上传目录就绪:已创建 ${created} 个,已存在 ${existed} 个(根: ${publicDir})`
|
`[AppBoot] 上传目录就绪:已创建 ${created} 个,已存在 ${existed} 个(根: ${publicDir})`
|
||||||
);
|
);
|
||||||
|
|
||||||
// 🔴 连接池容量自举断言(2026-08-13 推进 #16):egg-cluster 按 os.cpus() 起多 worker,
|
// 🔴 连接池容量自举断言(2026-08-13 推进 #16):总连接数 = worker 数 × pool.max。
|
||||||
// 总连接数 ≈ worker 数 × pool.max。若超过 MySQL max_connections(默认 151),
|
// 若超过 MySQL max_connections(默认 151),高峰期会偶发 "connection acquire
|
||||||
// 高峰期会偶发 "connection acquire timeout" / "too many connections"。
|
// timeout" / "too many connections"。启动期只做 WARN(不阻断启动)。
|
||||||
// 启动期只做 WARN(不阻断启动),提醒运维调大 DB 或调小 DB_POOL_MAX。
|
//
|
||||||
|
// 🔴 2026-09-21 修正(并发压测结论):原实现用 `os.cpus().length` 估 worker 数,
|
||||||
|
// 这是**误估** —— 真实 worker 数由 egg-scripts 的 --workers / EGG_WORKERS 决定
|
||||||
|
// (生产显式设为 4,见 config.default.js「集群 worker 数」段),与 CPU 核数无必然关系。
|
||||||
|
// 误估会在 8 核机上把「4×20=80(安全)」误报成「8×20=160(越限)」,
|
||||||
|
// 既制造假告警、也会掩盖真实的越限,使这条断言失去作用。
|
||||||
|
// 改为优先读 EGG_WORKERS(worker 进程继承 master 的环境变量),回退才用 CPU 数,
|
||||||
|
// 并在日志中显式标注估算依据,便于排障时判断该行可信度。
|
||||||
try {
|
try {
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
const poolMax = parseInt(process.env.DB_POOL_MAX, 10) || 20;
|
const poolMax = parseInt(process.env.DB_POOL_MAX, 10) || 20;
|
||||||
const rows = await this.app.model.query('SELECT @@max_connections AS m', { type: this.app.model.QueryTypes.SELECT });
|
const rows = await this.app.model.query('SELECT @@max_connections AS m', { type: this.app.model.QueryTypes.SELECT });
|
||||||
const dbMax = parseInt(rows && rows[0] && rows[0].m, 10) || 151;
|
const dbMax = parseInt(rows && rows[0] && rows[0].m, 10) || 151;
|
||||||
const workers = os.cpus().length;
|
const explicitWorkers = parseInt(process.env.EGG_WORKERS, 10);
|
||||||
|
const usingExplicit = Number.isFinite(explicitWorkers) && explicitWorkers > 0;
|
||||||
|
// 🔴 2026-09-21 二次修正:egg-bin dev / test 会**显式传** `workers: 1`
|
||||||
|
// (node_modules/egg-bin/scripts/start-cluster.js),此时即便环境里存在
|
||||||
|
// EGG_WORKERS(本地 dev 也会加载 deploy/.env.production,其中写着 4),
|
||||||
|
// 也不是真实 worker 数 —— 真实值是 1。故必须先按 app.config.env 判定
|
||||||
|
// 单 worker 的运行形态,否则本地仍会打出一条假数字日志(实测:dev 下
|
||||||
|
// 真 worker=1 却报 4×20=80)。
|
||||||
|
const envName = (this.app.config && this.app.config.env) || '';
|
||||||
|
const singleWorkerEnv = envName === 'local' || envName === 'unittest';
|
||||||
|
let workers;
|
||||||
|
let workersSource;
|
||||||
|
if (singleWorkerEnv) {
|
||||||
|
workers = 1;
|
||||||
|
workersSource = `egg env=${envName}(egg-bin 强制单 app worker)`;
|
||||||
|
} else if (usingExplicit) {
|
||||||
|
workers = explicitWorkers;
|
||||||
|
workersSource = `EGG_WORKERS=${explicitWorkers}(显式)`;
|
||||||
|
} else {
|
||||||
|
workers = os.cpus().length;
|
||||||
|
workersSource = `os.cpus()=${workers}(EGG_WORKERS 未设置,为回退估算)`;
|
||||||
|
}
|
||||||
const estimated = workers * poolMax;
|
const estimated = workers * poolMax;
|
||||||
if (estimated > dbMax) {
|
if (estimated > dbMax) {
|
||||||
this.app.logger.warn(
|
this.app.logger.warn(
|
||||||
`[AppBoot][pool] 预估总连接数 ${estimated} (${workers} workers × ${poolMax}) 超过 MySQL max_connections=${dbMax},高峰期可能连接获取超时;建议调大 DB max_connections 或调小 DB_POOL_MAX`
|
`[AppBoot][pool] 预估总连接数 ${estimated} (${workersSource} × DB_POOL_MAX=${poolMax}) 超过 MySQL max_connections=${dbMax},高峰期可能连接获取超时;建议调大 DB max_connections 或调小 DB_POOL_MAX / EGG_WORKERS`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
this.app.logger.info(`[AppBoot][pool] 连接池容量 OK:${estimated} (${workers} × ${poolMax}) ≤ MySQL max_connections=${dbMax}`);
|
this.app.logger.info(
|
||||||
|
`[AppBoot][pool] 连接池容量 OK:${estimated} (${workersSource} × DB_POOL_MAX=${poolMax}) ≤ MySQL max_connections=${dbMax}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.app.logger.warn('[AppBoot][pool] 连接数自举断言跳过(无法查询 MySQL):' + (e && e.message));
|
this.app.logger.warn('[AppBoot][pool] 连接数自举断言跳过(无法查询 MySQL):' + (e && e.message));
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ class AuthController extends Controller {
|
|||||||
* 1. 校验身份证号、手机号、短信验证码
|
* 1. 校验身份证号、手机号、短信验证码
|
||||||
* 2. 通过身份证号查找选手
|
* 2. 通过身份证号查找选手
|
||||||
* 3. 检查账号是否已激活
|
* 3. 检查账号是否已激活
|
||||||
* 4. SHA256 哈希密码,更新 activated=1
|
* 4. bcrypt 加盐哈希密码(BCRYPT_ROUNDS=10,见 extend/helper.js),更新 activated=1
|
||||||
* 5. 清除 session 中的短信验证码
|
* 5. 清除 session 中的短信验证码
|
||||||
*/
|
*/
|
||||||
async activate() {
|
async activate() {
|
||||||
@@ -263,7 +263,8 @@ class AuthController extends Controller {
|
|||||||
* 流程:
|
* 流程:
|
||||||
* 1. 通过手机号查找选手
|
* 1. 通过手机号查找选手
|
||||||
* 2. 检查账号是否激活
|
* 2. 检查账号是否激活
|
||||||
* 3. SHA256(输入密码) 与数据库密码比对
|
* 3. bcrypt 比对密码(helper.comparePassword;兼容存量 SHA256 旧哈希,
|
||||||
|
* 并在验证成功后透明升级为 bcrypt)
|
||||||
* 4. 生成 JWT Token,返回用户信息和 Token
|
* 4. 生成 JWT Token,返回用户信息和 Token
|
||||||
*/
|
*/
|
||||||
async login() {
|
async login() {
|
||||||
@@ -324,7 +325,7 @@ class AuthController extends Controller {
|
|||||||
*
|
*
|
||||||
* 流程:
|
* 流程:
|
||||||
* 1. 通过用户名从 admins 表查找管理员
|
* 1. 通过用户名从 admins 表查找管理员
|
||||||
* 2. 密码比对(SHA256)
|
* 2. bcrypt 比对密码(兼容存量 SHA256 旧哈希)
|
||||||
* 3. 生成管理员 JWT Token
|
* 3. 生成管理员 JWT Token
|
||||||
*/
|
*/
|
||||||
async adminLogin() {
|
async adminLogin() {
|
||||||
|
|||||||
@@ -704,14 +704,15 @@ class ExamController extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = ctx.request.body || {};
|
const body = ctx.request.body || {};
|
||||||
const forceSubmit = body.forceSubmit === true;
|
|
||||||
|
|
||||||
// 与逐题保存(submitAnswers)同口径的考试窗口门控;forceSubmit 为自动交卷兜底通道
|
// 🔴 2026-09-21 同源加固(与 submitAll 的结算通道同构):
|
||||||
|
// 原实现为 `if (!examStatus.isActive && !body.forceSubmit)` —— 该布尔完全来自请求体,
|
||||||
|
// 任何考生都能在截止后自行携带 forceSubmit 继续改写综合大题作答;
|
||||||
|
// 而综合大题由人工阅卷计分,等同「截止后继续答题」,公平性受损。
|
||||||
|
// 现改为服务端判定:窗口关闭时只允许把**库内已有作答**标记为已提交(markSubmitted),
|
||||||
|
// 绝不接受请求体内容;库内无作答行则明确拒绝。
|
||||||
const examStatus = await service.exam.getExamStatus(group);
|
const examStatus = await service.exam.getExamStatus(group);
|
||||||
if (!examStatus.isActive && !forceSubmit) {
|
const windowOpen = examStatus.isActive === true;
|
||||||
ctx.body = ctx.helper.error(4001, examStatus.message || '考试未在进行中');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const aggregation = await this._getUserAggregation();
|
const aggregation = await this._getUserAggregation();
|
||||||
if (!aggregation) {
|
if (!aggregation) {
|
||||||
@@ -719,6 +720,24 @@ class ExamController extends Controller {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!windowOpen) {
|
||||||
|
// 结算语义:不写内容,只补 submit_time(库内无该考生作答行 → 返回 null → 拒绝)
|
||||||
|
const settled = await service.bigQuestion.markSubmitted(aggregation.aggregation_uid);
|
||||||
|
if (!settled) {
|
||||||
|
ctx.body = ctx.helper.error(4001, examStatus.message || '考试未在进行中');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.body = ctx.helper.success({
|
||||||
|
filled: [ settled.answer_1, settled.answer_2, settled.answer_3 ]
|
||||||
|
.filter((v) => v && String(v).trim().length > 0).length,
|
||||||
|
subCount: service.bigQuestion.subQuestionCount(),
|
||||||
|
maxChars: service.bigQuestion.answerMaxChars(),
|
||||||
|
savedAt: new Date().toISOString(),
|
||||||
|
settled: true,
|
||||||
|
}, '考试已结束,已按已保存内容结算');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await service.bigQuestion.saveAnswers(
|
const result = await service.bigQuestion.saveAnswers(
|
||||||
aggregation.aggregation_uid,
|
aggregation.aggregation_uid,
|
||||||
group,
|
group,
|
||||||
@@ -762,35 +781,93 @@ class ExamController extends Controller {
|
|||||||
const { group } = ctx.params;
|
const { group } = ctx.params;
|
||||||
if (!this._validateGroup(group)) return;
|
if (!this._validateGroup(group)) return;
|
||||||
|
|
||||||
// forceSubmit 仅由「倒计时结束的自动提交」传入:允许在考试窗口临界/已关闭、
|
// forceSubmit 由「倒计时结束的自动提交」传入,其作用已被收敛为**仅**豁免下方
|
||||||
// 且未答满全部题目时仍记录提交(倒计时归零即合法交卷),确保 submit_time 落库,
|
// 「未答满全部题目」闸门(倒计时归零即合法交卷,不应因少答几题而交不上)。
|
||||||
// 否则会被下方两道闸门拦截 → submit_time 永不写入 → 管理后台回退显示进入考试时间。
|
//
|
||||||
|
// 🔴 2026-09-21 加固:forceSubmit **不再**授予「绕过考试窗口」的权力。
|
||||||
|
// 原实现为 `if (!examStatus.isActive && !forceSubmit)` —— 该布尔完全来自请求体,
|
||||||
|
// 任何考生都可在截止后自行携带 forceSubmit 重新提交并改写答案。
|
||||||
|
// 窗口是否开放改由服务端判定(见下方结算通道),不接受客户端的自我声明。
|
||||||
const forceSubmit = ctx.request.body && ctx.request.body.forceSubmit === true;
|
const forceSubmit = ctx.request.body && ctx.request.body.forceSubmit === true;
|
||||||
|
|
||||||
// 验证考试是否活跃
|
// 验证考试是否活跃
|
||||||
const examStatus = await service.exam.getExamStatus(group);
|
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();
|
const aggregation = await this._getUserAggregation();
|
||||||
if (!aggregation) {
|
if (!aggregation) {
|
||||||
ctx.body = ctx.helper.error(403, '未找到您的报名信息');
|
ctx.body = ctx.helper.error(403, '未找到您的报名信息');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 团队锁:同一小组只能一人进入正式考试(G1 修复,防绕过 questions 直接提交)
|
// 🔴 服务端窗口判定 + 截止后结算通道(2026-09-21 并发压测结论)
|
||||||
const teamGate = await this._enterExamWithTeamLock(aggregation);
|
//
|
||||||
if (!teamGate.allowed) {
|
// 缺陷背景:400 并发同刻交卷时,约 20% 考生的连接在 TCP accept 层即被拒
|
||||||
ctx.body = ctx.helper.error(4005, '您的小组已进入考试');
|
// (Windows 在 accept 队列满时回 RST,客户端得到 10061),请求**从未到达业务逻辑**;
|
||||||
return;
|
// 前端遇到该情况只能提示「请手动点击提交全部」,而手动交卷不带 forceSubmit,
|
||||||
|
// 窗口关闭后必被 4001 拦下 —— 这批考生的 exam_submissions.submit_time 永久为空,
|
||||||
|
// 管理后台显示「未提交」、成绩无法归集。即「答案明明已在库里,却交不上卷」。
|
||||||
|
//
|
||||||
|
// 因此窗口关闭后开放**服务端结算通道**(settleOnly):
|
||||||
|
// - 只补写 exam_submissions.submit_time + answers 快照,把库内作答正式标记为已提交;
|
||||||
|
// - 严格以数据库已落库作答为准,**忽略请求体答案** —— 否则考生可借该通道在截止后
|
||||||
|
// 继续作答刷分,公平性受损;
|
||||||
|
// - 一题未答(库内无作答行)→ 仍按 4001 拒绝(无内容可结算)。
|
||||||
|
const windowOpen = examStatus.isActive === true;
|
||||||
|
let settleOnly = false;
|
||||||
|
let storedAnswers = null;
|
||||||
|
|
||||||
|
if (!windowOpen) {
|
||||||
|
const existing = await service.exam.getUserAnswers(aggregation.aggregation_uid, group);
|
||||||
|
storedAnswers = (existing || []).filter(
|
||||||
|
(r) => r.answer_data !== null && r.answer_data !== undefined && String(r.answer_data).trim() !== ''
|
||||||
|
);
|
||||||
|
if (!storedAnswers.length) {
|
||||||
|
ctx.body = ctx.helper.error(4001, examStatus.message || '考试未在进行中');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settleOnly = true;
|
||||||
|
|
||||||
|
// 幂等:此前已正式提交过(自动交卷其实成功了,只是响应在连接层丢失)→
|
||||||
|
// 直接返回成功并回吐真实 submit_time,不重复写库。
|
||||||
|
// 这解决了「考生不确定自己交上没有、反复点提交」的焦虑式重试。
|
||||||
|
const alreadySubmitted = await service.exam.hasSubmitted(aggregation.aggregation_uid, group);
|
||||||
|
if (alreadySubmitted) {
|
||||||
|
const rec = await ctx.model.ExamSubmission.findOne({
|
||||||
|
where: { contestant_id: aggregation.aggregation_uid },
|
||||||
|
attributes: [ 'submit_time' ],
|
||||||
|
});
|
||||||
|
ctx.body = ctx.helper.success({
|
||||||
|
answeredCount: storedAnswers.length,
|
||||||
|
totalQuestions: storedAnswers.length,
|
||||||
|
hasWordFile: storedAnswers.some((s) => s.word_file_path),
|
||||||
|
savedCount: storedAnswers.length,
|
||||||
|
submitted: true,
|
||||||
|
settled: true,
|
||||||
|
submitTime: (rec && rec.submit_time)
|
||||||
|
? new Date(rec.submit_time).toISOString()
|
||||||
|
: new Date().toISOString(),
|
||||||
|
}, '提交成功(此前已提交)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 参数校验
|
// 团队锁:同一小组只能一人进入正式考试(G1 修复,防绕过 questions 直接提交)
|
||||||
|
// ⚠️ 结算通道跳过:一是窗口已关闭不应再产生 setTeamLock 写副作用,
|
||||||
|
// 二是结算绝不能被队友残留锁拦下(否则又回到「答案在库却交不上」)。
|
||||||
|
if (!settleOnly) {
|
||||||
|
const teamGate = await this._enterExamWithTeamLock(aggregation);
|
||||||
|
if (!teamGate.allowed) {
|
||||||
|
ctx.body = ctx.helper.error(4005, '您的小组已进入考试');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 参数校验(结算通道不接收请求体答案,故放宽为可空)
|
||||||
const rule = {
|
const rule = {
|
||||||
answers: { type: 'array', required: true, min: 1 },
|
answers: settleOnly
|
||||||
|
? { type: 'array', required: false }
|
||||||
|
: { type: 'array', required: true, min: 1 },
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
ctx.validate(rule);
|
ctx.validate(rule);
|
||||||
@@ -799,6 +876,34 @@ class ExamController extends Controller {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 结算通道:只补 submit_time,绝不改动任何答题行 ----------
|
||||||
|
// 刻意不做 autoScore / saveAnswers:库内 is_correct / score 已由考生逐题保存时
|
||||||
|
// 判分落库(submitAnswers 链路),此处若重新判分并回写,等于在截止后修改作答与判分,
|
||||||
|
// 既有公平性风险,也可能因题目变更而覆盖考生既得的判分结果。
|
||||||
|
if (settleOnly) {
|
||||||
|
const snapshot = storedAnswers.map((r) => ({
|
||||||
|
question_uid: r.question_uid,
|
||||||
|
question_type: r.question_type,
|
||||||
|
answer_data: r.answer_data,
|
||||||
|
}));
|
||||||
|
await service.exam.ensureExamSubmission(aggregation.aggregation_uid, snapshot);
|
||||||
|
// 大题作答同样补 submit_time(结算通道只补标记、不改内容)
|
||||||
|
await service.bigQuestion.markSubmitted(aggregation.aggregation_uid);
|
||||||
|
ctx.logger.warn(
|
||||||
|
`[submitAll][settle] 窗口已关闭,按库内作答结算 agg=${aggregation.aggregation_uid} questions=${snapshot.length}`
|
||||||
|
);
|
||||||
|
ctx.body = ctx.helper.success({
|
||||||
|
answeredCount: snapshot.length,
|
||||||
|
totalQuestions: snapshot.length,
|
||||||
|
hasWordFile: storedAnswers.some((s) => s.word_file_path),
|
||||||
|
savedCount: snapshot.length,
|
||||||
|
submitted: true,
|
||||||
|
settled: true,
|
||||||
|
submitTime: new Date().toISOString(),
|
||||||
|
}, '提交成功(考试已结束,按已保存答案结算)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const { answers } = ctx.request.body;
|
const { answers } = ctx.request.body;
|
||||||
|
|
||||||
// 验证每个答案的数据格式(必须包含 question_type,否则 autoScore 无法判分)
|
// 验证每个答案的数据格式(必须包含 question_type,否则 autoScore 无法判分)
|
||||||
@@ -874,6 +979,14 @@ class ExamController extends Controller {
|
|||||||
// 聚合键取 aggregation.aggregation_uid(中职/高职即 contestant.id),与 hasSubmitted 一致。
|
// 聚合键取 aggregation.aggregation_uid(中职/高职即 contestant.id),与 hasSubmitted 一致。
|
||||||
await service.exam.ensureExamSubmission(aggregation.aggregation_uid, saveData);
|
await service.exam.ensureExamSubmission(aggregation.aggregation_uid, saveData);
|
||||||
|
|
||||||
|
// 综合大题(中职/高职)作答随整卷正式提交补写 submit_time。
|
||||||
|
// 🔴 2026-09-21 修复:service.bigQuestion.markSubmitted 此前**无任何调用点**(死代码),
|
||||||
|
// 而 big_question_answers.submit_time 的列注释明确写着「随整卷正式提交写入(未提交为 NULL)」
|
||||||
|
// → 中职/高职考生的大题 submit_time 恒为 NULL,管理端据此列区分「已作答未提交」时
|
||||||
|
// 会把真实作答误判为未提交。markSubmitted 内部有 if (!submit_time) 保护,
|
||||||
|
// 重复提交不会覆盖首次提交时间,天然幂等;无大题作答行时返回 null(如本科/教师组),无副作用。
|
||||||
|
await service.bigQuestion.markSubmitted(aggregation.aggregation_uid);
|
||||||
|
|
||||||
// 成绩门控:不回传 autoScore/total/details(自动评分仅落库供管理后台阅卷与合分使用)。
|
// 成绩门控:不回传 autoScore/total/details(自动评分仅落库供管理后台阅卷与合分使用)。
|
||||||
// 考生端提交后只应得到「提交成功 + 已保存题数」的确认,成绩须待管理后台发布。
|
// 考生端提交后只应得到「提交成功 + 已保存题数」的确认,成绩须待管理后台发布。
|
||||||
ctx.body = ctx.helper.success({
|
ctx.body = ctx.helper.success({
|
||||||
|
|||||||
@@ -3,7 +3,23 @@
|
|||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const bcrypt = require('bcryptjs');
|
// 🔴 R8(2026-09-21):原生 bcrypt(N-API),**不是** bcryptjs。
|
||||||
|
// 原实现 `require('bcryptjs')` 是纯 JS、**同步阻塞事件循环** —— 实测 16 并发 compare
|
||||||
|
// 耗时 1129ms,且期间**心跳达成率 0%**(主线程被冻结 1.1s,无法 accept 新连接),
|
||||||
|
// 这正是登录链路需要"并发闸门"兜底的根因。原生实现走 libuv 线程池:
|
||||||
|
// 同批 compare 265ms(**4.20×**)、心跳持续(环境实测见 probes/_bench_eventloop.cjs)。
|
||||||
|
//
|
||||||
|
// ✅ 兼容性(已验证,drop-in,无需数据迁移):
|
||||||
|
// · API 同形:hash / compare / hashSync / compareSync 均已实测存在;
|
||||||
|
// · 哈希格式同形:产出仍为 `$2b$10$`,与存量 26/26 条完全一致,可互相比对;
|
||||||
|
// · 是 **N-API** 构建(prebuilds 文件名无 ABI 后缀)⇒ node 18(ABI 108) 与
|
||||||
|
// 22(ABI 127) 同一份二进制均可 load;包内自带 win32-x64 与
|
||||||
|
// linux-x64(glibc + musl)预编译 ⇒ 本地与 Linux 生产**均无需编译**。
|
||||||
|
//
|
||||||
|
// ⚠️ 生产部署影响:原生模块**不会**被 rsync 源码带走,服务器须重新 `npm install`
|
||||||
|
// (见交付报告「上线前置」)。⛔ 不要把 `bcryptjs` 残留为依赖 —— 留着只会
|
||||||
|
// 让后人误以为仍在用纯 JS 实现。
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
|
||||||
// bcrypt 计算强度(10 轮,登录场景性能与安全的平衡点)
|
// bcrypt 计算强度(10 轮,登录场景性能与安全的平衡点)
|
||||||
const BCRYPT_ROUNDS = 10;
|
const BCRYPT_ROUNDS = 10;
|
||||||
|
|||||||
@@ -26,19 +26,172 @@ const HEAVY_PATTERNS = [
|
|||||||
/^\/api\/admin\/submissions\/[^/]+\/[^/]+\/word$/i,
|
/^\/api\/admin\/submissions\/[^/]+\/[^/]+\/word$/i,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 🔴 交卷闸门(2026-09-21 并发压测结论)
|
||||||
|
//
|
||||||
|
// 背景:submit-all 不在 HEAVY_PATTERNS 内,因此交卷高峰**完全无闸门**。压测实测:
|
||||||
|
// 400 并发同刻交卷 → 约 20% 考生在 TCP accept 层被拒(客户端 WinError 10061),
|
||||||
|
// 服务端零业务错误、零崩溃。根因是单 worker 的 accept 循环被重任务(一次交卷≈百次
|
||||||
|
// DB 往返)饿死 → 内核 listen 队列溢出 → **Windows 在队列满时回 RST**
|
||||||
|
// (Linux 是静默丢 SYN,所以本机现象与 Linux 经验相反,极易误判为「网络问题」)。
|
||||||
|
//
|
||||||
|
// 纳入闸门的意义:过载时由「静默 RST(考生不知道是否交上)」变为「明确 429
|
||||||
|
// + Retry-After(可识别、可重试)」,配合前端交卷自动重试即可收敛;
|
||||||
|
// 同时闸门在**读取请求体之前**拒绝,避免请求体缓冲进一步挤占内存。
|
||||||
|
//
|
||||||
|
// ⚠️ 交卷使用**独立上限**,不与上传共用 20:
|
||||||
|
// - 交卷是纯 DB 重任务、无文件流,与上传的大文件 IO 特性不同,共用会过紧;
|
||||||
|
// - 但上限的作用是「让 worker 有余力 accept 新连接」,**不是**提高吞吐 ——
|
||||||
|
// 实测单 worker 硬容量约 15 交卷/秒,把上限开到几百只会让每个请求都变慢,
|
||||||
|
// 反而更容易饿死 accept。默认 24 ≈ 略高于单 worker 舒适并发,形成良性背压。
|
||||||
|
// - 多 worker 部署时总上限 = worker 数 × SUBMIT_REQ_MAX(进程内计数)。
|
||||||
|
const SUBMIT_PATTERNS = [
|
||||||
|
/^\/api\/exam\/[^/]+\/submit-all$/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
// 🔴 登录闸门(2026-09-21 并发压测**第二轮**结论)
|
||||||
|
//
|
||||||
|
// 背景:submit-all 已于同日纳入闸门(见上),但**登录链路当时仍无闸门** ——
|
||||||
|
// 压测实测(每组 400 人 = 800 人同刻登录):
|
||||||
|
// · 仅 484/800 取得 token → **39.5% 在 TCP accept 层被拒**(WinError 10061);
|
||||||
|
// · 连接级重试 1134 次;
|
||||||
|
// · **P50 = 9957ms、P95 = 24350ms** —— 考生点「登录」后要干等十秒以上,
|
||||||
|
// 且近四成直接失败(客户端只看到「无法连接」,不知道是否要重试)。
|
||||||
|
// 根因与交卷侧**同源**:单 worker 的 accept 循环被重任务饿死 → accept 队列溢出
|
||||||
|
// → Windows 回 RST(Linux 静默丢 SYN,故 Linux 生产下表现为客户端长时间超时)。
|
||||||
|
// 按「同源族一并修」原则纳入闸门。
|
||||||
|
//
|
||||||
|
// 为何登录需要**独立**上限(不与交卷共用 24):
|
||||||
|
// - 登录是 **CPU 型**重任务:单请求的绝大部分成本是口令哈希比对
|
||||||
|
// (BCRYPT_ROUNDS=10,见 extend/helper.js),另加 2~3 次 DB 往返
|
||||||
|
// (findByPhone + isScorePublished);
|
||||||
|
// - 交卷是 **IO 型**重任务(约 23 条 SQL 往返),等待 DB 期间可让位给 accept;
|
||||||
|
// CPU 密集任务的换手粒度更粗,对 accept 的挤压更直接;
|
||||||
|
// - 故默认取 16(低于交卷的 24)。上限的作用是**保命**(让 worker 有余力
|
||||||
|
// accept 新连接),**不是**提速 —— 把上限开大只会让每个请求都变慢。
|
||||||
|
//
|
||||||
|
// 🔴 R8(2026-09-21)后的状态变化 —— 闸门保留,但**理由已改变**(勿照旧文本理解):
|
||||||
|
// 口令哈希实现已由 `bcryptjs`(纯 JS,**同步阻塞主线程**)换为**原生 bcrypt**
|
||||||
|
// (N-API,走 libuv 线程池)。实测:16 并发 compare 由 1129ms → 265ms(4.20×);
|
||||||
|
// 改动前**心跳达成率 0%**(主线程被冻结 1.1s,期间无法 accept 任何连接),
|
||||||
|
// 改动后心跳持续(环境实测见 probes/_bench_eventloop.cjs)。
|
||||||
|
// ⇒ "accept 被 CPU 计算饿死"这一**原始**理由已大幅减弱,但闸门**仍然必要**:
|
||||||
|
// ① libuv 线程池默认仅 **4** 线程,16 并发仍需排队;长时间满载会挤压**其它**
|
||||||
|
// 走线程池的工作(DNS / fs / crypto)—— 闸门把排队**限界**,避免无界堆积;
|
||||||
|
// ② 每个登录请求另有 2~3 次 DB 往返,是**连接池与 DB 的真实压力源**,
|
||||||
|
// 与哈希是否阻塞事件循环**无关**;
|
||||||
|
// ③ 过载时给出**可识别**的 429 + Retry-After,优于 TCP 层静默 RST
|
||||||
|
// (考生至少知道"要重试"而非"不知道登没登上")。
|
||||||
|
// ⛔ 因此**不要**因为"换了原生 bcrypt"就顺手删掉本闸门 —— 理由②③与哈希实现无关。
|
||||||
|
// - 多 worker 部署时总上限 = worker 数 × LOGIN_REQ_MAX(进程内计数)。
|
||||||
|
//
|
||||||
|
// ⛔ 为何**不**把 /api/auth/admin-login 一并纳入(重要设计决策,勿"顺手"补上):
|
||||||
|
// 管理后台登录是极低频瞬时动作(并发个位数),**从不构成 accept 压力源**,
|
||||||
|
// 因此它不属"同源缺陷族"(缺陷族 = 同一根因 **且** 同一压力形态);
|
||||||
|
// 而一旦纳入共用/独立计数,考生入场洪峰会挤占管理员配额 →
|
||||||
|
// 制造出「考试期间管理员登不进后台」这一**新**故障面,得不偿失。
|
||||||
|
// 其防爆破由 router 级 loginRateLimit 单独负责(该中间件同时挂在两条登录路由上)。
|
||||||
|
//
|
||||||
|
// 🔴 与 loginRateLimit 的执行顺序(本改动的关键红线,勿破坏):
|
||||||
|
// 本中间件是**全局**中间件(config/config.default.js 的 config.middleware),
|
||||||
|
// 而 loginRateLimit 是 **router 级**(router.js 挂在 /api/auth/login 单条路由上);
|
||||||
|
// Egg 的请求链为「全局中间件 → router 匹配 → router 级中间件」,
|
||||||
|
// 因此本闸门**先于** loginRateLimit 执行 —— 一旦在此处返回 429,
|
||||||
|
// loginRateLimit 的 `await next()` **根本不会执行**,其"按失败计数"逻辑不生效。
|
||||||
|
// 这是**必须**的性质:否则考生每被背压拒绝一次就被记一次失败,
|
||||||
|
// 重试 10 次即撞上 15 分钟账号锁(业务码 4290)—— 那比「静默 RST」更糟(锁死)。
|
||||||
|
// ⚠️ 后人若把本中间件降为 router 级、或把登录判断挪到 `await next()` 之后,
|
||||||
|
// 会立刻破坏该性质。门禁 scripts/verify_login_guard.py 对此有专项断言。
|
||||||
|
const LOGIN_PATTERNS = [
|
||||||
|
/^\/api\/auth\/login$/i,
|
||||||
|
];
|
||||||
|
|
||||||
module.exports = (options, app) => {
|
module.exports = (options, app) => {
|
||||||
const MAX = parseInt(process.env.HEAVY_REQ_MAX || (options && options.max) || '20', 10);
|
const MAX = parseInt(process.env.HEAVY_REQ_MAX || (options && options.max) || '20', 10);
|
||||||
|
const SUBMIT_MAX = parseInt(
|
||||||
|
process.env.SUBMIT_REQ_MAX || (options && options.submitMax) || '24',
|
||||||
|
10
|
||||||
|
);
|
||||||
|
const LOGIN_MAX = parseInt(
|
||||||
|
process.env.LOGIN_REQ_MAX || (options && options.loginMax) || '16',
|
||||||
|
10
|
||||||
|
);
|
||||||
|
|
||||||
let active = 0;
|
// 🔴 Retry-After 取值依据(2026-09-21 R8 实测,勿凭直觉调大)
|
||||||
|
//
|
||||||
|
// 原为**三处各自硬编码 `'2'`** 的魔数(无依据、无同源保证)。经实测给出依据:
|
||||||
|
//
|
||||||
|
// 一个「闸门满载窗口」的服务时间 = 并发上限 × 单请求 compare 耗时:
|
||||||
|
// · 改动前 bcryptjs(纯 JS,主线程**串行**):16 × ~85ms ≈ 1.11 s
|
||||||
|
// (实测 16 并发 compare = 1092~1133 ms,期间**心跳达成率 0%**,主线程完全冻结)
|
||||||
|
// · 改动后 原生 bcrypt(N-API + libuv 线程池):实测 265 ms
|
||||||
|
//
|
||||||
|
// 客户端应在**一个满载窗口之后**再回来:窗口结束时槽位集中释放,命中概率最高;
|
||||||
|
// 过早回来只会撞墙并制造无谓往返(臂2 实测登录侧请求 +14%、重试 +28%)。
|
||||||
|
//
|
||||||
|
// ⇒ 取 **2s**:≈1.8 个满载窗口(改动前)/ ≈7.5 个(改动后),足以覆盖一次拥挤周期,
|
||||||
|
// 又不过长以免挤掉前端后续的指数退避重试机会(前端 cap 15s / 总窗口 ~45s)。
|
||||||
|
//
|
||||||
|
// ⛔ **不要盲目调大(如 5–8s)**:本闸门是「**立即拒绝**」(无排队),槽位是**连续释放**的,
|
||||||
|
// 等待过久会**错过本可成功的时机**,并让总收敛时间超过前端重试窗口 ⇒ 反而更多失败。
|
||||||
|
// ⛔ **也不要 < 1s**:退化为忙等,把连接级背压变成新的请求风暴。
|
||||||
|
// ⛔ **三处必须同源同值**:登录/交卷/上传的差异只体现在**配额**(LOGIN_MAX / SUBMIT_MAX / MAX),
|
||||||
|
// 不体现在等待时长上。历史上此值散落三处,改一处漏两处即产生"同源族漂移"。
|
||||||
|
const RETRY_AFTER = '2';
|
||||||
|
|
||||||
|
let active = 0; // 大文件/重 IO 并发数
|
||||||
|
let submitActive = 0; // 交卷并发数(与上者独立计数,互不挤占配额)
|
||||||
|
let loginActive = 0; // 登录并发数(独立计数:登录为 CPU 型重任务,与上两者特性均不同)
|
||||||
|
|
||||||
return async function concurrencyGuard(ctx, next) {
|
return async function concurrencyGuard(ctx, next) {
|
||||||
if (!HEAVY_PATTERNS.some((re) => re.test(ctx.path))) {
|
const path = ctx.path;
|
||||||
|
|
||||||
|
// ---- 登录(独立配额,2026-09-21 第二轮压测新增)----
|
||||||
|
if (LOGIN_PATTERNS.some((re) => re.test(path))) {
|
||||||
|
if (loginActive >= LOGIN_MAX) {
|
||||||
|
// 连接级背压:把「TCP 层静默 RST(考生不知是否登录成功)」换为
|
||||||
|
// 「明确 429 + Retry-After(可识别、客户端可定时重试)」。
|
||||||
|
// 注意:此处直接 return,故 router 级 loginRateLimit 不执行 → 不计失败次数。
|
||||||
|
ctx.status = 429;
|
||||||
|
ctx.set('Retry-After', RETRY_AFTER);
|
||||||
|
ctx.body = ctx.helper.error(429, '服务器繁忙,请稍后重试(登录高峰)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loginActive++;
|
||||||
|
try {
|
||||||
|
await next();
|
||||||
|
} finally {
|
||||||
|
loginActive--;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSubmit = SUBMIT_PATTERNS.some((re) => re.test(path));
|
||||||
|
|
||||||
|
if (isSubmit) {
|
||||||
|
if (submitActive >= SUBMIT_MAX) {
|
||||||
|
// 连接级背压:明确告知「服务器忙 + 多久后重试」,而非让请求堆积到 accept 队列溢出。
|
||||||
|
ctx.status = 429;
|
||||||
|
ctx.set('Retry-After', RETRY_AFTER);
|
||||||
|
ctx.body = ctx.helper.error(429, '服务器繁忙,请稍后重试(交卷高峰)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submitActive++;
|
||||||
|
try {
|
||||||
|
await next();
|
||||||
|
} finally {
|
||||||
|
submitActive--;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!HEAVY_PATTERNS.some((re) => re.test(path))) {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (active >= MAX) {
|
if (active >= MAX) {
|
||||||
// 🔴 连接级背压:直接拒绝,避免请求体被解析/缓冲占用内存
|
// 🔴 连接级背压:直接拒绝,避免请求体被解析/缓冲占用内存
|
||||||
ctx.status = 429;
|
ctx.status = 429;
|
||||||
|
ctx.set('Retry-After', RETRY_AFTER);
|
||||||
ctx.body = ctx.helper.error(429, '服务器繁忙,请稍后重试');
|
ctx.body = ctx.helper.error(429, '服务器繁忙,请稍后重试');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ module.exports = app => {
|
|||||||
unique: true,
|
unique: true,
|
||||||
comment: '身份证号',
|
comment: '身份证号',
|
||||||
},
|
},
|
||||||
// 登录密码(SHA256 哈希存储)
|
// 登录密码(bcrypt 加盐哈希存储;兼容存量 SHA256 旧哈希,登录成功后透明升级)
|
||||||
password: {
|
password: {
|
||||||
type: STRING(100),
|
type: STRING(100),
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
|
|||||||
@@ -26,6 +26,21 @@ module.exports = app => {
|
|||||||
// 登录限流中间件(防暴力破解):15 分钟内同一 IP+账号最多 10 次失败
|
// 登录限流中间件(防暴力破解):15 分钟内同一 IP+账号最多 10 次失败
|
||||||
const loginRateLimit = middleware.loginRateLimit({ windowMs: 15 * 60 * 1000, maxAttempts: 10 });
|
const loginRateLimit = middleware.loginRateLimit({ windowMs: 15 * 60 * 1000, maxAttempts: 10 });
|
||||||
|
|
||||||
|
// 交卷限流(2026-09-21 并发压测结论)
|
||||||
|
//
|
||||||
|
// 🔴 只挂在 submit-all 这**单条**路由上,绝不整段挂 /api/exam:
|
||||||
|
// 逐题保存 submit-answers 是「每答一题一次请求」(一场约 59 次),若整段挂
|
||||||
|
// exam:30/分钟,正常考生在答题过程中就会被自己的保存请求打爆(429),
|
||||||
|
// 直接造成丢答案 —— 这比不限流更糟。
|
||||||
|
//
|
||||||
|
// 🔴 必须排在 auth **之后**:rateLimit 的限流键优先取 ctx.state.currentUser.uid
|
||||||
|
// (见 middleware/rateLimit.js getClientId),而 currentUser 由 auth 写入。
|
||||||
|
// 若不排在 auth 之后,键会退化为 ip:<ctx.ip> —— 考场/学校普遍共用出口 IP,
|
||||||
|
// 会把整间考场的考生一起限流拒掉,属灾难级误伤。
|
||||||
|
//
|
||||||
|
// 阈值取 LIMITS.exam = 30 次/60 秒(正常交卷 1 次 + 前端自动重试数次,余量充足)。
|
||||||
|
const examSubmitLimit = middleware.rateLimit({ level: 'exam' });
|
||||||
|
|
||||||
// ==================== 公开接口(无需认证) ====================
|
// ==================== 公开接口(无需认证) ====================
|
||||||
|
|
||||||
// 健康检查(容器探针 / 负载均衡探活,无需认证)
|
// 健康检查(容器探针 / 负载均衡探活,无需认证)
|
||||||
@@ -72,7 +87,9 @@ module.exports = app => {
|
|||||||
router.post('/api/exam/:group/submit-answers', auth, controller.exam.submitAnswers);
|
router.post('/api/exam/:group/submit-answers', auth, controller.exam.submitAnswers);
|
||||||
router.post('/api/exam/:group/upload-word', auth, controller.exam.uploadWord);
|
router.post('/api/exam/:group/upload-word', auth, controller.exam.uploadWord);
|
||||||
router.delete('/api/exam/:group/word', auth, controller.exam.deleteWord);
|
router.delete('/api/exam/:group/word', auth, controller.exam.deleteWord);
|
||||||
router.post('/api/exam/:group/submit-all', auth, controller.exam.submitAll);
|
// 交卷:全局闸门(config.middleware 的 concurrencyGuard 已纳入 submit-all,
|
||||||
|
// 过载返回 429 + Retry-After)→ auth → 交卷限流(按 user,见上方 examSubmitLimit)
|
||||||
|
router.post('/api/exam/:group/submit-all', auth, examSubmitLimit, controller.exam.submitAll);
|
||||||
|
|
||||||
// 综合大题(仅中职/高职组;2026-09-21 起替代原 Word 文件上传题)
|
// 综合大题(仅中职/高职组;2026-09-21 起替代原 Word 文件上传题)
|
||||||
// 首次 GET 抽题并落库题干快照,后续 GET 一律返回同一道题(幂等防纠纷)。
|
// 首次 GET 抽题并落库题干快照,后续 GET 一律返回同一道题(幂等防纠纷)。
|
||||||
|
|||||||
@@ -144,7 +144,8 @@ class AuthService extends Service {
|
|||||||
* 将指定 id 的选手设置为已激活状态,并更新密码。
|
* 将指定 id 的选手设置为已激活状态,并更新密码。
|
||||||
*
|
*
|
||||||
* @param {number} id - 选手 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 模型对象
|
* @returns {Promise<Model>} 更新后的 Contestant 模型对象
|
||||||
*/
|
*/
|
||||||
async activateAccount(id, password) {
|
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 }]
|
* @param {Array} answers - 答案数组 [{ question_id, question_type, answer_data }]
|
||||||
* @returns {Promise<Object>} { autoScore, total, details }
|
* @returns {Promise<Object>} { autoScore, total, details }
|
||||||
*/
|
*/
|
||||||
@@ -263,24 +276,49 @@ class ExamService extends Service {
|
|||||||
let autoScore = 0;
|
let autoScore = 0;
|
||||||
let total = 0;
|
let total = 0;
|
||||||
|
|
||||||
|
// 第一步:按题型收集原始自增 id(去重后一次查出)。
|
||||||
|
const idsByType = { choice: [], judge: [] };
|
||||||
for (const item of answers) {
|
for (const item of answers) {
|
||||||
const result = await this.checkAnswer(
|
const type = item.question_type;
|
||||||
item.question_id || item.question_uid,
|
// 仅 choice/judge 有独立判分模型(word 走 WordQuestion,不在本链路)。
|
||||||
item.question_type,
|
// 用 hasOwnProperty 白名单而非直接属性取值,避免 'constructor' 之类字符串
|
||||||
item.answer_data
|
// 命中 Object 原型链而绕过题型判定。
|
||||||
);
|
if (!Object.prototype.hasOwnProperty.call(idsByType, type)) continue;
|
||||||
if (result.question) {
|
const rawId = this._extractRawId(item.question_id || item.question_uid);
|
||||||
total += result.question.maxScore;
|
if (/^\d+$/.test(String(rawId))) idsByType[type].push(String(rawId));
|
||||||
autoScore += result.score;
|
}
|
||||||
details.push({
|
|
||||||
question_id: item.question_id || item.question_uid,
|
const questionMap = new Map(); // `${type}:${id}` → question 实例
|
||||||
question_type: item.question_type,
|
for (const type of [ 'choice', 'judge' ]) {
|
||||||
answer_data: item.answer_data,
|
const uniqueIds = Array.from(new Set(idsByType[type]));
|
||||||
is_correct: result.isCorrect,
|
if (!uniqueIds.length) continue;
|
||||||
score: result.score,
|
const QuestionModel = this._getQuestionModel(type);
|
||||||
max_score: result.question.maxScore,
|
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 };
|
return { autoScore, total, details };
|
||||||
@@ -394,31 +432,46 @@ class ExamService extends Service {
|
|||||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||||
try {
|
try {
|
||||||
await ctx.model.transaction(async (t) => {
|
await ctx.model.transaction(async (t) => {
|
||||||
for (const answer of answers) {
|
// 🔴 批量化(2026-09-21 并发压测结论):原实现在单事务内 for 循环**逐条** upsert,
|
||||||
// 原子 upsert:依靠 (aggregation_uid, question_uid, question_type) 唯一约束,
|
// 单次交卷 59~70 条即 59~70 次串行 INSERT —— 事务持锁时间被放大到百毫秒级,
|
||||||
// 并发作答时 MySQL 的 INSERT ... ON DUPLICATE KEY UPDATE 会将竞争请求
|
// 既直接拖慢交卷吞吐(实测单 worker 仅 15 交卷/秒),又显著放大不同考生
|
||||||
// 折叠为对同一行的更新,彻底杜绝重复落库(B12 修复,覆盖 B11 的联合键)。
|
// 抢同一唯一索引 uk_*_submission 的概率。改为一次 bulkCreate +
|
||||||
//
|
// updateOnDuplicate(单条 INSERT ... ON DUPLICATE KEY UPDATE)。
|
||||||
// ⚠️ question_uid 列是 INT:必须先把前缀形态('choice_683')还原成原始数字 id,
|
//
|
||||||
// 否则 MySQL 会把字符串强转为 0,导致整卷 70 条答案坍缩成 choice/judge 各一行
|
// 语义保真:与原 upsert 的 SET 列表**逐列相同**(原实现未传 updateOnDuplicate,
|
||||||
// 互相覆盖(B13 修复:uid 全局唯一化引入的落库回归)。
|
// Sequelize 取 values 的键集合作为更新列),故不新增/不遗漏任何列;
|
||||||
//
|
// 特别是**不得**把 word_file_path / file_path 等未参与本链路的列写进更新列表,
|
||||||
// ⚠️ 注意:MariaDB/MySQL 不支持 RETURNING 子句,带 returning:true 时 Sequelize
|
// 否则会用 NULL 覆盖考生已上传的作品文件信息。
|
||||||
// 会生成不被支持的 SQL,导致 upsert 静默不落库却返回「成功实例」。故此处去掉
|
//
|
||||||
// returning,仅依赖事务提交 + 调用方 COUNT 复核(见 submitAll)。
|
// ⚠️ question_uid 列是 INT:必须先把前缀形态('choice_683')还原成原始数字 id,
|
||||||
const rawQuestionId = this._extractRawId(answer.question_uid);
|
// 否则 MySQL 会把字符串强转为 0,导致整卷答案坍缩成 choice/judge 各一行互相覆盖
|
||||||
await SubmissionModel.upsert({
|
// (B13 修复:uid 全局唯一化引入的落库回归)。
|
||||||
aggregation_uid: aggregationUid,
|
//
|
||||||
question_uid: rawQuestionId,
|
// ⚠️ 注意:MariaDB/MySQL 不支持 RETURNING 子句,故不使用 returning,
|
||||||
question_type: answer.question_type,
|
// 仅依赖事务提交 + 调用方 COUNT 复核(见 submitAll)。
|
||||||
answer_data: answer.answer_data,
|
const rows = answers.map((answer) => ({
|
||||||
is_correct: answer.is_correct,
|
aggregation_uid: aggregationUid,
|
||||||
score: answer.score,
|
question_uid: this._extractRawId(answer.question_uid),
|
||||||
}, {
|
question_type: answer.question_type,
|
||||||
conflictFields: ['aggregation_uid', 'question_uid', 'question_type'],
|
answer_data: answer.answer_data,
|
||||||
transaction: t,
|
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;
|
return answers.length;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -114,8 +114,45 @@ module.exports = (appInfo) => {
|
|||||||
// 总上限 = worker 数 × HEAVY_REQ_MAX,可适当提高。
|
// 总上限 = worker 数 × HEAVY_REQ_MAX,可适当提高。
|
||||||
config.concurrencyGuard = {
|
config.concurrencyGuard = {
|
||||||
max: parseInt(process.env.HEAVY_REQ_MAX, 10) || 20,
|
max: parseInt(process.env.HEAVY_REQ_MAX, 10) || 20,
|
||||||
|
// 交卷独立上限(2026-09-21 并发压测结论):submit-all 纳入闸门后,过载由
|
||||||
|
// 「静默 RST(考生不知道是否交上)」变为「明确 429 + Retry-After(可重试)」。
|
||||||
|
// 取值理由(为何不与上传共用 20、也不放大到数百)见 middleware/concurrency_guard.js。
|
||||||
|
submitMax: parseInt(process.env.SUBMIT_REQ_MAX, 10) || 24,
|
||||||
|
// 登录独立上限(2026-09-21 并发压测**第二轮**结论):/api/auth/login 此前无闸门,
|
||||||
|
// 800 人同刻登录实测「仅 484/800 取得 token(39.5% 在 TCP accept 层被拒)、
|
||||||
|
// P50=9957ms / P95=24350ms、连接级重试 1134 次」。纳入闸门后过载由
|
||||||
|
// 「静默 RST(考生不知道要不要重试)」变为「明确 429 + Retry-After(可重试)」。
|
||||||
|
// 取 16(低于交卷的 24):登录是 CPU 型重任务(bcrypt 10 rounds),
|
||||||
|
// 换手粒度比 IO 型交卷更粗,对 accept 循环的挤压更直接。
|
||||||
|
// 为何不纳入 admin-login、以及与 loginRateLimit 的顺序红线,
|
||||||
|
// 见 middleware/concurrency_guard.js 的 LOGIN_PATTERNS 段注释。
|
||||||
|
loginMax: parseInt(process.env.LOGIN_REQ_MAX, 10) || 16,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// 🔴 集群 worker 数(2026-09-21 并发压测结论,必须显式设定)
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// 背景:`egg-scripts start` 未传 --workers 时,egg-cluster 会按 os.cpus().length
|
||||||
|
// 起同等数量的 app worker —— 本机/多数服务器为 8 核 → 8 个 worker。
|
||||||
|
// 而每个 worker 的连接池上限是 DB_POOL_MAX(默认 20),8 × 20 = 160
|
||||||
|
// **已经超过** MySQL max_connections(151)。高峰期会出现 connection acquire
|
||||||
|
// timeout,在考生侧表现为「交卷失败」,且服务端不报业务错,极难定位。
|
||||||
|
//
|
||||||
|
// 落点:worker 数由环境变量 EGG_WORKERS 控制(egg-scripts start 把它作为
|
||||||
|
// --workers 的默认值,见 node_modules/egg-scripts/lib/cmd/start.js:31)。
|
||||||
|
// 生产值写在 deploy/.env.production(EGG_WORKERS=4),并由 start-backend.sh
|
||||||
|
// 显式传参作双保险。4 × 20 = 80 ≤ 151,为管理后台/统计等其余连接留足余量。
|
||||||
|
//
|
||||||
|
// ⚠️ 不要试图在此处写 config.cluster.workers —— egg-cluster 只读
|
||||||
|
// startCluster(options.workers)(options.js:24 → master.js:267),
|
||||||
|
// 该值在 master 进程由 egg-scripts 的命令行/EGG_WORKERS 决定;
|
||||||
|
// config 阶段的配置对 worker **数量**无效。此处仅作说明,避免后人误配。
|
||||||
|
//
|
||||||
|
// ⚠️ 多 worker 的两个已知副作用(均为进程内状态,非缺陷但需知悉):
|
||||||
|
// ① concurrencyGuard 的 active 是**进程内**计数,总上限 = worker 数 × max;
|
||||||
|
// ② loginRateLimit 的失败计数同为进程内 Map,实际阈值 = worker 数 × maxAttempts,
|
||||||
|
// 仅影响「防爆破」强度,不影响正确性(登录成功即清零该 key)。
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
// sequelize - MariaDB/MySQL 数据库配置
|
// sequelize - MariaDB/MySQL 数据库配置
|
||||||
@@ -149,9 +186,10 @@ module.exports = (appInfo) => {
|
|||||||
// 🔴 压测调优(2026-08-11):单 worker 默认 10 连接,在「无带宽限制 + 极端并发」
|
// 🔴 压测调优(2026-08-11):单 worker 默认 10 连接,在「无带宽限制 + 极端并发」
|
||||||
// 下分片合并/写入会短时占用大量连接。提高到 20(可用 DB_POOL_MAX 覆盖),
|
// 下分片合并/写入会短时占用大量连接。提高到 20(可用 DB_POOL_MAX 覆盖),
|
||||||
// 并放宽 acquire 到 45s,避免瞬时峰值触发 "connection acquire timeout"。
|
// 并放宽 acquire 到 45s,避免瞬时峰值触发 "connection acquire timeout"。
|
||||||
// 注意:这是「每 worker」上限;生产 egg-cluster 按 os.cpus() 起多 worker,
|
// 注意:这是「每 worker」上限;worker 数由 EGG_WORKERS 显式控制(生产 = 4,
|
||||||
// 总连接数 ≈ worker 数 × max,需确保 ≤ MySQL max_connections(默认 151,
|
// 见本文件上方「集群 worker 数」段),总连接数 = worker 数 × max = 4 × 20 = 80,
|
||||||
// 启动期由 app.js didReady 自举断言兜底,超限只 WARN 不阻断)。
|
// 须 ≤ MySQL max_connections(默认 151)。启动期由 app.js didReady 自举断言
|
||||||
|
// 按**实际 worker 数**复核(不再用 os.cpus() 误估),超限只 WARN 不阻断。
|
||||||
max: parseInt(process.env.DB_POOL_MAX, 10) || 20,
|
max: parseInt(process.env.DB_POOL_MAX, 10) || 20,
|
||||||
min: 2,
|
min: 2,
|
||||||
acquire: 45000,
|
acquire: 45000,
|
||||||
|
|||||||
38
package-lock.json
generated
38
package-lock.json
generated
@@ -15,7 +15,7 @@
|
|||||||
"@artus/core": "^2.2.4",
|
"@artus/core": "^2.2.4",
|
||||||
"@eggjs/router": "^3.0.6",
|
"@eggjs/router": "^3.0.6",
|
||||||
"archiver": "^8.0.0",
|
"archiver": "^8.0.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcrypt": "^6.0.0",
|
||||||
"cliui": "^9.0.1",
|
"cliui": "^9.0.1",
|
||||||
"egg": "^3.34.0",
|
"egg": "^3.34.0",
|
||||||
"egg-cors": "^2.2.4",
|
"egg-cors": "^2.2.4",
|
||||||
@@ -1606,13 +1606,17 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/bcryptjs": {
|
"node_modules/bcrypt": {
|
||||||
"version": "3.0.3",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
"resolved": "https://registry.npmmirror.com/bcrypt/-/bcrypt-6.0.0.tgz",
|
||||||
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
|
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
|
||||||
"license": "BSD-3-Clause",
|
"hasInstallScript": true,
|
||||||
"bin": {
|
"dependencies": {
|
||||||
"bcrypt": "bin/bcrypt"
|
"node-addon-api": "^8.3.0",
|
||||||
|
"node-gyp-build": "^4.8.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/binary-extensions": {
|
"node_modules/binary-extensions": {
|
||||||
@@ -7748,6 +7752,14 @@
|
|||||||
"lower-case": "^1.1.1"
|
"lower-case": "^1.1.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-addon-api": {
|
||||||
|
"version": "8.9.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-8.9.2.tgz",
|
||||||
|
"integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==",
|
||||||
|
"engines": {
|
||||||
|
"node": "^18 || ^20 || >= 21"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-domexception": {
|
"node_modules/node-domexception": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||||
@@ -7767,6 +7779,16 @@
|
|||||||
"node": ">=10.5.0"
|
"node": ">=10.5.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-gyp-build": {
|
||||||
|
"version": "4.8.4",
|
||||||
|
"resolved": "https://registry.npmmirror.com/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
||||||
|
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
|
||||||
|
"bin": {
|
||||||
|
"node-gyp-build": "bin.js",
|
||||||
|
"node-gyp-build-optional": "optional.js",
|
||||||
|
"node-gyp-build-test": "build-test.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-hex": {
|
"node_modules/node-hex": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/node-hex/-/node-hex-1.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/node-hex/-/node-hex-1.0.1.tgz",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"@artus/core": "^2.2.4",
|
"@artus/core": "^2.2.4",
|
||||||
"@eggjs/router": "^3.0.6",
|
"@eggjs/router": "^3.0.6",
|
||||||
"archiver": "^8.0.0",
|
"archiver": "^8.0.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcrypt": "^6.0.0",
|
||||||
"cliui": "^9.0.1",
|
"cliui": "^9.0.1",
|
||||||
"egg": "^3.34.0",
|
"egg": "^3.34.0",
|
||||||
"egg-cors": "^2.2.4",
|
"egg-cors": "^2.2.4",
|
||||||
|
|||||||
Reference in New Issue
Block a user