ec-admin/src/api/http.ts
liuyun 7ae220581a fix(security): 角色资源树回显防权限放大 + 401 刷新防死循环
- assign-resources-form:只保存叶子键 getCheckedKeys(true),回显 setCheckedKeys(leafOnly)
- http.ts:_retry 标记只重放一次 + 排除 login/refresh

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 16:10:32 +08:00

139 lines
4.8 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 AxiosError, type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from 'axios'
import { clearTokens, getAccessToken, getRefreshToken, setTokens } from '@/utils/token'
/** 后端统一响应包裹:{ code, message, data },code=0 成功。 */
interface ApiEnvelope<T = unknown> {
code: number
message: string
data: T
}
/** 业务错误(code 为 HTTP 状态码 * 100,如 40100/40300/50000)。 */
export class ApiError extends Error {
constructor(
public code: number,
message: string,
) {
super(message)
this.name = 'ApiError'
}
}
/** 会话过期回调(token 刷新失败时触发),由应用启动时注册。 */
let onAuthExpired: (() => void) | null = null
export function setAuthExpiredHandler(handler: () => void): void {
onAuthExpired = handler
}
interface HttpClientOptions {
baseURL?: string
timeout?: number
}
/**
* 面向对象的 Axios 封装:
* - 请求拦截器:注入 Authorization 头
* - 响应拦截器:拆 { code, message, data },code=0 返回 data;401 自动刷新重放;其余抛 ApiError
*/
export class HttpClient {
private instance: AxiosInstance
/** 无拦截器的裸实例,供刷新令牌等内部请求使用(避免递归拦截)。 */
private rawInstance: AxiosInstance
private refreshing: Promise<string> | null = null
constructor(options: HttpClientOptions = {}) {
const baseURL = options.baseURL ?? import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000/api/v1'
const timeout = options.timeout ?? 10000
this.instance = axios.create({ baseURL, timeout })
this.rawInstance = axios.create({ baseURL, timeout })
this.setupInterceptors()
}
private setupInterceptors(): void {
this.instance.interceptors.request.use((config) => {
const token = getAccessToken()
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
this.instance.interceptors.response.use(
(response) => {
const body = response.data as ApiEnvelope
if (body.code === 0) return body.data as AxiosResponse
return Promise.reject(new ApiError(body.code, body.message))
},
async (error: AxiosError) => {
const config = error.config as (AxiosRequestConfig & { _retry?: boolean }) | undefined
const url = config?.url ?? ''
const isAuthEndpoint = url.includes('/auth/login') || url.includes('/auth/refresh')
// 401 自动刷新并重放一次(登录/刷新接口自身 401 不重放,_retry 防死循环)
if (error.response?.status === 401 && config && !config._retry && !isAuthEndpoint) {
config._retry = true
try {
const token = await this.refresh()
config.headers = config.headers ?? {}
config.headers.Authorization = `Bearer ${token}`
return this.instance.request(config)
} catch {
clearTokens()
onAuthExpired?.()
}
}
const body = error.response?.data as ApiEnvelope | undefined
return Promise.reject(new ApiError(body?.code ?? -1, body?.message ?? error.message))
},
)
}
/** 刷新令牌(并发去重),返回新 accessToken。 */
private refresh(): Promise<string> {
if (!this.refreshing) {
this.refreshing = this.doRefresh().finally(() => {
this.refreshing = null
})
}
return this.refreshing
}
private async doRefresh(): Promise<string> {
const refreshToken = getRefreshToken()
if (!refreshToken) throw new ApiError(40100, '未登录')
const { data } = await this.rawInstance.post<ApiEnvelope<{ accessToken: string; refreshToken: string }>>(
'/auth/refresh',
{ refreshToken },
)
if (data.code !== 0) throw new ApiError(data.code, data.message)
setTokens(data.data.accessToken, data.data.refreshToken)
return data.data.accessToken
}
request<T>(config: AxiosRequestConfig): Promise<T> {
return this.instance.request(config) as Promise<T>
}
get<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
return this.request<T>({ ...config, method: 'GET', url })
}
post<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
return this.request<T>({ ...config, method: 'POST', url, data })
}
put<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
return this.request<T>({ ...config, method: 'PUT', url, data })
}
patch<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
return this.request<T>({ ...config, method: 'PATCH', url, data })
}
delete<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
return this.request<T>({ ...config, method: 'DELETE', url })
}
}
/** 全局单例,业务直接使用 http.get/post/... */
export const http = new HttpClient()