Skip to content

9.双 Token 刷新与文件下载流封装

S - Situation (项目背景)

“在开发 MineAdmin 企业级管理后台时,我们面临两个典型的工程化挑战:

  1. 用户体验与安全的矛盾:为了安全,Access Token 有效期设置较短(如 2 小时),导致用户在使用过程中经常被强制登出,体验极差。
  2. 文件下载的复杂性:系统中有大量报表导出需求,后端统一返回二进制流。但在文件生成失败(如无数据)时,后端会返回 JSON 错误信息,而前端统一配置了 responseType: 'blob',导致无法直接读取错误提示,且文件名的解析也经常因为编码问题出现乱码。”

T - Task (主要任务)

“我的任务是优化网络请求层(Axios 封装),实现两个核心目标:

  1. 无感刷新:设计一套机制,在 Token 过期时自动静默刷新,且必须处理好并发请求带来的竞态问题(即避免多个请求同时触发刷新)。
  2. 健壮的下载流处理:封装一个通用的响应拦截器,能自动识别文件流,兼容处理 JSON 错误,并准确解析各种编码格式的文件名。”

A - Action (关键行动)

我在 [http.ts] 中通过拦截器实施了以下方案:

  1. 并发锁 + 请求队列解决 Token 刷新竞态

    • 双 Token 机制:维护 access_tokenrefresh_token。当接口返回 401 时,不立即登出,而是尝试刷新。
    • 并发锁:我引入了一个 isRefreshToken 互斥锁。当第一个 401 请求触发刷新时,锁闭合。
    • 请求队列:后续并发到达的 401 请求,会被拦截并推入一个 requestList 队列挂起(返回一个未决的 Promise)。
    • 重放机制:一旦刷新成功,我更新本地 Token,然后遍历队列,将新 Token 注入 Header 并重新发起所有挂起的请求,用户完全无感知。
  2. 智能文件流适配

    • 类型嗅探:在响应拦截中检查 responseTypecontent-type。如果预期是 Blob 但返回了 application/json,说明下载出错。
    • 格式转换:此时利用 FileReader 将 Blob 转回文本,解析 JSON 里的错误信息并弹窗提示,避免了用户下载到一个写着错误信息的损坏文件。
    • 文件名解码:编写了专门的正则 filename[^;=\n]*=((['"]).*?\2|[^;\n]*)Content-Disposition 中提取文件名,并统一做 decodeURIComponent 处理,完美解决了中文文件名的乱码问题。

R - Result (最终结果)

“这套方案落地后:

  1. 用户零打扰:实现了真正的‘永久在线’体验,除非 Refresh Token 也过期(通常 7-30 天),否则用户无需重复登录。
  2. 并发零冲突:在高并发场景下(如页面初始化同时请求 5 个接口),也能稳定串行刷新,从未出现过多次调用刷新接口的问题。
  3. 开发零负担:业务开发人员只需调用 useHttp().post(),无需关心下载流的转换和错误处理,文件导出功能的开发效率提升了 50% 以上。”

详细版本

这段代码集中在 [src/utils/http.ts] 中,通过 Axios 拦截器实现了两大核心功能:双 Token 自动刷新机制通用文件下载流处理。以下是深度分析:

1. 双 Token 无感刷新机制

这是解决“Token 过期导致用户被强制登出”痛点的经典方案。

  • 核心逻辑:
    1. 触发时机: 当接口返回 401 Unauthorized (代码中对应 ResultCode.UNAUTHORIZED) 时。
    2. 并发锁 (isRefreshToken):
      • 使用一个 ref 变量 isRefreshToken 作为互斥锁。
      • 当第一个 401 请求触发刷新时,将锁置为 true
      • 并发处理: 后续并发的 401 请求会进入 else 分支,被推入 requestList 队列挂起,等待刷新完成。
    3. 刷新流程:
      • 调用 /admin/passport/refresh 接口,携带 refresh_token
      • 成功:
        • 更新本地存储的 tokenrefresh_token
        • 重放队列: 遍历 requestList,用新 Token 重新发送所有挂起的请求。
        • 重放当前请求: 修改当前失败请求的 Header 并重新发起 return http(config)
      • 失败: 执行 logout() 强制登出。
    4. 队列机制:
      • requestList 数组用于暂存并发请求的 resolve 函数。
      • 一旦刷新成功,队列中的函数被执行,挂起的 Promise 重新激活。

代码亮点:

typescript
// 并发锁控制
if (userStore.isLogin && !isRefreshToken.value) {
    isRefreshToken.value = true
    // ... 执行刷新逻辑 ...
} else {
    // 挂起并发请求
    return new Promise((resolve) => {
        requestList.value.push(() => {
            config.headers!.Authorization = `Bearer ${cache.get('token')}` // 注入新 Token
            resolve(http(config)) // 重新请求
        })
    })
}

2. 通用文件下载流处理

解决了前后端分离架构下,二进制文件流(Blob/ArrayBuffer)下载和文件名解析的难题。

  • 智能识别:
    • 通过 response.request.responseType === 'blob' || 'arraybuffer' 自动识别是否为文件流请求。
  • JSON 错误兼容:
    • 痛点: 后端在文件下载出错时(如文件不存在),往往返回 JSON 格式的错误信息,而不是 Blob。但前端设置了 responseType: 'blob',导致 JSON 也被转成了 Blob。
    • 解决: 代码通过 FileReader 尝试将 Blob 转回文本,解析 JSON 判断 code。如果发现是错误信息,弹出 Message 提示并 reject。
  • 文件名解析:
    • 优先从 content-disposition 响应头中提取文件名。
    • 使用正则 filename[^;=\n]*=((['"]).*?\2|[^;\n]*) 兼容各种编码格式(如 URL 编码的中文文件名)。
  • 统一返回:
    • data (Blob), fileName, headers 封装成对象返回,供业务层直接调用下载工具。

代码参考:

typescript
// 处理 JSON 格式的错误响应(当 responseType 为 blob 但实际返回 json 时)
if (response.data instanceof Blob && response.data.type === 'application/json') {
    // FileReader 读取 Blob 内容...
}

// 文件名解析
const match = disposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)
if (match && match[1]) {
    fileName = decodeURIComponent(match[1].replace(/['"]/g, ''))
}

总结

这段代码展现了成熟的工程化思维:

  1. 用户体验: 通过无感刷新,避免了 Token 短期过期导致的用户频繁登录。
  2. 健壮性: 考虑了并发请求的竞态问题(Race Condition),通过队列机制完美解决。
  3. 通用性: 统一封装了文件下载的边界情况(JSON 错误、文件名乱码),减轻了业务开发的负担。

Released under the MIT License.