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);