## 为什么要合并
远端 `origin/qyqy_develop` 领先 3 个提交(`5607751` / `2fe7d0c` / `74b7d00`:投顾需求与架构文档、
客户主动申报投顾方案 + 受理自动出草稿、方案交付落点与推荐依据 LLM 增强),而本地 `5d0becb`
按 `D4.4` / `D4.5`(CS-PURGE-2026-012/013)把投顾模块整体清除了。**两个目标不可兼得**:
远端新代码反向 import 已被清除的模块(`app.model.investment_goal`、
`app.service.product_recommendation_service`、`app.service.advisor_rollout_service`),
强行推进只会让两边都跑不起来。
**裁定:投顾组的新功能 > 本地的投顾清除。** 依据是 `D4.4` §0-②③ 自己写下的风险
——按名字清投顾会同时拆掉产品数据底座与 MVP 硬阻断,并失去"改 6 个底座文件时的对照组"。
本次合并因此**恢复投顾模块**;就代码面而言,`D4.4` / `D4.5` 的清除结果被本次合并取代
(留痕见 `开发文档\D1.6` §4.37)。
## 冲突怎么解的(12 处)
- **8 处 modify/delete 取远端**:`recommendations.py` / `product_recommendation_service.py` /
`employee-advisor/dashboard/{actions-module.js,dashboard.css,dashboard.js,index.html}` /
`tools/{check_portal_modules.py,grant_advisor_role.py}` —— 即"我删、远端改",保留投顾文件。
- **3 处内容冲突取远端**:`app/main.py`(投顾 import 与 `include_router`)、
`common/api-client.js`(投顾端点表)、`tests/unit/api/test_portal_frontend.py`(4 条投顾前端契约)。
- **1 处取远端 + 保留我方**:`app/main.py` 解除冲突的同时,保留本轮的
`/customer-service-test` 挂载移除(该联调页与用例已随重构作废)。
## 因"取消清除"而必须回滚的语义改动(否则恢复出来的投顾代码跑不动)
- `app/service/agent/bootstrap.py`:恢复 `AdvisorAgent` 与 5 个投顾工具注册
(`query_investment_goal` / `analyze_portfolio` / `generate_asset_allocation` /
`recommend_products` / `compare_products`);客服 Agent 注释按本轮口径保留。
- `app/core/config.py`:恢复 `advisor_rollout_enabled` / `advisor_rollout_customer_ids`。
- `app/static/portal/common/layout/app-shell.js`:恢复投顾工作台导航与 `advisor` 角色名。
- `tools/seed_test_rbac.py`:恢复"admin 取全量元组"的授权模型(保留远端新增的
9070-9074 权限码与客户侧 9071/9072 绑定)。
- `tools/portal_api_check.py`:恢复投顾实测用例(AD003/AD005/AD011/A047 与 `advisor_t` 登录),
并**新增判定**:被渲染的集合为空(0 条)时判 `SKIP` 而不是 `FAIL`
—— "没有行"与"字段没带"是两回事,混报会把排查方向带偏。
- `app/static/portal/common/api-client.js`:以远端为基准,重新叠加本轮的
**访客令牌 `Authorization` 优先**修复(浮窗访客身份稳定性)。
## 数据库夹具同步(代码恢复 ⇒ 夹具也要恢复)
- `tools/grant_advisor_role.py`:新建 `advisor` 角色并授权(实测 34 项权限)。
- `tools/create_test_user.py --id 9020 --username advisor_t --role advisor`:重建演示账号。
## 集成期发现并修掉的过期断言
- `tests/unit/test_advisor_migration_contract.py`:alembic 末端钉死值仍是
`20260914_baseline_auto_increment`,而远端新增了 `20260916_advisor_service_request`
⇒ 这条断言**在远端分支上本身就是红的**。本次把它更新到新末端并补了注释。
## 验证(本机实测)
| 门禁 | 结果 |
|---|---|
| `pytest -q`(全量,含集成) | **1909 passed / 3 skipped / 0 failed** |
| `ruff check app tools tests` | 20(远端分支 22,本地仅客服线基线 19) |
| `mypy app` | 2(= 既有基线) |
| `tools/portal_api_check.py` | 40 项:通过 35 / 失败 0 / 跳过 5 |
| `tools/e2e_smoke_test.py --read-only` | 31/31 |
| `_eval_harness/http_probe.py` | 11/11 succeeded |
| `_consistency.py` | GATE PASS |
| `_fe_boundary_http.py`(前端入参边界真机) | 全部符合预期 |
## 未做(如实登记)
- **投顾演示数据未灌**:`AD011` / `A047` 需要 `advisor_product_suitability_reference`
这类带 `source_url` + `document_sha256` 的证据行,而披露文件不在仓库里;
`tools/seed_advisor_demo.py` 明确"不编证据"(fail closed),故这两条按空集 SKIP。
- **客服线文档目录仍未入库**:`客服agent/`、`开发文档/`(权威副本在本机)与
`_chunks_report.txt`(本地构建产物)依旧排除在提交之外。
444 lines
28 KiB
JavaScript
444 lines
28 KiB
JavaScript
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' },
|
||
// 历史方案留档(本人 + 名下客户、**全部状态**,含待审/已驳回)。投顾工作台
|
||
// 「历史方案记录」面板用;与 ADVISOR_PUBLISHED 的区别只在状态口径(后者只给已发布)。
|
||
ADVISOR_HISTORY: { method: 'GET', path: '/api/v1/advisor/recommendations/history' },
|
||
// 投顾自助审核/发布自己生成的推荐方案(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 },
|
||
// 删除推荐方案(历史记录里的「删除」)。仅推荐方案可删 —— 投资方案书被
|
||
// `advisor_investment_goal.goal_book_content_id`(NO ACTION 外键)引用,删不掉。
|
||
// 权限码 `product-recommendation:delete`(advisor 与 admin 持有)。
|
||
ADVISOR_DELETE_RECOMMENDATION: { method: 'DELETE', path: '/api/v1/advisor/recommendations/{contentId}', idempotent: true },
|
||
// 客户侧:**本人**已发布的投顾交付物(推荐方案 + 投资方案书)。
|
||
// 是投顾「发送给客户」的落点,见 `customer/advisor-plans/`。
|
||
MY_ADVISOR_CONTENTS: { method: 'GET', path: '/api/v1/users/me/advisor-contents' },
|
||
// 客户**主动申报**投顾方案(本人提交 / 查看自己的申报状态)。
|
||
MY_ADVISOR_REQUESTS: { method: 'GET', path: '/api/v1/users/me/advisor-requests' },
|
||
ADVISOR_REQUEST_CREATE: { method: 'POST', path: '/api/v1/users/me/advisor-requests', idempotent: true },
|
||
// 投顾侧:名下客户的申报队列 + 受理/驳回(**受理会自动生成一份待审方案草稿**)。
|
||
ADVISOR_SERVICE_REQUESTS: { method: 'GET', path: '/api/v1/advisor/service-requests' },
|
||
ADVISOR_SERVICE_REQUEST_REVIEW: { method: 'POST', path: '/api/v1/advisor/service-requests/{requestNo}/reviews', 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();
|
||
// ⚠️ **显式传入的 `Authorization` 优先**。
|
||
//
|
||
// 原先这里无条件用登录令牌覆盖调用方传进来的头,于是“带访客令牌去取公开数据”
|
||
// 在浏览器恰好有登录令牌时会静默变成“用登录身份取数据” —— 症状是同一个公开页
|
||
// 对访客和已登录用户显示不同内容(`README.md` 明令公开数据不得与登录数据混用)。
|
||
// 客服浮窗的访客模式正是靠这条规则拿到**稳定的访客身份**。
|
||
const callerAuth = options.headers?.Authorization;
|
||
if (endpoint.auth !== false && token && !callerAuth) {
|
||
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 };
|