eagleye

在 Axios 请求拦截器中添加 Blob 数据检测功能

# 在 Axios 请求拦截器中添加 Blob 数据检测功能

我将在现有的 `AxiosService` 类中添加功能,用于检测请求数据中是否包含 Blob 数据,并自动设置正确的 Content-Type 头。

## 实现代码

```typescript
// src/services/axios.ts
// 导入axios库及相关类型定义
import axios, {
type AxiosError,
type AxiosInstance,
type AxiosRequestConfig,
type AxiosResponse,
type InternalAxiosRequestConfig,
type RawAxiosRequestHeaders,
} from 'axios'
import { useAuthStore } from 'stores/auth' // 导入状态管理(用于获取认证令牌)
import type { ApiResponse, TokenRefreshResponse } from './types'

/**
* 企业级Axios服务(核心HTTP客户端)
* 职责:
* 1. 管理HTTP请求生命周期
* 2. 实现认证令牌自动管理
* 3. 提供企业级安全特性(CSRF保护、幂等键、审计日志)
* 4. 统一错误处理机制
* 5. 自动检测Blob数据并设置正确的Content-Type
*/
export class AxiosService {
readonly instance: AxiosInstance // axios实例(核心请求工具)
private isRefreshing = false // 标记是否正在刷新令牌
private refreshSubscribers: ((token: string) => void)[] = [] // 令牌刷新订阅队列(存储等待令牌更新的请求回调)

constructor() {
// 初始化axios实例(配置基础URL、超时时间、请求头、跨域携带cookie)
this.instance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL, // 基础API地址(从环境变量获取,默认本地开发地址)
timeout: 10000, // 超时时间10秒
headers: { 'Content-Type': 'application/json; charset=utf-8' }, // 默认请求头(JSON格式)
withCredentials: true, // 跨域请求是否携带凭证(跨域请求携带cookie(用于CSRF认证等))
})

// 初始化拦截器(关键逻辑入口)
this.setupInterceptors()
}

/** 获取axios实例(供外部使用) */
public getInstance(): AxiosInstance {
return this.instance
}

/** 配置请求/响应拦截器(核心逻辑) */
private setupInterceptors(): void {
// 请求拦截器(处理认证、安全头、日志追踪)
this.instance.interceptors.request.use(
(config: InternalAxiosRequestConfig) => this.handleRequest(config), // 成功时处理请求配置
(error: unknown) => this.handleRequestError(error), // 失败时处理请求错误
)

// 响应拦截器(处理成功响应、错误重试、令牌刷新)
this.instance.interceptors.response.use(
(response: AxiosResponse) => this.handleResponse(response), // 成功时处理响应
(error: AxiosError) => this.handleResponseError(error), // 失败时处理响应错误
)
}

/**
* 检测请求数据中是否包含Blob数据
* @param data 要检测的数据
* @returns 是否包含Blob数据
*/
private containsBlobData(data: unknown): boolean {
if (data instanceof Blob) {
return true
}

if (data instanceof FormData) {
// 检查FormData中是否包含Blob
for (const value of data.values()) {
if (value instanceof Blob) {
return true
}
}
return false
}

if (Array.isArray(data)) {
// 检查数组中的每个元素
return data.some(item => this.containsBlobData(item))
}

if (typeof data === 'object' && data !== null) {
// 检查对象中的每个属性值
return Object.values(data).some(value => this.containsBlobData(value))
}

return false
}

/** 获取cookie(用于CSRF令牌) */
private getCookie(name: string): string | null {
if (typeof document === 'undefined') return null // 服务端渲染时无document对象
const value = `; ${document.cookie}` // 转换为"; name=value"格式便于分割
const parts = value.split(`; ${name}=`) // 按cookie名分割
return parts.length === 2 ? parts.pop()?.split(';').shift() || null : null // 提取cookie值
}

/** 转换响应头格式(将值统一为字符串类型) */
private transformHeaders(headers: AxiosResponse['headers']): Record<string, string> {
const result: Record<string, string> = {}
for (const [key, value] of Object.entries(headers)) {
if (value !== undefined && value !== null) {
result[key] = String(value) // 将值转换为字符串
}
}
return result
}

/** 处理请求配置(核心逻辑:认证、安全头、日志) */
private handleRequest(config: InternalAxiosRequestConfig): InternalAxiosRequestConfig {
config.headers = config.headers || ({} as RawAxiosRequestHeaders) // 初始化headers(防止undefined)

// 自动检测Blob数据并设置正确的Content-Type
if (config.data && this.containsBlobData(config.data)) {
// 如果包含Blob数据,自动设置为multipart/form-data
// 但保留用户显式设置的Content-Type(如果有)
if (!config.headers['Content-Type'] && !config.headers['content-type']) {
config.headers['Content-Type'] = 'multipart/form-data'
}
}

// 仅在需要时添加认证头
if (!config.isPublic && !config.skipToken) {
const authStore = useAuthStore()
const token = authStore.getAccessToken
// eslint-disable-next-line @typescript-eslint/no-base-to-string
config.headers.Authorization = `Bearer ${String(token)}`
}

// 生成唯一请求ID(用于日志追踪)
config.headers['X-Request-ID'] = crypto.randomUUID()

// 添加CSRF令牌(从cookie获取)
const csrfToken = this.getCookie('tokenise')
if (csrfToken) {
config.headers['X-CSRFToken'] = csrfToken
}

// 处理安全相关请求头(幂等键、权限、审计原因)
if (config.securityHeaders) {
const { idempotencyKey, permissionRequired, auditReason } = config.securityHeaders
// 幂等键(仅非认证请求添加,避免登录接口重复提交)
const isAuthRequest = config.url?.includes('/auth') || config.url?.includes('/login')
if (idempotencyKey && !isAuthRequest) {
config.headers['X-Idempotency-Key'] = idempotencyKey
}
if (permissionRequired) config.headers['X-Permission-Required'] = permissionRequired // 权限标识
if (auditReason) config.headers['X-Audit-Reason'] = auditReason // 审计原因
}

// 开发环境日志(调试用)
if (import.meta.env.DEV) {
console.debug('✅【请求拦截器-发送请求】', {
timestamp: new Date().toLocaleString(), //时间戳
method: config.method?.toUpperCase(), //请求方法
url: config.url, //请求URL
headers: config.headers, //请求头
params: config.params, //请求参数
data: config.data, //请求数据
security: config.securityHeaders,
bearer: config.headers.Authorization,
containsBlob: config.data ? this.containsBlobData(config.data) : false,
})
}

return config
}

/** 处理请求错误(打印错误日志并拒绝Promise) */
private handleRequestError(error: unknown): Promise<never> {
const errorObj =
error instanceof Error
? error
: new Error(typeof error === 'string' ? error : 'Unknown request error') // 转换为Error对象

console.error('[API Request Error]', errorObj)
return Promise.reject(errorObj) // 拒绝Promise(传递错误)
}

/** 处理成功响应(记录成功日志) */
private handleResponse(response: AxiosResponse): AxiosResponse {
this.logApiSuccess(response.config) // 记录成功日志
return response
}

/** 将axios响应转换为统一API响应格式(成功场景) */
private transformToApiResponse(response: AxiosResponse): ApiResponse {
return {
success: true,
data: response.data,
status: response.status,
headers: this.transformHeaders(response.headers), // 转换响应头格式
}
}

/** 将错误转换为统一API响应格式(失败场景) */
private transformErrorToApiResponse(error: unknown): ApiResponse {
if (axios.isAxiosError(error)) {
// 是axios错误
return {
success: false,
error: {
code: error.code || 'AXIOS_ERROR', // 错误代码(如"ECONNABORTED")
message: error.message, // 错误信息
details: error.response?.data, // 响应数据(可选)
},
status: error.response?.status || 500, // HTTP状态码(默认500)
headers: error.response ? this.transformHeaders(error.response.headers) : {}, // 响应头(可选)
}
}

// 非axios错误(如原生Error)
const message = error instanceof Error ? error.message : 'Unknown error'
return {
success: false,
error: {
code: 'UNKNOWN_ERROR', // 未知错误代码
message,
details: error, // 错误对象本身
},
status: 500,
headers: {},
}
}

/** 处理响应错误(核心逻辑:超时重试、令牌刷新) */
private async handleResponseError(error: AxiosError): Promise<ApiResponse> {
// 原始请求配置
const originalRequest = error.config as InternalAxiosRequestConfig & {
_retry?: boolean
isPublic?: boolean
}

// 处理连接超时(ECONNABORTED):自动重试一次
if (error.code === 'ECONNABORTED' && originalRequest) {
try {
// 延迟1秒后重试原请求
const response = await new Promise<AxiosResponse>((resolve) => {
setTimeout(() => resolve(this.instance(originalRequest)), 1000)
})
return this.transformToApiResponse(response) // 转换为统一响应格式
} catch (retryError) {
return this.transformErrorToApiResponse(retryError) // 重试失败时转换错误响应
}
}

// 处理401未授权错误(仅重试一次)
if (error.response?.status === 401 && originalRequest && !originalRequest._retry) {
return this.handleUnauthorizedError(error, originalRequest) // 调用令牌刷新逻辑
}

// 其他错误直接转换响应格式
return this.transformErrorToApiResponse(error)
}

/** 处理未授权错误(核心逻辑:刷新令牌并重试请求) */
private async handleUnauthorizedError(
_error: AxiosError,
originalRequest: InternalAxiosRequestConfig & { _retry?: boolean },
): Promise<ApiResponse> {
originalRequest._retry = true // 标记已重试(防止无限循环)
const authStore = useAuthStore()
const refreshToken = authStore.getRefreshToken

if (!refreshToken) {
// 无刷新令牌:登出并重定向登录
await authStore.logout()
this.redirectToLogin()
return this.transformErrorToApiResponse(new Error('Missing refresh token'))
}

// 正在刷新令牌时:将请求加入订阅队列,等待新令牌
if (this.isRefreshing) {
return new Promise<ApiResponse>((resolve) => {
this.refreshSubscribers.push((newToken: string) => {
originalRequest.headers.Authorization = `Bearer ${newToken}` // 更新请求头令牌
resolve(
this.instance(originalRequest).then(
(response) => this.transformToApiResponse(response), // 重试请求并转换响应
),
)
})
})
}

// 开始刷新令牌
this.isRefreshing = true
try {
// 调用刷新令牌接口(注意清除原认证头,避免旧令牌干扰)
const { data } = await this.instance.post<TokenRefreshResponse>(
'/auth/token/refresh/',
{ refresh: refreshToken },
{ headers: { Authorization: '' }, isPublic: true, skipToken: true }, // 清除Authorization头(使用refresh_token认证)
)

// 更新令牌存储(状态管理+本地存储)
await authStore.setTokens(data.access, data.refresh) // 更新两个令牌

// 更新原请求头令牌
originalRequest.headers.Authorization = `Bearer ${data.access}`

// 触发所有订阅的请求(使用新令牌重试)
this.refreshSubscribers.forEach((cb) => cb(data.access))
this.refreshSubscribers = [] // 清空订阅队列

// 重试原请求并返回结果
const response = await this.instance(originalRequest)
return this.transformToApiResponse(response)
} catch (refreshError) {
// 刷新令牌失败:登出并重定向登录
await authStore.logout()
this.redirectToLogin()
return this.transformErrorToApiResponse(refreshError)
} finally {
this.isRefreshing = false // 标记刷新完成
}
}

/** 记录API成功日志(开发环境打印) */
private logApiSuccess(config: AxiosRequestConfig): void {
const authStore = useAuthStore()
const requestId = config.headers?.['X-Request-ID'] as string // 请求ID(用于追踪)
const userId = authStore.user?.id || localStorage.getItem('user_id') || 'unknown' // 用户ID(用于关联日志)

// 开发环境输出调试日志
if (import.meta.env.DEV) {
console.debug('✅【响应拦截器-API Success】', {
timestamp: new Date().toLocaleString(), // 时间戳
method: config.method?.toUpperCase(), // 请求方法
url: config.url, // 请求URL
requestId, // 请求ID
userId, // 用户ID
containsBlob: config.data ? this.containsBlobData(config.data) : false,
})
}
}

/** 重定向到登录页面(携带当前路径作为重定向参数) */
private redirectToLogin(): void {
if (typeof window !== 'undefined') {
// 仅在浏览器环境执行
const currentPath = encodeURIComponent(window.location.pathname + window.location.search) // 当前路径编码
window.location.href = `/login?redirect=${currentPath}` // 跳转到登录页并传递重定向参数
}
}
}
```

