chore: 干净 ERP 基座 — 删除 health/ai/wechat 业务代码
删除内容: - 前端: 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 + 基座前端 + 通用组件
This commit is contained in:
261
apps/web/src/api/client.ts
Normal file
261
apps/web/src/api/client.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user