Files
webgame-frontend/src/api/request/requestClient.ts
2026-06-24 11:14:27 +08:00

222 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import axios, { type AxiosInstance, type CreateAxiosDefaults } from 'axios'
import { useAccountStore } from '@/stores'
import { eventBus, AppEvents } from '@/utils'
import type { ApiResponse, InternalRequestConfig } from './types'
import { createRefreshTokenInterceptor } from './refreshInterceptor'
/**
* 替换 URL 中的路径参数占位符
*
* @example
* resolvePathParams('/user/:id/posts', { id: 1 }) // → '/user/1/posts'
*/
function resolvePathParams(url: string, pathParams?: Record<string, string | number>): string {
if (!pathParams) return url
return url.replace(/:(\w+)/g, (_, key) => {
const value = pathParams[key]
if (value === undefined) {
throw new Error(`路径参数 "${key}" 缺失`)
}
return String(value)
})
}
/**
* 基于 axios 封装的网络请求客户端
*
* 不依赖任何 UI 库,错误通过事件总线发布,由外部订阅处理。
*
* @example
* ```ts
* // GET 请求
* const res = await requestClient.get<UserListResult>('/users', { page: 1 })
*
* // POST 请求
* const res = await requestClient.post<LoginResult>('/auth/login', { username, password })
* ```
*/
class RequestClient {
/** axios 实例 */
public instance: AxiosInstance
/** 是否正在刷新 token */
isRefreshing = false
/** 刷新期间等待队列401 请求在刷新完成前暂存,刷新后用新 token 重试) */
refreshTokenQueue: Array<(token: string) => void> = []
constructor(config?: CreateAxiosDefaults) {
this.instance = axios.create({
baseURL: '/api',
timeout: 15000,
headers: {
'Content-Type': 'application/json'
},
...config
})
this.setupInterceptors()
}
// @ts-ignore
/** 注册请求和响应拦截器 */
private setupInterceptors(): void {
// === 请求拦截器 ===
this.instance.interceptors.request.use(
(config) => {
const reqConfig = config as InternalRequestConfig
// 替换路径参数 :param → 实际值
if (reqConfig.pathParams && config.url) {
config.url = resolvePathParams(config.url, reqConfig.pathParams)
}
// 跳过鉴权时不注入 saToken
if (!reqConfig?.skipAuth) {
const accountStore = useAccountStore()
if (accountStore.accessToken) {
config.headers.saToken = accountStore.accessToken
}
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// === 通用响应拦截器 ===
// 职责:解包 ApiResponse + 按 HTTP 状态码分类处理错误。
// HTTP 401 全部透传,由 refreshInterceptor 统一处理。
this.instance.interceptors.response.use(
// @ts-expect-error 拦截器解包 axiosResponse → ApiResponse
(response) => {
const data = response.data as ApiResponse
if (data.code === 0) {
return data
}
// code !== 0 但 HTTP 2xx — 未改造端点或特殊场景,按业务错误处理
eventBus.emit(AppEvents.REQUEST_ERROR, {
type: 'business',
code: data.code,
httpStatus: response.status,
message: data.message || '请求失败'
})
return Promise.reject(data)
},
(error) => {
const status = error.response?.status
// HTTP 401 透传给刷新拦截器
if (status === 401) {
throw error
}
// HTTP 400/403/404/409/405/500 — 从 body 提取 ApiResponse
const body = error.response?.data as ApiResponse | undefined
if (body && status) {
eventBus.emit(AppEvents.REQUEST_ERROR, {
type: 'business',
code: body.code,
httpStatus: status,
message: body.message || `请求错误 (${status})`
})
return Promise.reject(body)
}
// 网络异常(无 response
eventBus.emit(AppEvents.REQUEST_ERROR, {
type: error.response ? 'http' : 'network',
code: status,
httpStatus: status,
message: error.response ? `请求错误 (${status})` : '网络异常,请检查网络连接'
})
return Promise.reject(error)
}
)
// === Token 刷新拦截器 ===
// 通用拦截器透传 401 → 此处统一处理刷新/重试/重新认证
const { onFulfilled, onRejected } = createRefreshTokenInterceptor({
client: this.instance,
doReAuthenticate: async () => {
const accountStore = useAccountStore()
accountStore.logout()
eventBus.emit(AppEvents.UNAUTHORIZED)
},
doRefreshToken: () => {
const accountStore = useAccountStore()
return accountStore.refreshAccessToken()
},
formatToken: (token: string) => token,
isRefreshing: {
get value() {
return requestClient.isRefreshing
},
set value(v: boolean) {
requestClient.isRefreshing = v
}
},
refreshTokenQueue: this.refreshTokenQueue
})
// 拦截器解包 axiosResponse → ApiResponse
this.instance.interceptors.response.use(onFulfilled, onRejected)
}
/**
* 通用请求方法
*
* 适用于路径参数、混合参数等复杂场景。
*/
request<T = unknown>(config: InternalRequestConfig): Promise<ApiResponse<T>> {
return this.instance.request(config)
}
/** GET 请求 */
get<T = unknown>(
url: string,
params?: Record<string, unknown>,
config?: InternalRequestConfig
): Promise<ApiResponse<T>> {
return this.instance.get(url, { params, ...config })
}
/** POST 请求 */
post<T = unknown>(
url: string,
data?: unknown,
config?: InternalRequestConfig
): Promise<ApiResponse<T>> {
return this.instance.post(url, data, config)
}
/** PUT 请求 */
put<T = unknown>(
url: string,
data?: unknown,
config?: InternalRequestConfig
): Promise<ApiResponse<T>> {
return this.instance.put(url, data, config)
}
/** DELETE 请求 */
delete<T = unknown>(url: string, config?: InternalRequestConfig): Promise<ApiResponse<T>> {
return this.instance.delete(url, config)
}
/** PATCH 请求 */
patch<T = unknown>(
url: string,
data?: unknown,
config?: InternalRequestConfig
): Promise<ApiResponse<T>> {
return this.instance.patch(url, data, config)
}
}
/** 请求客户端单例 */
export const requestClient = new RequestClient()