做法:把前端 `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 项因测试数据过短。
293 lines
16 KiB
JavaScript
293 lines
16 KiB
JavaScript
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 };
|