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

## 现象

上传 `业绩数据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();
@@ -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);