/** 全局认证 fetch 客户端 */ let tokenGetter: () => string | null = () => null; const inflightGetRequests = new Map>(); export class ApiRequestError extends Error { readonly status: number; readonly code: string | null; readonly retryable: boolean; constructor(message: string, status: number, code: string | null, retryable: boolean) { super(message); this.name = 'ApiRequestError'; this.status = status; this.code = code; this.retryable = retryable; } } interface ApiErrorPayload { error?: unknown; message?: unknown; code?: unknown; retryable?: unknown; } export function setTokenGetter(fn: () => string | null) { tokenGetter = fn; } async function executeJsonRequest(url: string, options: RequestInit | undefined, token: string | null): Promise { const res = await fetch(url, { ...options, headers: { ...options?.headers, ...(token ? { Authorization: `Bearer ${token}` } : {}), }, }); if (res.status === 401) { window.dispatchEvent(new CustomEvent('auth:unauthorized')); throw new Error('Unauthorized'); } if (!res.ok) { let payload: ApiErrorPayload = {}; try { const parsed: unknown = await res.json(); if (typeof parsed === 'object' && parsed !== null) payload = parsed as ApiErrorPayload; } catch { payload = {}; } const message = typeof payload?.error === 'string' ? payload.error : typeof payload?.message === 'string' ? payload.message : `请求失败(${res.status})`; const code = typeof payload?.code === 'string' ? payload.code : null; const retryable = typeof payload?.retryable === 'boolean' ? payload.retryable : res.status >= 500; throw new ApiRequestError(message, res.status, code, retryable); } return res.json(); } export async function fetchJson(url: string, options?: RequestInit): Promise { const token = tokenGetter(); const method = (options?.method || 'GET').toUpperCase(); const canDedupe = method === 'GET' && options?.body == null && options?.signal == null; if (!canDedupe) return executeJsonRequest(url, options, token); const key = `${token || 'anonymous'}:${url}`; const existing = inflightGetRequests.get(key) as Promise | undefined; if (existing) return existing; const request = executeJsonRequest(url, options, token) .finally(() => { if (inflightGetRequests.get(key) === request) inflightGetRequests.delete(key); }); inflightGetRequests.set(key, request); return request; } export function _clearApiClientInflightForTests(): void { inflightGetRequests.clear(); }