import axios, { type InternalAxiosRequestConfig } from 'axios' import { parseApiJsonResponse } from '@chongchong/shared' export interface ApiEnvelope { code: string message: string data: T requestId?: string | null traceId?: string | null timestamp?: string } export class ApiResponseError extends Error { readonly code: string readonly status?: number readonly details?: unknown readonly requestId?: string readonly traceId?: string constructor(message: string, code: string, status?: number, details?: unknown, requestId?: string | null, traceId?: string | null) { super(message) this.name = 'ApiResponseError' this.code = code this.status = status this.details = details this.requestId = requestId || undefined this.traceId = traceId || undefined } } export interface ApiErrorDetails { message: string code?: string requestId?: string traceId?: string } export interface ApiErrorDetailsOptions { preserveAuthenticationMessage?: boolean } export const http = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || '/api', timeout: 15_000, transformResponse: [(data: unknown) => parseApiJsonResponse(data)], }) type RetryableRequestConfig = InternalAxiosRequestConfig & { _authRetry?: boolean } type AuthRefreshHandler = () => Promise type AuthSessionExpiredHandler = () => void | Promise let authRefreshHandler: AuthRefreshHandler | null = null let authSessionExpiredHandler: AuthSessionExpiredHandler | null = null let activeRefresh: Promise | null = null export function registerAuthRefreshHandler(handler: AuthRefreshHandler) { authRefreshHandler = handler } export function registerAuthSessionExpiredHandler(handler: AuthSessionExpiredHandler) { authSessionExpiredHandler = handler } export async function refreshAuthenticatedSession(): Promise { if (!authRefreshHandler) throw new Error('认证刷新处理器尚未初始化') if (!activeRefresh) { activeRefresh = authRefreshHandler() .catch(async (refreshError) => { try { await authSessionExpiredHandler?.() } catch { // 会话已清理,跳转失败不应覆盖原始认证错误。 } throw refreshError }) .finally(() => { activeRefresh = null }) } return activeRefresh } http.interceptors.request.use((config) => { const token = localStorage.getItem('cc-admin-token') const isAnonymousAuthEndpoint = typeof config.url === 'string' && ['/auth/login', '/auth/refresh', '/auth/register'].includes(config.url) if (token && !isAnonymousAuthEndpoint) { config.headers.Authorization = `Bearer ${token}` } return config }) http.interceptors.response.use( (response) => response.data, async (error) => { const config = error.config as RetryableRequestConfig | undefined const isAuthEndpoint = typeof config?.url === 'string' && config.url.startsWith('/auth/') if (error.response?.status !== 401 || !config || config._authRetry || isAuthEndpoint || !authRefreshHandler) { return Promise.reject(error) } config._authRetry = true try { const nextToken = await refreshAuthenticatedSession() config.headers.Authorization = `Bearer ${nextToken}` return http.request(config) } catch { return Promise.reject(error) } }, ) export function unwrapApiResponse(response: ApiEnvelope): T { if (response?.code !== '200') { throw new ApiResponseError( response?.message || '接口请求失败', response?.code || 'UNKNOWN', undefined, response?.data, response?.requestId, response?.traceId, ) } return response.data } export function getApiErrorDetails( error: unknown, fallback = '接口请求失败', options: ApiErrorDetailsOptions = {}, ): ApiErrorDetails { if (error instanceof ApiResponseError) { const message = error.code === 'A0401' && !options.preserveAuthenticationMessage ? '登录状态已失效,请重新登录' : error.code === 'A0403' ? '当前账号无权访问该数据,请联系管理员确认权限' : error.code.startsWith('S05') ? '服务暂时不可用,请稍后重试' : error.message return { message, code: error.code, requestId: error.requestId, traceId: error.traceId } } if (axios.isAxiosError(error)) { const response = error.response?.data as { code?: string message?: string error?: string requestId?: string | null traceId?: string | null } | undefined const status = error.response?.status const statusMessage = status === 401 ? '登录状态已失效,请重新登录' : status === 403 ? '当前账号无权访问该数据,请联系管理员确认权限' : status === 413 ? '上传文件的请求体过大,服务器已拒绝;请压缩后重试,如文件未超过 5MB,请联系管理员检查网关上传限制' : status && status >= 500 ? '服务暂时不可用,请稍后重试' : undefined return { message: statusMessage || response?.message || response?.error || fallback, code: response?.code || (error.response?.status ? `HTTP_${error.response.status}` : undefined), requestId: response?.requestId || undefined, traceId: response?.traceId || undefined, } } // Local/runtime errors are not user-facing API messages and may contain stack details. return { message: fallback } } export function getApiErrorMessage(error: unknown, fallback = '接口请求失败'): string { return getApiErrorDetails(error, fallback).message }