import { apiErrorBodySchema } from '@lingxi/shared'; import { useAuthStore } from '@/stores/auth-store'; const DEFAULT_BASE_URL = 'http://127.0.0.1:3000/api/v1'; export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; export interface RequestOptions { method?: HttpMethod; body?: unknown; headers?: Record; signal?: AbortSignal; /** 登录、刷新等接口不需要携带 token */ auth?: boolean; } export interface ApiErrorInit { code: number; status: number; message: string; details?: unknown; traceId?: string; } /** 前端统一 API 错误:与服务端错误体 {code,message,details,trace_id} 对齐 */ export class ApiError extends Error { readonly code: number; readonly status: number; readonly details?: unknown; readonly traceId?: string; constructor(init: ApiErrorInit) { super(init.message); this.name = 'ApiError'; this.code = init.code; this.status = init.status; this.details = init.details; this.traceId = init.traceId; } } let unauthorizedHandler: (() => void) | null = null; export function setUnauthorizedHandler(handler: (() => void) | null): void { unauthorizedHandler = handler; } function baseUrl(): string { const configured = import.meta.env.VITE_PUBLIC_API_BASE_URL; return (configured ?? DEFAULT_BASE_URL).replace(/\/+$/, ''); } function buildUrl(path: string): string { if (/^https?:\/\//.test(path)) { return path; } return `${baseUrl()}${path.startsWith('/') ? path : `/${path}`}`; } function serializeBody(body: unknown): BodyInit | undefined { if (body === undefined) { return undefined; } if ( typeof body === 'string' || body instanceof FormData || body instanceof Blob || body instanceof URLSearchParams || body instanceof ArrayBuffer ) { return body; } return JSON.stringify(body); } function fallbackCode(status: number): number { if (status === 401) return 40101; if (status === 403) return 40301; if (status === 404) return 40401; if (status === 409) return 40903; if (status === 429) return 42901; if (status >= 500) return 50001; return 40001; } async function toApiError(response: Response): Promise { let payload: unknown = null; try { payload = await response.json(); } catch { payload = null; } const parsed = apiErrorBodySchema.safeParse(payload); if (parsed.success) { return new ApiError({ code: parsed.data.code, status: response.status, message: parsed.data.message, details: parsed.data.details, traceId: parsed.data.trace_id, }); } return new ApiError({ code: fallbackCode(response.status), status: response.status, message: `请求失败(HTTP ${response.status})`, }); } /** 刷新失败:清本地会话并通知外壳跳登录(会话过期统一入口) */ function failRefresh(): false { useAuthStore.getState().clearSession(); unauthorizedHandler?.(); return false; } let refreshPromise: Promise | null = null; /** 单飞刷新:并发 401 只触发一次 /auth/refresh */ export function refreshSession(): Promise { refreshPromise ??= doRefresh().finally(() => { refreshPromise = null; }); return refreshPromise; } async function doRefresh(): Promise { const { refreshToken } = useAuthStore.getState(); if (refreshToken === null) { return failRefresh(); } let response: Response; try { response = await fetch(buildUrl('/auth/refresh'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refreshToken }), }); } catch { return failRefresh(); } if (!response.ok) { return failRefresh(); } const payload: unknown = await response.json().catch(() => null); const accessToken = typeof payload === 'object' && payload !== null && 'accessToken' in payload ? payload.accessToken : null; const nextRefreshToken = typeof payload === 'object' && payload !== null && 'refreshToken' in payload ? payload.refreshToken : null; if (typeof accessToken !== 'string' || accessToken.length === 0) { return failRefresh(); } useAuthStore.getState().setTokens({ accessToken, refreshToken: typeof nextRefreshToken === 'string' ? nextRefreshToken : refreshToken, }); return true; } async function send(path: string, options: RequestOptions, allowRefresh: boolean): Promise { const { accessToken } = useAuthStore.getState(); const headers = new Headers(options.headers); const body = serializeBody(options.body); if (body !== undefined && !(body instanceof FormData) && !headers.has('Content-Type')) { headers.set('Content-Type', 'application/json'); } if (options.auth !== false && accessToken !== null) { headers.set('Authorization', `Bearer ${accessToken}`); } const response = await fetch(buildUrl(path), { method: options.method ?? 'GET', headers, body, signal: options.signal, }); if (response.status === 401 && allowRefresh && options.auth !== false) { const refreshed = await refreshSession(); if (refreshed) { return send(path, options, false); } } if (!response.ok) { throw await toApiError(response); } if (response.status === 204) { return undefined as T; } return (await response.json()) as T; } /** 业务请求入口:自动注入 token、401 单飞刷新、统一错误体解析 */ export function request(path: string, options: RequestOptions = {}): Promise { return send(path, options, true); }