import type { ApiError, ErrorResponse } from '@/contracts' import { isDesktop } from './platform/desktop' import { coreRequest, type RequestProgress } from './platform/coreRequest' // 所有 HTTP 请求都经过此边界,以统一地址、请求追踪和错误契约。 const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? import.meta.env.VITE_API_BASE ?? '' interface DesktopCoreResponse { status: number content_type: string body: string body_base64?: string } function responseFromDesktopCore(response: DesktopCoreResponse): Response { let body: BodyInit = response.body if (response.body_base64 !== undefined) { const binary = atob(response.body_base64) const bytes = new Uint8Array(binary.length) for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index) body = bytes.buffer } return new Response(body, { status: response.status, headers: response.content_type ? { 'Content-Type': response.content_type } : undefined, }) } export function resolveApiUrl(path: string): string { if (/^https?:\/\//i.test(path)) return path return `${BASE_URL.replace(/\/$/, '')}/${path.replace(/^\//, '')}` } interface RequestOptions extends RequestInit { timeoutMs?: number params?: Record token?: string } export class ApiErrorClass extends Error { code: string details?: Record constructor(code: string, message: string, details?: Record) { super(message) this.name = 'ApiError' this.code = code this.details = details } } async function request(path: string, options: RequestOptions = {}): Promise { const { params, token, headers, timeoutMs, ...rest } = options const desktop = isDesktop() const deadlineMs = timeoutMs ?? (desktop ? 30_000 : undefined) if (deadlineMs !== undefined && (!Number.isFinite(deadlineMs) || deadlineMs <= 0 || (desktop && deadlineMs > 600_000))) { throw new ApiErrorClass('CORE_TIMEOUT_INVALID', '请求超时设置无效') } const controller = new AbortController() const progress: RequestProgress = { issued: false } let timedOut = false const abort = () => controller.abort() if (rest.signal?.aborted) abort() rest.signal?.addEventListener('abort', abort, { once: true }) const timer = deadlineMs ? setTimeout(() => { timedOut = true; controller.abort() }, deadlineMs) : undefined let url = resolveApiUrl(path) if (params) { const usp = new URLSearchParams() Object.entries(params).forEach(([k, v]) => { if (v !== undefined && v !== null) usp.append(k, String(v)) }) const qs = usp.toString() if (qs) url += `?${qs}` } const reqHeaders: Record = { 'Content-Type': 'application/json', ...(headers as Record), } if (token) { reqHeaders['Authorization'] = `Bearer ${token}` } const reqId = crypto.randomUUID() reqHeaders['X-Request-Id'] = reqId try { controller.signal.throwIfAborted() if (desktop) { const parsed = new URL(url, 'http://localhost') let bodyBase64: string | undefined if (rest.body instanceof Blob) { if (rest.body.size > 64 * 1024 * 1024) throw new ApiErrorClass('CORE_REQUEST_TOO_LARGE', '上传文件超过 64 MiB') const bytes = new Uint8Array(await rest.body.arrayBuffer()) const parts: string[] = [] for (let offset = 0; offset < bytes.length; offset += 16384) parts.push(String.fromCharCode(...bytes.subarray(offset, offset + 16384))) bodyBase64 = btoa(parts.join('')) } const response = await coreRequest({ method: rest.method ?? 'GET', path: `${parsed.pathname}${parsed.search}`, body: typeof rest.body === 'string' ? JSON.parse(rest.body) : undefined, bodyBase64, contentType: new Headers(reqHeaders).get('Content-Type'), idempotencyKey: new Headers(reqHeaders).get('Idempotency-Key') ?? undefined, }, controller.signal, Math.ceil(deadlineMs!), progress) if (response.status >= 200 && response.status < 300) { if (response.status === 204) return undefined as T return (response.content_type.includes('application/json') ? JSON.parse(response.body) : responseFromDesktopCore(response)) as T } let error: ErrorResponse | null = null try { error = JSON.parse(response.body) as ErrorResponse } catch { /* 非 JSON 错误 */ } throw new ApiErrorClass(error?.error?.code ?? `HTTP_${response.status}`, error?.error?.message ?? `Request failed with status ${response.status}`, error?.error?.details) } const resp = await fetch(url, { ...rest, signal: controller.signal, headers: reqHeaders, }) if (resp.ok) { if (resp.status === 204) return undefined as T const ct = resp.headers.get('content-type') || '' if (ct.includes('application/json')) return (await resp.json()) as T return resp as unknown as T } // 后端约定返回 ErrorResponse;代理或网关的非 JSON 错误仍降级为 HTTP 状态码。 let errBody: ErrorResponse | null = null try { errBody = (await resp.json()) as ErrorResponse } catch { /* ignore */ } const code = errBody?.error?.code || `HTTP_${resp.status}` const message = errBody?.error?.message || `Request failed with status ${resp.status}` const details = errBody?.error?.details throw new ApiErrorClass(code, message, details) } catch (e) { const nativeCode = (e as { code?: string })?.code const uncertain = desktop && progress.issued && !['GET', 'HEAD'].includes(rest.method ?? 'GET') const details = desktop ? { request_id: progress.requestId, outcome: progress.issued ? 'unknown' : 'not_sent' } : undefined if (timedOut || nativeCode === 'REQUEST_TIMEOUT') throw new ApiErrorClass('REQUEST_TIMEOUT', uncertain ? '请求超时,变更可能已提交,请先检查结果。' : '请求超时,请检查连接。', details) if (controller.signal.aborted || nativeCode === 'REQUEST_CANCELLED') throw new ApiErrorClass('REQUEST_CANCELLED', uncertain ? '请求已取消,变更可能已提交,请先检查结果。' : '请求已取消。', details) if (e instanceof ApiErrorClass) throw e throw new ApiErrorClass('NETWORK_ERROR', (e as Error).message || 'Network error') } finally { if (timer) clearTimeout(timer) rest.signal?.removeEventListener('abort', abort) } } export const apiClient = { postBinary(path: string, body: Blob, headers: Record = { 'Content-Type': 'application/zip' }) { return request(path, { method: 'POST', body, headers }) }, get(path: string, options?: Omit) { return request(path, { ...options, method: 'GET' }) }, post(path: string, body?: unknown, options?: Omit) { return request(path, { ...options, method: 'POST', body: body !== undefined ? JSON.stringify(body) : undefined, }) }, patch(path: string, body?: unknown, options?: Omit) { return request(path, { ...options, method: 'PATCH', body: body !== undefined ? JSON.stringify(body) : undefined, }) }, put(path: string, body?: unknown, options?: Omit) { return request(path, { ...options, method: 'PUT', body: body !== undefined ? JSON.stringify(body) : undefined, }) }, delete(path: string, options?: Omit) { return request(path, { ...options, method: 'DELETE' }) }, } export default apiClient