From e6147bb6ec041cb6780ab06a91e645f172b29b16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=BF=E4=BA=91=E7=A7=8B=E6=9C=88?= <15273589815@163.com> Date: Mon, 14 Sep 2026 22:36:49 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A8=E4=BB=8B=E6=9D=90=E6=96=99=EF=BC=9A?= =?UTF-8?q?=E4=B8=AD=E6=96=87=E6=96=87=E4=BB=B6=E5=90=8D=E8=AE=A9=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E8=AF=B7=E6=B1=82=E5=A4=B4=E9=9D=9E=E6=B3=95=20?= =?UTF-8?q?=E2=86=92=20=E4=B8=A4=E4=B8=AA=E9=99=84=E4=BB=B6=E9=83=BD"?= =?UTF-8?q?=E7=BD=91=E7=BB=9C=E8=BF=9E=E6=8E=A5=E5=A4=B1=E8=B4=A5"?= =?UTF-8?q?=EF=BC=88=E6=9C=8D=E5=8A=A1=E7=AB=AF=E4=B8=80=E6=9D=A1=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E9=83=BD=E6=B2=A1=E6=9C=89=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 现象 上传 `业绩数据1.xlsx` 与 `基金经理1号王建龙.png`(扩展名都在白名单内): 页面提示 `业绩数据文件上传失败:网络连接失败;基金经理照片上传失败:网络连接失败`, 而库里、盘上、审计里**都没有任何上传痕迹**。 ## 排查与根因 1. API 是健康的(`/portal/` 200、129 条路由在、8000 正常监听); 2. `api_client` 里 "网络连接失败" 的触发条件是 **`fetch` 抛了非超时的异常**(超时会显示"请求超时"); 3. **`api_request_receipt` 里没有任何上传记录** —— 同一时段的建任务/存资料/生成三条都有 receipt (连"生成未通过合规校验"这种业务失败也落 receipt)⇒ **上传请求根本没到应用**; 4. `promotion.js` 给上传传的幂等键是 `` `${taskNo}-${type}-${file.name}-${file.size}` `` —— **把中文文件名拼进了 HTTP 头 `Idempotency-Key`**; 5. HTTP 头值只能由 ≤0xFF 的码点组成:实测同一请求用标准客户端发送时抛 `UnicodeEncodeError: 'ascii' codec can't encode characters in position 34-45`; 浏览器更严格,`fetch` 在**构造请求头时直接抛 `TypeError`**,请求一个字节都没发出去, 却被 `api-client.js` 的 catch 包装成"网络连接失败"——**"参数非法"伪装成了"网络故障"**。 6. 反证:换成纯 ASCII 的幂等键,同两个文件、同一端点**立刻 200 成功** (attachment_id=67/68,`performance_summary` 正常返回)。 ## 修法 1. `employee-operations/promotion/promotion.js` - 新增 `asciiOnly()` / `uploadKey()`:幂等键改为 `任务号-类型-字节数-最后修改时间` (**刻意不用文件名** —— 中文名即非法头值),纯 ASCII 且同一文件重传得到同一键(幂等回放); 2. `common/api-client.js` - 对幂等键做**前置校验**(平台规范:16-128 位可打印 ASCII),不满足时抛 `IDEMPOTENCY_KEY_INVALID` 并**带上端点与键值** —— 下一次这类问题 10 秒可定位, 不必再从"网络故障"倒推。 ## 验证 - 从源文件抽出 `asciiOnly`/`uploadKey` 用 Node 断言:中文文件名 → 键全 ASCII 且通过校验、 同一文件两次同键、不同文件不同键、任务号含中文也被转义、旧写法会被拦下(全部通过); - `node --check` 两个文件通过; - 真实 HTTP(带令牌、真实文件)验证:ASCII 键 → **HTTP 200**,中文键 → 客户端抛 `UnicodeEncodeError`; - 该账号的两个附件已成功入库(id=67/68),业绩摘要 = `as_of_date 2025-12-31 / history_months 23 / product_return 17.1% / max_drawdown -1.15%`; - `pytest tests/unit tests/contract` 全绿。 --- app/static/portal/common/api-client.js | 16 +++++++++++- .../promotion/promotion.js | 26 ++++++++++++++++++- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/app/static/portal/common/api-client.js b/app/static/portal/common/api-client.js index 3dcf77b..db88538 100644 --- a/app/static/portal/common/api-client.js +++ b/app/static/portal/common/api-client.js @@ -179,7 +179,21 @@ async function request(endpointId, options = {}) { const token = getAccessToken(); if (endpoint.auth !== false && token) headers.Authorization = `Bearer ${token}`; if (options.body !== undefined && !endpoint.formData) headers['Content-Type'] = 'application/json'; - if (endpoint.idempotent) headers['Idempotency-Key'] = options.idempotencyKey || crypto.randomUUID().replaceAll('-', ''); + if (endpoint.idempotent) { + const key = options.idempotencyKey || crypto.randomUUID().replaceAll('-', ''); + // ⚠️ HTTP 头值只能由 ≤0xFF 的码点组成,而 `fetch` 对含中文/emoji 的头值会**直接抛 + // `TypeError`** —— 请求根本没发出去,却在本文件末尾被包装成"网络连接失败", + // 把"参数非法"伪装成"网络故障":现象是两个附件都上传失败、服务端一条记录都没有。 + // 2026-09-14 就是这条链路(`promotion.js` 把中文文件名拼进了幂等键)害得排查绕了很久。 + // 这里提前校验,把它变成一条能直接定位的错误;键的规范与平台一致:16-128 位可打印 ASCII。 + if (!/^[\x20-\x7e]{16,128}$/.test(key)) { + throw new ApiError( + `幂等键必须是 16-128 位 ASCII 字符(端点 ${endpointId}):${key}`, + { code: 'IDEMPOTENCY_KEY_INVALID' }, + ); + } + headers['Idempotency-Key'] = key; + } for (let attempt = 0; attempt < 2; attempt += 1) { const controller = new AbortController(); diff --git a/app/static/portal/employee-operations/promotion/promotion.js b/app/static/portal/employee-operations/promotion/promotion.js index 6f328e8..6e82847 100644 --- a/app/static/portal/employee-operations/promotion/promotion.js +++ b/app/static/portal/employee-operations/promotion/promotion.js @@ -148,6 +148,24 @@ if (requireOperator()) { const reviewDecision = result.querySelector('[data-review-decision]'); if (reviewDecision) reviewDecision.value = state.reviewDecision; } + /** 压成 ASCII:非 ASCII 码点转成 `uXXXX`,保证拼出来的东西能当 HTTP 头值。 */ + function asciiOnly(value) { + return Array.from(String(value ?? '')) + .map((ch) => (ch.codePointAt(0) < 128 ? ch : `u${ch.codePointAt(0).toString(16)}`)) + .join(''); + } + + /** + * 上传用的幂等键:**必须全 ASCII**。 + * + * 这里刻意**不用文件名** —— 中文名(如「业绩数据1.xlsx」)会让 `fetch` 构造请求头时抛 + * `TypeError`,被 `api-client.js` 包装成"网络连接失败",请求其实一个字节都没发出去。 + * 字节数 + 最后修改时间同样能唯一标识一次上传,且同一文件重传得到同一个键(幂等回放命中同一 receipt)。 + */ + function uploadKey(taskNo, type, file) { + return `${asciiOnly(taskNo)}-${asciiOnly(type)}-${file.size}-${file.lastModified || 0}`; + } + function validateAttachment(type, file) { const extensions = { manager_photo: ['.jpg', '.jpeg', '.png', '.webp'], @@ -191,7 +209,13 @@ if (requireOperator()) { const response = await apiClient.upload('PROMOTION_ATTACHMENT', form, { pathParams: { taskNo: state.taskNo }, query: { attachment_type: type }, - idempotencyKey: `${state.taskNo}-${type}-${file.name}-${file.size}`, + // ⚠️ 幂等键必须是 **ASCII**(HTTP 头值只能由 ≤0xFF 的码点组成)。 + // 这里原先拼的是 `${taskNo}-${type}-${file.name}-${file.size}`,而中文文件名 + // (如「业绩数据1.xlsx」)会让 `fetch` 在**构造请求头时直接抛 TypeError**, + // 请求根本发不出去;`api-client.js` 把它包装成"网络连接失败", + // 于是表现为"两个附件都上传失败、服务端却一条记录都没有"(2026-09-14 实测)。 + // 改成纯 ASCII 且仍可复现:同一文件重传得到同一个键(幂等回放命中同一 receipt)。 + idempotencyKey: uploadKey(state.taskNo, type, file), }); if (type === 'performance_data') { applyPerformanceSummary(response.data.performance_summary);