数智人事系统(八):401 自动续期与并发去重刷新

101 阅读8分钟

数智人事系统(八):401 自动续期与并发去重刷新

上一期完成了后端双 Token 链路:登录签发两个 httpOnly Cookie,token 15 分钟过期、refreshToken 7 天仅在 /refresh 使用。本期处理前端对这套机制的对接——Access Token 过期、后端返回 1200 时,用户不应看到任何提示、不应丢失数据、页面不应跳转。

该需求即“无感续期”。hrm-admin/src/utils/request.js 中约 60 行拦截器逻辑表面上只是“收到 401 就去刷新”,关键问题在于:页面同时有 5 个请求都返回 401 时,是否会发出 5 个 /refresh?答案是不会,依靠的是“并发去重刷新”模式。

动手前提

需完成第 7 期:后端 /refresh 端点可用、双 Cookie 已验证。本期需要前端能运行(npm run serve),并打开浏览器 DevTools 的 Network 面板。

本期目标

Access Token 过期后,任意接口返回 1200 时自动触发一次 /refresh,新 token 写回 Cookie 后原请求自动重试;多个并发 401 只发一次 /refresh;刷新失败跳登录页。整个过程用户无感知。

并发去重刷新:并发 401 只发一次 /refresh

本期第一个术语:并发去重刷新指多个并发请求触发同一副作用操作时,只执行一次该操作,其余请求排队等待结果复用。

对应到续期场景:5 个请求同时 401,第一个请求调用 /refresh,其余 4 个排队等待其成功后一起重试——不出现 5 个各发一次 /refresh,也不出现 5 个全部失败。

hrm-admin/src/utils/request.js:33-34 的两个变量是该机制的骨架:

let isRefreshing = false        // 刷新进行中的标志
let pendingRequests = []        // 等待刷新的请求队列

isRefreshing 是互斥锁,pendingRequests 是等待队列。拦截器的用法(request.js:51-84):

instance.interceptors.response.use(response => {
  const res = response.data
  
  // 401/1200 → 静默刷新(排除登录和刷新接口本身)
  if ((res.code === 1200 || res.code === 401) &&
      !response.config.url.includes('/login') &&
      !response.config.url.includes('/refresh')) {
    
    const originalRequest = response.config
    
    if (!isRefreshing) {
      // 第一个 401:由它负责刷新
      isRefreshing = true
      return axios.post(BASE_API + '/refresh', {}, { withCredentials: true })
        .then(refreshRes => {
          if (refreshRes.data.code === 200) {
            onRefreshed()                    // 唤醒队列里所有等待的请求
            return instance(originalRequest) // 重试原请求
          }
          toLogin()                          // 刷新失败 → 跳登录页
          return Promise.reject(res.message)
        })
        .finally(() => {
          isRefreshing = false               // 释放锁
        })
    } else {
      // 第二个及以后的 401:排队等刷新完成
      return new Promise(resolve => {
        pendingRequests.push(() => {
          resolve(instance(originalRequest))  // 刷新成功后重试
        })
      })
    }
  }
  // ... 其他错误处理
})

onRefreshed() 函数(request.js:38-40):

function onRefreshed() {
  pendingRequests.forEach(callback => callback())  // 依次执行队列里的重试
  pendingRequests = []                              // 清空队列
}

设计依据:三个必须解决的问题

  • 并发请求会同时触发刷新。 典型的列表页加载时并发发 3-5 个请求(用户信息、菜单、列表数据、字典枚举),共享同一个 15 分钟的 Access Token,过期时刻几乎相同——5 个 401 在毫秒级同时到达拦截器。若每个都独立调 /refresh,后端瞬间收到 5 个刷新请求,5 个新 token 写回 Cookie,最后一个覆盖前面 4 个,前 4 个请求的重试随之失败。isRefreshing 标志将该情况转为“第一个到的负责刷新,后来的排队”。队列中的请求既不直接失败也不直接重试,而是包装成 new Promise 暂存——Promise 的 resolve 时机被延后,等 onRefreshed() 被调用时才触发 instance(originalRequest) 重试。

  • /refresh 自身返回 401。 refreshToken 也过期时,/refresh 返回 401,若拦截器对其再次触发刷新会陷入无限循环:/refresh 401 → 触发刷新 → 又 /refresh → 又 401。代码中的两行排除(request.js:54-55)解决了该问题:

    !response.config.url.includes('/login') &&
    !response.config.url.includes('/refresh')
    

    /login 与 /refresh 的响应不参与刷新逻辑——登录接口返回 401 是“密码错误”,刷新接口返回 401 是“refreshToken 过期”,两种情况都直接 toLogin()。

  • HTTP 错误与业务错误都要接住。 拦截器第一个回调处理 response(HTTP 200 但业务码可能是 1200),第二个回调处理 error(HTTP 4xx/5xx)。后端“HTTP 200 + 业务码”的设计(第 5 期的 WebUtil.setStatus(200))使大部分认证失败走第一个分支,但网络层错误、代理层 401 等边缘情况会走 error 分支。因此 request.js:96-140 的 error 分支中有一套几乎相同的逻辑:检查 error.response.data.code === 1200 || res.status === 401,同样使用 isRefreshing 与 pendingRequests。两个分支共享同一套状态变量,无论 401 从哪边来,都只有一个 /refresh 在进行。

