Files
group_fqcd_jr/app/static/portal/common/api-client.js
T
lzf_0626 e6147bb6ec 推介材料:中文文件名让上传请求头非法 → 两个附件都"网络连接失败"(服务端一条记录都没有)
## 现象

上传 `业绩数据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` 全绿。
2026-09-14 22:36:49 +08:00

387 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { clearAuthSession, getAccessToken } from '/static/portal/common/auth.js?v=20260913';
const ENDPOINTS = Object.freeze({
// 健康检查没有 `data` 信封(是裸的 `{"status": ...}`),所以必须 `raw: true` ——
// 否则调用方拿到 `payload.data`(undefined),会把"后端在线"判成"离线"。
HEALTH: { method: 'GET', path: '/health', auth: false, raw: true },
A034: { method: 'POST', path: '/api/v1/auth/tokens', auth: false },
V001: { method: 'POST', path: '/api/v1/visitor-tokens', auth: false, raw: true },
P001: { method: 'GET', path: '/api/v1/products' },
P002: { method: 'GET', path: '/api/v1/products/{productCode}/nav-history' },
C001: { method: 'POST', path: '/api/v1/conversations', idempotent: true },
C002: { method: 'GET', path: '/api/v1/conversations/{sessionId}' },
C003: { method: 'GET', path: '/api/v1/conversations/{sessionId}/messages' },
C005: { method: 'POST', path: '/api/v1/conversations/{sessionId}/handover-requests', idempotent: true },
A035: { method: 'GET', path: '/api/v1/admin/roles' },
A036: { method: 'GET', path: '/api/v1/admin/roles/{roleCode}' },
A037: { method: 'GET', path: '/api/v1/admin/roles/{roleCode}/permissions' },
A038: { method: 'GET', path: '/api/v1/admin/users/{userId}/roles' },
A039: { method: 'GET', path: '/api/v1/admin/customer-profile-candidates' },
A040: { method: 'POST', path: '/api/v1/admin/customer-profile-candidates/{candidateId}/reviews' },
A001: { method: 'POST', path: '/api/v1/admin/config-releases', idempotent: true },
A041: { method: 'GET', path: '/api/v1/admin/advisor/profile-drift-reviews' },
A042: { method: 'POST', path: '/api/v1/admin/advisor/profile-drift-reviews/{reviewId}/reviews', idempotent: true },
A002: { method: 'GET', path: '/api/v1/admin/config-releases' },
A008: { method: 'POST', path: '/api/v1/admin/config-releases/{releaseId}/platform-config-items', idempotent: true },
A009: { method: 'GET', path: '/api/v1/admin/config-releases/{releaseId}/platform-config-items' },
A010: { method: 'PUT', path: '/api/v1/admin/config-releases/{releaseId}/platform-config-items/{itemId}', idempotent: true },
// 详情端点:**更新必须先拿到这一行的 etag**(PUT 要求 If-Match),
// 而列表的 meta 里没有它 —— 见 `docs/05` §19 的 A048/A049 说明。
A048: { method: 'GET', path: '/api/v1/admin/config-releases/{releaseId}/platform-config-items/{itemId}' },
A018: { method: 'POST', path: '/api/v1/admin/config-releases/{releaseId}/model-routing-rules', idempotent: true },
A019: { method: 'GET', path: '/api/v1/admin/config-releases/{releaseId}/model-routing-rules' },
A020: { method: 'PUT', path: '/api/v1/admin/config-releases/{releaseId}/model-routing-rules/{ruleId}', idempotent: true },
A049: { method: 'GET', path: '/api/v1/admin/config-releases/{releaseId}/model-routing-rules/{ruleId}' },
A003: { method: 'GET', path: '/api/v1/admin/config-releases/{releaseId}' },
A004: { method: 'POST', path: '/api/v1/admin/config-releases/{releaseId}/validations', idempotent: true },
A005: { method: 'POST', path: '/api/v1/admin/config-releases/{releaseId}/reviews', idempotent: true },
A006: { method: 'POST', path: '/api/v1/admin/config-releases/{releaseId}/activations', idempotent: true },
A012: { method: 'GET', path: '/api/v1/admin/model-endpoints' },
A033: { method: 'GET', path: '/api/v1/admin/audit-records' },
ADMIN_HANDOVERS: { method: 'GET', path: '/api/v1/admin/customer-service/handover-tickets' },
ADMIN_HANDOVER_DETAIL: { method: 'GET', path: '/api/v1/admin/customer-service/handover-tickets/{ticketNo}' },
ADMIN_ADVISOR_PENDING: { method: 'GET', path: '/api/v1/admin/advisor/pending-contents' },
ADMIN_ADVISOR_REVIEW: { method: 'POST', path: '/api/v1/admin/advisor/recommendations/{contentId}/reviews', idempotent: true },
ADMIN_ADVISOR_PUBLISH: { method: 'POST', path: '/api/v1/admin/advisor/recommendations/{contentId}/publications', idempotent: true },
ONB001: { method: 'GET', path: '/api/v1/onboarding/risk-questionnaire' },
ONB002: { method: 'POST', path: '/api/v1/onboarding/risk-questionnaire/submissions', idempotent: true },
R001: { method: 'POST', path: '/api/v1/agent-runs' },
R002: { method: 'GET', path: '/api/v1/agent-runs/{runId}' },
R003: { method: 'GET', path: '/api/v1/agent-runs/{runId}/events', stream: true },
RK001: { method: 'GET', path: '/api/v1/risk/overview' },
RK002: { method: 'GET', path: '/api/v1/risk/alerts' },
RK003: { method: 'GET', path: '/api/v1/risk/alerts/{alertNo}' },
RK004: { method: 'GET', path: '/api/v1/risk/evidence/{source}' },
RK005: { method: 'GET', path: '/api/v1/risk/notifications' },
RK006: { method: 'POST', path: '/api/v1/risk/alerts/scan', idempotent: true, timeout: 60000 },
RK007: { method: 'POST', path: '/api/v1/risk/alerts/{alertNo}/acknowledgements', idempotent: true },
RK008: { method: 'POST', path: '/api/v1/risk/alerts/{alertNo}/investigations', idempotent: true },
RK009: { method: 'POST', path: '/api/v1/risk/alerts/{alertNo}/exclusions', idempotent: true },
RK010: { method: 'POST', path: '/api/v1/risk/alerts/{alertNo}/resolutions', idempotent: true },
RK011: { method: 'POST', path: '/api/v1/risk/alerts/{alertNo}/escalations', idempotent: true },
RK012: { method: 'POST', path: '/api/v1/risk/alerts/{alertNo}/evidence', formData: true },
// ⚠️ 保留:同上,前端契约测试要求这张表里有它。风控日报现在走 `RK014`(SSE 流式),
// 非流式这条当前无人调用。另注:RK013–RK015 目前**尚未登记进 `docs/05` §19**
// (与投顾 AD 段原先的情况相同),属于文档缺口。
RK013: { method: 'POST', path: '/api/v1/risk/daily-report' },
RK014: { method: 'POST', path: '/api/v1/risk/daily-report/stream', stream: true },
RK015: { method: 'POST', path: '/api/v1/risk/daily-report/mail' },
T001: { method: 'GET', path: '/api/v1/users/me/account/dashboard' },
T002: { method: 'POST', path: '/api/v1/users/me/orders', idempotent: true },
T003: { method: 'GET', path: '/api/v1/users/me/orders' },
T004: { method: 'GET', path: '/api/v1/users/me/orders/{orderNo}' },
T005: { method: 'POST', path: '/api/v1/users/me/orders/{orderNo}/cancellations', idempotent: true },
T006: { method: 'GET', path: '/api/v1/users/me/holdings' },
T007: { method: 'GET', path: '/api/v1/users/me/transactions' },
T008: { method: 'GET', path: '/api/v1/users/me/transactions/{transactionNo}' },
T009: { method: 'GET', path: '/api/v1/users/me/cash-ledger' },
ADVISOR_PUBLISHED: { method: 'GET', path: '/api/v1/advisor/recommendations/published' },
// ⚠️ 保留:前端契约测试(`tests/unit/api/test_portal_frontend.py`)把"页面会用到的端点"
// 固定成一张清单,**删注册会破坏它**。它对应 AD002,当前页面确实没调用
// (投顾本人没有"自己的投资目标",调它返回 404)—— 但**注册与调用是两件事**。
ADVISOR_GOAL: { method: 'GET', path: '/api/v1/advisor/investment-goals/current' },
// ⚠️ 这三个 POST 的响应形状**取决于是否带 `Idempotency-Key`**:
// · 不带键(ALLOCATION / ANALYSIS 的常态)→ **裸业务文档**,顶层键是 `status` / `allocation` / `summary`…,
// 必须标 `raw`,否则 `payload.data` 取到 `undefined`,整包被丢掉(2026-09-14 踩过)。
// · 带键(RECOMMEND 标了 `idempotent`,浏览器必带)→ **`{data, meta}` 信封**,且 `data` 是
// `{content_id, status, plan:{…}}` —— 真正的文档嵌在 `plan` 里(方案已落库待审核)。
// 所以 RECOMMEND **不能**标 `raw`,让 `request()` 正常解包;`plan` 这层嵌套由
// `actions-module` 的 `normalizeRecommend()` 归一。曾把 raw 误加到 RECOMMEND 上,
// 结果信封被当成数据,页面显示「后端返回状态:undefined」。
ADVISOR_ANALYSIS: { method: 'POST', path: '/api/v1/advisor/portfolio-analysis', raw: true },
ADVISOR_ALLOCATION: { method: 'POST', path: '/api/v1/advisor/asset-allocation', raw: true },
ADVISOR_RECOMMEND: { method: 'POST', path: '/api/v1/advisor/recommendations', idempotent: true },
ADVISOR_CREATE_GOAL: { method: 'POST', path: '/api/v1/advisor/investment-goals', idempotent: true },
ADVISOR_CUSTOMER_GOAL: { method: 'GET', path: '/api/v1/advisor/customers/{customerId}/investment-goals/current' },
ADVISOR_CONFIRM_GOAL: { method: 'POST', path: '/api/v1/advisor/investment-goals/{goalNo}/confirmations', idempotent: true },
ADVISOR_GOAL_BOOK: { method: 'GET', path: '/api/v1/advisor/investment-goals/{goalNo}/goal-book' },
ADVISOR_REVIEW_BOOK: { method: 'POST', path: '/api/v1/advisor/investment-goals/{goalNo}/goal-book/reviews', idempotent: true },
ADVISOR_PUBLISH_BOOK: { method: 'POST', path: '/api/v1/advisor/investment-goals/{goalNo}/goal-book/publications', idempotent: true },
// ⚠️ K002 / K003 必须标 `raw: true`:它们的成功体是**裸的**(没有 `data` 信封)——
// K002 直接返回 `{knowledge_ids, filename, chunk_count}`,K003 返回 `{items, count}`。
// 不标的话 `request()` 会去取 `payload.data`(undefined),调用方拿到空值:
// 上传显示"已入库 0 块"、列表显示"知识库为空",而库里其实有数据。
// 与 `V001`(访客令牌)同一个道理。
K002: { method: 'POST', path: '/api/v1/knowledge/upload', raw: true },
K003: { method: 'GET', path: '/api/v1/knowledge/list', raw: true },
K004: { method: 'DELETE', path: '/api/v1/knowledge/{knowledgeId}', idempotent: true },
OFFSITE_MAILS: { method: 'GET', path: '/api/v1/offsite-fund/mails' },
OFFSITE_MAIL: { method: 'GET', path: '/api/v1/offsite-fund/mails/{mailId}' },
OFFSITE_MAIL_DELETE: { method: 'POST', path: '/api/v1/offsite-fund/mails/{mailId}/deletions', idempotent: true },
OFFSITE_RECOGNITION: { method: 'GET', path: '/api/v1/offsite-fund/mails/{mailId}/recognition-fields' },
OFFSITE_RECOGNITION_SAVE: { method: 'PUT', path: '/api/v1/offsite-fund/mails/{mailId}/recognition-fields', idempotent: true },
OFFSITE_NL2SQL_FIELDS: { method: 'GET', path: '/api/v1/offsite-fund/documents/{taskId}/nl2sql-fields' },
OFFSITE_NL2SQL_FIELDS_SAVE: { method: 'PUT', path: '/api/v1/offsite-fund/documents/{taskId}/nl2sql-fields', idempotent: true },
OFFSITE_RULE_RESULTS: { method: 'GET', path: '/api/v1/offsite-fund/documents/{taskId}/rule-results' },
OFFSITE_RULE_RECALCULATE: { method: 'POST', path: '/api/v1/offsite-fund/documents/{taskId}/rule-results/recalculations', idempotent: true },
OFFSITE_MAILBOX: { method: 'GET', path: '/api/v1/offsite-fund/mailbox-status' },
OFFSITE_MAILBOX_RECOVER: { method: 'POST', path: '/api/v1/offsite-fund/mailbox-status/recoveries', idempotent: true },
OFFSITE_ATTACHMENT_FILE: { method: 'GET', path: '/api/v1/offsite-fund/attachments/{attachmentId}/file' },
OFFSITE_CONFIRM: { method: 'POST', path: '/api/v1/offsite-fund/documents/{taskId}/confirmations', idempotent: true },
OFFSITE_RECOGNITION_RETRY: { method: 'POST', path: '/api/v1/offsite-fund/documents/{taskId}/recognition-retries', idempotent: true },
OFFSITE_NOTIFICATION_CREATE: { method: 'POST', path: '/api/v1/offsite-fund/documents/{taskId}/notifications', idempotent: true },
OFFSITE_NOTIFICATION_SEND: { method: 'POST', path: '/api/v1/offsite-fund/notifications/{notificationId}/send', idempotent: true },
OFFSITE_SETTLEMENT_RECALCULATE: { method: 'POST', path: '/api/v1/offsite-fund/settlement-statistics/recalculate', idempotent: true },
OFFSITE_TRIGGER_NL2SQL: { method: 'POST', path: '/api/tasks/{taskId}/trigger-agent-nl2sql', idempotent: true },
PROMOTION_CREATE: { method: 'POST', path: '/api/v1/fund-promotion-materials', idempotent: true },
PROMOTION_TASK: { method: 'GET', path: '/api/v1/fund-promotion-materials/{taskNo}' },
PROMOTION_INPUTS: { method: 'PUT', path: '/api/v1/fund-promotion-materials/{taskNo}/inputs', idempotent: true },
PROMOTION_ATTACHMENT: { method: 'POST', path: '/api/v1/fund-promotion-materials/{taskNo}/attachments', formData: true, idempotent: true },
// 阿里云背景图生成采用异步任务,前端等待时间必须覆盖后端的 90 秒上游超时。
PROMOTION_GENERATE: { method: 'POST', path: '/api/v1/fund-promotion-materials/{taskNo}/generations', idempotent: true, timeout: 120000 },
PROMOTION_CHECKS: { method: 'GET', path: '/api/v1/fund-promotion-materials/{taskNo}/compliance-checks' },
PROMOTION_REVIEW: { method: 'POST', path: '/api/v1/fund-promotion-materials/{taskNo}/reviews', idempotent: true },
PROMOTION_DELIVERY: { method: 'POST', path: '/api/v1/fund-promotion-materials/{taskNo}/deliveries', idempotent: true },
AGENT_RUN_CREATE: { method: 'POST', path: '/api/v1/agent-runs', idempotent: true },
AGENT_RUN: { method: 'GET', path: '/api/v1/agent-runs/{runId}' },
});
export class ApiError extends Error {
constructor(message, options = {}) {
super(message);
this.name = 'ApiError';
this.code = options.code || 'NETWORK_ERROR';
this.status = options.status || 0;
this.retryable = Boolean(options.retryable);
this.fieldErrors = options.fieldErrors || [];
this.traceId = options.traceId || '';
this.payload = options.payload || null;
}
}
function pathFor(endpoint, pathParams = {}) {
return Object.entries(pathParams).reduce(
(path, [key, value]) => path.replace(`{${key}}`, encodeURIComponent(String(value))),
endpoint.path,
);
}
function wait(milliseconds) {
return new Promise((resolve) => window.setTimeout(resolve, milliseconds));
}
function shouldRetry(error, attempt) {
if (attempt > 0) return false;
return error.status >= 500 || error.status === 0 || error.retryable;
}
async function request(endpointId, options = {}) {
const endpoint = ENDPOINTS[endpointId];
if (!endpoint) throw new ApiError(`未注册端点 ${endpointId}`, { code: 'ENDPOINT_NOT_REGISTERED' });
const traceId = crypto.randomUUID();
document.documentElement.dataset.traceId = traceId;
const query = new URLSearchParams();
Object.entries(options.query || {}).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') query.set(key, String(value));
});
const queryString = query.size ? `?${query.toString()}` : '';
const headers = { Accept: 'application/json', 'X-Trace-ID': traceId, ...(options.headers || {}) };
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) {
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();
const abortListener = () => controller.abort();
options.signal?.addEventListener('abort', abortListener, { once: true });
const timeoutId = window.setTimeout(
() => controller.abort(),
options.timeout || endpoint.timeout || 8000,
);
try {
const response = await fetch(`${pathFor(endpoint, options.pathParams)}${queryString}`, {
method: endpoint.method,
headers,
body: options.body === undefined || endpoint.method === 'GET'
? undefined
: (endpoint.formData ? options.body : JSON.stringify(options.body)),
cache: 'no-store',
signal: controller.signal,
});
const payload = await response.json().catch(() => ({}));
if (response.status === 401 && endpoint.auth !== false) {
clearAuthSession({ eventType: 'session-expired' });
window.dispatchEvent(new CustomEvent('portal:auth-expired'));
}
const responseTraceId = payload.meta?.trace_id || response.headers.get('X-Trace-ID') || traceId;
document.documentElement.dataset.traceId = responseTraceId;
const hasBusinessError = Object.prototype.hasOwnProperty.call(payload, 'code') && payload.code !== 0;
if (!response.ok || payload.error || hasBusinessError) {
const detail = payload.error || {};
const validationDetail = Array.isArray(payload.detail)
? payload.detail
.map((item) => item?.msg || item?.message || '')
.filter(Boolean)
.join(';')
: (typeof payload.detail === 'string' ? payload.detail : '');
const message = detail.message
|| (hasBusinessError ? payload.message : '')
|| validationDetail
|| (response.status ? `请求失败(HTTP ${response.status})` : '请求未完成');
const businessStatus = hasBusinessError && Number(payload.code) >= 400
? Number(payload.code)
: response.status;
const error = new ApiError(message, {
code: detail.code || (hasBusinessError ? `BUSINESS_${payload.code}` : undefined),
status: businessStatus,
retryable: detail.retryable,
fieldErrors: detail.field_errors,
traceId: responseTraceId,
payload,
});
if (response.status === 429 && attempt === 0) await wait(5000);
else if (shouldRetry(error, attempt)) await wait(2000);
else throw error;
continue;
}
return { data: endpoint.raw ? payload : payload.data, meta: payload.meta || {}, traceId: responseTraceId };
} catch (caught) {
const error = caught instanceof ApiError
? caught
: new ApiError(caught?.name === 'AbortError' ? '请求超时,请检查网络后重试' : '网络连接失败', { traceId });
if (!shouldRetry(error, attempt)) throw error;
await wait(2000);
} finally {
window.clearTimeout(timeoutId);
options.signal?.removeEventListener('abort', abortListener);
}
}
throw new ApiError('网络不稳定,请稍后重试', { traceId });
}
async function stream(endpointId, body, options = {}) {
const endpoint = ENDPOINTS[endpointId];
if (!endpoint?.stream) throw new ApiError(`端点 ${endpointId} 不支持流式请求`, { code: 'ENDPOINT_NOT_STREAMABLE' });
const traceId = crypto.randomUUID();
const token = getAccessToken();
const headers = {
Accept: 'text/event-stream',
'Content-Type': 'application/json',
'X-Trace-ID': traceId,
...(options.headers || {}),
};
if (token) headers.Authorization = `Bearer ${token}`;
const response = await fetch(pathFor(endpoint, options.pathParams), {
method: endpoint.method,
headers,
body: endpoint.method === 'GET' ? undefined : JSON.stringify(body ?? {}),
signal: options.signal,
});
if (!response.ok || !response.body) {
const payload = await response.json().catch(() => ({}));
if (response.status === 401 && endpoint.auth !== false) {
clearAuthSession({ eventType: 'session-expired' });
window.dispatchEvent(new CustomEvent('portal:auth-expired'));
}
const detail = payload.error || {};
throw new ApiError(detail.message || '流式请求未完成', {
code: detail.code,
status: response.status,
traceId: payload.meta?.trace_id || traceId,
});
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
const dispatch = (block) => {
if (!block.trim() || block.trimStart().startsWith(':')) return;
let eventName = 'message';
const dataLines = [];
block.split(/\r?\n/).forEach((line) => {
if (line.startsWith('event:')) eventName = line.slice(6).trim();
if (line.startsWith('data:')) dataLines.push(line.slice(5).trim());
});
if (!dataLines.length) return;
const raw = dataLines.join('\n');
let data = raw;
try { data = JSON.parse(raw); } catch { /* Plain-text SSE data is valid. */ }
options.onEvent?.({ type: eventName, data });
};
while (true) {
const { done, value } = await reader.read();
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
const blocks = buffer.split(/\r?\n\r?\n/);
buffer = blocks.pop() || '';
blocks.forEach(dispatch);
if (done) break;
}
if (buffer.trim()) dispatch(buffer);
}
async function requestFile(endpointId, options = {}) {
const endpoint = ENDPOINTS[endpointId];
if (!endpoint) throw new ApiError(`未注册端点 ${endpointId}`, { code: 'ENDPOINT_NOT_REGISTERED' });
const traceId = crypto.randomUUID();
const headers = { Accept: '*/*', 'X-Trace-ID': traceId, ...(options.headers || {}) };
const token = getAccessToken();
if (endpoint.auth !== false && token) headers.Authorization = `Bearer ${token}`;
const response = await fetch(`${pathFor(endpoint, options.pathParams)}${new URLSearchParams(options.query || {}).toString() ? `?${new URLSearchParams(options.query).toString()}` : ''}`, {
method: endpoint.method,
headers,
cache: 'no-store',
signal: options.signal,
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
const detail = payload.error || {};
if (response.status === 401 && endpoint.auth !== false) clearAuthSession();
throw new ApiError(detail.message || `文件请求失败(HTTP ${response.status})`, {
code: detail.code,
status: response.status,
traceId: payload.meta?.trace_id || response.headers.get('X-Trace-ID') || traceId,
});
}
return {
blob: await response.blob(),
filename: response.headers.get('Content-Disposition') || '',
contentType: response.headers.get('Content-Type') || '',
traceId: response.headers.get('X-Trace-ID') || traceId,
};
}
export const apiClient = Object.freeze({
get(endpointId, options = {}) { return request(endpointId, options); },
post(endpointId, body, options = {}) { return request(endpointId, { ...options, body }); },
/**
* 带请求体的 PUT(更新类端点)。
*
* 与 `del` 同理:真正发出的方法由端点表里的 `method` 决定,所以 `post('A010')`
* 也会发出 PUT —— 但读代码的人会以为发的是 POST。用它表达"这是更新"。
*/
put(endpointId, body, options = {}) { return request(endpointId, { ...options, body }); },
/**
* 无请求体的写方法(DELETE 等)。
*
* 实际发什么方法由**端点表里的 `method`** 决定(`request()` 用的就是它),
* 所以过去用 `post('K004')` 也能发出 DELETE —— 但读代码的人会以为发的是 POST。
* 有了这个方法,`del('K004')` 的意图与行为一致。
*/
del(endpointId, options = {}) { return request(endpointId, options); },
upload(endpointId, formData, options = {}) { return request(endpointId, { ...options, body: formData, timeout: options.timeout || 30000 }); },
file(endpointId, options = {}) { return requestFile(endpointId, options); },
stream,
reportError(error) {
window.dispatchEvent(new CustomEvent('portal:error', { detail: { message: error.message, traceId: error.traceId || '' } }));
},
track(eventName, payload = {}) {
window.dispatchEvent(new CustomEvent('portal:track', { detail: { eventName, payload, at: Date.now() } }));
},
});
export { ENDPOINTS };