160 lines
5.0 KiB
TypeScript
160 lines
5.0 KiB
TypeScript
/**
|
||
* 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";
|
||
}
|
||
}
|