在 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,同时保持代码的类型安全和性能优化。
 
                    
                     
                    
                 
                    
                 
                
            
         
 
         浙公网安备 33010602011771号
浙公网安备 33010602011771号