Files
group_fqcd_jr/app/static/portal/common/api-client.js
T
lzf_0626 daf73a2865 fix(portal): 照接口逐条核对前端,修掉 5 处「照着表结构写、看着像对」的缺陷
做法:把前端 `api-client.js` 注册的端点**按前端完全相同的方式**(同样的路径、参数、
身份)逐个调用,再拿真实返回去核对前端 render 用到的字段。
**这类问题纯读代码看不出来** —— 只有把真实返回和期望字段摆在一起才会暴露。

## 1. 知识库功能实际是坏的(K002 / K003 是裸信封)

`K002` 的成功体是 `{knowledge_ids, filename, chunk_count}`、`K003` 是 `{items, count}`,
**都没有 `data` 信封**。而 `request()` 默认取 `payload.data`(undefined),于是:

- 上传后前端显示「已入库 **0** 块」,而库里其实切了 23 块;
- 列表永远显示「知识库为空」。

端点表里标 `raw: true` 后(与 `V001` 同一做法)两者都正常。
实测:上传 4805 字符的产品手册 → 切 23 块并出现在列表里。

## 2. 委托/成交详情页有 5 行永远显示「--」

前端按 `fin_sim_order` / `fin_transaction` 的**建表字段**写了 `quote_source`、
`nav`、`fee_rate_snapshot`、`confirmed_at`、`auto_confirmed` ——
但这些字段**接口的返回视图没有带**(表里有、返回里没有)。已按实际返回重写字段表,
并在注释里写明"以接口返回为准,不要照表写"。

## 3. 配置项与路由规则的**编辑功能不可能成功**(接口缺口)

`PUT` 硬性要求 `If-Match`,校验的是该行内容的 digest;而这两个资源是 `detail=False`
—— **没有任何端点能返回这个 digest**(列表的 `meta` 只有 trace_id)。
乐观并发在"读不到版本"的前提下等于死锁:**首次编辑必然 409**。
(配置发布能用,是因为它有详情端点 `A003`。)

- 新增详情端点 `A048` / `A049`(`detail=True`),已登记 `docs/05` §19;
- 前端编辑前先 GET 详情取 etag,再带 `If-Match` 提交。
- 实测:编辑配置项与路由规则均 200;**不带 `If-Match` 仍返回 409**,
  说明乐观并发没有被削弱。

## 4. 路由规则表单**必然提交失败**

前端固定写 `max_attempts: 2` 且 `fallbacks: []`,而后端要求
`max_attempts ≤ 端点总数`(主 + 兜底)→ 422「重试次数超过端点数量」。
改为 `1` 并注明约束。

## 5. 主端点手填 ID 会 422

后端对不存在/未激活的 `primary_endpoint_id` 直接 422「模型端点不存在或未激活」。
把输入框改成**下拉**,只列 `status='active'` 的端点(数据复用已有的端点列表)。

## 顺带

- `apiClient` 增加 `del()` / `put()`:发出的方法一直由端点表决定,所以 `post('K004')`
  也能发 DELETE —— 语义太绕,现在意图与行为一致。
- 清理了测试期间上传的 31 条知识残留(客服会检索到它们),库内恢复到 23 条产品手册。

## 关于"逐条核对"的方法论

前两轮跑出来的 9 个和 5 个"失败"里,**多数是我测试脚本自己的假设错了**,不是前端问题:
`T001` 是 `{account, summary}` 嵌套、`RK002` 的字段叫 `risk_level`、
`RK002/RK004/RK005` 的 limit 上限是 5/10/10(前端传的正是 5/10/10)、
`AD011/A002/A047` 的 data 是裸 list。每一处都回到前端源码确认后才下结论 ——
**先把"我以为"改成"代码里写的"**,否则报告出去的就是假 bug。

验证:unit+contract **1397 passed**;integration **110 passed**;ruff 通过;
mypy 251 文件 0 错;§19 现 93 个端点无重复;e2e 冒烟 **40/40**。
前端等价测试:只读 31 项全绿、写操作(含 ETag 链路)9 项 8 绿 1 项因测试数据过短。
2026-09-14 01:19:22 +08:00

293 lines
16 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({
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 },
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' },
ADVISOR_ANALYSIS: { method: 'POST', path: '/api/v1/advisor/portfolio-analysis' },
ADVISOR_ALLOCATION: { method: 'POST', path: '/api/v1/advisor/asset-allocation' },
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_MAILBOX: { method: 'GET', path: '/api/v1/offsite-fund/mailbox-status' },
});
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 || '';
}
}
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) headers['Idempotency-Key'] = options.idempotencyKey || crypto.randomUUID().replaceAll('-', '');
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)),
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;
if (!response.ok || payload.error) {
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
|| validationDetail
|| (response.status ? `请求失败(HTTP ${response.status})` : '请求未完成');
const error = new ApiError(message, {
code: detail.code,
status: response.status,
retryable: detail.retryable,
fieldErrors: detail.field_errors,
traceId: responseTraceId,
});
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);
}
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 }); },
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 };