From 1b569a38e6a238010b7f4f89eafcdb7e6cb8e721 Mon Sep 17 00:00:00 2001 From: qubiaobiao <3294694717@qq.com> Date: Mon, 12 Oct 2026 11:06:10 +0800 Subject: [PATCH] =?UTF-8?q?fix(download):=20=E5=93=A8=E5=85=B5=E6=8C=89=20?= =?UTF-8?q?x-file-size=20=E2=86=92=20CD=20size=3D=20=E5=9B=9E=E8=90=BD?= =?UTF-8?q?=EF=BC=88R103=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 配套 backend app/utils/sendFile.js 的同一处修复(见该提交):X-Accel-Redirect 分支下 nginx 会丢弃上游自定义响应头 X-File-Size,故后端把同一字节数写进 RFC 6266 §4.1 的 disposition 扩展参数 `size=`(实测 CD 能穿透 X-Accel)。 本提交: · 新增 readDeclaredSize(headers):先取 x-file-size,缺失或非正数时回落解析 Content-Disposition 的 `; size=`;两通道都取不到返回 NaN(调用方跳过校验, 优雅降级,不误伤)。 · assertRealFile() 改用它,替换原先只读 x-file-size 的 parseInt。 · 判据可靠性:filename*= 的值是百分号编码的,其中不可能出现字面量 `size=`, 且 `size=` 必须落在参数边界(行首或 `;` 之后),故不会与文件名误匹配。 验证: · 本地判别力自证 verify_cd_size_channel.js:pass=12 fail=0 (F0 断言探针判据与本源码逐字同源;F0b 断言确实经由 readDeclaredSize; C1 变异测试证明旧形态解不出 ⇒ 判据有判别力;D1/D2/D3 覆盖无误伤边界) · 生产 ARM B(经 nginx)实测 CD 的 size=19009 穿透成功 (r106_prod_sentinel.js pass=11 fail=0) --- src/utils/download.js | 36 +++++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/src/utils/download.js b/src/utils/download.js index 3a0abdb..28b378a 100644 --- a/src/utils/download.js +++ b/src/utils/download.js @@ -95,6 +95,32 @@ function getHeader(headers, name) { return '' } +/** + * 读取服务端声明的「真实字节数」哨兵(下载完整性校验用)。 + * + * 🔴 R103(2026-10-12 生产实测):X-Accel-Redirect 分支下 nginx 会**丢弃上游自定义响应头**。 + * 双通道实测同一文件:直连后端 ⇒ 带 `x-file-size: 19009`;经 nginx ⇒ 返回 19009 字节 + * 但该头缺失(而 `Content-Disposition` 能穿透)。 + * ⇒ 后端把同一字节数同时写入 RFC 6266 §4.1 的 disposition 扩展参数 `size=`, + * 这里按「x-file-size → CD 的 size=」顺序回落;两通道同源同值,任一缺失只降级不误伤。 + * + * 判据可靠性:`filename*=` 的值是百分号编码的,其中不可能出现字面量 `size=`, + * 且 `size=` 必须落在参数边界(行首或 `;` 之后),故不会与文件名误匹配。 + * + * @returns {number} 声明字节数;两通道都取不到时返回 NaN(调用方跳过校验,优雅降级) + */ +function readDeclaredSize(headers) { + const direct = parseInt(getHeader(headers, 'x-file-size'), 10) + if (Number.isFinite(direct) && direct > 0) return direct + const cd = getHeader(headers, 'content-disposition') + const m = /(?:^|;)\s*size\s*=\s*(\d+)\s*(?:;|$)/i.exec(cd || '') + if (m) { + const n = parseInt(m[1], 10) + if (Number.isFinite(n) && n > 0) return n + } + return NaN +} + // 归一化路径:dl 实例已带 baseURL '/api',若调用方传入 '/api/xxx' 会重复前缀。 function normalizeUrl(url) { if (url && url.startsWith('/api')) return url.slice(4) @@ -155,8 +181,10 @@ function triggerDownload(blob, filename, originalUrl) { // 原有「HTTP 状态 + JSON 信封」两道校验对 HTML 完全无感,于是损坏文件被静默存盘。 // 对策(两道,任一命中即拒绝落盘并弹出可行动的错误): // ① HTML 嗅探:Content-Type 为 text/html,或正文以 0 && blob.size !== declared) { return `文件传输不完整:应为 ${declared} 字节,实际收到 ${blob.size} 字节,请重试` }