272 lines
12 KiB
TypeScript
272 lines
12 KiB
TypeScript
import axios, { AxiosError, type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios'
|
|
import { parseApiJsonResponse } from '@chongchong/shared'
|
|
import { accessTokenIdentity, clearSessionTokens, getAccessToken, getRefreshToken, persistTokenPair, type TokenPair } from '@/api/session'
|
|
import { apiErrorMessage, asFieldErrors } from '@/api/errorDetails'
|
|
import { ApiError, isApiEnvelope, isApiFailure, type ApiEnvelope } from '@/api/types'
|
|
import { coordinateCrossTabRefresh, CrossTabRefreshTimeoutError } from '@/api/crossTabRefresh'
|
|
|
|
const transport = axios.create({
|
|
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
|
|
timeout: 15_000,
|
|
transformResponse: [(data: unknown) => parseApiJsonResponse(data)],
|
|
})
|
|
|
|
interface RetryableRequest extends InternalAxiosRequestConfig {
|
|
_retriedAfterRefresh?: boolean
|
|
_sessionIdentity?: string | null
|
|
_logoutGeneration?: number
|
|
}
|
|
|
|
function unwrap<T>(envelope: ApiEnvelope<T>) {
|
|
if (!isApiEnvelope(envelope)) {
|
|
const invalid: unknown = envelope
|
|
if (isApiFailure(invalid)) {
|
|
throw new ApiError(invalid.message, invalid.code, undefined, undefined, invalid.traceId, invalid.requestId)
|
|
}
|
|
throw new ApiError('接口响应格式错误', 'INVALID_RESPONSE')
|
|
}
|
|
if (envelope.code !== '200') {
|
|
const fieldErrors = asFieldErrors(envelope.data)
|
|
throw new ApiError(apiErrorMessage(envelope.message, fieldErrors), envelope.code, undefined, fieldErrors, envelope.traceId, envelope.requestId)
|
|
}
|
|
return envelope.data
|
|
}
|
|
|
|
function mapError(error: unknown) {
|
|
if (error instanceof ApiError) return error
|
|
if (!axios.isAxiosError(error)) return new ApiError(error instanceof Error ? error.message : '请求失败')
|
|
|
|
const body = error.response?.data
|
|
if (isApiEnvelope(body) || isApiFailure(body)) {
|
|
const fieldErrors = asFieldErrors(isApiEnvelope(body) ? body.data : undefined)
|
|
return new ApiError(
|
|
apiErrorMessage(body.message, fieldErrors),
|
|
body.code,
|
|
error.response?.status,
|
|
fieldErrors,
|
|
typeof body.traceId === 'string' ? body.traceId : null,
|
|
typeof body.requestId === 'string' ? body.requestId : null,
|
|
)
|
|
}
|
|
if (error.code === AxiosError.ERR_NETWORK) return new ApiError('无法连接服务器,请确认后端服务已启动', 'NETWORK_ERROR')
|
|
if (error.code === AxiosError.ECONNABORTED) return new ApiError('请求超时,请稍后重试', 'TIMEOUT')
|
|
return new ApiError(error.message || '请求失败', 'HTTP_ERROR', error.response?.status)
|
|
}
|
|
|
|
let refreshExchange: { identity: string; refreshToken: string; promise: Promise<TokenPair> } | null = null
|
|
let logoutGeneration = 0
|
|
let refreshPromise: Promise<string> | null = null
|
|
let refreshIdentity: string | null = null
|
|
let redirectingToLogin = false
|
|
const publicAuthPaths = ['/auth/login', '/auth/register', '/auth/refresh', '/auth/logout', '/auth/impersonations/exchange']
|
|
|
|
function isPublicAuthRequest(url?: string) {
|
|
return publicAuthPaths.some((path) => url?.endsWith(path))
|
|
}
|
|
|
|
function redirectToLoginAfterSessionExpiry() {
|
|
if (redirectingToLogin || typeof window === 'undefined') return
|
|
|
|
const { pathname, search, hash } = window.location
|
|
if (pathname === '/login' || pathname === '/register') return
|
|
|
|
redirectingToLogin = true
|
|
const redirect = `${pathname}${search}${hash}`
|
|
window.location.replace(`/login?redirect=${encodeURIComponent(redirect)}`)
|
|
}
|
|
|
|
function requireCurrentIdentity(identity: string | null | undefined, accessToken = getAccessToken()) {
|
|
if (!identity || accessTokenIdentity(accessToken) !== identity) throw new ApiError('登录身份已变化,请重新操作', 'SESSION_CHANGED')
|
|
}
|
|
|
|
export async function refreshAuthenticatedSession() {
|
|
const observedIdentity = accessTokenIdentity(getAccessToken())
|
|
requireCurrentIdentity(observedIdentity)
|
|
const observedRefreshToken = getRefreshToken()
|
|
if (!observedRefreshToken) throw new ApiError('登录已过期,请重新登录', 'A0401', 401)
|
|
if (!refreshPromise) {
|
|
refreshIdentity = observedIdentity
|
|
refreshPromise = coordinateCrossTabRefresh({
|
|
lockName: 'cc-client-auth-refresh',
|
|
observedRefreshToken,
|
|
currentRefreshToken: getRefreshToken,
|
|
reuseCurrentSession: async () => {
|
|
requireCurrentIdentity(observedIdentity)
|
|
const currentAccessToken = getAccessToken()
|
|
if (!currentAccessToken) throw new ApiError('登录已过期,请重新登录', 'A0401', 401)
|
|
return currentAccessToken
|
|
},
|
|
refresh: async (currentRefreshToken) => {
|
|
requireCurrentIdentity(observedIdentity)
|
|
const exchange = { identity: observedIdentity!, refreshToken: currentRefreshToken, promise: transport.post<ApiEnvelope<TokenPair>>('/auth/refresh', { refreshToken: currentRefreshToken }).then(response => unwrap(response.data)) }
|
|
refreshExchange = exchange
|
|
let tokens: TokenPair
|
|
try { tokens = await exchange.promise } finally { if (refreshExchange === exchange) refreshExchange = null }
|
|
requireCurrentIdentity(observedIdentity)
|
|
requireCurrentIdentity(observedIdentity, tokens.accessToken)
|
|
if (!getRefreshToken()) throw new ApiError('登录已在其他标签页退出', 'A0401', 401)
|
|
if (getRefreshToken() !== currentRefreshToken) throw new ApiError('登录状态已变化,请重新操作', 'SESSION_CHANGED')
|
|
persistTokenPair(tokens)
|
|
if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent('cc-client-session-updated'))
|
|
return tokens.accessToken
|
|
},
|
|
})
|
|
.catch((error) => {
|
|
requireCurrentIdentity(observedIdentity)
|
|
if (error instanceof CrossTabRefreshTimeoutError) {
|
|
if (getRefreshToken() && getRefreshToken() !== observedRefreshToken) throw new ApiError('登录状态已变化,请重新操作', 'SESSION_CHANGED')
|
|
const expiredError = new ApiError(error.message, 'A0401', 401)
|
|
clearSessionTokens()
|
|
window.dispatchEvent(new CustomEvent('cc-client-session-expired'))
|
|
redirectToLoginAfterSessionExpiry()
|
|
throw expiredError
|
|
}
|
|
throw error
|
|
})
|
|
.finally(() => {
|
|
refreshPromise = null
|
|
refreshIdentity = null
|
|
})
|
|
}
|
|
if (refreshIdentity !== observedIdentity) throw new ApiError('登录身份已变化,请重新操作', 'SESSION_CHANGED')
|
|
const refreshed = await refreshPromise
|
|
requireCurrentIdentity(observedIdentity)
|
|
return refreshed
|
|
}
|
|
|
|
transport.interceptors.request.use((config) => {
|
|
const token = getAccessToken()
|
|
const request = config as RetryableRequest
|
|
if (request._retriedAfterRefresh && !isPublicAuthRequest(config.url)) requireCurrentIdentity(request._sessionIdentity, token)
|
|
if (token && !isPublicAuthRequest(config.url)) {
|
|
if (!request._retriedAfterRefresh) {
|
|
request._sessionIdentity = accessTokenIdentity(token)
|
|
request._logoutGeneration = logoutGeneration
|
|
}
|
|
config.headers.Authorization = `Bearer ${token}`
|
|
}
|
|
return config
|
|
})
|
|
|
|
transport.interceptors.response.use(
|
|
(response) => {
|
|
const request = response.config as RetryableRequest
|
|
if (request._sessionIdentity) {
|
|
if (request._logoutGeneration !== logoutGeneration) throw new ApiError('登录身份已变化,请重新操作', 'SESSION_CHANGED')
|
|
requireCurrentIdentity(request._sessionIdentity)
|
|
}
|
|
return response
|
|
},
|
|
async (error: AxiosError) => {
|
|
const request = error.config as RetryableRequest | undefined
|
|
if (error.response?.status !== 401 || !request || request._retriedAfterRefresh || isPublicAuthRequest(request.url)) {
|
|
throw mapError(error)
|
|
}
|
|
|
|
if (request._logoutGeneration !== logoutGeneration) throw new ApiError('登录身份已变化,请重新操作', 'SESSION_CHANGED')
|
|
requireCurrentIdentity(request._sessionIdentity)
|
|
request._retriedAfterRefresh = true
|
|
const refreshTokenBeforeRetry = getRefreshToken()
|
|
try {
|
|
const accessToken = await refreshAuthenticatedSession()
|
|
if (request._logoutGeneration !== logoutGeneration) throw new ApiError('登录身份已变化,请重新操作', 'SESSION_CHANGED')
|
|
requireCurrentIdentity(request._sessionIdentity)
|
|
request.headers.Authorization = `Bearer ${accessToken}`
|
|
return await transport.request(request)
|
|
} catch (refreshError) {
|
|
if (request._logoutGeneration !== logoutGeneration) throw new ApiError('登录身份已变化,请重新操作', 'SESSION_CHANGED')
|
|
requireCurrentIdentity(request._sessionIdentity)
|
|
const mappedError = mapError(refreshError)
|
|
if (mappedError.status === 401 || mappedError.code === 'A0401') {
|
|
if (getRefreshToken() && getRefreshToken() !== refreshTokenBeforeRetry) throw mappedError
|
|
clearSessionTokens()
|
|
window.dispatchEvent(new CustomEvent('cc-client-session-expired'))
|
|
redirectToLoginAfterSessionExpiry()
|
|
}
|
|
throw mappedError
|
|
}
|
|
},
|
|
)
|
|
|
|
// Captured revocation never persists credentials or restores local login state.
|
|
export async function revokeCapturedSession(accessToken: string, refreshToken: string) {
|
|
logoutGeneration++
|
|
const identity = accessTokenIdentity(accessToken)
|
|
if (!identity || !refreshToken) throw new ApiError('服务器会话注销未确认', 'SESSION_CHANGED')
|
|
const pending = refreshExchange?.identity === identity && refreshExchange.refreshToken === refreshToken ? refreshExchange.promise : null
|
|
const revoke = (pair: Pick<TokenPair, 'accessToken' | 'refreshToken'>) => apiRequest<void>({
|
|
method: 'POST', url: '/auth/logout', data: { refreshToken: pair.refreshToken },
|
|
headers: { Authorization: `Bearer ${pair.accessToken}` },
|
|
})
|
|
const validate = (pair: TokenPair) => {
|
|
if (accessTokenIdentity(pair.accessToken) !== identity || !pair.refreshToken) throw new ApiError('刷新身份不一致,服务器会话注销未确认', 'SESSION_CHANGED')
|
|
return pair
|
|
}
|
|
// An already sent refresh can rotate the pair. Consume its actual result
|
|
// without issuing a competing refresh or restoring it to local storage.
|
|
if (pending) {
|
|
await revoke(validate(await pending))
|
|
return
|
|
}
|
|
try { await revoke({ accessToken, refreshToken }) } catch (error) {
|
|
if (!(error instanceof ApiError) || error.status !== 401) throw error
|
|
const pair = await apiRequest<TokenPair>({ method: 'POST', url: '/auth/refresh', data: { refreshToken } })
|
|
await revoke(validate(pair))
|
|
}
|
|
}
|
|
|
|
export async function apiRequest<T>(config: AxiosRequestConfig) {
|
|
try {
|
|
const response = await transport.request<ApiEnvelope<T>>(config)
|
|
return unwrap(response.data)
|
|
} catch (error) {
|
|
throw mapError(error)
|
|
}
|
|
}
|
|
|
|
export async function apiBlobRequest(config: AxiosRequestConfig) {
|
|
try {
|
|
const response = await transport.request<Blob>({ ...config, responseType: 'blob' })
|
|
await throwBlobApiError(response.data, response.status)
|
|
return response.data
|
|
} catch (error) {
|
|
await throwBlobApiError(error)
|
|
throw mapError(error)
|
|
}
|
|
}
|
|
|
|
export async function apiBlobResponseRequest(config: AxiosRequestConfig) {
|
|
try {
|
|
const response = await transport.request<Blob>({ ...config, responseType: 'blob' })
|
|
await throwBlobApiError(response.data, response.status)
|
|
return { blob: response.data, headers: response.headers }
|
|
} catch (error) {
|
|
await throwBlobApiError(error)
|
|
throw mapError(error)
|
|
}
|
|
}
|
|
|
|
async function throwBlobApiError(error: unknown, responseStatus?: number) {
|
|
const blob = error instanceof Blob
|
|
? error
|
|
: axios.isAxiosError(error) && error.response?.data instanceof Blob ? error.response.data : null
|
|
if (!blob || !blob.type.toLowerCase().includes('json')) return
|
|
try {
|
|
const body = JSON.parse(await blob.text()) as unknown
|
|
if (!isApiEnvelope(body) && !isApiFailure(body)) return
|
|
const fieldErrors = asFieldErrors(isApiEnvelope(body) ? body.data : undefined)
|
|
throw new ApiError(
|
|
apiErrorMessage(body.message, fieldErrors),
|
|
body.code,
|
|
responseStatus ?? (axios.isAxiosError(error) ? error.response?.status : undefined),
|
|
fieldErrors,
|
|
typeof body.traceId === 'string' ? body.traceId : null,
|
|
typeof body.requestId === 'string' ? body.requestId : null,
|
|
)
|
|
} catch (parsedError) {
|
|
if (parsedError instanceof ApiError) throw parsedError
|
|
}
|
|
}
|
|
|
|
export { ApiError }
|