推介材料:中文文件名让上传请求头非法 → 两个附件都"网络连接失败"(服务端一条记录都没有)

## 现象

上传 `业绩数据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` 全绿。
This commit is contained in:
2026-09-14 22:36:49 +08:00
parent 84bf51c0d0
commit e6147bb6ec
2 changed files with 40 additions and 2 deletions
+15 -1
View File
@@ -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();