// ============================================================ // ZCLAW Admin V2 — Axios 实例 + 认证拦截器 // ============================================================ // // 认证策略: HttpOnly cookie(浏览器自动附加到同域请求)。 // 所有 token 均通过 cookie 传递,前端 JS 无法读取。 // withCredentials: true 确保浏览器发送 HttpOnly cookie。 import axios from 'axios' import type { AxiosError, InternalAxiosRequestConfig } from 'axios' import type { AxiosRequestConfig } from 'axios' import type { ApiError } from '@/types' import { useAuthStore } from '@/stores/authStore' const BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api/v1' const TIMEOUT_MS = 30_000 /** API 业务错误 */ export class ApiRequestError extends Error { constructor( public status: number, public body: ApiError, ) { super(body.message || `Request failed with status ${status}`) this.name = 'ApiRequestError' } } const request = axios.create({ baseURL: BASE_URL, timeout: TIMEOUT_MS, headers: { 'Content-Type': 'application/json' }, withCredentials: true, // 发送 HttpOnly cookies }) // ── 响应拦截器:401 自动刷新 cookie ────────────────────── let isRefreshing = false let pendingRequests: Array<{ resolve: (value: unknown) => void reject: (error: unknown) => void }> = [] function onTokenRefreshed() { pendingRequests.forEach(({ resolve }) => resolve(undefined)) pendingRequests = [] } function onTokenRefreshFailed(error: unknown) { pendingRequests.forEach(({ reject }) => reject(error)) pendingRequests = [] } request.interceptors.response.use( (response) => response, async (error: AxiosError<{ error?: string; message?: string }>) => { const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean } // 401 -> 尝试刷新 cookie if (error.response?.status === 401 && !originalRequest._retry) { const store = useAuthStore.getState() if (!store.isAuthenticated) { store.logout() window.location.href = '/login' return Promise.reject(error) } if (isRefreshing) { return new Promise((resolve, reject) => { pendingRequests.push({ resolve: () => resolve(request(originalRequest)), reject, }) }) } originalRequest._retry = true isRefreshing = true try { // Refresh endpoint uses HttpOnly cookie (sent automatically via withCredentials) await axios.post(`${BASE_URL}/auth/refresh`, null, { withCredentials: true, }) // Cookie is refreshed server-side; browser has the new cookie automatically onTokenRefreshed() return request(originalRequest) } catch (refreshError) { // Refresh failed — reject all pending requests to prevent hangs onTokenRefreshFailed(refreshError) store.logout() window.location.href = '/login' return Promise.reject(refreshError) } finally { isRefreshing = false } } // 构造 ApiRequestError if (error.response) { const body: ApiError = { error: error.response.data?.error || 'unknown', message: error.response.data?.message || `请求失败 (${error.response.status})`, status: error.response.status, } return Promise.reject(new ApiRequestError(error.response.status, body)) } // 网络错误统一包装为 ApiRequestError return Promise.reject( new ApiRequestError(0, { error: 'network_error', message: error.message || '网络连接失败,请检查网络后重试', status: 0, }) ) }, ) export default request /** 将 AbortSignal 注入 Axios config,用于 TanStack Query 的请求取消 */ export function withSignal(config: AxiosRequestConfig = {}, signal?: AbortSignal): AxiosRequestConfig { if (signal) { return { ...config, signal } } return config }