import axios from "axios"; import { message as antMessage } from "antd"; // 请求缓存:短时间内相同请求复用结果 interface CacheEntry { data: unknown; timestamp: number; } const requestCache = new Map(); const CACHE_TTL = 5000; // 5 秒缓存 function getCacheKey(config: { url?: string; params?: unknown; method?: string; }): string { return `${config.method || "get"}:${config.url || ""}:${JSON.stringify(config.params || {})}`; } const defaultAdapter = axios.getAdapter(axios.defaults.adapter); const client = axios.create({ baseURL: "/api/v1", timeout: 10000, headers: { "Content-Type": "application/json" }, adapter: (config) => { // GET 请求检查缓存 if (config.method === "get" && config.url) { const key = getCacheKey(config); const entry = requestCache.get(key); if (entry && Date.now() - entry.timestamp < CACHE_TTL) { return Promise.resolve({ data: entry.data, status: 200, statusText: "OK (cached)", headers: new axios.AxiosHeaders(), config, }); } } return defaultAdapter(config); }, }); // Decode JWT payload without external library function decodeJwtPayload( token: string, ): { exp?: number; sub?: string } | null { try { const parts = token.split("."); if (parts.length !== 3) return null; const payload = JSON.parse( atob(parts[1].replace(/-/g, "+").replace(/_/g, "/")), ); return payload; } catch { return null; } } // Check if token is expired or about to expire (within 30s buffer) function isTokenExpiringSoon(token: string): boolean { const payload = decodeJwtPayload(token); if (!payload?.exp) return true; return Date.now() / 1000 > payload.exp - 30; } // Request interceptor: attach access token + proactive refresh client.interceptors.request.use(async (config) => { const token = localStorage.getItem("access_token"); if (token) { // If token is about to expire, proactively refresh before sending the request if (isTokenExpiringSoon(token)) { const refreshToken = localStorage.getItem("refresh_token"); if (refreshToken && !isRefreshing) { isRefreshing = true; try { const { data } = await axios.post("/api/v1/auth/refresh", { refresh_token: refreshToken, }); const newAccess = data.data.access_token; const newRefresh = data.data.refresh_token; // 验证新 token 的用户身份一致 const currentUserSub = decodeJwtPayload(token)?.sub; const newTokenSub = decodeJwtPayload(newAccess)?.sub; if (currentUserSub && newTokenSub && currentUserSub !== newTokenSub) { localStorage.removeItem("access_token"); localStorage.removeItem("refresh_token"); localStorage.removeItem("user"); window.location.hash = "/login"; return Promise.reject(new Error("身份验证失败,请重新登录")); } localStorage.setItem("access_token", newAccess); localStorage.setItem("refresh_token", newRefresh); processQueue(null, newAccess); config.headers.Authorization = `Bearer ${newAccess}`; return config; } catch { processQueue(new Error("refresh failed"), null); // Continue with old token, let 401 handler deal with it } finally { isRefreshing = false; } } } config.headers.Authorization = `Bearer ${token}`; } return config; }); // 响应拦截器:缓存 GET 响应 + 自动刷新 token client.interceptors.response.use( (response) => { // 缓存 GET 响应 if (response.config.method === "get" && response.config.url) { const key = getCacheKey(response.config); requestCache.set(key, { data: response.data, timestamp: Date.now() }); } return response; }, async (error) => { const originalRequest = error.config; if ( error.response?.status === 401 && !originalRequest._retry && !originalRequest.url?.includes("/auth/login") ) { if (isRefreshing) { return new Promise((resolve, reject) => { failedQueue.push({ resolve, reject }); }).then((token) => { originalRequest.headers.Authorization = `Bearer ${token}`; return client(originalRequest); }); } originalRequest._retry = true; isRefreshing = true; try { const refreshToken = localStorage.getItem("refresh_token"); if (!refreshToken) throw new Error("No refresh token"); const { data } = await axios.post("/api/v1/auth/refresh", { refresh_token: refreshToken, }); const newAccessToken = data.data.access_token; const newRefreshToken = data.data.refresh_token; // 验证新 token 的用户身份与当前用户一致,防止并发场景下身份切换 const currentToken = localStorage.getItem("access_token"); const currentUserSub = currentToken ? decodeJwtPayload(currentToken)?.sub : null; const newTokenSub = decodeJwtPayload(newAccessToken)?.sub; if (currentUserSub && newTokenSub && currentUserSub !== newTokenSub) { // 身份不一致,强制登出 localStorage.removeItem("access_token"); localStorage.removeItem("refresh_token"); localStorage.removeItem("user"); window.location.hash = "/login"; return Promise.reject(new Error("身份验证失败,请重新登录")); } localStorage.setItem("access_token", newAccessToken); localStorage.setItem("refresh_token", newRefreshToken); processQueue(null, newAccessToken); originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; return client(originalRequest); } catch (refreshError) { processQueue(refreshError, null); localStorage.removeItem("access_token"); localStorage.removeItem("refresh_token"); window.location.hash = "/login"; return Promise.reject(refreshError); } finally { isRefreshing = false; } } return Promise.reject(error); }, ); // 全局错误提示(仅对未被组件处理的错误显示) let globalErrorTimer: ReturnType | null = null; function showGlobalError(msg: string) { // 防止短时间内弹出大量相同提示 if (globalErrorTimer) return; antMessage.error(msg, 3); globalErrorTimer = setTimeout(() => { globalErrorTimer = null; }, 3000); } // 全局错误拦截 — 在响应拦截器之后、组件 catch 之前执行 // 组件可通过 axios config 中设置 skipGlobalError: true 来抑制全局提示 declare module "axios" { interface AxiosRequestConfig { skipGlobalError?: boolean; } interface InternalAxiosRequestConfig { skipGlobalError?: boolean; } } client.interceptors.response.use( (response) => response, (error) => { if (error.config?.skipGlobalError) { return Promise.reject(error); } if (!error.response) { showGlobalError("网络连接异常,请检查网络"); } else if (error.response.status === 403) { // 403 通常是权限不足,不全局提示 — 组件层通过 AuthButton 已隐藏操作入口 } else if (error.response.status === 404) { // 404 通常由组件自行处理(如跳转),不全局提示 } else if (error.response.status >= 500) { showGlobalError("服务器异常,请稍后重试"); } return Promise.reject(error); }, ); let isRefreshing = false; let failedQueue: Array<{ resolve: (token: string) => void; reject: (error: unknown) => void; }> = []; function processQueue(error: unknown, token: string | null) { failedQueue.forEach(({ resolve, reject }) => { if (token) resolve(token); else reject(error); }); failedQueue = []; } // 清除缓存(登录/登出时调用) export function clearApiCache() { requestCache.clear(); } // 通用错误处理:提取后端错误消息并展示 export function handleApiError(err: unknown, fallback = "操作失败"): string { const msg = (err as { response?: { data?: { message?: string } } })?.response?.data ?.message || fallback; antMessage.error(msg); return msg; } export default client;