2026-09-13 23:04:35 +08:00
|
|
|
|
import { clearAuthSession, getAccessToken } from '/static/portal/common/auth.js?v=20260913';
|
2026-09-13 15:56:54 +08:00
|
|
|
|
|
|
|
|
|
|
const ENDPOINTS = Object.freeze({
|
|
|
|
|
|
A034: { method: 'POST', path: '/api/v1/auth/tokens', auth: false },
|
2026-09-13 18:31:38 +08:00
|
|
|
|
V001: { method: 'POST', path: '/api/v1/visitor-tokens', auth: false, raw: true },
|
2026-09-13 22:57:48 +08:00
|
|
|
|
P001: { method: 'GET', path: '/api/v1/products' },
|
2026-09-13 23:38:13 +08:00
|
|
|
|
P002: { method: 'GET', path: '/api/v1/products/{productCode}/nav-history' },
|
2026-09-13 18:31:38 +08:00
|
|
|
|
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 },
|
2026-09-13 15:56:54 +08:00
|
|
|
|
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' },
|
|
|
|
|
|
A002: { method: 'GET', path: '/api/v1/admin/config-releases' },
|
|
|
|
|
|
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}' },
|
|
|
|
|
|
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' },
|
2026-09-13 21:08:57 +08:00
|
|
|
|
RK006: { method: 'POST', path: '/api/v1/risk/alerts/scan', idempotent: true, timeout: 60000 },
|
2026-09-13 15:56:54 +08:00
|
|
|
|
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 },
|
|
|
|
|
|
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' },
|
2026-09-13 18:31:38 +08:00
|
|
|
|
ADVISOR_PUBLISHED: { method: 'GET', path: '/api/v1/advisor/recommendations/published' },
|
|
|
|
|
|
ADVISOR_GOAL: { method: 'GET', path: '/api/v1/advisor/investment-goals/current' },
|
|
|
|
|
|
ADVISOR_ANALYSIS: { method: 'POST', path: '/api/v1/advisor/portfolio-analysis' },
|
2026-09-13 23:04:35 +08:00
|
|
|
|
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' },
|
2026-09-13 18:31:38 +08:00
|
|
|
|
OFFSITE_MAILS: { method: 'GET', path: '/api/v1/offsite-fund/mails' },
|
|
|
|
|
|
OFFSITE_MAILBOX: { method: 'GET', path: '/api/v1/offsite-fund/mailbox-status' },
|
2026-09-13 15:56:54 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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 });
|
2026-09-13 21:08:57 +08:00
|
|
|
|
const timeoutId = window.setTimeout(
|
|
|
|
|
|
() => controller.abort(),
|
|
|
|
|
|
options.timeout || endpoint.timeout || 8000,
|
|
|
|
|
|
);
|
2026-09-13 15:56:54 +08:00
|
|
|
|
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(() => ({}));
|
2026-09-13 19:24:59 +08:00
|
|
|
|
if (response.status === 401 && endpoint.auth !== false) {
|
|
|
|
|
|
clearAuthSession({ eventType: 'session-expired' });
|
|
|
|
|
|
window.dispatchEvent(new CustomEvent('portal:auth-expired'));
|
|
|
|
|
|
}
|
2026-09-13 15:56:54 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-09-13 18:31:38 +08:00
|
|
|
|
return { data: endpoint.raw ? payload : payload.data, meta: payload.meta || {}, traceId: responseTraceId };
|
2026-09-13 15:56:54 +08:00
|
|
|
|
} 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(() => ({}));
|
2026-09-13 19:24:59 +08:00
|
|
|
|
if (response.status === 401 && endpoint.auth !== false) {
|
|
|
|
|
|
clearAuthSession({ eventType: 'session-expired' });
|
|
|
|
|
|
window.dispatchEvent(new CustomEvent('portal:auth-expired'));
|
|
|
|
|
|
}
|
2026-09-13 15:56:54 +08:00
|
|
|
|
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 }); },
|
|
|
|
|
|
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 };
|