Files
Neo-ZQYY/apps/admin-web/src/api/client.ts

160 lines
5.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* axios 实例 & JWT 拦截器。
*
* - 请求拦截器:自动从 localStorage 读取 access_token 并附加 Authorization header
* - 响应拦截器:遇到 401 时尝试用 refresh_token 刷新,刷新失败则清除令牌并跳转 /login
* - 并发刷新保护:多个请求同时 401 时只触发一次 refresh其余排队等待
*/
import axios, {
type AxiosError,
type AxiosRequestConfig,
type InternalAxiosRequestConfig,
} from "axios";
/* ------------------------------------------------------------------ */
/* 常量 */
/* ------------------------------------------------------------------ */
const ACCESS_TOKEN_KEY = "access_token";
const REFRESH_TOKEN_KEY = "refresh_token";
/* ------------------------------------------------------------------ */
/* axios 实例 */
/* ------------------------------------------------------------------ */
export const apiClient = axios.create({
baseURL: "/api",
});
/* ------------------------------------------------------------------ */
/* 请求拦截器 — 附加 JWT */
/* ------------------------------------------------------------------ */
apiClient.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const token = localStorage.getItem(ACCESS_TOKEN_KEY);
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
/* ------------------------------------------------------------------ */
/* 响应拦截器 — 401 自动刷新 */
/* ------------------------------------------------------------------ */
/** 是否正在刷新中 */
let isRefreshing = false;
/** 等待刷新完成的请求队列 */
let pendingQueue: {
resolve: (token: string) => void;
reject: (err: unknown) => void;
}[] = [];
/** 刷新完成后,依次重放排队的请求 */
function processPendingQueue(token: string | null, error: unknown) {
pendingQueue.forEach(({ resolve, reject }) => {
if (token) {
resolve(token);
} else {
reject(error);
}
});
pendingQueue = [];
}
apiClient.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as AxiosRequestConfig & {
_retried?: boolean;
};
// 非 401、无原始请求、或已重试过 → 直接抛出
if (
error.response?.status !== 401 ||
!originalRequest ||
originalRequest._retried
) {
return Promise.reject(error);
}
// 刷新端点本身 401 → 不再递归刷新
if (originalRequest.url === "/auth/refresh") {
clearTokensAndRedirect();
return Promise.reject(error);
}
// 已有刷新请求在飞 → 排队等待
if (isRefreshing) {
return new Promise<string>((resolve, reject) => {
pendingQueue.push({ resolve, reject });
}).then((newToken) => {
originalRequest.headers = {
...originalRequest.headers,
Authorization: `Bearer ${newToken}`,
};
originalRequest._retried = true;
return apiClient(originalRequest);
});
}
// 发起刷新
isRefreshing = true;
originalRequest._retried = true;
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY);
if (!refreshToken) {
isRefreshing = false;
processPendingQueue(null, error);
clearTokensAndRedirect();
return Promise.reject(error);
}
try {
// 用独立 axios 调用避免被自身拦截器干扰
const { data } = await axios.post<{
access_token: string;
refresh_token: string;
}>("/api/auth/refresh", { refresh_token: refreshToken });
localStorage.setItem(ACCESS_TOKEN_KEY, data.access_token);
localStorage.setItem(REFRESH_TOKEN_KEY, data.refresh_token);
processPendingQueue(data.access_token, null);
// 重放原始请求
originalRequest.headers = {
...originalRequest.headers,
Authorization: `Bearer ${data.access_token}`,
};
return apiClient(originalRequest);
} catch (refreshError) {
processPendingQueue(null, refreshError);
clearTokensAndRedirect();
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
},
);
/* ------------------------------------------------------------------ */
/* 辅助 */
/* ------------------------------------------------------------------ */
function clearTokensAndRedirect() {
localStorage.removeItem(ACCESS_TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
// 派发自定义事件,让 authStore 监听并重置状态
// 避免直接 import authStore 导致循环依赖
window.dispatchEvent(new Event("auth:force-logout"));
// 避免在登录页反复跳转
if (window.location.pathname !== "/login") {
window.location.href = "/login";
}
}