diff --git a/app/controller/upload.js b/app/controller/upload.js index cee3a12..1599b88 100644 --- a/app/controller/upload.js +++ b/app/controller/upload.js @@ -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(); } } diff --git a/app/middleware/concurrency_guard.js b/app/middleware/concurrency_guard.js index 68132a7..d57219f 100644 --- a/app/middleware/concurrency_guard.js +++ b/app/middleware/concurrency_guard.js @@ -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,