From cf91f6ba346686f3165fd00f9b824defd3817a54 Mon Sep 17 00:00:00 2001 From: qubiaobiao <3294694717@qq.com> Date: Mon, 12 Oct 2026 11:05:47 +0800 Subject: [PATCH] =?UTF-8?q?fix(download):=20=E5=93=A8=E5=85=B5=E6=97=81?= =?UTF-8?q?=E8=B7=AF=E6=94=B9=E8=B5=B0=20Content-Disposition=20=E7=9A=84?= =?UTF-8?q?=20size=3D=20=E6=89=A9=E5=B1=95=E5=8F=82=E6=95=B0=EF=BC=88R103?= =?UTF-8?q?=20=E6=94=B6=E5=8F=A3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景(R103-3 生产实测已定因): X-Accel-Redirect 分支下真正吐字节的是 nginx,Node 侧无从得知 nginx 是否吐对 了内容(内部 location 缺失时会吐前端 index.html)。原实现靠自定义响应头 X-File-Size 把「服务端 stat 的真实字节数」透出给前端比对,但双通道对照实测 (.workbuddy/probes/2026-10-12-r103/prod_xfs_passthrough.js)证伪了原注释所称 「nginx 会保留上游自定义响应头」: · 直连后端 :8023 ⇒ 204 空体,响应带 x-file-size: 19009 · 经 nginx :80 ⇒ 200 / 19009 字节(与磁盘逐字节一致),而 x-file-size **缺失** · 对照 /api/health 经 nginx ⇒ 自定义头 x-readtime **保留** ⇒ 丢弃只发生在 X-Accel 内部重定向这一条路径。 影响:该分支下前端哨兵空转(下载完整性校验被静默跳过)。 修法: 把同一份字节数写成 RFC 6266 §4.1 的 disposition 扩展参数 `size=`(实测 Content-Disposition 能穿透 X-Accel),前端按 x-file-size → CD size= 顺序回落。 两通道同源同值;任一缺失只降级不误伤。Node 流分支(absolutePath)不加旁路 —— 其响应走 nginx 常规转发,自定义头本就保留。 验证(2026-10-12 生产部署后实测): · 本地判别力自证 verify_cd_size_channel.js:pass=12 fail=0 (含 C1 变异测试:修复前旧 CD 形态解不出 ⇒ 判据确判别力) · 生产判决点 r106_prod_sentinel.js:pass=11 fail=0 ARM A(直连) CD 含 size=19009;ARM B(经 nginx) CD 的 size=19009 穿透成功; B2 端到端 sha256 与磁盘逐字节一致;C1 对照组佐证丢弃仅限 X-Accel 通道。 --- app/utils/sendFile.js | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/app/utils/sendFile.js b/app/utils/sendFile.js index e2815b1..b811597 100644 --- a/app/utils/sendFile.js +++ b/app/utils/sendFile.js @@ -104,12 +104,27 @@ async function sendFile(ctx, app, opts) { ctx.set('Content-Type', contentType); // 🔴 下载完整性哨兵:把「服务端认定的真实字节数」透出给前端。 // X-Accel 分支下真正吐字节的是 nginx,Node 无从得知 nginx 是否吐对了东西 - // (内部 location 缺失时会吐前端 index.html)。前端会比对 blob.size 与本头,不一致即拒落盘。 - // nginx 的 X-Accel-Redirect 会保留上游自定义响应头,故此头可穿透到浏览器。 + // (内部 location 缺失时会吐前端 index.html)。前端会比对 blob.size 与哨兵,不一致即拒落盘。 ctx.set('X-File-Size', String(fileStat.size)); + // 🔴🔴 2026-10-12(R103)生产实测修正 —— 原注释「nginx 的 X-Accel-Redirect 会保留上游自定义 + // 响应头,故此头可穿透到浏览器」**不成立**,已证伪: + // 双通道对照实测(探针 .workbuddy/probes/2026-10-12-r103/prod_xfs_passthrough.js): + // · 直连后端 :8023 ⇒ 204 空体,但响应带 `x-file-size: 19009`(后端确实发到了线上) + // · 经 nginx :80 ⇒ 200 / 19009 字节(sha256 与磁盘逐字节一致,确由 nginx 吐出), + // 而 **`x-file-size` 缺失** + // · 对照 /api/health 经 nginx ⇒ 自定义头 `x-readtime` **保留** + // ⇒ 丢弃只发生在 **X-Accel 内部重定向**这条路径(非「nginx 一概丢自定义头」)。 + // 影响:该分支下前端哨兵空转(下载完整性校验被静默跳过)。 + // 修法:改走「nginx 会保留的通道」—— Content-Disposition 实测能穿透(ARM B 仍在), + // 故把同一份字节数写成 RFC 6266 §4.1 的 disposition 扩展参数 `size=`。 + // 前端按 `x-file-size` → CD `size=` 顺序回落(见 utils/download.js readDeclaredSize)。 + // 两通道同源同值;任一缺失只降级不误伤。 + // ⚠️ Node 流分支(absolutePath)无需此旁路:其响应走 nginx 常规转发,自定义头本就保留。 // 中文名按 RFC 5987 编码(filename*=UTF-8''),浏览器自动解码; // 切勿再包 encodeURIComponent 造成双重编码。 - ctx.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`); + // `size=` 追加在 filename* 之后:浏览器忽略未知 disposition 参数,不影响下载行为。 + ctx.set('Content-Disposition', + `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}; size=${fileStat.size}`); // ⚠️ 必须用编码后的 internalUri(纯 ASCII),不可直接用含中文的 internalPath。 ctx.set('X-Accel-Redirect', internalUri); ctx.set('X-Accel-Buffering', 'no'); // 大文件边收边发,避免 nginx 缓冲撑爆内存