// ============================================================ // ZCLAW Admin V2 — Axios 实例 + JWT 拦截器 // ============================================================ // // 认证策略: 主路径使用 HttpOnly cookie(浏览器自动附加), // Authorization header 作为 fallback 保留用于 API 客户端。 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 }) // ── 请求拦截器:附加 Authorization header fallback ────────── request.interceptors.request.use((config: InternalAxiosRequestConfig) => { const token = useAuthStore.getState().token if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) // ── 响应拦截器:401 自动刷新 ────────────────────────────── let isRefreshing = false let pendingRequests: Array<(token: string) => void> = [] function onTokenRefreshed(newToken: string) { pendingRequests.forEach((cb) => cb(newToken)) pendingRequests = [] } request.interceptors.response.use( (response) => response, async (error: AxiosError<{ error?: string; message?: string }>) => { const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean } // 401 → 尝试刷新 Token if (error.response?.status === 401 && !originalRequest._retry) { const store = useAuthStore.getState() if (!store.refreshToken) { store.logout() window.location.href = '/login' return Promise.reject(error) } if (isRefreshing) { return new Promise((resolve) => { pendingRequests.push((newToken: string) => { originalRequest.headers.Authorization = `Bearer ${newToken}` resolve(request(originalRequest)) }) }) } originalRequest._retry = true isRefreshing = true try { const res = await axios.post(`${BASE_URL}/auth/refresh`, null, { headers: { Authorization: `Bearer ${store.refreshToken}` }, withCredentials: true, // 发送 refresh cookie }) const newToken = res.data.token as string const newRefreshToken = res.data.refresh_token as string // 更新内存中的 token(实际认证通过 HttpOnly cookie,浏览器已自动更新) store.setToken(newToken) if (newRefreshToken) { store.setRefreshToken(newRefreshToken) } onTokenRefreshed(newToken) originalRequest.headers.Authorization = `Bearer ${newToken}` return request(originalRequest) } catch { store.logout() window.location.href = '/login' return Promise.reject(error) } 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)) } return Promise.reject(error) }, ) export default request /** 将 AbortSignal 注入 Axios config,用于 TanStack Query 的请求取消 */ export function withSignal(config: AxiosRequestConfig = {}, signal?: AbortSignal): AxiosRequestConfig { if (signal) { return { ...config, signal } } return config }