export interface ApiEnvelope { code: string; message: string; data: T; requestId: string; } export interface ApiFailure { code: string; message: string; status: number | undefined; requestId: string | undefined; } export interface ApiClientOptions { fetcher?: typeof fetch; baseUrl?: string; getToken: () => string | null; setToken: (token: string | null) => void; onUnauthorized?: () => void; } export class ApiError extends Error implements ApiFailure { public status: number | undefined; public requestId: string | undefined; constructor( public code: string, message: string, status?: number, requestId?: string, ) { super(message); this.status = status; this.requestId = requestId; } } export function createApiClient(options: ApiClientOptions) { const fetcher = options.fetcher ?? fetch; const baseUrl = options.baseUrl ?? "/api/v1"; let refreshing: Promise | null = null; let failedRefresh: { token: string | null; error: unknown } | null = null; async function refresh( staleToken: string | null = options.getToken(), ): Promise { // 同一认证失败周期内:只要令牌仍是失败时的令牌(或已被清空), // 就直接抛出已记录的失败,不再请求 refresh 接口或重复触发登出。 if ( failedRefresh !== null && (staleToken === failedRefresh.token || staleToken === null) ) throw failedRefresh.error; if (!refreshing) { refreshing = (async () => { try { const response = await fetcher(`${baseUrl}/auth/token/refresh`, { method: "POST", credentials: "include", }); const body = (await response.json()) as ApiEnvelope<{ accessToken: string; }>; if (!response.ok) throw new ApiError( body.code, body.message, response.status, body.requestId, ); options.setToken(body.data.accessToken); failedRefresh = null; return body.data.accessToken; } catch (error) { const failure = networkError(error); failedRefresh = { token: staleToken, error: failure }; options.setToken(null); options.onUnauthorized?.(); throw failure; } finally { refreshing = null; } })(); } return refreshing; } async function request( path: string, init: RequestInit = {}, retry = true, ): Promise { const headers = new Headers(init.headers); const token = options.getToken(); if (token) headers.set("authorization", `Bearer ${token}`); if (init.body && !headers.has("content-type")) headers.set("content-type", "application/json"); let response: Response; try { response = await fetcher(`${baseUrl}${path}`, { ...init, headers, credentials: "include", }); } catch (error) { throw networkError(error); } const body = (await response.json()) as ApiEnvelope; if (response.status === 401 && retry && path !== "/auth/token/refresh") { await refresh(token); return request(path, init, false); } if (!response.ok) throw new ApiError( body.code, body.message, response.status, body.requestId, ); return body.data; } return { get: (path: string) => request(path), post: (path: string, body?: unknown, headers?: HeadersInit) => request(path, { method: "POST", ...(body === undefined ? {} : { body: JSON.stringify(body) }), ...(headers === undefined ? {} : { headers }), }), put: (path: string, body: unknown) => request(path, { method: "PUT", body: JSON.stringify(body) }), patch: (path: string, body: unknown) => request(path, { method: "PATCH", body: JSON.stringify(body) }), delete: (path: string) => request(path, { method: "DELETE" }), refresh, }; } function networkError(error: unknown): ApiError { if (error instanceof ApiError) return error; return new ApiError( "NETWORK_ERROR", error instanceof Error ? error.message : "网络连接失败", ); }