ebcaf05625
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
604 lines
20 KiB
TypeScript
604 lines
20 KiB
TypeScript
import axios, { AxiosRequestConfig, AxiosResponse, isAxiosError } from 'axios';
|
||
import { mockData, type MockApiResponse } from './mock';
|
||
import { API_BASE_URL, DOCUMENT_URL } from '../config/api-config';
|
||
import { toastService } from '../components/ui/Toast';
|
||
|
||
/**
|
||
* API响应类型
|
||
*/
|
||
export type ApiResponse<T> = {
|
||
data?: T;
|
||
error?: string;
|
||
status: number;
|
||
headers?: Record<string, string>;
|
||
};
|
||
|
||
export type QueryParams = Record<string, string | number | boolean | undefined | number[] | string[]>;
|
||
|
||
// 获取 API 基础 URL (从配置文件导入)
|
||
// const API_BASE_URL = 'http://172.16.0.58:8008';
|
||
// const API_BASE_URL = 'http://nas.7bm.co:3000';
|
||
// const API_BASE_URL = 'http://172.18.0.100:3000';
|
||
// const API_BASE_URL = 'http://172.16.0.119:9000/admin';
|
||
|
||
// 调试:打印当前API_BASE_URL的值
|
||
console.log('🔍 axios-client.ts - API_BASE_URL:', API_BASE_URL);
|
||
|
||
// 文档URL前缀 (从配置文件导入)
|
||
// export const DOCUMENT_URL = 'http://nas.7bm.co:9000/docauditai/';
|
||
export { DOCUMENT_URL };
|
||
|
||
// 是否使用模拟数据(开发环境使用)
|
||
const USE_MOCK_DATA = false; // 设置为true使用模拟数据,避免API连接问题
|
||
|
||
// 设置超时时间(毫秒)
|
||
const DEFAULT_TIMEOUT = 30000; // 增加到30秒
|
||
|
||
// 创建 axios 实例
|
||
const axiosInstance = axios.create({
|
||
baseURL: API_BASE_URL,
|
||
timeout: DEFAULT_TIMEOUT, // 增加超时时间
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Accept': 'application/json'
|
||
}
|
||
});
|
||
|
||
// 请求白名单 - 这些接口不需要添加 Authorization 头
|
||
const AUTH_WHITELIST = [
|
||
'/auth/login',
|
||
'/auth/refresh',
|
||
'/auth/register',
|
||
'/oauth/token',
|
||
'/oauth/userinfo'
|
||
];
|
||
|
||
// 错误容忍白名单 - 这些接口即使返回 401/403 也不触发强制登出
|
||
const ERROR_TOLERANT_WHITELIST = [
|
||
'/admin/statistics/top-error-points',
|
||
'/admin/statistics/top-risk-users'
|
||
];
|
||
|
||
/**
|
||
* 检查请求URL是否在白名单中
|
||
*/
|
||
function isInAuthWhitelist(url?: string): boolean {
|
||
if (!url) return false;
|
||
return AUTH_WHITELIST.some(path => url.includes(path));
|
||
}
|
||
|
||
/**
|
||
* 检查请求URL是否在错误容忍白名单中
|
||
*/
|
||
function isInErrorTolerantWhitelist(url?: string): boolean {
|
||
if (!url) return false;
|
||
return ERROR_TOLERANT_WHITELIST.some(path => url.includes(path));
|
||
}
|
||
|
||
/**
|
||
* 请求拦截器 - 自动添加 Authorization 头
|
||
*/
|
||
axiosInstance.interceptors.request.use(
|
||
(config) => {
|
||
// ⭐ 检测 FormData,删除默认的 Content-Type,让 axios 自动处理
|
||
if (config.data instanceof FormData) {
|
||
console.log('📦 [Request Interceptor] 检测到FormData,删除Content-Type让axios自动处理');
|
||
delete config.headers['Content-Type'];
|
||
}
|
||
|
||
// 检查是否在白名单中
|
||
if (isInAuthWhitelist(config.url)) {
|
||
console.log('🔓 [Request Interceptor] URL在白名单中,跳过Authorization:', config.url);
|
||
return config;
|
||
}
|
||
|
||
// 从 localStorage 获取 token (浏览器环境)
|
||
if (typeof window !== 'undefined') {
|
||
const token = localStorage.getItem('access_token');
|
||
if (token) {
|
||
config.headers.Authorization = `Bearer ${token}`;
|
||
// console.log('🔑 [Request Interceptor] 添加Authorization头:', {
|
||
// url: config.url,
|
||
// method: config.method,
|
||
// hasToken: !!token,
|
||
// tokenPreview: token.substring(0, 20) + '...'
|
||
// });
|
||
} else {
|
||
console.warn('⚠️ [Request Interceptor] 没有找到access_token:', {
|
||
url: config.url,
|
||
localStorage: Object.keys(localStorage)
|
||
});
|
||
}
|
||
}
|
||
|
||
return config;
|
||
},
|
||
(error) => {
|
||
console.error('❌ [Request Interceptor] 请求拦截器错误:', error);
|
||
return Promise.reject(error);
|
||
}
|
||
);
|
||
|
||
/**
|
||
* 自定义错误类:表示需要重新登录
|
||
*/
|
||
export class AuthenticationError extends Error {
|
||
constructor(message = 'Token 已过期或无效,请重新登录') {
|
||
super(message);
|
||
this.name = 'AuthenticationError';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 响应拦截器 - 处理 401 错误(token 过期)
|
||
*/
|
||
axiosInstance.interceptors.response.use(
|
||
(response) => {
|
||
// console.log('✅ [Response Interceptor] 请求成功:', {
|
||
// url: response.config.url,
|
||
// status: response.status,
|
||
// statusText: response.statusText
|
||
// });
|
||
return response;
|
||
},
|
||
(error) => {
|
||
console.error('❌ [Response Interceptor] 请求失败:', {
|
||
url: error.config?.url,
|
||
status: error.response?.status,
|
||
statusText: error.response?.statusText,
|
||
data: error.response?.data
|
||
});
|
||
|
||
if (isAxiosError(error) && error.response?.status === 401) {
|
||
// 检查是否在错误容忍白名单中
|
||
const requestUrl = error.config?.url;
|
||
if (isInErrorTolerantWhitelist(requestUrl)) {
|
||
console.warn('⚠️ [容错白名单] 接口返回 401,但不触发强制登出:', requestUrl);
|
||
// 直接返回错误,不触发登出
|
||
return Promise.reject(error);
|
||
}
|
||
|
||
// Token 过期或无效
|
||
console.warn('⚠️ Token 已过期或无效,请重新登录');
|
||
console.warn('⚠️ 401 错误详情:', {
|
||
url: requestUrl,
|
||
status: error.response?.status,
|
||
statusText: error.response?.statusText,
|
||
data: error.response?.data,
|
||
headers: error.response?.headers
|
||
});
|
||
|
||
if (typeof window !== 'undefined') {
|
||
// 🌐 客户端环境:清除 localStorage 并跳转
|
||
localStorage.removeItem('access_token');
|
||
localStorage.removeItem('user_info');
|
||
window.location.href = '/login';
|
||
} else {
|
||
// 🖥️ 服务端环境:抛出特殊错误,由 loader/action 处理
|
||
console.warn('⚠️ [Server] 检测到 401 错误,抛出 AuthenticationError');
|
||
throw new AuthenticationError('Token 已过期或无效,请重新登录');
|
||
}
|
||
}
|
||
|
||
// 🔒 403 无权限错误处理
|
||
if (isAxiosError(error) && error.response?.status === 403) {
|
||
console.warn('⚠️ [403 Forbidden] 无权限访问:', error.config?.url);
|
||
|
||
// 修改错误消息为友好提示,避免显示原始的 "Request failed with status code 403"
|
||
// 注意:不在这里显示 toast,由组件层统一处理,避免重复提示
|
||
error.message = '无权限';
|
||
}
|
||
|
||
return Promise.reject(error);
|
||
}
|
||
);
|
||
|
||
// 最大重试次数
|
||
const MAX_RETRIES = 2;
|
||
|
||
/**
|
||
* 带重试功能的请求方法
|
||
* @param config Axios请求配置
|
||
* @param retries 当前重试次数
|
||
* @returns Axios响应
|
||
*/
|
||
async function axiosRetry(config: AxiosRequestConfig, retries = 0): Promise<AxiosResponse> {
|
||
try {
|
||
return await axiosInstance(config);
|
||
} catch (error) {
|
||
if (isAxiosError(error) && error.code === 'ECONNABORTED' && retries < MAX_RETRIES) {
|
||
console.log(`请求超时,第${retries + 1}次重试...`);
|
||
// 递增重试次数并重新发送请求
|
||
return axiosRetry(config, retries + 1);
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 将编码后的URL解码为可读格式
|
||
* 当前已注释掉日志,暂未使用此函数
|
||
* @param url 编码后的URL
|
||
* @returns 解码后的可读URL
|
||
* @unused 保留供将来使用
|
||
*/
|
||
// function decodeUrlForDisplay(url: string): string {
|
||
// try {
|
||
// // 首先解码整个URL
|
||
// const decodedUrl = decodeURIComponent(url);
|
||
//
|
||
// // 如果URL中包含@符号作为前缀,则移除它
|
||
// if (decodedUrl.startsWith('@')) {
|
||
// return decodedUrl.substring(1);
|
||
// }
|
||
//
|
||
// return decodedUrl;
|
||
// } catch (error) {
|
||
// // 如果解码失败,返回原始URL
|
||
// console.error('URL解码失败:', error);
|
||
// return url;
|
||
// }
|
||
// }
|
||
|
||
/**
|
||
* 构建完整的 API URL
|
||
*/
|
||
function buildUrl(endpoint: string, params?: QueryParams): string {
|
||
let fullUrl;
|
||
|
||
// 检查endpoint是否已经是完整URL
|
||
if (endpoint.startsWith('http')) {
|
||
fullUrl = endpoint;
|
||
} else {
|
||
// 处理相对路径的情况
|
||
if (API_BASE_URL === '/api') {
|
||
// 如果是相对路径,直接使用endpoint
|
||
const path = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
|
||
fullUrl = path;
|
||
} else {
|
||
// 确保API_BASE_URL格式正确
|
||
const baseUrl = API_BASE_URL.endsWith('/') ? API_BASE_URL.slice(0, -1) : API_BASE_URL;
|
||
const path = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
|
||
fullUrl = `${baseUrl}${path}`;
|
||
}
|
||
}
|
||
|
||
try {
|
||
// 创建URL对象
|
||
const url = new URL(fullUrl);
|
||
|
||
// 添加查询参数
|
||
if (params) {
|
||
Object.entries(params).forEach(([key, value]) => {
|
||
if (value !== undefined) {
|
||
// 处理数组参数:使用逗号分隔
|
||
if (Array.isArray(value)) {
|
||
url.searchParams.append(key, value.join(','));
|
||
} else {
|
||
url.searchParams.append(key, String(value));
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
return url.toString();
|
||
} catch (error) {
|
||
console.error('URL构建错误:', fullUrl, error);
|
||
throw new Error(`无法构建URL: ${fullUrl}, 错误: ${error}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取模拟响应数据
|
||
*/
|
||
function getMockResponse<T>(endpoint: string): ApiResponse<T> {
|
||
// console.log(`[开发模式] 使用模拟数据: ${endpoint}`);
|
||
|
||
// 移除开头的斜杠以便于匹配
|
||
const path = endpoint.startsWith('/') ? endpoint.substring(1) : endpoint;
|
||
|
||
// 查找匹配的模拟数据
|
||
for (const mockPath in mockData) {
|
||
const normalizedMockPath = mockPath.startsWith('/') ? mockPath.substring(1) : mockPath;
|
||
if (path === normalizedMockPath || path.startsWith(normalizedMockPath + '/')) {
|
||
// 如果是详情查询 (如 /evaluation_points/1)
|
||
if (path.includes('/') && path !== normalizedMockPath) {
|
||
const id = parseInt(path.split('/')[1]);
|
||
const mockDataItem = mockData[mockPath as keyof typeof mockData] as MockApiResponse<unknown>;
|
||
|
||
if (Array.isArray(mockDataItem.data)) {
|
||
const item = mockDataItem.data.find((item: {id: number}) => item.id === id);
|
||
|
||
if (item) {
|
||
return {
|
||
data: {
|
||
code: 0,
|
||
msg: "成功",
|
||
data: item
|
||
} as unknown as T,
|
||
status: 200
|
||
};
|
||
}
|
||
}
|
||
|
||
return { error: '未找到数据', status: 404 };
|
||
}
|
||
|
||
// 返回列表数据
|
||
return {
|
||
data: mockData[mockPath as keyof typeof mockData] as unknown as T,
|
||
status: 200
|
||
};
|
||
}
|
||
}
|
||
|
||
return { error: '没有匹配的模拟数据', status: 404 };
|
||
}
|
||
|
||
/**
|
||
* 扩展AxiosRequestConfig类型,以支持body参数
|
||
*/
|
||
interface ExtendedAxiosRequestConfig extends AxiosRequestConfig {
|
||
body?: string;
|
||
}
|
||
|
||
/**
|
||
* 通用 API 请求函数
|
||
*/
|
||
export async function apiRequest<T>(
|
||
endpoint: string,
|
||
options: ExtendedAxiosRequestConfig = {},
|
||
params?: QueryParams
|
||
): Promise<ApiResponse<T>> {
|
||
// 如果使用模拟数据,直接返回模拟响应
|
||
if (USE_MOCK_DATA) {
|
||
return getMockResponse<T>(endpoint);
|
||
}
|
||
|
||
// console.log('api-base-url-----------',API_BASE_URL)
|
||
|
||
try {
|
||
// 构建 URL
|
||
const url = buildUrl(endpoint, params);
|
||
|
||
// 只有在 options.headers 存在时才处理,否则让拦截器处理
|
||
let headers = options.headers;
|
||
if (headers) {
|
||
// 设置默认请求头(仅当 headers 已存在时)
|
||
if (!headers['Content-Type'] && options.method !== 'GET') {
|
||
headers['Content-Type'] = 'application/json';
|
||
}
|
||
if (!headers['Accept']) {
|
||
headers['Accept'] = 'application/json';
|
||
}
|
||
}
|
||
|
||
// 针对 PostgREST 的额外处理
|
||
if (endpoint.includes('evaluation_point_groups') && (options.method === 'POST' || options.method === 'PATCH')) {
|
||
// console.log('使用 PostgREST 特定配置处理请求');
|
||
// 确保请求体是有效的 JSON 对象
|
||
if (options.body && typeof options.body === 'string') {
|
||
try {
|
||
JSON.parse(options.body); // 验证 JSON 是否有效
|
||
} catch (e) {
|
||
console.error('请求体不是有效的 JSON:', options.body);
|
||
throw new Error('请求体必须是有效的 JSON');
|
||
}
|
||
}
|
||
}
|
||
|
||
// console.log(`📦 axios-client.ts->请求URL: ${url}`);
|
||
// console.log(`axios-client.ts->发送 ${options.method || 'GET'} 请求到: ${url}`);
|
||
|
||
// 处理body参数,转换为data
|
||
if (options.body) {
|
||
// console.log(`axios-client.ts->请求体: \n${options.body}`);
|
||
options.data = options.body;
|
||
} else if (options.data) {
|
||
// console.log(`axios-client.ts->请求体: \n${typeof options.data === 'string' ? options.data : JSON.stringify(options.data)}`);
|
||
}
|
||
|
||
// 构建请求配置
|
||
// 如果没有传入 headers,就不设置 headers,让拦截器自动添加
|
||
const config: AxiosRequestConfig = {
|
||
...options,
|
||
url,
|
||
// 确保使用默认超时时间
|
||
timeout: options.timeout || DEFAULT_TIMEOUT
|
||
};
|
||
|
||
// 只有在 headers 存在时才设置
|
||
if (headers) {
|
||
config.headers = headers;
|
||
|
||
// 🔍 调试:打印 Authorization 头
|
||
if (headers['Authorization']) {
|
||
// console.log('🔑 [apiRequest] 请求包含 Authorization 头:', headers['Authorization'].substring(0, 20) + '...');
|
||
} else {
|
||
console.warn('⚠️ [apiRequest] 请求缺少 Authorization 头!headers:', Object.keys(headers));
|
||
}
|
||
}
|
||
// console.log(`📦 axios-client.ts->请求配置: \n${JSON.stringify(config)}`);
|
||
|
||
// 删除body属性,避免axios警告
|
||
if ('body' in config) {
|
||
delete (config as ExtendedAxiosRequestConfig).body;
|
||
}
|
||
|
||
// 使用带重试功能的请求方法
|
||
const response: AxiosResponse = await axiosRetry(config);
|
||
|
||
// 收集响应头信息
|
||
const responseHeaders: Record<string, string> = {};
|
||
Object.entries(response.headers).forEach(([key, value]) => {
|
||
if (typeof value === 'string') {
|
||
responseHeaders[key] = value;
|
||
}
|
||
});
|
||
|
||
// 打印响应信息
|
||
// console.log(`响应状态: ${response.status}`);
|
||
// console.log(`响应头:`, responseHeaders);
|
||
// console.log(`响应体:`, response.data);
|
||
|
||
// 检查 PostgREST 特定错误
|
||
if (response.status >= 400) {
|
||
if (response.status === 400) {
|
||
console.error('PostgREST 错误 - 无效请求:', response.data);
|
||
return {
|
||
error: response.data?.message || response.data?.msg || '无效的请求格式,请检查数据格式是否正确',
|
||
status: response.status,
|
||
headers: responseHeaders
|
||
};
|
||
} else if (response.status === 401) {
|
||
console.error('PostgREST 错误 - 未授权:', response.data);
|
||
return {
|
||
error: response.data?.message || response.data?.msg || '未授权,请检查认证信息',
|
||
status: response.status,
|
||
headers: responseHeaders
|
||
};
|
||
} else if (response.status === 403) {
|
||
console.error('PostgREST 错误 - 禁止访问:', response.data);
|
||
return {
|
||
error: response.data?.message || response.data?.msg || '没有权限执行此操作',
|
||
status: response.status,
|
||
headers: responseHeaders
|
||
};
|
||
} else if (response.status === 404) {
|
||
console.error('PostgREST 错误 - 资源不存在:', response.data);
|
||
return {
|
||
error: response.data?.message || response.data?.msg || '请求的资源不存在',
|
||
status: response.status,
|
||
headers: responseHeaders
|
||
};
|
||
} else {
|
||
console.error(`HTTP请求失败: ${response.status} - ${url}`, response.data);
|
||
return {
|
||
error: response.data?.message || response.data?.msg || `请求失败: ${response.status}`,
|
||
status: response.status,
|
||
headers: responseHeaders
|
||
};
|
||
}
|
||
}
|
||
|
||
// 检查API返回的状态码
|
||
const data = response.data;
|
||
// 修复:支持code=0(PostgREST)和code=200(RBAC API)两种成功响应
|
||
// 🔑 只有当 code 是数字类型时才认为是 API 状态码,字符串 code 是业务编码
|
||
if (data && typeof data === 'object' && 'code' in data &&
|
||
typeof data.code === 'number' && data.code !== 0 && data.code !== 200) {
|
||
const errorMessage = data.message || data.msg || '未知错误';
|
||
console.error(`API请求失败: ${errorMessage} - ${url}`);
|
||
|
||
// 🔑 检测令牌过期错误
|
||
const isTokenExpired = errorMessage.includes('令牌已过期') ||
|
||
errorMessage.includes('令牌') ||
|
||
errorMessage.includes('token') ||
|
||
errorMessage.includes('expired') ||
|
||
errorMessage.includes('认证') ||
|
||
errorMessage.includes('未授权');
|
||
|
||
if (isTokenExpired) {
|
||
console.error('🔑 [API Client] 检测到令牌过期,准备清除会话并重定向...');
|
||
|
||
// 只在客户端执行重定向
|
||
if (typeof window !== 'undefined') {
|
||
console.error('🔑 [API Client] 客户端环境,清除 localStorage 并重定向到登录页');
|
||
// 清除所有认证相关数据
|
||
localStorage.removeItem('access_token');
|
||
localStorage.removeItem('user_info');
|
||
sessionStorage.clear();
|
||
|
||
// 重定向到登录页
|
||
window.location.href = '/login?expired=true';
|
||
}
|
||
}
|
||
|
||
return {
|
||
error: errorMessage,
|
||
status: response.status,
|
||
headers: responseHeaders
|
||
};
|
||
}
|
||
|
||
return {
|
||
data,
|
||
status: response.status,
|
||
headers: responseHeaders
|
||
};
|
||
} catch (error: unknown) {
|
||
console.error('API请求失败:', error);
|
||
|
||
if (isAxiosError(error)) {
|
||
const status = error.response?.status || 500;
|
||
const errorData = error.response?.data;
|
||
|
||
// 如果超时或网络错误,使用模拟数据(仅开发环境)
|
||
if (error.code === 'ECONNABORTED' && process.env.NODE_ENV !== 'production') {
|
||
console.warn('自动使用模拟数据作为回退');
|
||
return getMockResponse<T>(endpoint);
|
||
}
|
||
|
||
return {
|
||
error: errorData?.message || errorData?.msg || error.message || '未知错误',
|
||
status
|
||
};
|
||
}
|
||
|
||
// 如果超时或网络错误,使用模拟数据(仅开发环境)
|
||
if (process.env.NODE_ENV !== 'production') {
|
||
console.warn('自动使用模拟数据作为回退');
|
||
return getMockResponse<T>(endpoint);
|
||
}
|
||
|
||
return {
|
||
error: error instanceof Error ? error.message : '未知错误',
|
||
status: 500
|
||
};
|
||
}
|
||
}
|
||
|
||
// GET请求简化方法
|
||
export async function get<T>(endpoint: string, params?: QueryParams): Promise<ApiResponse<T>> {
|
||
return apiRequest<T>(endpoint, { method: 'GET' }, params);
|
||
}
|
||
|
||
// POST请求简化方法
|
||
export async function post<T>(endpoint: string, data?: unknown, params?: QueryParams): Promise<ApiResponse<T>> {
|
||
return apiRequest<T>(endpoint, { method: 'POST', data }, params);
|
||
}
|
||
|
||
// PUT请求简化方法
|
||
export async function put<T>(endpoint: string, data?: unknown, params?: QueryParams): Promise<ApiResponse<T>> {
|
||
return apiRequest<T>(endpoint, { method: 'PUT', data }, params);
|
||
}
|
||
|
||
// PATCH请求简化方法
|
||
export async function patch<T>(endpoint: string, data?: unknown, params?: QueryParams): Promise<ApiResponse<T>> {
|
||
return apiRequest<T>(endpoint, { method: 'PATCH', data }, params);
|
||
}
|
||
|
||
// DELETE请求简化方法
|
||
export async function del<T>(endpoint: string, params?: QueryParams): Promise<ApiResponse<T>> {
|
||
return apiRequest<T>(endpoint, { method: 'DELETE' }, params);
|
||
}
|
||
|
||
// 下载文件的方法
|
||
export async function downloadFile(path: string): Promise<Blob> {
|
||
// 使用 PDF 代理路由获取文件,自动添加 JWT 认证
|
||
const downloadUrl = `/api/pdf-proxy?path=${encodeURIComponent(path)}`;
|
||
|
||
try {
|
||
// console.log(`📦 axios-client.ts->下载文件: ${downloadUrl}`);
|
||
const response = await fetch(downloadUrl);
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`下载失败: ${response.status} ${response.statusText}`);
|
||
}
|
||
|
||
return await response.blob();
|
||
} catch (error) {
|
||
console.error('下载文件失败:', error);
|
||
throw error;
|
||
}
|
||
} |