fix(backend): 补齐单次上传端点的并发护栏(BACKLOG C7)

- middleware/concurrency_guard.js: HEAVY_PATTERNS 增加单段上传正则
  /^\/api\/upload\/[^/]+$/i —— 单段锚定([^/]+$)保证不误伤"两段"的轻路由:
  GET /status、GET /submissions、DELETE /:id(放宽成前缀匹配会让「查状态也 429」)
- controller/upload.js: upload() 补 await acquireFileIo() +
  finally { if (ioAcquired) releaseFileIo(); }
  ⚠️ 不用 uploadChunk 的无条件 release:本方法 429 分支是 acquire 失败后 return,
     无条件释放会把"别人的"配额放掉 => 计数下溢 => 护栏静默失效

背景:修复前该端点是**唯一在用**的作品上传路径(Competition.vue:477),却既不在
      concurrencyGuard.HEAVY_PATTERNS(表内 chunk/merge 已成死路由)、也不调 file_io_guard
      => 零护栏。因服务器入带宽仅 ≈1.25MB/s 天然节流,当前风险有限,但按 C3 升入带宽
      后即变实质风险 => 与 C3 绑定,先补护栏。

验证:
- verify-guard-r43.mjs 33/33(单元:路径判别力 + 满载 429 + 配额隔离 + 释放语义 + 变异自证)
- verify-guard-r43-live.mjs 8/9(真接口:30 路 5MB 并发上传 => 20 成功 / 10 个 429 +
  Retry-After: 2;15 个轻路由探针在并发中全 200;唯一失败为清理步骤,根因是
  saveSubmissionRecord 的 upsert 语义,非缺陷)
- 重启后 master PID 23684;[AppBoot][pool] 40 <= 151
This commit is contained in:
2026-09-29 13:17:53 +08:00
parent 070195a22c
commit e8a5b34d42
2 changed files with 55 additions and 0 deletions

View File

@@ -52,11 +52,36 @@ class UploadController extends Controller {
*/
async upload() {
const { ctx, service } = this;
// 🔴 配额获取标志(见方法末 finally 的说明:为何不照搬 uploadChunk 的无条件 release)
let ioAcquired = false;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
// 🔴 文件 IO 并发护栏(2026-09-29 · R43 补齐 —— 本端点此前**零护栏**)
//
// 背景:本端点是**在用的**作品上传路径(前端 Competition.vue:477 逐文件串行调用),
// 而同文件的 uploadChunk(:156) / mergeChunks(:275) / exam.uploadWord(:503) 都调了本护栏,
// 唯独真正承载考生上传的这一条**既未调 file_io_guard、也不在 concurrency_guard
// 的 HEAVY_PATTERNS 内**(R42 发现,已同步补入中间件)。两处都补 = 闸门 + 护栏双保险。
//
// 位置:与 uploadChunk 同构 —— 在**读取请求体之前**(ctx.getFileStream() 在下方)取配额,
// 提供连接级背压:极端并发下超限即回 429,请求体不会被解析/缓冲,避免 fd/事件循环雪崩。
//
// ⚠️ 生产缓解事实(诚实标注,避免高估本改动):服务器入方向带宽仅 ≈1.25 MB/s,
// 收包速率被物理封顶、并发上传大多耗在「等字节」⇒ **当前实际风险有限**;
// 但一旦按 C3 提升入带宽,收包不再被封顶,护栏即从「保险」变为「必需」
// (故 C7 与 C3 绑定:先补护栏再升带宽)。
try {
await acquireFileIo();
ioAcquired = true;
} catch (e) {
ctx.status = 429;
ctx.body = ctx.helper.error(429, '服务器繁忙,上传通道暂满,请稍后重试');
return;
}
// 验证截止时间
const deadlineCheck = await service.upload.validateDeadline(group);
if (!deadlineCheck.valid) {
@@ -133,6 +158,14 @@ class UploadController extends Controller {
ctx.logger.error('文件上传失败:', err);
const msg = (err && err.message) ? err.message : '未知错误';
ctx.body = ctx.helper.error(500, msg);
} finally {
// 🔴 释放配额。为何用 `if (ioAcquired)` 而非像 uploadChunk 那样无条件 release:
// 本方法的外层 try 包住了「校验 + 取配额 + 落盘」全过程,而 429 分支是
// **acquire 失败**(配额没拿到)后 return 的 —— 若无条件释放,会把**别人的**配额
// 放掉,配额计数下溢 ⇒ 护栏静默失效(真实并发上限被虚高),是最难查的一类退化。
// ⚠️ 反之也不能省 last 这个 finally:中途任何 return 都不允许泄漏配额,
// 否则配额只减不增,若干轮后上传通道**永久 429**。
if (ioAcquired) releaseFileIo();
}
}

View File

@@ -18,7 +18,29 @@
// file_io_guard 限制「同时进行的文件流数(按流生命周期释放)」(防 fd/事件循环耗尽)。
// 双保险,使单 worker 在极端并发下「降级(429)而非崩溃」。
// 🔴 单次流式上传(2026-09-29 · R43 补齐,原为**零护栏**)
//
// 背景:R42 排查 C5(作品上传错峰)时发现 —— **在用的**上传端点 `POST /api/upload/:group`
// (router.js:101 → controller/upload.js 的 upload())**不在本表**,因此它既不经过本闸门,
// 当时也未调用 file_io_guard ⇒ 真正承载考生作品上传的唯一路径**完全无护栏**。
// 同表内 `chunk`/`merge` 反倒是**前端已无调用点的死路由**(Upload.vue 于 B2 删除后
// 失去唯一调用方,见 api/upload.js 的死代码清理),护栏加在了无人走的路上。
//
// 性质判定:单次上传是**重 IO**(长连接 + 大文件流式落盘 + 边写边算 md5),
// 与 chunk/merge 同类,故**共享同一配额 MAX**(不另开独立上限)——
// 配额的意义是「让 worker 有余力 accept 新连接」(见本文件顶部背景),
// 三者共享 20 比各自开 20 更能守住这个目的。
//
// ⚠️ 正则为何必须是**单段** `[^/]+$`(勿"顺手"放宽成 `/^\/api\/upload\//i`):
// 同一前缀下还有三条**轻**路由,路径同前缀但**多一段**:
// · GET /api/upload/:group/status (查状态,轻)
// · GET /api/upload/:group/submissions (列表,轻)
// · DELETE /api/upload/:group/:id (删作品,轻)
// `[^/]+$` 要求「/api/upload/」之后**恰好一段且到串尾**,故上面三条(两段)**天然不命中**;
// 放宽成前缀匹配会把它们一并计入配额,在考生端表现为「查状态也 429」——语义错误。
// 本表的 `chunk`/`merge` 同理带 `$` 锚定(既有写法,勿动)。
const HEAVY_PATTERNS = [
/^\/api\/upload\/[^/]+$/i,
/^\/api\/upload\/[^/]+\/chunk$/i,
/^\/api\/upload\/[^/]+\/merge$/i,
/^\/api\/exam\/[^/]+\/word$/i,