数智人事系统(八):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,若拦截器对其再次触发刷新会陷入无限循环:/refresh401 → 触发刷新 → 又/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 }时后端拿不到refreshTokenCookie,返回 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