删除内容: - 前端: health/(67文件), ai/(2文件), Copilot, MediaPicker, 相关API/Store/Hook - 后端: wechat_handler, wechat_service, wechat_user entity, analytics handler, ai_workflow_seed - 配置: WechatConfig, AppConfig.wechat, AuthState wechat 字段 - 启动: 微信凭据检查块, ensure_ai_workflows() 调用 - 迁移: 新增 m20260613_000170_drop_wechat_users.rs - 脚本: api_test_health_alert.py, api_test_mp.py, mpsync.sh/ps1 - E2E: health-data page, flows/ 目录 保留: erp-core/auth/workflow/message/config/plugin + 基座前端 + 通用组件
262 lines
8.2 KiB
TypeScript
262 lines
8.2 KiB
TypeScript
import axios from "axios";
|
|
import { message as antMessage } from "antd";
|
|
|
|
// 请求缓存:短时间内相同请求复用结果
|
|
interface CacheEntry {
|
|
data: unknown;
|
|
timestamp: number;
|
|
}
|
|
|
|
const requestCache = new Map<string, CacheEntry>();
|
|
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<typeof setTimeout> | 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;
|