import axios, { type AxiosError, type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from 'axios' import { clearTokens, getAccessToken, getRefreshToken, setTokens } from '@/utils/token' /** 后端统一响应包裹:{ code, message, data },code=0 成功。 */ interface ApiEnvelope { code: number message: string data: T } /** 业务错误(code 为 HTTP 状态码 * 100,如 40100/40300/50000)。 */ export class ApiError extends Error { constructor( public code: number, message: string, ) { super(message) this.name = 'ApiError' } } /** 会话过期回调(token 刷新失败时触发),由应用启动时注册。 */ let onAuthExpired: (() => void) | null = null export function setAuthExpiredHandler(handler: () => void): void { onAuthExpired = handler } interface HttpClientOptions { baseURL?: string timeout?: number } /** * 面向对象的 Axios 封装: * - 请求拦截器:注入 Authorization 头 * - 响应拦截器:拆 { code, message, data },code=0 返回 data;401 自动刷新重放;其余抛 ApiError */ export class HttpClient { private instance: AxiosInstance /** 无拦截器的裸实例,供刷新令牌等内部请求使用(避免递归拦截)。 */ private rawInstance: AxiosInstance private refreshing: Promise | null = null constructor(options: HttpClientOptions = {}) { const baseURL = options.baseURL ?? import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000/api/v1' const timeout = options.timeout ?? 10000 this.instance = axios.create({ baseURL, timeout }) this.rawInstance = axios.create({ baseURL, timeout }) this.setupInterceptors() } private setupInterceptors(): void { this.instance.interceptors.request.use((config) => { const token = getAccessToken() if (token) config.headers.Authorization = `Bearer ${token}` return config }) this.instance.interceptors.response.use( (response) => { const body = response.data as ApiEnvelope if (body.code === 0) return body.data as AxiosResponse return Promise.reject(new ApiError(body.code, body.message)) }, async (error: AxiosError) => { const config = error.config as (AxiosRequestConfig & { _retry?: boolean }) | undefined const url = config?.url ?? '' const isAuthEndpoint = url.includes('/auth/login') || url.includes('/auth/refresh') // 401 自动刷新并重放一次(登录/刷新接口自身 401 不重放,_retry 防死循环) if (error.response?.status === 401 && config && !config._retry && !isAuthEndpoint) { config._retry = true try { const token = await this.refresh() config.headers = config.headers ?? {} config.headers.Authorization = `Bearer ${token}` return this.instance.request(config) } catch { clearTokens() onAuthExpired?.() } } const body = error.response?.data as ApiEnvelope | undefined return Promise.reject(new ApiError(body?.code ?? -1, body?.message ?? error.message)) }, ) } /** 刷新令牌(并发去重),返回新 accessToken。 */ private refresh(): Promise { if (!this.refreshing) { this.refreshing = this.doRefresh().finally(() => { this.refreshing = null }) } return this.refreshing } private async doRefresh(): Promise { const refreshToken = getRefreshToken() if (!refreshToken) throw new ApiError(40100, '未登录') const { data } = await this.rawInstance.post>( '/auth/refresh', { refreshToken }, ) if (data.code !== 0) throw new ApiError(data.code, data.message) setTokens(data.data.accessToken, data.data.refreshToken) return data.data.accessToken } request(config: AxiosRequestConfig): Promise { return this.instance.request(config) as Promise } get(url: string, config?: AxiosRequestConfig): Promise { return this.request({ ...config, method: 'GET', url }) } post(url: string, data?: unknown, config?: AxiosRequestConfig): Promise { return this.request({ ...config, method: 'POST', url, data }) } put(url: string, data?: unknown, config?: AxiosRequestConfig): Promise { return this.request({ ...config, method: 'PUT', url, data }) } patch(url: string, data?: unknown, config?: AxiosRequestConfig): Promise { return this.request({ ...config, method: 'PATCH', url, data }) } delete(url: string, config?: AxiosRequestConfig): Promise { return this.request({ ...config, method: 'DELETE', url }) } } /** 全局单例,业务直接使用 http.get/post/... */ export const http = new HttpClient()