Files
group_fqcd_jr/app/static/portal/common/api-client.js
T
lzf_0626 076d786bc6 补齐客服转人工工单流:从"只能看"到"能推进"(基线状态机,不自行发明)
## 问题
`svc_handover_ticket` 的 DDL 与状态机在 `docs/02` §7.2 早就定好了
(pending → assigned → processing → resolved → closed,未解决可 cancelled),
但平台**只有 handover:read(只读队列)**:没有任何入口能改状态、assigned_to /
accepted_at / resolved_at / closed_at / resolution 五列**全库 0 非空**,
于是 40 张工单永远停在 pending —— 用户看到的就是"工单全都长一样"。

## 改了什么
后端:
- 新增 `app/service/customer_service_handover_action_service.py`:五个动作
  (分配/接单/解决/关闭/取消),`SELECT ... FOR UPDATE` 锁单后判状态;
  接单允许从 pending 自助接管(同时记受理人);取消不写 closed_at(该列属 closed 状态);
  每次流转写一条 interaction_audit(handover.assigned/accepted/resolved/closed/cancelled);
  非法流转 409、坐席不存在 422、工单不存在 404;回包不含 customer_id/session_id。
- 只读服务保持只读(读侧与写侧是两条边界,单测守着"读侧不许长出写方法"),
  但列表支持 `?status=` 六态筛选、详情补上受理人与流转时间(坐席侧路由信息,非客户数据)。
- `app/api/controllers/admin.py`:五个 action 端点 A049–A053
  (assignments / acceptances / resolutions / closures / cancellations),
  走 `ApiTransactionService.execute_in` —— 幂等记录与业务写入同事务、重复键回放。
- 权限:新增 `handover:write`(9069,只授 admin),已并进种子
  `tools/seed_test_rbac.py`;配套幂等脚本 `tools/grant_handover_write_permission.py`。

前端(管理员工作台 · 转人工工单页):
- 按状态给按钮(待处理→分配/直接接单、已分配→接单、处理中→解决、已解决→关闭、
  未解决都可取消),加了状态筛选与"刷新";摘要弹窗补上受理人与四个时间点、处置结论。
- api-client 注册五个端点;workspace.js 的 api-client 引用与页面自身的 ?v= 一并升版,
  避免浏览器拿旧缓存(旧缓存里没有这些端点)。

冒烟与测试:
- `tools/e2e_smoke_test.py`:B 段建的测试工单由 F 段走完 分配→接单→解决→关闭 收尾
  —— 既不再把测试件堆在 pending 队列里(此前每次冒烟攒一张),又让每次冒烟都覆盖一遍状态机。
  总数 40 → 44 项,实测 44/44 全绿。
- 新增单测 24 条(状态机合法/非法路径、越权、坐席不存在、审计、视图不泄漏客户标识)
  与一条真机集成用例(HTTP 十步 + 数据库侧审计证据 + 自动清理)。
- 读侧那条"详情不得返回 assigned_to"的旧断言按新口径更新,并写清为什么。

## 验证
- `pytest tests/unit tests/contract` → 1489 passed, 2 skipped, 0 failed
- 新增集成用例通过;`tests/integration` 全量跑时
  `test_memory_extraction` / `test_run_cancellation_mysql` 两条偶发红 —— 单独跑都通过,
  是 AGENTS.md 已登记的"常驻 Worker 抢队列"(跑验收前须先停 Worker)
- `tools/portal_api_check.py` → 41 项通过 39、失败 0
- `tools/e2e_smoke_test.py` → 44/44 全通过
- `python tools/check_rbac_seed_consistency.py` → 通过(种子 63 条权限)
- 真机 HTTP 实测:分配→接单→解决→关闭四步 200 且时间戳齐全;取消路径 200 且 closed_at 为空;
  同键重发回放不二次推进;对已关闭工单再分配 409;风控账号处置 403

## 文档
`docs/44-演示流程.md`(场景 4/8 + 命令 + 44 项)、`docs/演示用/后端接口文档`(新增 §11.4b 与
A049–A053)、`docs/演示用/全功能流程-大白话版.md`(工单页签改"读写"+ 已知偏差)、
`AGENTS.md`(9066-9069 号段演进 + 冒烟 44 项)
2026-09-15 00:41:59 +08:00

