213 lines
5.6 KiB
TypeScript
213 lines
5.6 KiB
TypeScript
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from 'axios'
|
||
import { useUserStore } from '@/stores/user'
|
||
|
||
const BASE_API = import.meta.env.VITE_APP_BASE_API
|
||
|
||
// API响应结构
|
||
interface ApiResponse<T = any> {
|
||
code: number
|
||
message: string
|
||
data: T
|
||
}
|
||
|
||
// 加密配置
|
||
interface CryptoConfig {
|
||
enabled: boolean
|
||
publicKey: string
|
||
aesKey: string // AES密钥的Base64编码
|
||
}
|
||
|
||
// 加密配置缓存
|
||
let cryptoConfigCache: CryptoConfig | null = null
|
||
|
||
// 获取加密配置
|
||
export async function fetchCryptoConfig(): Promise<CryptoConfig> {
|
||
if (cryptoConfigCache) {
|
||
return cryptoConfigCache
|
||
}
|
||
try {
|
||
const response = await axios.get('/api/crypto/config')
|
||
if (response.data?.code === 200) {
|
||
cryptoConfigCache = response.data.data
|
||
return cryptoConfigCache!
|
||
}
|
||
} catch (error) {
|
||
console.error('获取加密配置失败', error)
|
||
}
|
||
return { enabled: false, publicKey: '', aesKey: '' }
|
||
}
|
||
|
||
// 清除加密配置缓存
|
||
export function clearCryptoConfigCache() {
|
||
cryptoConfigCache = null
|
||
}
|
||
|
||
// 判断是否是AES加密的响应数据(格式:iv.encryptedData)
|
||
function isAesEncryptedData(data: any): boolean {
|
||
if (typeof data !== 'string') {
|
||
return false
|
||
}
|
||
const parts = data.split('.')
|
||
if (parts.length !== 2) {
|
||
return false
|
||
}
|
||
// 检查两部分是否都是有效的Base64,且IV长度正确
|
||
try {
|
||
atob(parts[0])
|
||
atob(parts[1])
|
||
// IV是12字节,Base64后是16字符
|
||
return parts[0].length === 16 && parts[1].length > 10
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
// AES-GCM解密
|
||
async function aesDecrypt(encryptedData: string, aesKeyBase64: string): Promise<string> {
|
||
const parts = encryptedData.split('.')
|
||
if (parts.length !== 2) {
|
||
throw new Error('加密数据格式错误')
|
||
}
|
||
|
||
const iv = Uint8Array.from(atob(parts[0]), c => c.charCodeAt(0))
|
||
const data = Uint8Array.from(atob(parts[1]), c => c.charCodeAt(0))
|
||
const keyBytes = Uint8Array.from(atob(aesKeyBase64), c => c.charCodeAt(0))
|
||
|
||
// 导入AES密钥
|
||
const aesKey = await crypto.subtle.importKey(
|
||
'raw',
|
||
keyBytes,
|
||
{ name: 'AES-GCM' },
|
||
false,
|
||
['decrypt']
|
||
)
|
||
|
||
// 解密
|
||
const decrypted = await crypto.subtle.decrypt(
|
||
{ name: 'AES-GCM', iv: iv },
|
||
aesKey,
|
||
data
|
||
)
|
||
|
||
return new TextDecoder().decode(decrypted)
|
||
}
|
||
|
||
// 解密响应数据
|
||
async function decryptResponseData(data: string): Promise<any> {
|
||
const config = await fetchCryptoConfig()
|
||
|
||
if (!config.aesKey) {
|
||
return data
|
||
}
|
||
|
||
try {
|
||
const decryptedStr = await aesDecrypt(data, config.aesKey)
|
||
return JSON.parse(decryptedStr)
|
||
} catch (error) {
|
||
console.error('响应解密失败', error)
|
||
// 解密失败可能是密钥过期,清除缓存
|
||
cryptoConfigCache = null
|
||
return data
|
||
}
|
||
}
|
||
|
||
//axios.defaults.headers["Content-Type"] = 'application/json;charset=utf-8' //"application/x-www-form-urlencoded;charset=utf-8"; //"application/json;charset=utf-8";
|
||
|
||
// 创建axios实例
|
||
// 后端 API 统一使用 /api 前缀
|
||
const service: AxiosInstance = axios.create({
|
||
// baseURL:'/api',
|
||
baseURL: BASE_API,
|
||
timeout: 30000
|
||
})
|
||
|
||
// 请求拦截器
|
||
service.interceptors.request.use(
|
||
(config) => {
|
||
const userStore = useUserStore()
|
||
if (userStore.token) {
|
||
config.headers['Authorization'] = userStore.token
|
||
}
|
||
|
||
if (config.data instanceof FormData) {
|
||
// 文件上传:不设置 JSON 请求头、不序列化,交给浏览器自动处理 multipart
|
||
return config
|
||
}
|
||
|
||
config.headers["Content-Type"] = 'application/json;charset=utf-8'
|
||
config.data = config.data instanceof Object ? JSON.stringify(config.data) : config.data
|
||
return config
|
||
},
|
||
(error) => {
|
||
return Promise.reject(error)
|
||
}
|
||
)
|
||
|
||
// 防止重复 logout
|
||
let isLoggingOut = false
|
||
|
||
// 响应拦截器
|
||
service.interceptors.response.use(
|
||
async (response: AxiosResponse<ApiResponse>) => {
|
||
// 如果是 blob 类型响应(文件下载),直接返回
|
||
if (response.config.responseType === 'blob') {
|
||
return response.data
|
||
}
|
||
|
||
if (response.config.responseType === 'arraybuffer') {
|
||
// 二进制流不走code校验,直接完整返回ArrayBuffer
|
||
return response.data
|
||
}
|
||
|
||
|
||
const res = response.data
|
||
|
||
if (res.code !== 200) {
|
||
// logout 接口返回 401 时不显示错误消息(避免干扰)
|
||
const isLogoutRequest = response.config.url?.includes('/auth/logout')
|
||
|
||
// 401 未授权,弹出明确提示并跳转登录(防止重复调用)
|
||
if (res.code === 401 && !isLoggingOut && !isLogoutRequest) {
|
||
window.$message?.warning('当前用户登录已过期,请重新登录')
|
||
|
||
isLoggingOut = true
|
||
|
||
const userStore = useUserStore()
|
||
await userStore.logout()
|
||
isLoggingOut = false
|
||
return Promise.reject(new Error('登录已过期'))
|
||
}
|
||
|
||
if (!isLogoutRequest) {
|
||
window.$message?.error(res.message || '请求失败')
|
||
}
|
||
|
||
return Promise.reject(new Error(res.message || '请求失败'))
|
||
}
|
||
|
||
// 检查响应数据是否是AES加密的,自动解密
|
||
if (isAesEncryptedData(res.data)) {
|
||
try {
|
||
return await decryptResponseData(res.data)
|
||
} catch (error) {
|
||
console.error('解密响应失败', error)
|
||
return res.data
|
||
}
|
||
}
|
||
|
||
return res.data
|
||
},
|
||
(error) => {
|
||
const message = error.response?.data?.message || error.message || '网络错误'
|
||
window.$message?.error(message)
|
||
return Promise.reject(error)
|
||
}
|
||
)
|
||
|
||
// 封装请求方法
|
||
export function request<T = any>(config: AxiosRequestConfig): Promise<T> {
|
||
return service(config) as Promise<T>
|
||
}
|
||
|
||
export default service
|