import type { AuthResponse } from './types'; import { clearStoredSession, createSession, readStoredSession, saveStoredSession } from './session'; interface ApiEnvelope { code: number; msg: string; data: T; } interface ApiRequestInit extends Omit { body?: unknown; } interface ApiUploadRequestInit { body: FormData; headers?: HeadersInit; method?: 'POST' | 'PUT' | 'PATCH'; onProgress?: (progress: {loaded: number; total: number}) => void; signal?: AbortSignal; } interface ApiBinaryUploadRequestInit { body: Blob; headers?: HeadersInit; method?: 'PUT' | 'POST'; onProgress?: (progress: {loaded: number; total: number}) => void; signal?: AbortSignal; } const API_BASE_URL = (import.meta.env?.VITE_API_BASE_URL || '/api').replace(/\/$/, ''); const AUTH_REFRESH_PATH = '/auth/refresh'; let refreshRequestPromise: Promise | null = null; export class ApiError extends Error { code?: number; status: number; isNetworkError: boolean; constructor(message: string, status = 500, code?: number) { super(message); this.name = 'ApiError'; this.status = status; this.code = code; this.isNetworkError = status === 0; } } function isNetworkFailure(error: unknown) { return error instanceof TypeError || error instanceof DOMException; } function sleep(ms: number) { return new Promise((resolve) => { setTimeout(resolve, ms); }); } function getRetryDelayMs(attempt: number) { const schedule = [500, 1200, 2200]; return schedule[Math.min(attempt, schedule.length - 1)]; } function getMaxRetryAttempts(path: string, init: ApiRequestInit = {}) { const method = (init.method || 'GET').toUpperCase(); if (method === 'POST' && path === '/auth/login') { return 1; } if (method === 'PATCH' && /^\/files\/\d+\/rename$/.test(path)) { return 0; } if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') { return 2; } return -1; } function getRetryDelayForRequest(path: string, init: ApiRequestInit = {}, attempt: number) { const method = (init.method || 'GET').toUpperCase(); if (method === 'POST' && path === '/auth/login') { const loginSchedule = [350, 800]; return loginSchedule[Math.min(attempt, loginSchedule.length - 1)]; } return getRetryDelayMs(attempt); } function resolveUrl(path: string) { if (/^https?:\/\//.test(path)) { return path; } const normalizedPath = path.startsWith('/') ? path : `/${path}`; return `${API_BASE_URL}${normalizedPath}`; } function normalizePath(path: string) { return path.startsWith('/') ? path : `/${path}`; } function shouldAttemptTokenRefresh(path: string) { const normalizedPath = normalizePath(path); return ![ '/auth/login', '/auth/register', '/auth/dev-login', AUTH_REFRESH_PATH, ].includes(normalizedPath); } function buildRequestBody(body: ApiRequestInit['body']) { if (body == null) { return undefined; } if ( body instanceof FormData || body instanceof Blob || body instanceof URLSearchParams || typeof body === 'string' || body instanceof ArrayBuffer ) { return body; } return JSON.stringify(body); } async function refreshAccessToken() { const currentSession = readStoredSession(); if (!currentSession?.refreshToken) { clearStoredSession(); return false; } if (refreshRequestPromise) { return refreshRequestPromise; } refreshRequestPromise = (async () => { try { const response = await fetch(resolveUrl(AUTH_REFRESH_PATH), { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json', }, body: JSON.stringify({ refreshToken: currentSession.refreshToken, }), }); const contentType = response.headers.get('content-type') || ''; if (!response.ok || !contentType.includes('application/json')) { clearStoredSession(); return false; } const payload = (await response.json()) as ApiEnvelope; if (payload.code !== 0 || !payload.data) { clearStoredSession(); return false; } saveStoredSession({ ...currentSession, ...createSession(payload.data), user: payload.data.user ?? currentSession.user, }); return true; } catch { clearStoredSession(); return false; } finally { refreshRequestPromise = null; } })(); return refreshRequestPromise; } async function parseApiError(response: Response) { const contentType = response.headers.get('content-type') || ''; if (!contentType.includes('application/json')) { return new ApiError(`请求失败 (${response.status})`, response.status); } const payload = (await response.json()) as ApiEnvelope; return new ApiError(payload.msg || `请求失败 (${response.status})`, response.status, payload.code); } export function toNetworkApiError(error: unknown) { const fallbackMessage = '网络连接异常,请稍后重试'; const message = error instanceof Error && error.message ? error.message : fallbackMessage; return new ApiError(message === 'Failed to fetch' ? fallbackMessage : message, 0); } function toUploadAbortApiError() { return new ApiError('上传已取消', 0); } export function shouldRetryRequest( path: string, init: ApiRequestInit = {}, error: unknown, attempt: number, ) { if (!isNetworkFailure(error)) { return false; } return attempt <= getMaxRetryAttempts(path, init); } async function performRequest(path: string, init: ApiRequestInit = {}, allowRefresh = true): Promise { const session = readStoredSession(); const headers = new Headers(init.headers); const requestBody = buildRequestBody(init.body); if (session?.token) { headers.set('Authorization', `Bearer ${session.token}`); } if (requestBody && !(requestBody instanceof FormData) && !headers.has('Content-Type')) { headers.set('Content-Type', 'application/json'); } if (!headers.has('Accept')) { headers.set('Accept', 'application/json'); } let response: Response; let lastError: unknown; for (let attempt = 0; attempt <= 3; attempt += 1) { try { response = await fetch(resolveUrl(path), { ...init, headers, body: requestBody, }); break; } catch (error) { lastError = error; if (!shouldRetryRequest(path, init, error, attempt)) { throw toNetworkApiError(error); } await sleep(getRetryDelayForRequest(path, init, attempt)); } } if (!response!) { throw toNetworkApiError(lastError); } if (response.status === 401 && allowRefresh && shouldAttemptTokenRefresh(path)) { const refreshed = await refreshAccessToken(); if (refreshed) { return performRequest(path, init, false); } } if (response.status === 401) { clearStoredSession(); } return response; } export async function apiRequest(path: string, init?: ApiRequestInit) { const response = await performRequest(path, init); const contentType = response.headers.get('content-type') || ''; if (!contentType.includes('application/json')) { if (!response.ok) { throw new ApiError(`请求失败 (${response.status})`, response.status); } return undefined as T; } const payload = (await response.json()) as ApiEnvelope; if (!response.ok || payload.code !== 0) { throw new ApiError(payload.msg || `请求失败 (${response.status})`, response.status, payload.code); } return payload.data; } function apiUploadRequestInternal(path: string, init: ApiUploadRequestInit, allowRefresh: boolean): Promise { const session = readStoredSession(); const headers = new Headers(init.headers); if (session?.token) { headers.set('Authorization', `Bearer ${session.token}`); } if (!headers.has('Accept')) { headers.set('Accept', 'application/json'); } return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); let settled = false; const detachAbortSignal = () => { init.signal?.removeEventListener('abort', handleAbortSignal); }; const resolveOnce = (value: T | PromiseLike) => { if (settled) { return; } settled = true; detachAbortSignal(); resolve(value); }; const rejectOnce = (error: unknown) => { if (settled) { return; } settled = true; detachAbortSignal(); reject(error); }; const handleAbortSignal = () => { if (settled) { return; } xhr.abort(); rejectOnce(toUploadAbortApiError()); }; if (init.signal?.aborted) { rejectOnce(toUploadAbortApiError()); return; } xhr.open(init.method || 'POST', resolveUrl(path)); headers.forEach((value, key) => { xhr.setRequestHeader(key, value); }); if (init.onProgress) { xhr.upload.addEventListener('progress', (event) => { if (!event.lengthComputable) { return; } init.onProgress?.({ loaded: event.loaded, total: event.total, }); }); } xhr.onerror = () => { if (init.signal?.aborted) { rejectOnce(toUploadAbortApiError()); return; } rejectOnce(toNetworkApiError(new TypeError('Failed to fetch'))); }; xhr.onabort = () => { rejectOnce(toUploadAbortApiError()); }; xhr.onload = () => { const contentType = xhr.getResponseHeader('content-type') || ''; if (xhr.status === 401 && allowRefresh && shouldAttemptTokenRefresh(path)) { refreshAccessToken() .then((refreshed) => { if (refreshed) { resolveOnce(apiUploadRequestInternal(path, init, false)); return; } clearStoredSession(); rejectOnce(new ApiError('登录状态已失效,请重新登录', 401)); }) .catch((error) => { clearStoredSession(); rejectOnce(error instanceof ApiError ? error : toNetworkApiError(error)); }); return; } if (!contentType.includes('application/json')) { if (xhr.status >= 200 && xhr.status < 300) { resolveOnce(undefined as T); return; } rejectOnce(new ApiError(`请求失败 (${xhr.status})`, xhr.status)); return; } const payload = JSON.parse(xhr.responseText) as ApiEnvelope; if (xhr.status < 200 || xhr.status >= 300 || payload.code !== 0) { if (xhr.status === 401) { clearStoredSession(); } rejectOnce(new ApiError(payload.msg || `请求失败 (${xhr.status})`, xhr.status, payload.code)); return; } resolveOnce(payload.data); }; if (init.signal) { init.signal.addEventListener('abort', handleAbortSignal, {once: true}); } xhr.send(init.body); }); } export function apiUploadRequest(path: string, init: ApiUploadRequestInit): Promise { return apiUploadRequestInternal(path, init, true); } export function apiBinaryUploadRequest(path: string, init: ApiBinaryUploadRequestInit) { const headers = new Headers(init.headers); return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); let settled = false; const detachAbortSignal = () => { init.signal?.removeEventListener('abort', handleAbortSignal); }; const resolveOnce = () => { if (settled) { return; } settled = true; detachAbortSignal(); resolve(); }; const rejectOnce = (error: unknown) => { if (settled) { return; } settled = true; detachAbortSignal(); reject(error); }; const handleAbortSignal = () => { if (settled) { return; } xhr.abort(); rejectOnce(toUploadAbortApiError()); }; if (init.signal?.aborted) { rejectOnce(toUploadAbortApiError()); return; } xhr.open(init.method || 'PUT', resolveUrl(path)); headers.forEach((value, key) => { xhr.setRequestHeader(key, value); }); if (init.onProgress) { xhr.upload.addEventListener('progress', (event) => { if (!event.lengthComputable) { return; } init.onProgress?.({ loaded: event.loaded, total: event.total, }); }); } xhr.onerror = () => { if (init.signal?.aborted) { rejectOnce(toUploadAbortApiError()); return; } rejectOnce(toNetworkApiError(new TypeError('Failed to fetch'))); }; xhr.onabort = () => { rejectOnce(toUploadAbortApiError()); }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { resolveOnce(); return; } rejectOnce(new ApiError(`请求失败 (${xhr.status})`, xhr.status)); }; if (init.signal) { init.signal.addEventListener('abort', handleAbortSignal, {once: true}); } xhr.send(init.body); }); } export async function apiDownload(path: string) { const response = await performRequest(path, { headers: { Accept: '*/*', }, }); if (!response.ok) { throw await parseApiError(response); } return response; }