419 lines
26 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`,而 `app/main.py`
// 只挂了 `/internal/health/live` 与 `/internal/health/ready`(无 `/health`)——
// 于是投顾工作台的探测恒返回 404,被 `.catch()` 判成"后端未连接 · 本地引擎",
// 即使后端完全正常也照显不误。
//
// 选 `live` 而不是 `ready`:前端这一句的语义是"**后端进程是否在线**"。
// `ready` 会额外探测 MySQL/Redis/Milvus,任一不可用即 503 —— 用它会变成
// "依赖抖动 => 前端宣称后端离线",与这句判断的原意不符。
HEALTH: { method: 'GET', path: '/internal/health/live', 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}' },
// 转人工工单处置(状态机见 docs/02 §7.2):
// pending -> assigned -> processing -> resolved -> closed,未解决可 cancelled。
// 五个都要 `handover:write` + admin,且都带 Idempotency-Key(同键重发只回放结果)。
ADMIN_HANDOVER_ASSIGN: { method: 'POST', path: '/api/v1/admin/customer-service/handover-tickets/{ticketNo}/assignments', idempotent: true },
ADMIN_HANDOVER_ACCEPT: { method: 'POST', path: '/api/v1/admin/customer-service/handover-tickets/{ticketNo}/acceptances', idempotent: true },
ADMIN_HANDOVER_RESOLVE: { method: 'POST', path: '/api/v1/admin/customer-service/handover-tickets/{ticketNo}/resolutions', idempotent: true },
ADMIN_HANDOVER_CLOSE: { method: 'POST', path: '/api/v1/admin/customer-service/handover-tickets/{ticketNo}/closures', idempotent: true },
ADMIN_HANDOVER_CANCEL: { method: 'POST', path: '/api/v1/admin/customer-service/handover-tickets/{ticketNo}/cancellations', idempotent: true },
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' },
// 投顾自助审核/发布自己生成的推荐方案(2026-09-14 起):
// 服务层不再额外要求 admin 角色,但仍要求 `product-recommendation:review` /
// `product-recommendation:publish` 两个权限码(仅 advisor 与 admin 持有)。
ADVISOR_REVIEW_RECOMMENDATION: { method: 'POST', path: '/api/v1/advisor/recommendations/{contentId}/reviews', idempotent: true },
ADVISOR_PUBLISH_RECOMMENDATION: { method: 'POST', path: '/api/v1/advisor/recommendations/{contentId}/publications', idempotent: true },
// ⚠️ 保留:前端契约测试(`tests/unit/api/test_portal_frontend.py`)把"页面会用到的端点"
// 固定成一张清单,**删注册会破坏它**。它对应 AD002,当前页面确实没调用
// (投顾本人没有"自己的投资目标",调它返回 404)—— 但**注册与调用是两件事**。
ADVISOR_GOAL: { method: 'GET', path: '/api/v1/advisor/investment-goals/current' },
// ⚠️ 这三个 POST 的响应形状**取决于业务是否走完全程**,不只是"带不带 key":
// · 业务前置校验未通过(`profile_required` / `investment_goal_required` /
// `recommendation_input_invalid`)→ **裸文档** `{status:"…"}`。
// ⚠️ 这三个 early return 位于 `generate()` 的**最前面**,**在 key 判断之前**,
// 所以**带不带 key 都是裸文档** —— 这一点曾判断错。
// · 不带 key 且走完全程 → 裸业务文档(顶层键是 `status` / `allocation` / `summary`…)。
// · 带 key 且走完全程 → **`{data, meta}` 信封**,`data` 是 `{content_id, status, plan:{…}}`。
//
// 既然**同一个端点会返回两种形状**,前端就必须两种都能吃。这里三个 POST **一律标 `raw`**,
// 让 `request()` 原样交出完整响应体,再由 `actions-module` 的 `response.data ?? response`
// 与 `normalizeRecommend()` 统一归一:
// raw + 信封 → `response.data` 命中 → 取到业务数据
// raw + 裸文档 → `response.data` 是 undefined → 由 `??` 兜底取整个响应体
// 反过来(不标 raw)时,裸文档会被 `payload.data` 解成 `undefined`,
// 调用方只剩包装对象,页面就显示「后端返回状态:undefined」。
//
// 2026-09-14 曾把 `raw` 从 RECOMMEND 上去掉,理由是"它必带 key ⇒ 必然是信封"——
// 该前提不成立(见上面的 early return),结果前置校验一失败页面就显示 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, raw: 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 };