You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
596 lines
15 KiB
596 lines
15 KiB
/** |
|
* Axios API 配置 |
|
* 统一管理 HTTP 请求 |
|
* |
|
* 功能特性: |
|
* - 自动添加 Token |
|
* - Token 自动刷新 |
|
* - 请求重试机制 |
|
* - 请求取消功能 |
|
* - 统一错误处理 |
|
* - 请求/响应日志 |
|
* - Loading 状态管理 |
|
*/ |
|
|
|
import axios, { |
|
AxiosError, |
|
AxiosRequestConfig, |
|
AxiosResponse, |
|
InternalAxiosRequestConfig, |
|
CancelTokenSource, |
|
AxiosRequestHeaders, |
|
} from 'axios'; |
|
import AsyncStorage from '@react-native-async-storage/async-storage'; |
|
import { router } from 'expo-router'; |
|
import { config } from '../config'; |
|
import { transformRequest, parseResponse } from './helper'; |
|
import { cloneDeep, pick, includes } from 'lodash-es'; |
|
import md5 from 'md5'; |
|
|
|
/** |
|
* API 响应数据结构 |
|
*/ |
|
export interface ApiResponse<T = any> { |
|
code: number; |
|
message: string; |
|
data: T; |
|
success?: boolean; |
|
} |
|
|
|
/** |
|
* API 错误响应 |
|
*/ |
|
export interface ApiError { |
|
code: number; |
|
message: string; |
|
errors?: Record<string, string[]>; |
|
} |
|
|
|
/** |
|
* 请求配置扩展 |
|
*/ |
|
export interface RequestConfig extends AxiosRequestConfig { |
|
/** 是否显示 loading */ |
|
showLoading?: boolean; |
|
/** 是否显示错误提示 */ |
|
showError?: boolean; |
|
/** 是否重试 */ |
|
retry?: boolean; |
|
/** 重试次数 */ |
|
retryCount?: number; |
|
/** 是否需要 token */ |
|
requiresAuth?: boolean; |
|
/** 自定义错误处理 */ |
|
customErrorHandler?: (error: AxiosError<ApiError>) => void; |
|
} |
|
|
|
// API 基础配置 |
|
const API_CONFIG = { |
|
baseURL: config.api.baseURL, |
|
timeout: config.api.timeout, |
|
headers: { |
|
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', |
|
Accept: 'application/json, application/xml, text/play, text/html, *.*', |
|
}, |
|
}; |
|
|
|
// 创建 axios 实例 |
|
const api = axios.create(API_CONFIG); |
|
|
|
// 请求队列(用于取消请求) |
|
const pendingRequests = new Map<string, CancelTokenSource>(); |
|
|
|
// 是否正在刷新 token |
|
let isRefreshing = false; |
|
|
|
// 刷新 token 时的请求队列 |
|
let refreshSubscribers: Array<(token: string) => void> = []; |
|
|
|
/** |
|
* 生成请求唯一标识 |
|
*/ |
|
function generateRequestKey(config: InternalAxiosRequestConfig): string { |
|
const cmdId = config.headers.cmdId || config.url; |
|
const data = cloneDeep(config.method === 'post' ? config.data : config.params); |
|
return `${cmdId}&${data ? md5(JSON.stringify(data)) : ''}`; |
|
} |
|
|
|
/** |
|
* 添加请求到队列 |
|
*/ |
|
function addPendingRequest(config: InternalAxiosRequestConfig): void { |
|
const requestKey = generateRequestKey(config); |
|
|
|
// 如果已存在相同请求,取消之前的请求 |
|
if (pendingRequests.has(requestKey)) { |
|
const source = pendingRequests.get(requestKey); |
|
source?.cancel('重复请求已取消'); |
|
} |
|
|
|
// 创建新的取消令牌 |
|
const source = axios.CancelToken.source(); |
|
config.cancelToken = source.token; |
|
pendingRequests.set(requestKey, source); |
|
} |
|
|
|
/** |
|
* 从队列中移除请求 |
|
*/ |
|
function removePendingRequest(config: InternalAxiosRequestConfig | AxiosRequestConfig): void { |
|
const requestKey = generateRequestKey(config as InternalAxiosRequestConfig); |
|
pendingRequests.delete(requestKey); |
|
} |
|
|
|
/** |
|
* 订阅 token 刷新 |
|
*/ |
|
function subscribeTokenRefresh(callback: (token: string) => void): void { |
|
refreshSubscribers.push(callback); |
|
} |
|
|
|
/** |
|
* 通知所有订阅者 token 已刷新 |
|
*/ |
|
function onTokenRefreshed(token: string): void { |
|
refreshSubscribers.forEach((callback) => callback(token)); |
|
refreshSubscribers = []; |
|
} |
|
|
|
/** |
|
* 刷新 token |
|
*/ |
|
async function refreshAccessToken(): Promise<string | null> { |
|
try { |
|
const refreshToken = await AsyncStorage.getItem('refresh_token'); |
|
|
|
if (!refreshToken) { |
|
throw new Error('No refresh token'); |
|
} |
|
|
|
// 调用刷新 token 接口 |
|
const response = await axios.post<ApiResponse<{ token: string; refreshToken: string }>>( |
|
`${config.api.baseURL}/auth/refresh-token`, |
|
{ refreshToken } |
|
); |
|
|
|
const { token, refreshToken: newRefreshToken } = response.data.data; |
|
|
|
// 保存新的 token |
|
await AsyncStorage.setItem('auth_token', token); |
|
await AsyncStorage.setItem('refresh_token', newRefreshToken); |
|
|
|
return token; |
|
} catch (error) { |
|
// 刷新失败,清除所有 token |
|
await AsyncStorage.multiRemove(['auth_token', 'refresh_token']); |
|
return null; |
|
} |
|
} |
|
|
|
/** |
|
* 请求拦截器 |
|
* 在请求发送前添加 token 等信息 |
|
*/ |
|
api.interceptors.request.use( |
|
async (config: InternalAxiosRequestConfig) => { |
|
try { |
|
// 添加到请求队列(防止重复请求) |
|
addPendingRequest(config); |
|
|
|
const { apiName } = config.headers; |
|
|
|
const { headers, data } = transformRequest(pick(config, ['headers', 'data'])); |
|
|
|
config.headers = { |
|
...headers, |
|
...(__DEV__ ? { apiName } : {}), |
|
} as AxiosRequestHeaders; |
|
|
|
config.data = data; |
|
|
|
if (Number(config.headers.cmdId) !== 381120) { |
|
config.url = '/v2/'; |
|
} |
|
|
|
// if (__DEV__ && apiName) { |
|
// config.url = `${config.url}?${apiName}`; |
|
// } |
|
|
|
// // 从本地存储获取 token |
|
// const token = await AsyncStorage.getItem('auth_token'); |
|
// |
|
// // 添加 token 到请求头 |
|
// if (token && config.headers) { |
|
// config.headers.Authorization = `Bearer ${token}`; |
|
// } |
|
|
|
// 添加请求时间戳(用于计算请求耗时) |
|
(config as any).metadata = { startTime: Date.now() }; |
|
|
|
// 打印请求信息(开发环境) |
|
if (__DEV__) { |
|
console.log('📤 API Request:', { |
|
method: config.method?.toUpperCase(), |
|
url: config.url, |
|
baseURL: config.baseURL, |
|
fullURL: `${config.baseURL}${config.url}`, |
|
params: config.params, |
|
headers: config.headers, |
|
}); |
|
} |
|
|
|
return config; |
|
} catch (error) { |
|
console.error('❌ Request interceptor error:', error); |
|
return Promise.reject(error); |
|
} |
|
}, |
|
(error) => { |
|
console.error('❌ Request error:', error); |
|
return Promise.reject(error); |
|
} |
|
); |
|
|
|
/** |
|
* 响应拦截器 |
|
* 统一处理响应和错误 |
|
*/ |
|
api.interceptors.response.use( |
|
async (response: AxiosResponse) => { |
|
// 从请求队列中移除 |
|
removePendingRequest(response.config); |
|
|
|
// 计算请求耗时 |
|
const duration = Date.now() - (response.config as any).metadata?.startTime; |
|
|
|
const resData: any = await parseResponse(response); |
|
|
|
// 打印响应信息(开发环境) |
|
// if (__DEV__) { |
|
// console.log('📥 API Response:', { |
|
// url: response.config.url, |
|
// status: response.status, |
|
// duration: `${duration}ms`, |
|
// data: response.data, |
|
// }); |
|
// } |
|
|
|
// 统一处理响应数据格式 |
|
// const apiResponse = response.data as ApiResponse; |
|
|
|
// 如果后端返回的数据结构包含 code 和 data |
|
// if (apiResponse && typeof apiResponse === 'object' && 'code' in apiResponse) { |
|
// // 检查业务状态码 |
|
// if (apiResponse.code !== 0 && apiResponse.code !== 200) { |
|
// // 业务错误 |
|
// const error = new Error(apiResponse.message || '请求失败') as any; |
|
// error.code = apiResponse.code; |
|
// error.response = response; |
|
// return Promise.reject(error); |
|
// } |
|
// |
|
// // 返回 data 字段 |
|
// return apiResponse.data; |
|
// } |
|
|
|
// 直接返回响应数据 |
|
// return response.data; |
|
return Promise.resolve(resData); |
|
}, |
|
async (error: AxiosError<ApiError>) => { |
|
// 从请求队列中移除 |
|
if (error.config) { |
|
removePendingRequest(error.config); |
|
} |
|
|
|
// 如果是取消的请求,直接返回 |
|
if (axios.isCancel(error)) { |
|
if (__DEV__) { |
|
console.log('🚫 Request cancelled:', error.message); |
|
} |
|
return Promise.reject(error); |
|
} |
|
|
|
const originalRequest = error.config as RequestConfig & { _retry?: boolean }; |
|
|
|
// 打印错误信息 |
|
if (__DEV__) { |
|
console.error('❌ API Error:', { |
|
method: error.config?.method, |
|
cmdId: error.config?.headers?.cmdId, |
|
status: error.response?.status, |
|
message: error.message, |
|
data: error.response?.data, |
|
}); |
|
} |
|
|
|
if ( |
|
includes([500, 502, 503], error.response?.status) && |
|
includes(['371130'], `${error.config?.headers?.cmdId}`) |
|
) { |
|
router.replace('/maintenance' as any); |
|
} |
|
|
|
// 处理不同的错误状态码 |
|
if (error.response) { |
|
const { status, data } = error.response; |
|
|
|
switch (status) { |
|
case 401: { |
|
// Token 过期,尝试刷新 |
|
if (!originalRequest._retry) { |
|
if (isRefreshing) { |
|
// 如果正在刷新,将请求加入队列 |
|
return new Promise((resolve) => { |
|
subscribeTokenRefresh((token: string) => { |
|
if (originalRequest.headers) { |
|
originalRequest.headers.Authorization = `Bearer ${token}`; |
|
} |
|
resolve(api(originalRequest)); |
|
}); |
|
}); |
|
} |
|
|
|
originalRequest._retry = true; |
|
isRefreshing = true; |
|
|
|
try { |
|
const newToken = await refreshAccessToken(); |
|
|
|
if (newToken) { |
|
// Token 刷新成功 |
|
isRefreshing = false; |
|
onTokenRefreshed(newToken); |
|
|
|
// 重试原请求 |
|
if (originalRequest.headers) { |
|
originalRequest.headers.Authorization = `Bearer ${newToken}`; |
|
} |
|
return api(originalRequest); |
|
} else { |
|
// Token 刷新失败,跳转到登录页 |
|
isRefreshing = false; |
|
await AsyncStorage.multiRemove(['auth_token', 'refresh_token']); |
|
|
|
// 跳转到登录页 |
|
if (router.canGoBack()) { |
|
router.replace('/(auth)/login' as any); |
|
} |
|
} |
|
} catch (refreshError) { |
|
isRefreshing = false; |
|
await AsyncStorage.multiRemove(['auth_token', 'refresh_token']); |
|
|
|
// 跳转到登录页 |
|
if (router.canGoBack()) { |
|
router.replace('/(auth)/login' as any); |
|
} |
|
|
|
return Promise.reject(refreshError); |
|
} |
|
} |
|
break; |
|
} |
|
|
|
case 403: |
|
// 禁止访问 |
|
console.error('❌ 403: 没有权限访问该资源'); |
|
break; |
|
|
|
case 404: |
|
// 资源不存在 |
|
console.error('❌ 404: 请求的资源不存在'); |
|
break; |
|
|
|
case 422: |
|
// 表单验证错误 |
|
console.error('❌ 422: 表单验证失败', data); |
|
break; |
|
|
|
case 429: |
|
// 请求过于频繁 |
|
console.error('❌ 429: 请求过于频繁,请稍后再试'); |
|
break; |
|
|
|
case 500: |
|
// 服务器错误 |
|
console.error('❌ 500: 服务器内部错误'); |
|
break; |
|
|
|
case 502: |
|
// 网关错误 |
|
console.error('❌ 502: 网关错误'); |
|
break; |
|
|
|
case 503: |
|
// 服务不可用 |
|
console.error('❌ 503: 服务暂时不可用'); |
|
break; |
|
|
|
default: |
|
console.error(`❌ ${status}: 未知错误`); |
|
} |
|
} else if (error.request) { |
|
// 请求已发送但没有收到响应 |
|
console.error('❌ 网络错误: 请检查网络连接'); |
|
} else { |
|
// 请求配置出错 |
|
console.error('❌ 请求配置错误:', error.message); |
|
} |
|
|
|
// 自定义错误处理 |
|
if (originalRequest?.customErrorHandler) { |
|
originalRequest.customErrorHandler(error); |
|
} |
|
|
|
return Promise.reject(error); |
|
} |
|
); |
|
|
|
/** |
|
* 取消所有待处理的请求 |
|
*/ |
|
export function cancelAllRequests(message = '请求已取消'): void { |
|
pendingRequests.forEach((source) => { |
|
source.cancel(message); |
|
}); |
|
pendingRequests.clear(); |
|
} |
|
|
|
/** |
|
* 取消指定 URL 的请求 |
|
*/ |
|
export function cancelRequest(url: string): void { |
|
pendingRequests.forEach((source, key) => { |
|
if (key.includes(url)) { |
|
source.cancel('请求已取消'); |
|
pendingRequests.delete(key); |
|
} |
|
}); |
|
} |
|
|
|
/** |
|
* 通用请求方法(增强版) |
|
*/ |
|
export const request = { |
|
/** |
|
* GET 请求 |
|
*/ |
|
get: <T = any>(url: string, config?: RequestConfig) => api.get<T, T>(url, config), |
|
|
|
/** |
|
* POST 请求 |
|
*/ |
|
post: <T = any>(url: string, data?: any, config?: RequestConfig) => |
|
api.post<T, T>(url, data, config), |
|
|
|
/** |
|
* PUT 请求 |
|
*/ |
|
put: <T = any>(url: string, data?: any, config?: RequestConfig) => |
|
api.put<T, T>(url, data, config), |
|
|
|
/** |
|
* DELETE 请求 |
|
*/ |
|
delete: <T = any>(url: string, config?: RequestConfig) => api.delete<T, T>(url, config), |
|
|
|
/** |
|
* PATCH 请求 |
|
*/ |
|
patch: <T = any>(url: string, data?: any, config?: RequestConfig) => |
|
api.patch<T, T>(url, data, config), |
|
|
|
/** |
|
* 上传文件 |
|
*/ |
|
upload: <T = any>( |
|
url: string, |
|
file: File | Blob, |
|
onProgress?: (progress: number) => void, |
|
config?: RequestConfig |
|
) => { |
|
const formData = new FormData(); |
|
formData.append('file', file); |
|
|
|
return api.post<T, T>(url, formData, { |
|
...config, |
|
headers: { |
|
'Content-Type': 'multipart/form-data', |
|
...config?.headers, |
|
}, |
|
onUploadProgress: (progressEvent) => { |
|
if (onProgress && progressEvent.total) { |
|
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total); |
|
onProgress(progress); |
|
} |
|
}, |
|
}); |
|
}, |
|
|
|
/** |
|
* 下载文件 |
|
*/ |
|
download: async ( |
|
url: string, |
|
filename?: string, |
|
onProgress?: (progress: number) => void, |
|
config?: RequestConfig |
|
) => { |
|
const response: any = await api.get(url, { |
|
...config, |
|
responseType: 'blob', |
|
onDownloadProgress: (progressEvent) => { |
|
if (onProgress && progressEvent.total) { |
|
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total); |
|
onProgress(progress); |
|
} |
|
}, |
|
}); |
|
|
|
// 创建下载链接 |
|
const blob = new Blob([response]); |
|
const downloadUrl = window.URL.createObjectURL(blob); |
|
const link = document.createElement('a'); |
|
link.href = downloadUrl; |
|
link.download = filename || 'download'; |
|
document.body.appendChild(link); |
|
link.click(); |
|
document.body.removeChild(link); |
|
window.URL.revokeObjectURL(downloadUrl); |
|
|
|
return response; |
|
}, |
|
|
|
/** |
|
* 并发请求 |
|
*/ |
|
all: <T = any>(requests: Promise<T>[]) => Promise.all(requests), |
|
|
|
/** |
|
* 串行请求 |
|
*/ |
|
series: async <T = any>(requests: (() => Promise<T>)[]): Promise<T[]> => { |
|
const results: T[] = []; |
|
for (const request of requests) { |
|
const result = await request(); |
|
results.push(result); |
|
} |
|
return results; |
|
}, |
|
}; |
|
|
|
/** |
|
* 创建带重试的请求 |
|
*/ |
|
export function createRetryRequest<T = any>( |
|
requestFn: () => Promise<T>, |
|
maxRetries = 3, |
|
retryDelay = 1000 |
|
): Promise<T> { |
|
return new Promise((resolve, reject) => { |
|
let retries = 0; |
|
|
|
const attempt = async () => { |
|
try { |
|
const result = await requestFn(); |
|
resolve(result); |
|
} catch (error) { |
|
retries++; |
|
|
|
if (retries < maxRetries) { |
|
if (__DEV__) { |
|
console.log(`🔄 Retrying request (${retries}/${maxRetries})...`); |
|
} |
|
setTimeout(attempt, retryDelay * retries); |
|
} else { |
|
reject(error); |
|
} |
|
} |
|
}; |
|
|
|
attempt(); |
|
}); |
|
} |
|
|
|
export default api;
|
|
|