288 lines
11 KiB
TypeScript
288 lines
11 KiB
TypeScript
import axios from 'axios'
|
||
import type { InternalAxiosRequestConfig, AxiosResponse, AxiosError } from 'axios'
|
||
import { ElMessage } from 'element-plus'
|
||
import { useUserStore } from '@/stores/user'
|
||
|
||
// 1. 创建 axios 实例
|
||
const service = axios.create({
|
||
// 【关键修改】
|
||
// 设置为 '/api',请求会自动拼接成 http://localhost:5173/api/...
|
||
// 然后被 Vite 代理转发到 http://127.0.0.1:8000/api/...
|
||
baseURL: '/api',
|
||
timeout: 60000
|
||
})
|
||
|
||
// ============================================================
|
||
// 2. 防重复提交 - Pending 请求池
|
||
// ============================================================
|
||
const pendingRequests = new Map<string, AbortController>()
|
||
|
||
// 生成唯一请求 Key:方法 + URL + 序列化参数
|
||
const generateRequestKey = (config: InternalAxiosRequestConfig): string => {
|
||
const method = (config.method || 'get').toLowerCase()
|
||
const url = config.url || ''
|
||
const params = config.params ? JSON.stringify(config.params) : ''
|
||
const data = config.data ? JSON.stringify(config.data) : ''
|
||
return `${method}:${url}:${params}:${data}`
|
||
}
|
||
|
||
// ============================================================
|
||
// 3. 无感刷新 Token 核心逻辑
|
||
// ============================================================
|
||
|
||
// 标记是否正在刷新 Token
|
||
let isRefreshing = false
|
||
// 重试队列:存储失败的请求,刷新成功后重新发送
|
||
const retryQueue: {
|
||
resolve: (value: unknown) => void
|
||
reject: (reason?: unknown) => void
|
||
config: InternalAxiosRequestConfig
|
||
}[] = []
|
||
|
||
// 处理队列中的请求
|
||
const processQueue = (newToken: string | null, error?: Error) => {
|
||
retryQueue.forEach(({ resolve, reject, config }) => {
|
||
if (newToken) {
|
||
// 更新 Authorization 头
|
||
config.headers['Authorization'] = 'Bearer ' + newToken
|
||
// 重新发送请求
|
||
service(config).then(resolve).catch(reject)
|
||
} else {
|
||
reject(error)
|
||
}
|
||
})
|
||
// 清空队列
|
||
retryQueue.length = 0
|
||
}
|
||
|
||
// 刷新 Token 接口
|
||
const refreshToken = async (refreshTokenValue: string): Promise<string> => {
|
||
const res = await axios.post('/api/v1/auth/refresh', {
|
||
refresh_token: refreshTokenValue
|
||
})
|
||
const data = res.data
|
||
if (data.access_token) {
|
||
return data.access_token
|
||
}
|
||
throw new Error('Token 刷新失败')
|
||
}
|
||
|
||
// ============================================================
|
||
// 4. 请求拦截器(添加 Token + 防重复提交)
|
||
// ============================================================
|
||
service.interceptors.request.use(
|
||
(config) => {
|
||
// 1. 添加 Token
|
||
const token = localStorage.getItem('access_token') || localStorage.getItem('token')
|
||
if (token && config.headers) {
|
||
// Flask-JWT-Extended 默认需要 'Bearer <token>' 格式
|
||
config.headers['Authorization'] = 'Bearer ' + token
|
||
}
|
||
|
||
// 2. 防重复提交检查
|
||
const requestKey = generateRequestKey(config)
|
||
|
||
// 排除一些不需要防重复的请求(如查询类 GET 请求可以根据需求调整)
|
||
const ignoreMethods = ['get', 'head']
|
||
if (!ignoreMethods.includes((config.method || 'get').toLowerCase())) {
|
||
if (pendingRequests.has(requestKey)) {
|
||
// 取消之前的请求
|
||
const controller = pendingRequests.get(requestKey)
|
||
controller?.abort('正在处理中,请勿重复操作')
|
||
pendingRequests.delete(requestKey)
|
||
console.warn(`[防重复] 取消重复请求: ${requestKey}`)
|
||
}
|
||
|
||
// 创建新的 AbortController 并存储
|
||
const controller = new AbortController()
|
||
config.signal = controller.signal
|
||
pendingRequests.set(requestKey, controller)
|
||
}
|
||
|
||
return config
|
||
},
|
||
(error) => {
|
||
return Promise.reject(error)
|
||
}
|
||
)
|
||
|
||
// ============================================================
|
||
// 5. 响应拦截器(核心:无感刷新 + 清理 pending)
|
||
// ============================================================
|
||
service.interceptors.response.use(
|
||
(response) => {
|
||
// 清理 pending 请求池
|
||
const requestKey = generateRequestKey(response.config)
|
||
if (pendingRequests.has(requestKey)) {
|
||
pendingRequests.delete(requestKey)
|
||
}
|
||
|
||
// Axios 默认包了一层 data,所以这里取 response.data
|
||
const res = response.data
|
||
|
||
// 如果后端返回的是标准 Flask jsonify 结果,通常没有 code 字段(除非你自己封装了)
|
||
// 如果你使用了标准 HTTP 状态码(200, 201等),Axios 会直接进入这里
|
||
|
||
// 只有当业务逻辑明确返回错误码时才报错 (根据你的后端封装调整)
|
||
if (res.code && res.code !== 200) {
|
||
ElMessage.error(res.msg || 'Error')
|
||
return Promise.reject(new Error(res.msg || 'Error'))
|
||
} else {
|
||
return res // 返回解包后的数据
|
||
}
|
||
},
|
||
async (error: AxiosError) => {
|
||
console.log('err: ' + error) // for debug
|
||
|
||
// 清理 pending 请求池(无论成功还是失败都要清理)
|
||
if (error.config) {
|
||
const requestKey = generateRequestKey(error.config)
|
||
if (pendingRequests.has(requestKey)) {
|
||
pendingRequests.delete(requestKey)
|
||
}
|
||
}
|
||
|
||
// 如果不是 axios 错误,直接抛出
|
||
if (!error.response) {
|
||
return Promise.reject(error)
|
||
}
|
||
|
||
const originalConfig = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
|
||
|
||
let message = error.message || '请求失败'
|
||
|
||
// 处理 HTTP 状态码错误
|
||
const isLoginEndpoint = error.config && error.config.url && error.config.url.includes('/login')
|
||
const isRefreshEndpoint = error.config && error.config.url && error.config.url.includes('/refresh')
|
||
|
||
const status = error.response.status
|
||
const data = error.response.data as any
|
||
|
||
// ============================================================
|
||
// 核心:401 错误处理 + 无感刷新
|
||
// ============================================================
|
||
if (status === 401) {
|
||
// 0. 检查是否是互踢情况(账号在其他设备登录)
|
||
if (data?.reason === 'token_mismatch') {
|
||
message = '您的账号已在其他设备登录,请重新登录'
|
||
ElMessage.warning(message)
|
||
localStorage.clear()
|
||
window.location.href = '/login'
|
||
return Promise.reject(error)
|
||
}
|
||
|
||
// 1. 如果是登录接口的 401,不执行刷新
|
||
if (isLoginEndpoint) {
|
||
message = data?.msg || '用户名或密码错误'
|
||
ElMessage.error(message)
|
||
return Promise.reject(error)
|
||
}
|
||
|
||
// 2. 如果是刷新接口的 401,说明 refresh_token 也过期了
|
||
if (isRefreshEndpoint) {
|
||
message = '登录已彻底过期,请重新登录'
|
||
ElMessage.error(message)
|
||
// 清空所有 Token,跳转登录页
|
||
localStorage.clear()
|
||
window.location.href = '/login'
|
||
return Promise.reject(error)
|
||
}
|
||
|
||
// 3. 业务接口返回 401,尝试无感刷新
|
||
if (!originalConfig) {
|
||
return Promise.reject(error)
|
||
}
|
||
|
||
// 如果已经重试过(防止无限循环)
|
||
if (originalConfig._retry) {
|
||
message = '登录已过期,请重新登录'
|
||
localStorage.clear()
|
||
window.location.href = '/login'
|
||
return Promise.reject(error)
|
||
}
|
||
|
||
// 标记为已重试
|
||
originalConfig._retry = true
|
||
|
||
// 获取 refresh_token
|
||
const refreshTokenValue = localStorage.getItem('refresh_token')
|
||
|
||
if (!refreshTokenValue) {
|
||
// 没有 refresh_token,直接跳转登录
|
||
message = '登录已过期,请重新登录'
|
||
ElMessage.error(message)
|
||
localStorage.clear()
|
||
window.location.href = '/login'
|
||
return Promise.reject(error)
|
||
}
|
||
|
||
// 4. 尝试刷新 Token
|
||
if (!isRefreshing) {
|
||
isRefreshing = true
|
||
|
||
try {
|
||
console.log('正在刷新 Token...')
|
||
const newAccessToken = await refreshToken(refreshTokenValue)
|
||
|
||
// 刷新成功,更新本地 Token
|
||
const userStore = useUserStore()
|
||
userStore.setToken(newAccessToken)
|
||
|
||
// 更新当前请求的 Authorization
|
||
originalConfig.headers['Authorization'] = 'Bearer ' + newAccessToken
|
||
|
||
console.log('Token 刷新成功,重发队列中的请求')
|
||
|
||
// 处理队列中的请求
|
||
processQueue(newAccessToken)
|
||
|
||
// 重发当前请求
|
||
return service(originalConfig)
|
||
|
||
} catch (refreshError) {
|
||
console.error('Token 刷新失败:', refreshError)
|
||
|
||
// 刷新失败,清空队列并跳转登录
|
||
processQueue(null, refreshError as Error)
|
||
|
||
message = '登录已彻底过期,请重新登录'
|
||
ElMessage.error(message)
|
||
localStorage.clear()
|
||
window.location.href = '/login'
|
||
return Promise.reject(refreshError)
|
||
|
||
} finally {
|
||
isRefreshing = false
|
||
}
|
||
} else {
|
||
// 5. 正在刷新中,将当前请求加入队列等待
|
||
return new Promise((resolve, reject) => {
|
||
retryQueue.push({
|
||
resolve,
|
||
reject,
|
||
config: originalConfig
|
||
})
|
||
})
|
||
}
|
||
|
||
} else if (status === 403) {
|
||
message = '权限不足'
|
||
} else if (status === 404) {
|
||
message = '请求的资源不存在'
|
||
} else if (status === 500) {
|
||
message = '服务器内部错误'
|
||
} else if (data && data.msg) {
|
||
// 优先显示后端返回的错误信息
|
||
message = data.msg
|
||
}
|
||
|
||
// 登录接口的错误由调用方单独处理,不再显示全局提示
|
||
if (!isLoginEndpoint) {
|
||
ElMessage.error(message)
|
||
}
|
||
return Promise.reject(error)
|
||
}
|
||
)
|
||
|
||
export default service
|