2025-12-11 10:03:10 +08:00
|
|
|
|
import axios, { getAdapter } from 'axios'
|
|
|
|
|
|
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig, AxiosAdapter } from 'axios'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
import { NETWORK_CONFIG, AUTH_CONFIG } from '@/config/constants'
|
2025-12-11 10:03:10 +08:00
|
|
|
|
import { isDemoMode } from '@/config/demo'
|
2026-03-17 16:34:09 +08:00
|
|
|
|
import { getClientDeviceId } from '@/utils/deviceId'
|
|
|
|
|
|
import { CrossTabRefreshCoordinator } from '@/utils/crossTabRefresh'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
import { log } from '@/utils/logger'
|
2026-07-15 23:47:19 +08:00
|
|
|
|
import { cache } from '@/utils/cache'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
// 在开发环境下使用代理,生产环境使用环境变量
|
|
|
|
|
|
const API_BASE_URL = import.meta.env.VITE_API_URL || ''
|
2026-03-17 16:34:09 +08:00
|
|
|
|
export const AUTH_STATE_CHANGE_EVENT = 'aether-auth-state-change'
|
2026-09-04 03:45:52 +08:00
|
|
|
|
export const AUTH_SESSION_SIGNAL_KEY = 'aether_auth_session_signal'
|
|
|
|
|
|
|
|
|
|
|
|
export type AuthStateChangeDetail = {
|
|
|
|
|
|
authenticated: boolean
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export type AuthSessionSignal = AuthStateChangeDetail & {
|
|
|
|
|
|
eventId: string
|
|
|
|
|
|
emittedAt: number
|
|
|
|
|
|
}
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-07-15 23:47:19 +08:00
|
|
|
|
type MockRuntime = typeof import('@/mocks')
|
|
|
|
|
|
|
|
|
|
|
|
let mockRuntimePromise: Promise<MockRuntime> | null = null
|
|
|
|
|
|
let currentMockUserToken: string | null = null
|
|
|
|
|
|
|
|
|
|
|
|
function loadMockRuntime(): Promise<MockRuntime> {
|
|
|
|
|
|
if (!mockRuntimePromise) {
|
|
|
|
|
|
mockRuntimePromise = import('@/mocks').catch((error) => {
|
|
|
|
|
|
mockRuntimePromise = null
|
|
|
|
|
|
throw error
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
return mockRuntimePromise
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 判断请求是否为公共端点
|
|
|
|
|
|
*/
|
2026-10-01 11:48:17 +08:00
|
|
|
|
function requestPath(url?: string): string {
|
|
|
|
|
|
if (!url) return ''
|
|
|
|
|
|
try { return new URL(url, 'http://aether.local').pathname } catch { return '' }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
function isPublicEndpoint(url?: string, method?: string): boolean {
|
2026-10-01 11:48:17 +08:00
|
|
|
|
const path = requestPath(url)
|
|
|
|
|
|
const isHealthCheck = ['/health', '/v1/health', '/_gateway/health'].includes(path) &&
|
|
|
|
|
|
method?.toLowerCase() === 'get'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-10-01 11:48:17 +08:00
|
|
|
|
return path === '/api/public' || path.startsWith('/api/public/') ||
|
|
|
|
|
|
path === '/public' || path.startsWith('/public/') ||
|
|
|
|
|
|
(!path.startsWith('/api/') && path.endsWith('.json')) ||
|
2025-12-10 20:52:44 +08:00
|
|
|
|
isHealthCheck
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 判断是否为认证相关请求
|
|
|
|
|
|
*/
|
|
|
|
|
|
function isAuthRequest(url?: string): boolean {
|
2026-10-01 11:48:17 +08:00
|
|
|
|
return ['/api/auth/login', '/api/auth/refresh', '/api/auth/logout'].includes(requestPath(url))
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-04 03:45:52 +08:00
|
|
|
|
function isProtectedOperationalEndpoint(url?: string): boolean {
|
2026-10-01 11:48:17 +08:00
|
|
|
|
const path = requestPath(url)
|
2026-09-04 03:45:52 +08:00
|
|
|
|
return path === '/_gateway/metrics' ||
|
|
|
|
|
|
path.startsWith('/_gateway/audit/') ||
|
|
|
|
|
|
path.startsWith('/_gateway/async-tasks/')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
/**
|
2026-03-17 16:34:09 +08:00
|
|
|
|
* 判断 403 错误是否表示用户账号级别的问题(需要清除认证并跳转)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
*/
|
2026-03-17 16:34:09 +08:00
|
|
|
|
function isAccountLevelForbidden(status: number, errorDetail: string): boolean {
|
|
|
|
|
|
if (status !== 403) return false
|
|
|
|
|
|
const accountErrors = [
|
2025-12-10 20:52:44 +08:00
|
|
|
|
'用户不存在或已禁用',
|
|
|
|
|
|
'用户已禁用',
|
|
|
|
|
|
]
|
2026-03-17 16:34:09 +08:00
|
|
|
|
return accountErrors.some((msg) => errorDetail.includes(msg))
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-11 10:03:10 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 创建 Demo 模式的自定义 adapter
|
|
|
|
|
|
* 在 Demo 模式下拦截请求并返回 mock 数据
|
|
|
|
|
|
*/
|
|
|
|
|
|
function createDemoAdapter(defaultAdapter: AxiosAdapter) {
|
|
|
|
|
|
return async (config: InternalAxiosRequestConfig): Promise<AxiosResponse> => {
|
|
|
|
|
|
if (isDemoMode()) {
|
|
|
|
|
|
try {
|
2026-07-15 23:47:19 +08:00
|
|
|
|
const mockRuntime = await loadMockRuntime()
|
|
|
|
|
|
mockRuntime.setMockUserToken(currentMockUserToken)
|
|
|
|
|
|
const mockResponse = await mockRuntime.handleMockRequest({
|
2025-12-11 10:03:10 +08:00
|
|
|
|
method: config.method?.toUpperCase(),
|
|
|
|
|
|
url: config.url,
|
|
|
|
|
|
data: config.data,
|
|
|
|
|
|
params: config.params,
|
|
|
|
|
|
})
|
|
|
|
|
|
if (mockResponse) {
|
|
|
|
|
|
// 确保响应包含 config
|
|
|
|
|
|
mockResponse.config = config
|
|
|
|
|
|
return mockResponse
|
|
|
|
|
|
}
|
2026-02-22 00:43:41 +08:00
|
|
|
|
} catch (error: unknown) {
|
2025-12-11 10:03:10 +08:00
|
|
|
|
// Mock 错误需要附加 config,否则 handleResponseError 会崩溃
|
2026-02-22 00:43:41 +08:00
|
|
|
|
if (axios.isAxiosError(error)) {
|
2025-12-11 10:03:10 +08:00
|
|
|
|
error.config = config
|
2026-02-22 00:43:41 +08:00
|
|
|
|
if (error.response) {
|
|
|
|
|
|
error.response.config = config
|
|
|
|
|
|
}
|
2025-12-11 10:03:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
throw error
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// 非 Demo 模式或没有 mock 响应时,使用默认 adapter
|
|
|
|
|
|
return defaultAdapter(config)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
class ApiClient {
|
|
|
|
|
|
private client: AxiosInstance
|
|
|
|
|
|
private token: string | null = null
|
2026-09-04 03:45:52 +08:00
|
|
|
|
private authStateVersion = 0
|
2025-12-10 20:52:44 +08:00
|
|
|
|
private isRefreshing = false
|
2026-03-17 16:34:09 +08:00
|
|
|
|
private refreshPromise: Promise<string> | null = null
|
|
|
|
|
|
private readonly refreshCoordinator = new CrossTabRefreshCoordinator()
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
constructor() {
|
|
|
|
|
|
this.client = axios.create({
|
|
|
|
|
|
baseURL: API_BASE_URL,
|
|
|
|
|
|
timeout: NETWORK_CONFIG.API_TIMEOUT,
|
2026-03-17 16:34:09 +08:00
|
|
|
|
withCredentials: true,
|
2025-12-10 20:52:44 +08:00
|
|
|
|
headers: {
|
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2025-12-11 10:03:10 +08:00
|
|
|
|
// 设置自定义 adapter 处理 Demo 模式
|
|
|
|
|
|
const defaultAdapter = getAdapter(this.client.defaults.adapter)
|
|
|
|
|
|
this.client.defaults.adapter = createDemoAdapter(defaultAdapter)
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
this.setupInterceptors()
|
2026-09-04 03:45:52 +08:00
|
|
|
|
this.purgeLegacyStoredTokens()
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 配置请求和响应拦截器
|
|
|
|
|
|
*/
|
|
|
|
|
|
private setupInterceptors(): void {
|
2025-12-11 10:03:10 +08:00
|
|
|
|
// 请求拦截器 - 仅处理认证
|
2025-12-10 20:52:44 +08:00
|
|
|
|
this.client.interceptors.request.use(
|
|
|
|
|
|
(config) => {
|
2026-10-01 11:48:17 +08:00
|
|
|
|
const carriesSessionCredentials = requestPath(config.url).startsWith('/api/') ||
|
2026-09-04 03:45:52 +08:00
|
|
|
|
isProtectedOperationalEndpoint(config.url)
|
|
|
|
|
|
|
|
|
|
|
|
if (carriesSessionCredentials) {
|
2026-03-17 16:34:09 +08:00
|
|
|
|
config.headers['X-Client-Device-Id'] = getClientDeviceId()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
const requiresAuth = !isPublicEndpoint(config.url, config.method) &&
|
2026-09-04 03:45:52 +08:00
|
|
|
|
carriesSessionCredentials
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-04 03:45:52 +08:00
|
|
|
|
private emitAuthStateChange(authenticated: boolean): void {
|
2026-03-17 16:34:09 +08:00
|
|
|
|
if (typeof window === 'undefined') {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
window.dispatchEvent(
|
2026-09-04 03:45:52 +08:00
|
|
|
|
new CustomEvent<AuthStateChangeDetail>(AUTH_STATE_CHANGE_EVENT, {
|
|
|
|
|
|
detail: { authenticated },
|
2026-03-17 16:34:09 +08:00
|
|
|
|
})
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-04 03:45:52 +08:00
|
|
|
|
private publishAuthSessionSignal(authenticated: boolean): void {
|
|
|
|
|
|
if (typeof window === 'undefined') {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const signal: AuthSessionSignal = {
|
|
|
|
|
|
authenticated,
|
|
|
|
|
|
eventId: typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
|
|
|
|
|
? crypto.randomUUID()
|
|
|
|
|
|
: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
|
|
|
|
emittedAt: Date.now(),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
window.localStorage.setItem(AUTH_SESSION_SIGNAL_KEY, JSON.stringify(signal))
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// Cross-tab notification is best effort. The HttpOnly cookie remains the
|
|
|
|
|
|
// source of truth when another tab starts or makes its next request.
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private purgeLegacyStoredTokens(): void {
|
|
|
|
|
|
if (typeof window === 'undefined') {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const storage of [window.localStorage, window.sessionStorage]) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
storage.removeItem('access_token')
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// Storage may be disabled; the token still only lives in memory.
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 处理响应错误
|
|
|
|
|
|
*/
|
2026-09-07 21:14:27 +08:00
|
|
|
|
private async handleResponseError(error: unknown): Promise<AxiosResponse> {
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 请求被取消
|
|
|
|
|
|
if (axios.isCancel(error)) {
|
|
|
|
|
|
return Promise.reject(error)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-22 00:43:41 +08:00
|
|
|
|
if (!axios.isAxiosError(error)) {
|
|
|
|
|
|
return Promise.reject(error)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const originalRequest = error.config
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 网络错误或服务器不可达
|
|
|
|
|
|
if (!error.response) {
|
|
|
|
|
|
log.warn('Network error or server unreachable', error.message)
|
|
|
|
|
|
return Promise.reject(error)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 认证请求错误,直接返回
|
|
|
|
|
|
if (isAuthRequest(originalRequest?.url)) {
|
|
|
|
|
|
return Promise.reject(error)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 16:34:09 +08:00
|
|
|
|
const status = error.response?.status ?? 0
|
|
|
|
|
|
|
|
|
|
|
|
// 处理 403 用户账号级别错误(被禁用/删除)
|
|
|
|
|
|
if (status === 403) {
|
|
|
|
|
|
const rawDetail = (error.response?.data as Record<string, unknown>)?.detail
|
|
|
|
|
|
const errorDetail = typeof rawDetail === 'string' ? rawDetail : ''
|
|
|
|
|
|
if (isAccountLevelForbidden(status, errorDetail)) {
|
|
|
|
|
|
log.info('User account issue detected, clearing auth', { errorDetail })
|
|
|
|
|
|
this.clearAuth()
|
|
|
|
|
|
window.location.href = '/'
|
|
|
|
|
|
return Promise.reject(error)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 处理401错误
|
2026-03-17 16:34:09 +08:00
|
|
|
|
if (status === 401) {
|
2025-12-10 20:52:44 +08:00
|
|
|
|
return this.handle401Error(error, originalRequest)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return Promise.reject(error)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 处理401认证错误
|
|
|
|
|
|
*/
|
2026-02-22 00:43:41 +08:00
|
|
|
|
private async handle401Error(error: import('axios').AxiosError, originalRequest: InternalAxiosRequestConfig & { _retry?: boolean; _retryCount?: number } | undefined): Promise<AxiosResponse> {
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 如果不需要认证,直接返回错误
|
|
|
|
|
|
if (isPublicEndpoint(originalRequest?.url, originalRequest?.method)) {
|
|
|
|
|
|
return Promise.reject(error)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 如果已经重试过,不再重试
|
2026-02-22 00:43:41 +08:00
|
|
|
|
if (!originalRequest || originalRequest._retry) {
|
2025-12-10 20:52:44 +08:00
|
|
|
|
return Promise.reject(error)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 16:34:09 +08:00
|
|
|
|
log.debug('Got 401 error, attempting token refresh')
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
// 标记为已重试
|
|
|
|
|
|
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 {
|
2026-03-17 16:34:09 +08:00
|
|
|
|
const accessToken = await this.refreshPromise
|
|
|
|
|
|
originalRequest.headers.Authorization = `Bearer ${accessToken}`
|
2025-12-10 20:52:44 +08:00
|
|
|
|
return this.client.request(originalRequest)
|
2025-12-12 16:14:33 +08:00
|
|
|
|
} catch {
|
2025-12-10 20:52:44 +08:00
|
|
|
|
return Promise.reject(error)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 开始刷新token
|
2026-03-17 16:34:09 +08:00
|
|
|
|
return this.refreshTokenAndRetry(originalRequest, error)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 刷新token并重试原始请求
|
|
|
|
|
|
*/
|
|
|
|
|
|
private async refreshTokenAndRetry(
|
2026-02-22 00:43:41 +08:00
|
|
|
|
originalRequest: InternalAxiosRequestConfig,
|
|
|
|
|
|
originalError: import('axios').AxiosError
|
|
|
|
|
|
): Promise<AxiosResponse> {
|
2025-12-10 20:52:44 +08:00
|
|
|
|
try {
|
2026-09-04 03:45:52 +08:00
|
|
|
|
const accessToken = await this.restoreSession()
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
// 重试原始请求
|
2026-03-17 16:34:09 +08:00
|
|
|
|
originalRequest.headers.Authorization = `Bearer ${accessToken}`
|
2025-12-10 20:52:44 +08:00
|
|
|
|
return this.client.request(originalRequest)
|
2026-02-22 00:43:41 +08:00
|
|
|
|
} catch (refreshError: unknown) {
|
|
|
|
|
|
log.error('Token refresh failed', refreshError instanceof Error ? refreshError.message : String(refreshError))
|
2026-09-04 03:45:52 +08:00
|
|
|
|
const status = axios.isAxiosError(refreshError) ? refreshError.response?.status : undefined
|
|
|
|
|
|
// Network errors and refresh-rotation conflicts do not prove that the
|
|
|
|
|
|
// current access token or another tab's newly rotated session is invalid.
|
|
|
|
|
|
// Only an authoritative refresh rejection signs the browser out.
|
|
|
|
|
|
if (status === 401 || status === 403) {
|
|
|
|
|
|
this.clearAuth()
|
|
|
|
|
|
}
|
2025-12-10 20:52:44 +08:00
|
|
|
|
return Promise.reject(originalError)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 16:34:09 +08:00
|
|
|
|
private async coordinatedRefresh(): Promise<string> {
|
|
|
|
|
|
return this.refreshCoordinator.run(async () => {
|
|
|
|
|
|
const response = await this.refreshToken()
|
|
|
|
|
|
const accessToken = response.data.access_token
|
|
|
|
|
|
if (!accessToken) {
|
|
|
|
|
|
throw new Error('Refresh response missing access token')
|
|
|
|
|
|
}
|
|
|
|
|
|
return accessToken
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private syncTokenState(token: string | null): void {
|
2026-07-15 23:47:19 +08:00
|
|
|
|
if (this.token !== token) {
|
|
|
|
|
|
cache.clear()
|
2025-12-11 10:03:10 +08:00
|
|
|
|
}
|
2026-07-15 23:47:19 +08:00
|
|
|
|
this.token = token
|
|
|
|
|
|
currentMockUserToken = token
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-04 03:45:52 +08:00
|
|
|
|
setToken(token: string, notifyOtherTabs = false): void {
|
|
|
|
|
|
this.purgeLegacyStoredTokens()
|
|
|
|
|
|
this.authStateVersion += 1
|
2026-07-15 23:47:19 +08:00
|
|
|
|
if (this.token === token) {
|
|
|
|
|
|
cache.clear()
|
|
|
|
|
|
}
|
2026-03-17 16:34:09 +08:00
|
|
|
|
this.syncTokenState(token)
|
2026-09-04 03:45:52 +08:00
|
|
|
|
this.emitAuthStateChange(true)
|
|
|
|
|
|
if (notifyOtherTabs) {
|
|
|
|
|
|
this.publishAuthSessionSignal(true)
|
|
|
|
|
|
}
|
2026-03-17 16:34:09 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
getToken(): string | null {
|
|
|
|
|
|
return this.token
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-04 03:45:52 +08:00
|
|
|
|
clearAuth(notifyOtherTabs = true, emitLocalEvent = true): void {
|
|
|
|
|
|
const hadAuth = this.token !== null
|
|
|
|
|
|
this.authStateVersion += 1
|
2026-03-17 16:34:09 +08:00
|
|
|
|
this.syncTokenState(null)
|
2026-09-04 03:45:52 +08:00
|
|
|
|
this.purgeLegacyStoredTokens()
|
|
|
|
|
|
if (emitLocalEvent && hadAuth) {
|
|
|
|
|
|
this.emitAuthStateChange(false)
|
2025-12-11 10:03:10 +08:00
|
|
|
|
}
|
2026-09-04 03:45:52 +08:00
|
|
|
|
if (notifyOtherTabs) {
|
|
|
|
|
|
this.publishAuthSessionSignal(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async restoreSession(notifyOtherTabs = false): Promise<string> {
|
|
|
|
|
|
if (this.refreshPromise) {
|
|
|
|
|
|
return this.refreshPromise
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.isRefreshing = true
|
|
|
|
|
|
const requestAuthStateVersion = this.authStateVersion
|
2026-09-07 09:03:51 +08:00
|
|
|
|
const restorePromise = (async () => {
|
2026-09-04 03:45:52 +08:00
|
|
|
|
const accessToken = await this.coordinatedRefresh()
|
|
|
|
|
|
if (requestAuthStateVersion !== this.authStateVersion) {
|
|
|
|
|
|
throw new Error('Auth state changed during session restore')
|
|
|
|
|
|
}
|
|
|
|
|
|
this.setToken(accessToken, notifyOtherTabs)
|
|
|
|
|
|
return accessToken
|
|
|
|
|
|
})().finally(() => {
|
|
|
|
|
|
if (this.refreshPromise === restorePromise) {
|
|
|
|
|
|
this.refreshPromise = null
|
|
|
|
|
|
this.isRefreshing = false
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
this.refreshPromise = restorePromise
|
|
|
|
|
|
return restorePromise
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 16:34:09 +08:00
|
|
|
|
async refreshToken(): Promise<AxiosResponse> {
|
2026-05-27 15:06:51 +08:00
|
|
|
|
return this.client.post('/api/auth/refresh')
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-11 10:03:10 +08:00
|
|
|
|
// 以下方法直接委托给 axios client,Demo 模式由 adapter 统一处理
|
2026-02-22 00:43:41 +08:00
|
|
|
|
async request<T = unknown>(config: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
2025-12-10 20:52:44 +08:00
|
|
|
|
return this.client.request<T>(config)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-22 00:43:41 +08:00
|
|
|
|
async get<T = unknown>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
2025-12-10 20:52:44 +08:00
|
|
|
|
return this.client.get<T>(url, config)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-22 00:43:41 +08:00
|
|
|
|
async post<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
2025-12-10 20:52:44 +08:00
|
|
|
|
return this.client.post<T>(url, data, config)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-22 00:43:41 +08:00
|
|
|
|
async put<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
2025-12-10 20:52:44 +08:00
|
|
|
|
return this.client.put<T>(url, data, config)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-22 00:43:41 +08:00
|
|
|
|
async patch<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
2025-12-10 20:52:44 +08:00
|
|
|
|
return this.client.patch<T>(url, data, config)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-22 00:43:41 +08:00
|
|
|
|
async delete<T = unknown>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
2025-12-10 20:52:44 +08:00
|
|
|
|
return this.client.delete<T>(url, config)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-04 03:45:52 +08:00
|
|
|
|
export function parseAuthSessionSignal(raw: string | null): AuthSessionSignal | null {
|
|
|
|
|
|
if (!raw) return null
|
|
|
|
|
|
try {
|
|
|
|
|
|
const signal = JSON.parse(raw) as Partial<AuthSessionSignal>
|
|
|
|
|
|
if (
|
|
|
|
|
|
typeof signal.authenticated !== 'boolean' ||
|
|
|
|
|
|
typeof signal.eventId !== 'string' ||
|
|
|
|
|
|
typeof signal.emittedAt !== 'number'
|
|
|
|
|
|
) {
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
return signal as AuthSessionSignal
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
export default new ApiClient()
|