import axios, { AxiosError, type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios' import { parseApiJsonResponse } from '@chongchong/shared' import { clearSessionTokens, getAccessToken, getRefreshToken, persistTokenPair, type TokenPair } from '@/api/session' import { apiErrorMessage, asFieldErrors } from '@/api/errorDetails' import { ApiError, isApiEnvelope, type ApiEnvelope } from '@/api/types' import { coordinateCrossTabRefresh, CrossTabRefreshTimeoutError } from '@/api/crossTabRefresh' const transport = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || '/api', timeout: 15_000, transformResponse: [(data: unknown) => parseApiJsonResponse(data)], }) interface RetryableRequest extends InternalAxiosRequestConfig { _retriedAfterRefresh?: boolean } function unwrap(envelope: ApiEnvelope) { if (envelope.code !== '200') { const fieldErrors = asFieldErrors(envelope.data) throw new ApiError(apiErrorMessage(envelope.message, fieldErrors), envelope.code, undefined, fieldErrors, envelope.traceId, envelope.requestId) } return envelope.data } function mapError(error: unknown) { if (error instanceof ApiError) return error if (!axios.isAxiosError(error)) return new ApiError(error instanceof Error ? error.message : '请求失败') const body = error.response?.data if (isApiEnvelope(body)) { const fieldErrors = asFieldErrors(body.data) return new ApiError( apiErrorMessage(body.message, fieldErrors), body.code, error.response?.status, fieldErrors, typeof body.traceId === 'string' ? body.traceId : null, typeof body.requestId === 'string' ? body.requestId : null, ) } if (error.code === AxiosError.ERR_NETWORK) return new ApiError('无法连接服务器,请确认后端服务已启动', 'NETWORK_ERROR') if (error.code === AxiosError.ECONNABORTED) return new ApiError('请求超时,请稍后重试', 'TIMEOUT') return new ApiError(error.message || '请求失败', 'HTTP_ERROR', error.response?.status) } let refreshPromise: Promise | null = null let redirectingToLogin = false function redirectToLoginAfterSessionExpiry() { if (redirectingToLogin || typeof window === 'undefined') return const { pathname, search, hash } = window.location if (pathname === '/login' || pathname === '/register') return redirectingToLogin = true const redirect = `${pathname}${search}${hash}` window.location.replace(`/login?redirect=${encodeURIComponent(redirect)}`) } export async function refreshAuthenticatedSession() { const observedRefreshToken = getRefreshToken() if (!observedRefreshToken) throw new ApiError('登录已过期,请重新登录', 'A0401', 401) if (!refreshPromise) { refreshPromise = coordinateCrossTabRefresh({ lockName: 'cc-client-auth-refresh', observedRefreshToken, currentRefreshToken: getRefreshToken, reuseCurrentSession: async () => { const currentAccessToken = getAccessToken() if (!currentAccessToken) throw new ApiError('登录已过期,请重新登录', 'A0401', 401) return currentAccessToken }, refresh: async (currentRefreshToken) => { const response = await transport.post>('/auth/refresh', { refreshToken: currentRefreshToken }) const tokens = unwrap(response.data) if (!getRefreshToken()) throw new ApiError('登录已在其他标签页退出', 'A0401', 401) persistTokenPair(tokens) return tokens.accessToken }, }) .catch((error) => { if (error instanceof CrossTabRefreshTimeoutError) { const expiredError = new ApiError(error.message, 'A0401', 401) clearSessionTokens() window.dispatchEvent(new CustomEvent('cc-client-session-expired')) redirectToLoginAfterSessionExpiry() throw expiredError } throw error }) .finally(() => { refreshPromise = null }) } return refreshPromise } transport.interceptors.request.use((config) => { const token = getAccessToken() if (token) config.headers.Authorization = `Bearer ${token}` return config }) transport.interceptors.response.use( (response) => response, async (error: AxiosError) => { const request = error.config as RetryableRequest | undefined const publicAuthPaths = ['/auth/login', '/auth/register', '/auth/refresh', '/auth/impersonations/exchange'] const isPublicAuthRequest = publicAuthPaths.some((path) => request?.url?.endsWith(path)) if (error.response?.status !== 401 || !request || request._retriedAfterRefresh || isPublicAuthRequest) { throw mapError(error) } request._retriedAfterRefresh = true try { const accessToken = await refreshAuthenticatedSession() request.headers.Authorization = `Bearer ${accessToken}` return await transport.request(request) } catch (refreshError) { const mappedError = mapError(refreshError) if (mappedError.status === 401 || mappedError.code === 'A0401') { clearSessionTokens() window.dispatchEvent(new CustomEvent('cc-client-session-expired')) redirectToLoginAfterSessionExpiry() } throw mappedError } }, ) export async function apiRequest(config: AxiosRequestConfig) { try { const response = await transport.request>(config) return unwrap(response.data) } catch (error) { throw mapError(error) } } export async function apiBlobRequest(config: AxiosRequestConfig) { try { const response = await transport.request({ ...config, responseType: 'blob' }) return response.data } catch (error) { await throwBlobApiError(error) throw mapError(error) } } export async function apiBlobResponseRequest(config: AxiosRequestConfig) { try { const response = await transport.request({ ...config, responseType: 'blob' }) return { blob: response.data, headers: response.headers } } catch (error) { await throwBlobApiError(error) throw mapError(error) } } async function throwBlobApiError(error: unknown) { if (!axios.isAxiosError(error) || !(error.response?.data instanceof Blob)) return try { const body = JSON.parse(await error.response.data.text()) as unknown if (!isApiEnvelope(body)) return const fieldErrors = asFieldErrors(body.data) throw new ApiError( apiErrorMessage(body.message, fieldErrors), body.code, error.response.status, fieldErrors, typeof body.traceId === 'string' ? body.traceId : null, typeof body.requestId === 'string' ? body.requestId : null, ) } catch (parsedError) { if (parsedError instanceof ApiError) throw parsedError } } export { ApiError }