Files
012-kaopeilian/frontend/src/api/request.ts
yuliang_guo ac686c27e7
Some checks failed
continuous-integration/drone/push Build is failing
feat: 优化登录策略 - 延长token有效期并支持自动刷新
1. 后端配置 (.env.ex):
   - ACCESS_TOKEN_EXPIRE_MINUTES: 30 -> 480 (8小时)

2. 前端 (request.ts):
   - 401错误时先尝试使用refresh_token刷新
   - 刷新成功后自动重试原请求
   - 支持并发请求时的token刷新队列
   - 刷新失败才清除状态并跳转登录页
2026-02-02 17:35:29 +08:00

348 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 请求封装
* 统一处理请求和响应,支持模拟数据和真实接口切换
*/
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