mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-04 00:32:26 +08:00
- 新增 demo 配置模块,支持静态托管环境(GitHub Pages/Vercel/Netlify) - API client 集成自定义 adapter,Demo 模式下自动拦截请求返回 mock 数据 - 登录对话框适配 Demo 模式,显示演示账号提示信息 - 添加完整的 mock 数据处理器(用户、配额、用量、供应商等) - 同步 token 状态到 mock handler,支持页面刷新后恢复会话
308 lines
8.9 KiB
TypeScript
308 lines
8.9 KiB
TypeScript
import axios, { getAdapter } from 'axios'
|
||
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig, AxiosAdapter } from 'axios'
|
||
import { NETWORK_CONFIG, AUTH_CONFIG } from '@/config/constants'
|
||
import { isDemoMode } from '@/config/demo'
|
||
import { handleMockRequest, setMockUserToken } from '@/mocks'
|
||
import { log } from '@/utils/logger'
|
||
|
||
// 在开发环境下使用代理,生产环境使用环境变量
|
||
const API_BASE_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
/**
|
||
* 判断请求是否为公共端点
|
||
*/
|
||
function isPublicEndpoint(url?: string, method?: string): boolean {
|
||
if (!url) return false
|
||
|
||
const isHealthCheck = url.includes('/health') &&
|
||
method?.toLowerCase() === 'get' &&
|
||
!url.includes('/api/admin')
|
||
|
||
return url.includes('/public') ||
|
||
url.includes('.json') ||
|
||
isHealthCheck
|
||
}
|
||
|
||
/**
|
||
* 判断是否为认证相关请求
|
||
*/
|
||
function isAuthRequest(url?: string): boolean {
|
||
return url?.includes('/auth/login') || url?.includes('/auth/refresh') || url?.includes('/auth/logout') || false
|
||
}
|
||
|
||
/**
|
||
* 判断是否为可刷新的认证错误
|
||
*/
|
||
function isRefreshableAuthError(errorDetail: string): boolean {
|
||
const nonRefreshableErrors = [
|
||
'用户不存在或已禁用',
|
||
'需要管理员权限',
|
||
'权限不足',
|
||
'用户已禁用',
|
||
]
|
||
|
||
return !nonRefreshableErrors.some((msg) => errorDetail.includes(msg))
|
||
}
|
||
|
||
/**
|
||
* 创建 Demo 模式的自定义 adapter
|
||
* 在 Demo 模式下拦截请求并返回 mock 数据
|
||
*/
|
||
function createDemoAdapter(defaultAdapter: AxiosAdapter) {
|
||
return async (config: InternalAxiosRequestConfig): Promise<AxiosResponse> => {
|
||
if (isDemoMode()) {
|
||
try {
|
||
const mockResponse = await handleMockRequest({
|
||
method: config.method?.toUpperCase(),
|
||
url: config.url,
|
||
data: config.data,
|
||
params: config.params,
|
||
})
|
||
if (mockResponse) {
|
||
// 确保响应包含 config
|
||
mockResponse.config = config
|
||
return mockResponse
|
||
}
|
||
} catch (error: any) {
|
||
// Mock 错误需要附加 config,否则 handleResponseError 会崩溃
|
||
if (error.response) {
|
||
error.config = config
|
||
error.response.config = config
|
||
}
|
||
throw error
|
||
}
|
||
}
|
||
// 非 Demo 模式或没有 mock 响应时,使用默认 adapter
|
||
return defaultAdapter(config)
|
||
}
|
||
}
|
||
|
||
class ApiClient {
|
||
private client: AxiosInstance
|
||
private token: string | null = null
|
||
private isRefreshing = false
|
||
private refreshPromise: Promise<AxiosResponse> | null = null
|
||
|
||
constructor() {
|
||
this.client = axios.create({
|
||
baseURL: API_BASE_URL,
|
||
timeout: NETWORK_CONFIG.API_TIMEOUT,
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
})
|
||
|
||
// 设置自定义 adapter 处理 Demo 模式
|
||
const defaultAdapter = getAdapter(this.client.defaults.adapter)
|
||
this.client.defaults.adapter = createDemoAdapter(defaultAdapter)
|
||
|
||
this.setupInterceptors()
|
||
}
|
||
|
||
/**
|
||
* 配置请求和响应拦截器
|
||
*/
|
||
private setupInterceptors(): void {
|
||
// 请求拦截器 - 仅处理认证
|
||
this.client.interceptors.request.use(
|
||
(config) => {
|
||
const requiresAuth = !isPublicEndpoint(config.url, config.method) &&
|
||
config.url?.includes('/api/')
|
||
|
||
if (requiresAuth) {
|
||
const token = this.getToken()
|
||
if (token) {
|
||
config.headers.Authorization = `Bearer ${token}`
|
||
}
|
||
}
|
||
return config
|
||
},
|
||
(error) => Promise.reject(error)
|
||
)
|
||
|
||
// 响应拦截器
|
||
this.client.interceptors.response.use(
|
||
(response) => response,
|
||
async (error) => this.handleResponseError(error)
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 处理响应错误
|
||
*/
|
||
private async handleResponseError(error: any): Promise<any> {
|
||
const originalRequest = error.config
|
||
|
||
// 请求被取消
|
||
if (axios.isCancel(error)) {
|
||
return Promise.reject(error)
|
||
}
|
||
|
||
// 网络错误或服务器不可达
|
||
if (!error.response) {
|
||
log.warn('Network error or server unreachable', error.message)
|
||
return Promise.reject(error)
|
||
}
|
||
|
||
// 认证请求错误,直接返回
|
||
if (isAuthRequest(originalRequest?.url)) {
|
||
return Promise.reject(error)
|
||
}
|
||
|
||
// 处理401错误
|
||
if (error.response?.status === 401) {
|
||
return this.handle401Error(error, originalRequest)
|
||
}
|
||
|
||
return Promise.reject(error)
|
||
}
|
||
|
||
/**
|
||
* 处理401认证错误
|
||
*/
|
||
private async handle401Error(error: any, originalRequest: any): Promise<any> {
|
||
// 如果不需要认证,直接返回错误
|
||
if (isPublicEndpoint(originalRequest?.url, originalRequest?.method)) {
|
||
return Promise.reject(error)
|
||
}
|
||
|
||
// 如果已经重试过,不再重试
|
||
if (originalRequest._retry) {
|
||
return Promise.reject(error)
|
||
}
|
||
|
||
const errorDetail = error.response?.data?.detail || ''
|
||
log.debug('Got 401 error, attempting token refresh', { errorDetail })
|
||
|
||
// 检查是否为业务相关的401错误
|
||
if (!isRefreshableAuthError(errorDetail)) {
|
||
log.info('401 error but not authentication issue, keeping session', { errorDetail })
|
||
return Promise.reject(error)
|
||
}
|
||
|
||
// 获取refresh token
|
||
const refreshToken = localStorage.getItem('refresh_token')
|
||
if (!refreshToken) {
|
||
log.info('No refresh token available, clearing invalid token')
|
||
this.clearAuth()
|
||
return Promise.reject(error)
|
||
}
|
||
|
||
// 标记为已重试
|
||
originalRequest._retry = true
|
||
originalRequest._retryCount = (originalRequest._retryCount || 0) + 1
|
||
|
||
// 超过最大重试次数
|
||
if (originalRequest._retryCount > AUTH_CONFIG.MAX_RETRY_COUNT) {
|
||
log.error('Max retry attempts reached')
|
||
return Promise.reject(error)
|
||
}
|
||
|
||
// 如果正在刷新,等待刷新完成
|
||
if (this.isRefreshing) {
|
||
try {
|
||
await this.refreshPromise
|
||
originalRequest.headers.Authorization = `Bearer ${this.getToken()}`
|
||
return this.client.request(originalRequest)
|
||
} catch (refreshError) {
|
||
return Promise.reject(error)
|
||
}
|
||
}
|
||
|
||
// 开始刷新token
|
||
return this.refreshTokenAndRetry(refreshToken, originalRequest, error)
|
||
}
|
||
|
||
/**
|
||
* 刷新token并重试原始请求
|
||
*/
|
||
private async refreshTokenAndRetry(
|
||
refreshToken: string,
|
||
originalRequest: any,
|
||
originalError: any
|
||
): Promise<any> {
|
||
this.isRefreshing = true
|
||
this.refreshPromise = this.refreshToken(refreshToken)
|
||
|
||
try {
|
||
const response = await this.refreshPromise
|
||
this.setToken(response.data.access_token)
|
||
localStorage.setItem('refresh_token', response.data.refresh_token)
|
||
this.isRefreshing = false
|
||
this.refreshPromise = null
|
||
|
||
// 重试原始请求
|
||
originalRequest.headers.Authorization = `Bearer ${response.data.access_token}`
|
||
return this.client.request(originalRequest)
|
||
} catch (refreshError: any) {
|
||
log.error('Token refresh failed', refreshError)
|
||
this.isRefreshing = false
|
||
this.refreshPromise = null
|
||
this.clearAuth()
|
||
return Promise.reject(originalError)
|
||
}
|
||
}
|
||
|
||
setToken(token: string): void {
|
||
this.token = token
|
||
localStorage.setItem('access_token', token)
|
||
// 同步到 mock handler
|
||
if (isDemoMode()) {
|
||
setMockUserToken(token)
|
||
}
|
||
}
|
||
|
||
getToken(): string | null {
|
||
if (!this.token) {
|
||
this.token = localStorage.getItem('access_token')
|
||
// 页面刷新时,从 localStorage 恢复 token 到 mock handler
|
||
if (this.token && isDemoMode()) {
|
||
setMockUserToken(this.token)
|
||
}
|
||
}
|
||
return this.token
|
||
}
|
||
|
||
clearAuth(): void {
|
||
this.token = null
|
||
localStorage.removeItem('access_token')
|
||
localStorage.removeItem('refresh_token')
|
||
// 同步清除 mock token
|
||
if (isDemoMode()) {
|
||
setMockUserToken(null)
|
||
}
|
||
}
|
||
|
||
async refreshToken(refreshToken: string): Promise<AxiosResponse> {
|
||
// refreshToken 会通过 adapter 处理 Demo 模式
|
||
return this.client.post('/api/auth/refresh', { refresh_token: refreshToken })
|
||
}
|
||
|
||
// 以下方法直接委托给 axios client,Demo 模式由 adapter 统一处理
|
||
async request<T = any>(config: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||
return this.client.request<T>(config)
|
||
}
|
||
|
||
async get<T = any>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||
return this.client.get<T>(url, config)
|
||
}
|
||
|
||
async post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||
return this.client.post<T>(url, data, config)
|
||
}
|
||
|
||
async put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||
return this.client.put<T>(url, data, config)
|
||
}
|
||
|
||
async patch<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||
return this.client.patch<T>(url, data, config)
|
||
}
|
||
|
||
async delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||
return this.client.delete<T>(url, config)
|
||
}
|
||
}
|
||
|
||
export default new ApiClient()
|