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} 字节,请重试` }