## 主要改进和功能说明

### 1. 添加了 `containsBlobData` 方法

这个方法递归检查请求数据中是否包含任何 Blob 对象:

```typescript
private containsBlobData(data: unknown): boolean {
if (data instanceof Blob) {
return true
}

if (data instanceof FormData) {
// 检查FormData中是否包含Blob
for (const value of data.values()) {
if (value instanceof Blob) {
return true
}
}
return false
}

if (Array.isArray(data)) {
// 检查数组中的每个元素
return data.some(item => this.containsBlobData(item))
}

if (typeof data === 'object' && data !== null) {
// 检查对象中的每个属性值
return Object.values(data).some(value => this.containsBlobData(value))
}

return false
}
```

### 2. 在请求拦截器中自动检测和设置 Content-Type

在 `handleRequest` 方法中添加了以下逻辑:

```typescript
// 自动检测Blob数据并设置正确的Content-Type
if (config.data && this.containsBlobData(config.data)) {
// 如果包含Blob数据,自动设置为multipart/form-data
// 但保留用户显式设置的Content-Type(如果有)
if (!config.headers['Content-Type'] && !config.headers['content-type']) {
config.headers['Content-Type'] = 'multipart/form-data'
}
}
```

### 3. 企业级特性

1. **智能检测**:递归检查各种数据结构中的 Blob 对象
2. **保留用户设置**:如果用户已经显式设置了 Content-Type,不会覆盖
3. **类型安全**:完全避免使用 `any` 类型,使用 TypeScript 的 `unknown` 类型进行安全检测
4. **性能优化**:在开发环境中添加了调试日志,帮助开发者了解请求内容
5. **完整覆盖**:支持检测 FormData、数组、对象等各种数据结构中的 Blob

### 4. 调试日志增强

在开发环境中,现在会显示请求是否包含 Blob 数据:

```typescript
if (import.meta.env.DEV) {
console.debug('✅【请求拦截器-发送请求】', {
// ... 其他日志字段
containsBlob: config.data ? this.containsBlobData(config.data) : false,
})
}
```

## 使用示例

这个功能现在会自动工作,无需开发者手动设置 Content-Type。例如:

```typescript
// 上传文件 - 会自动检测并设置 multipart/form-data
const formData = new FormData()
formData.append('file', fileBlob)
formData.append('name', 'example.txt')

await apiClient.post('/upload', formData)

// 普通JSON数据 - 保持默认的 application/json
await apiClient.post('/data', { name: 'John', age: 30 })
```

这个实现提供了企业级的自动化处理,确保文件上传等包含 Blob 数据的请求能够正确设置 Content-Type,同时保持代码的类型安全和性能优化。

posted on 2025-08-25 10:13  GoGrid  阅读(20)  评论(0)    收藏  举报

导航