Files
base/apps/web/src/api/client.ts
iven 3772afd987 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 + 基座前端 + 通用组件
2026-06-13 00:32:50 +08:00

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;