/refresh 请求的关键细节

刷新请求本身有三个设计点(request.js:60-63):

axios.post(
  BASE_API + '/refresh',
  {},                              // 空 body,refreshToken 在 Cookie 里
  { withCredentials: true }        // 必须!否则浏览器不带 Cookie
)
  • withCredentials: true 是必要条件。 axios 默认跨域不带 Cookie,该配置使浏览器把 refreshToken(Path=/refresh 的 Cookie)自动放进请求头。后端 SecurityConfig 中的 cors.allowCredentials(true) 需与之配合——缺任何一边,refreshToken 都不会出现在请求里,/refresh 返回 401 后触发 toLogin()。
  • 空 body。 refreshToken 不在请求体中,在 Cookie 里。后端 LoginController.refresh 从 request.getCookies() 中取 refreshToken 字段(LoginController.java:56-63),不解析 body——这是 httpOnly Cookie 方案的直接结果,前端代码接触不到 token 值。
  • 使用裸 axios 而非 instance。 该处直接调 axios.post 而非 instance.post——instance 是正在配置的 axios 实例,用它发 /refresh 会再次经过拦截器,刷新失败时可能再次触发刷新逻辑。裸 axios 绕过拦截器,/refresh 的响应只在该 .then 中处理。

整条链路

假设页面同时发出 3 个请求(A、B、C),Access Token 刚好过期:

并发去重刷新时序图

关键点:队列中的请求不是“先存起来再一个个发”,而是“Promise 挂起等 resolve”。pendingRequests.push(() => resolve(instance(originalRequest))) 把重试逻辑包在回调中,onRefreshed() 一次性触发所有回调,各回调的 resolve 使对应 Promise 完成,原请求的 .then 继续执行——对调用方而言,与一次正常成功的请求没有区别。

动手验证:DevTools 观察三次请求

前端启动后(npm run serve),打开浏览器 DevTools 的 Network 面板:

  • 制造 Access Token 过期:将后端 JwtUtil.java:26 的 ACCESS_EXPIRATION 改为 10 * 1000(10 秒)并重启后端,登录后等 10 秒再操作;或在 DevTools 的 Application → Cookies 中把 token 的值改成乱码。
  • 触发多个并发请求:在首页(Dashboard)刷新页面——该页面并发发 3-4 个请求(用户信息、菜单、统计数据)。Network 面板中应看到:第一个 401 响应后紧跟一个 POST /refresh;/refresh 成功后原来的 3-4 个请求自动重发(新的请求条目,非同一请求的 retry 标记);整个过程没有跳转登录页,页面正常渲染。
  • 验证并发去重效果:在 Network 面板勾选 “Preserve log”,快速连续刷新页面两次——第一次刷新的 401 触发一个 /refresh,第二次刷新时若 /refresh 仍在进行,新的 401 会排队等待,不会又发一个 /refresh。
  • 验证 refreshToken 过期:在 Application → Cookies 中删除 refreshToken 或改成乱码,再触发 401——/refresh 返回 1200,页面跳转 /login,两个 Cookie(token 与 refreshToken)都被清掉。

小坑提醒

  • withCredentials 漏写则 refreshToken 不会携带:axios 默认跨域不发送 Cookie,/refresh 请求缺 { withCredentials: true } 时后端拿不到 refreshToken Cookie,返回 1200 后前端 toLogin()——表现为“刷新永远失败”,实际是 Cookie 未发出。
  • isRefreshing 的释放必须在 finally 里:若放在 .then 中,/refresh 网络异常(超时/断网)时 isRefreshing 永远为 true,后续所有请求都排队等待一个不会到来的 onRefreshed(),整个应用卡死。
  • 队列里的 Promise 不能忘 resolve:pendingRequests.push(() => resolve(instance(originalRequest))) 中的 resolve 使挂起的 Promise 在刷新成功后完成。若写成 pendingRequests.push(originalRequest) 只存配置不存 resolve,Promise 永远不会完成,调用方的 .then 永远不执行。
  • /login 和 /refresh 必须排除:缺少 !url.includes('/refresh') 时 refreshToken 过期会无限循环调 /refresh;缺少 !url.includes('/login') 时登录失败(密码错误)也会误触发刷新——刷新虽会成功(refreshToken 有效),但登录页的错误提示被吞掉。

小结

  • 并发去重刷新靠两个变量:isRefreshing 是互斥锁,保证只有一个 /refresh 在进行;pendingRequests 是 Promise 队列,使并发 401 排队等结果。
  • /refresh 请求的三个设计点:裸 axios 绕过拦截器防递归、withCredentials: true 使浏览器自动带 Cookie、空 body(token 在 Cookie 不在请求体)。
  • 双分支覆盖:response 分支处理 HTTP 200 + 业务码 1200,error 分支处理 HTTP 401/网络错误,两个分支共享同一套并发去重状态。
  • 失败兜底是 toLogin():/refresh 失败(refreshToken 过期/无效)时清空队列、跳登录页,用户重新登录后新 token 生效。

认证的前后端链路至此打通,下一期进入权限本身——per_menu/per_role/per_role_menu/per_staff_role 四张表的配合方式、@PreAuthorize 的权限点校验、v-permission 指令的按钮显隐控制。RBAC 层明确后,业务模块的权限管控才有依据。

GitHub:github.com/quuuuj/hrm