Some checks failed
continuous-integration/drone/push Build is failing
1. 后端配置 (.env.ex): - ACCESS_TOKEN_EXPIRE_MINUTES: 30 -> 480 (8小时) 2. 前端 (request.ts): - 401错误时先尝试使用refresh_token刷新 - 刷新成功后自动重试原请求 - 支持并发请求时的token刷新队列 - 刷新失败才清除状态并跳转登录页
348 lines
11 KiB
TypeScript
348 lines
11 KiB
TypeScript
/**
|
||
* 请求封装
|
||
* 统一处理请求和响应,支持模拟数据和真实接口切换
|
||
*/
|
||
|
||
import { API_CONFIG, ApiResponse } from './config'
|
||
import { handleHttpError } from '@/utils/errorHandler'
|
||
import { loadingManager } from '@/utils/loadingManager'
|
||
|
||
// 模拟延迟,使体验更真实
|
||
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
|
||
|
||
// Token刷新状态管理
|
||
let isRefreshing = false
|
||
let refreshSubscribers: Array<(token: string) => void> = []
|
||
|
||
// 订阅token刷新
|
||
const subscribeTokenRefresh = (callback: (token: string) => void) => {
|
||
refreshSubscribers.push(callback)
|
||
}
|
||
|
||
// 通知所有订阅者
|
||
const onTokenRefreshed = (token: string) => {
|
||
refreshSubscribers.forEach(callback => callback(token))
|
||
refreshSubscribers = []
|
||
}
|
||
|
||
// 扩展RequestInit接口以支持transformRequest
|
||
interface ExtendedRequestInit extends RequestInit {
|
||
transformRequest?: Array<(data: any, headers?: any) => any>
|
||
}
|
||
|
||
class Request {
|
||
/**
|
||
* 通用请求方法
|
||
* @param url 请求路径
|
||
* @param options 请求选项
|
||
* @param showLoading 是否显示加载状态
|
||
* @returns Promise<ApiResponse<T>>
|
||
*/
|
||
async request<T = any>(
|
||
url: string,
|
||
options: ExtendedRequestInit = {},
|
||
showLoading: boolean = false
|
||
): Promise<ApiResponse<T>> {
|
||
const loadingKey = `api-${url}-${options.method || 'GET'}`
|
||
|
||
if (showLoading) {
|
||
loadingManager.start(loadingKey, {
|
||
message: '请求中...',
|
||
background: 'rgba(0, 0, 0, 0.3)'
|
||
})
|
||
}
|
||
|
||
// 添加认证头
|
||
const token = localStorage.getItem('access_token')
|
||
if (token && !url.includes('/auth/login') && !url.includes('/auth/register')) {
|
||
options.headers = {
|
||
...options.headers,
|
||
'Authorization': `Bearer ${token}`
|
||
}
|
||
}
|
||
|
||
try {
|
||
// 如果使用模拟数据,直接返回模拟数据
|
||
if (API_CONFIG.useMockData) {
|
||
// 模拟网络延迟
|
||
await delay(Math.random() * 500 + 200)
|
||
|
||
// 动态导入对应的模拟数据
|
||
const mockModule = await this.getMockData(url, options.method || 'GET')
|
||
if (mockModule) {
|
||
return {
|
||
code: 200,
|
||
message: '操作成功',
|
||
data: mockModule
|
||
}
|
||
}
|
||
|
||
// 如果没有模拟数据,抛出错误
|
||
throw new Error(`未找到模拟数据: ${url}`)
|
||
}
|
||
|
||
// 真实 API 请求
|
||
// 强制使用配置的基础URL,不使用代理
|
||
let fullUrl = url.startsWith('http') ? url : `${API_CONFIG.baseURL}${url.startsWith('/') ? url : '/' + url}`
|
||
|
||
// 生产环境安全检查:强制升级 HTTP 为 HTTPS
|
||
if (fullUrl.startsWith('http://') && !fullUrl.includes('localhost') && !fullUrl.includes('127.0.0.1')) {
|
||
fullUrl = fullUrl.replace('http://', 'https://')
|
||
console.warn('[安全] 请求 URL 已自动升级为 HTTPS:', fullUrl)
|
||
}
|
||
|
||
const response = await fetch(fullUrl, {
|
||
...options,
|
||
headers: {
|
||
'Content-Type': 'application/json; charset=utf-8',
|
||
'Accept': 'application/json; charset=utf-8',
|
||
...options.headers
|
||
}
|
||
})
|
||
|
||
// 确保正确解码 UTF-8 响应
|
||
const text = await response.text()
|
||
let data: any
|
||
try {
|
||
data = JSON.parse(text)
|
||
} catch {
|
||
data = { detail: text || `请求失败: ${response.status}` }
|
||
}
|
||
|
||
if (!response.ok) {
|
||
// 解析后端返回的错误详情
|
||
const errorDetail = data?.detail || data?.message || `请求失败: ${response.status}`
|
||
const error = new Error(typeof errorDetail === 'string' ? errorDetail : JSON.stringify(errorDetail)) as any
|
||
error.status = response.status
|
||
error.detail = errorDetail
|
||
error.response = { data, status: response.status }
|
||
throw error
|
||
}
|
||
|
||
return data
|
||
} catch (error) {
|
||
// 处理HTTP错误
|
||
const errorInfo = handleHttpError(error)
|
||
const status = (errorInfo as any)?.status || (error as any)?.status
|
||
|
||
// 401 处理:先尝试刷新Token,失败后再跳转登录
|
||
if (status === 401 && !url.includes('/auth/refresh') && !url.includes('/auth/login')) {
|
||
const refreshToken = localStorage.getItem('refresh_token')
|
||
|
||
if (refreshToken) {
|
||
// 如果已经在刷新中,等待刷新完成后重试
|
||
if (isRefreshing) {
|
||
return new Promise<ApiResponse<T>>((resolve, reject) => {
|
||
subscribeTokenRefresh((newToken: string) => {
|
||
// 使用新token重试原请求
|
||
options.headers = {
|
||
...options.headers,
|
||
'Authorization': `Bearer ${newToken}`
|
||
}
|
||
this.request<T>(url, options, showLoading).then(resolve).catch(reject)
|
||
})
|
||
})
|
||
}
|
||
|
||
isRefreshing = true
|
||
console.info('[Auth] Token过期,尝试刷新...')
|
||
|
||
try {
|
||
// 调用刷新接口
|
||
const refreshResponse = await fetch(`${API_CONFIG.baseURL}/api/v1/auth/refresh`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ refresh_token: refreshToken })
|
||
})
|
||
|
||
if (refreshResponse.ok) {
|
||
const refreshData = await refreshResponse.json()
|
||
if (refreshData.code === 200 && refreshData.data?.token) {
|
||
const newAccessToken = refreshData.data.token.access_token
|
||
const newRefreshToken = refreshData.data.token.refresh_token
|
||
|
||
// 保存新token
|
||
localStorage.setItem('access_token', newAccessToken)
|
||
if (newRefreshToken) {
|
||
localStorage.setItem('refresh_token', newRefreshToken)
|
||
}
|
||
|
||
console.info('[Auth] Token刷新成功')
|
||
isRefreshing = false
|
||
onTokenRefreshed(newAccessToken)
|
||
|
||
// 使用新token重试原请求
|
||
options.headers = {
|
||
...options.headers,
|
||
'Authorization': `Bearer ${newAccessToken}`
|
||
}
|
||
return this.request<T>(url, options, showLoading)
|
||
}
|
||
}
|
||
|
||
// 刷新失败,执行登出
|
||
console.warn('[Auth] Token刷新失败,需要重新登录')
|
||
isRefreshing = false
|
||
} catch (refreshError) {
|
||
console.error('[Auth] Token刷新异常:', refreshError)
|
||
isRefreshing = false
|
||
}
|
||
}
|
||
|
||
// 无refresh_token或刷新失败,清理状态并跳转登录
|
||
console.warn('[Auth] Token过期或无效,正在清理认证状态', { url, status })
|
||
localStorage.removeItem('access_token')
|
||
localStorage.removeItem('refresh_token')
|
||
localStorage.removeItem('current_user')
|
||
|
||
if (!location.pathname.startsWith('/login')) {
|
||
console.info('[Auth] 重定向到登录页')
|
||
location.href = '/login'
|
||
}
|
||
}
|
||
|
||
throw errorInfo
|
||
} finally {
|
||
if (showLoading) {
|
||
loadingManager.stop(loadingKey)
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* GET 请求
|
||
*/
|
||
get<T = any>(url: string, options?: { params?: Record<string, any> }, showLoading?: boolean): Promise<ApiResponse<T>> {
|
||
// 提取params参数
|
||
const params = options?.params || {}
|
||
|
||
// 过滤掉 undefined 和 null 的参数
|
||
const filteredParams = Object.fromEntries(
|
||
Object.entries(params).filter(([_, value]) => value !== undefined && value !== null)
|
||
)
|
||
|
||
const queryString = Object.keys(filteredParams).length > 0
|
||
? '?' + new URLSearchParams(filteredParams as any).toString()
|
||
: ''
|
||
return this.request<T>(url + queryString, { method: 'GET' }, showLoading)
|
||
}
|
||
|
||
/**
|
||
* POST 请求
|
||
*/
|
||
post<T = any>(url: string, data?: any, options?: ExtendedRequestInit, showLoading?: boolean): Promise<ApiResponse<T>> {
|
||
let body = data
|
||
let headers = { 'Content-Type': 'application/json', ...options?.headers }
|
||
|
||
// 处理 transformRequest
|
||
if (options?.transformRequest && Array.isArray(options.transformRequest)) {
|
||
for (const transform of options.transformRequest) {
|
||
if (typeof transform === 'function') {
|
||
body = transform(data)
|
||
}
|
||
}
|
||
} else if (data && typeof data === 'object' && !(data instanceof FormData) && !(data instanceof URLSearchParams)) {
|
||
body = JSON.stringify(data)
|
||
}
|
||
|
||
return this.request<T>(url, {
|
||
method: 'POST',
|
||
body,
|
||
...options,
|
||
headers
|
||
}, showLoading)
|
||
}
|
||
|
||
/**
|
||
* PUT 请求
|
||
*/
|
||
put<T = any>(url: string, data?: any, showLoading?: boolean): Promise<ApiResponse<T>> {
|
||
return this.request<T>(url, {
|
||
method: 'PUT',
|
||
body: JSON.stringify(data)
|
||
}, showLoading)
|
||
}
|
||
|
||
/**
|
||
* DELETE 请求
|
||
*/
|
||
delete<T = any>(url: string, showLoading?: boolean): Promise<ApiResponse<T>> {
|
||
return this.request<T>(url, { method: 'DELETE' }, showLoading)
|
||
}
|
||
|
||
/**
|
||
* 文件上传请求
|
||
*/
|
||
upload<T = any>(url: string, file: File, showLoading?: boolean): Promise<ApiResponse<T>> {
|
||
const formData = new FormData()
|
||
formData.append('file', file)
|
||
|
||
return this.request<T>(url, {
|
||
method: 'POST',
|
||
body: formData,
|
||
headers: {
|
||
// 不设置 Content-Type,让浏览器自动设置(包含 boundary)
|
||
}
|
||
}, showLoading)
|
||
}
|
||
|
||
/**
|
||
* 获取模拟数据
|
||
* @private
|
||
*/
|
||
private async getMockData(url: string, _method: string) {
|
||
// 根据 URL 和 method 动态加载对应的模拟数据
|
||
const mockPath = this.parseMockPath(url, _method)
|
||
try {
|
||
const module = await import(`./mock/${mockPath}.ts`)
|
||
let mockData = module.default || module[_method.toLowerCase()]
|
||
|
||
// 如果模拟数据是函数,则调用它
|
||
if (typeof mockData === 'function') {
|
||
// 解析查询参数
|
||
const urlObj = new URL(url, 'http://localhost')
|
||
const params: any = {}
|
||
urlObj.searchParams.forEach((value, key) => {
|
||
params[key] = value
|
||
})
|
||
mockData = mockData(params)
|
||
}
|
||
|
||
return mockData
|
||
} catch (error) {
|
||
console.warn(`未找到模拟数据: ${mockPath}`, error)
|
||
return null
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 解析模拟数据路径
|
||
* @private
|
||
*/
|
||
private parseMockPath(url: string, _method: string): string {
|
||
// 移除查询参数
|
||
const cleanUrl = url.split('?')[0]
|
||
// 移除开头的斜杠
|
||
const path = cleanUrl.startsWith('/') ? cleanUrl.slice(1) : cleanUrl
|
||
// 将路径转换为模拟数据文件路径
|
||
return path.replace(/\//g, '-')
|
||
}
|
||
}
|
||
|
||
// 导出请求实例
|
||
export const request = new Request()
|
||
|
||
// 导出便捷方法
|
||
export const get = (url: string, params?: Record<string, any>, showLoading?: boolean) =>
|
||
request.get(url, params ? { params } : undefined, showLoading)
|
||
|
||
export const post = (url: string, data?: any, showLoading?: boolean) =>
|
||
request.post(url, data, undefined, showLoading)
|
||
|
||
export const put = (url: string, data?: any, showLoading?: boolean) =>
|
||
request.put(url, data, showLoading)
|
||
|
||
export const del = (url: string, showLoading?: boolean) =>
|
||
request.delete(url, showLoading)
|
||
|
||
export default request |