import { useAuthStore } from '@/auth/store' import type { ApiErrorPayload, ApiResponse, ListParams, TokenGrant } from './types' const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? '' export const ADMIN_CLIENT_MACHINE_NAME = 'admin-api-client' export class ApiError extends Error { constructor( public status: number, public code: string, message: string, public errors?: Record, ) { super(message) this.name = 'ApiError' } } interface RequestOptions { method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' body?: unknown params?: ListParams /** skip Bearer + refresh handling (login/refresh calls) */ anonymous?: boolean signal?: AbortSignal } function buildUrl(path: string, params?: ListParams): string { const url = new URL(path, BASE_URL) if (params) { for (const [key, value] of Object.entries(params)) { if (value !== undefined && value !== null && value !== '') { url.searchParams.set(key, String(value)) } } } return url.toString() } // Single-flight refresh: concurrent 401s share one refresh attempt. let refreshInFlight: Promise | null = null async function tryRefresh(): Promise { refreshInFlight ??= (async () => { const { refreshToken } = useAuthStore.getState() if (!refreshToken) return false try { const res = await fetch(buildUrl('/api/v1/auth/refresh-token'), { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ refresh_token: refreshToken, client_machine_name: ADMIN_CLIENT_MACHINE_NAME, }), }) if (!res.ok) return false const json = (await res.json()) as ApiResponse if (!json.success || !json.data.access_token) return false useAuthStore.getState().setTokens(json.data) return true } catch { return false } finally { // allow the next expiry to trigger a fresh attempt setTimeout(() => { refreshInFlight = null }, 0) } })() return refreshInFlight } async function parseError(res: Response): Promise { let payload: ApiErrorPayload | null = null try { payload = (await res.json()) as ApiErrorPayload } catch { // non-JSON error body } return new ApiError( res.status, payload?.code ?? `HTTP_${res.status}`, payload?.message ?? res.statusText, payload?.data?.errors, ) } async function rawRequest(path: string, options: RequestOptions, retried = false): Promise { const { accessToken } = useAuthStore.getState() const headers: Record = { Accept: 'application/json', 'X-Language-Locale': 'zh-CN', } if (options.body !== undefined) headers['Content-Type'] = 'application/json' if (!options.anonymous && accessToken) headers.Authorization = `Bearer ${accessToken}` const res = await fetch(buildUrl(path, options.params), { method: options.method ?? 'GET', headers, body: options.body !== undefined ? JSON.stringify(options.body) : undefined, signal: options.signal, }) if (res.status === 401 && !options.anonymous && !retried) { if (await tryRefresh()) { return rawRequest(path, options, true) } useAuthStore.getState().clear() } return res } export async function request(path: string, options: RequestOptions = {}): Promise { const res = await rawRequest(path, options) if (!res.ok) throw await parseError(res) return (await res.json()) as T } export const api = { get: (path: string, params?: ListParams, signal?: AbortSignal) => request(path, { params, signal }), post: (path: string, body?: unknown) => request(path, { method: 'POST', body }), put: (path: string, body?: unknown) => request(path, { method: 'PUT', body }), patch: (path: string, body?: unknown) => request(path, { method: 'PATCH', body }), delete: (path: string, body?: unknown) => request(path, { method: 'DELETE', body }), anonymousPost: (path: string, body?: unknown) => request(path, { method: 'POST', body, anonymous: true }), }