import type { OperationInput, PlatformOperation } from './types'; import { tokenStore, type TokenPair } from './token-store'; const apiBaseUrl = (import.meta.env.VITE_API_BASE_URL || '').replace(/\/$/, ''); let refreshPromise: Promise | null = null; export class ApiError extends Error { constructor( message: string, readonly status: number, readonly details?: unknown, ) { super(message); } } async function parseResponse(response: Response): Promise { if (response.status === 204) return null; const contentType = response.headers.get('content-type') || ''; if (contentType.includes('json')) return response.json(); const text = await response.text(); return text || null; } async function refreshTokens(): Promise { const tokens = tokenStore.get(); if (!tokens?.refreshToken) return null; const response = await fetch(`${apiBaseUrl}/api/auth/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refreshToken: tokens.refreshToken }), }); if (!response.ok) { tokenStore.set(null); return null; } const next = (await response.json()) as TokenPair; tokenStore.set(next); return next; } async function authorizedFetch(url: string, init: RequestInit, retry = true): Promise { const accessToken = tokenStore.get()?.accessToken; const headers = new Headers(init.headers); if (accessToken) headers.set('Authorization', `Bearer ${accessToken}`); const response = await fetch(`${apiBaseUrl}${url}`, { ...init, headers }); if (response.status !== 401 || !retry) return response; refreshPromise ??= refreshTokens().finally(() => { refreshPromise = null; }); const refreshed = await refreshPromise; return refreshed ? authorizedFetch(url, init, false) : response; } function appendQuery(url: URL, query: Record) { for (const [key, value] of Object.entries(query)) { if (value === undefined || value === null || value === '') continue; if (Array.isArray(value)) value.forEach((item) => url.searchParams.append(key, String(item))); else url.searchParams.set(key, String(value)); } } export async function apiRequest( operation: PlatformOperation, input: OperationInput = {}, signal?: AbortSignal, ): Promise { const pathValues = (input.path || {}) as Record; let route = operation.path.replace(/\{([^}]+)\}/g, (_, name: string) => { const value = pathValues[name]; if (value === undefined || value === null) throw new Error(`缺少必填路径参数:${name}`); return encodeURIComponent(String(value)); }); const url = new URL(route, window.location.origin); appendQuery(url, ((input.query || {}) as Record)); route = `${url.pathname}${url.search}`; const headers: Record = {}; const init: RequestInit = { method: operation.method, headers, signal }; if (input.body !== undefined && operation.method !== 'GET') { headers['Content-Type'] = 'application/json'; init.body = JSON.stringify(input.body); } const response = await authorizedFetch(route, init); const payload = await parseResponse(response); if (!response.ok) { const problem = payload as { detail?: string; title?: string } | null; throw new ApiError(problem?.detail || problem?.title || `请求失败(HTTP ${response.status})`, response.status, payload); } return payload as T; } export async function authRequest(path: string, body?: unknown): Promise { const response = await fetch(`${apiBaseUrl}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body), }); const payload = await parseResponse(response); if (!response.ok) { const problem = payload as { detail?: string; title?: string } | null; throw new ApiError(problem?.detail || problem?.title || `请求失败(HTTP ${response.status})`, response.status, payload); } return payload as T; } export async function getCurrentUser(): Promise { const response = await authorizedFetch('/api/me', { method: 'GET' }); const payload = await parseResponse(response); if (!response.ok) throw new ApiError('登录会话已失效', response.status, payload); return payload as T; }