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

@@ -114,8 +114,45 @@ module.exports = (appInfo) => {
// 总上限 = worker 数 × HEAVY_REQ_MAX,可适当提高。
config.concurrencyGuard = {
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 数据库配置
@@ -149,9 +186,10 @@ module.exports = (appInfo) => {
// 🔴 压测调优(2026-08-11):单 worker 默认 10 连接,在「无带宽限制 + 极端并发」
// 下分片合并/写入会短时占用大量连接。提高到 20(可用 DB_POOL_MAX 覆盖),
// 并放宽 acquire 到 45s,避免瞬时峰值触发 "connection acquire timeout"。
// 注意:这是「每 worker」上限;生产 egg-cluster 按 os.cpus() 起多 worker,
// 总连接数 ≈ worker 数 × max,需确保 ≤ MySQL max_connections(默认 151,
// 启动期由 app.js didReady 自举断言兜底,超限只 WARN 不阻断)。
// 注意:这是「每 worker」上限;worker 数由 EGG_WORKERS 显式控制(生产 = 4,
// 见本文件上方「集群 worker 数」段),总连接数 = worker 数 × max = 4 × 20 = 80,
// 须 ≤ MySQL max_connections(默认 151)。启动期由 app.js didReady 自举断言
// 按**实际 worker 数**复核(不再用 os.cpus() 误估),超限只 WARN 不阻断。
max: parseInt(process.env.DB_POOL_MAX, 10) || 20,
min: 2,
acquire: 45000,