import { clearAuthSession, getAccessToken } from '/static/portal/common/auth.js'; 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 }, 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' }, 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' }, RK006: { method: 'POST', path: '/api/v1/risk/alerts/scan', idempotent: true }, 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' }, 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' }, 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 }, PROMOTION_GENERATE: { method: 'POST', path: '/api/v1/fund-promotion-materials/{taskNo}/generations', idempotent: true }, 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) 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 || 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(); 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(() => ({})); 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 }); }, 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 };