ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

前端会话续期控制面设计:从token刷新到请求收敛的完整实践

2026/9/18 15:32:51 拓冰建站 浏览量
前端会话续期控制面设计:从token刷新到请求收敛的完整实践 1. 会话续期大多数前端团队都在将就的一小时做过带登录态的前端项目几乎都遇到过这种场景用户打开后台管理系统填了半小时报表起身接杯水回来点击“保存”按钮接口直接返回401页面跳回登录页。辛辛苦苦填的内容全没了。用户骂产品难用产品找前端背锅前端扶着额头说“这不是我写的逻辑有问题是token过期了我们的会话续期一直没做好”。这就是“登录后的那一个小时”里最真实的痛点。市面上大多数系统的accessToken有效期就是60分钟左右而在这60分钟内用户的操作节奏完全不可控。有人5分钟就点一次接口有人45分钟才做了第一次操作还有人正好在第59分59秒提交表单。不同节奏下会话续期的触发时机、刷新频率、失败处理完全不一样。如果每个接口各自为战自己刷新自己的token那么必然出现并发刷新、竞态覆盖、局部401、登录状态误判等一串问题。把“会话续期”从每个请求里抽出来做成一个可收敛的控制面——这是我个人实践下来认为前端基建里最值得做的一件事。它本质上不是某个库、某个函数而是一种分层思想让业务请求只关心“数据是否拿到”让控制面统一关心“会话是否有效、何时刷新、刷新失败怎么办”。这篇文章我会把完整的设计思路、参数计算、核心实现和踩坑记录都整理出来适合正在做中台系统、后台管理系统、低代码平台或者任何有长会话需求的前端团队参考。2. 控制面与数据面重新理解会话续期的边界2.1 为什么传统的 axios 拦截器方案不够用大部分前端团队处理会话续期的第一反应都是在HTTP客户端里加一个响应拦截器。拿到401就刷新token然后重放请求。这个思路本身没错但工程上继续往下走一定会碰壁。第一个是并发问题。假设页面同时发出3个请求三个请求都携带过期token到达后端后端返回3个401。拦截器里如果每个401都触发一次refresh等于同一时刻向后端发了3个刷新请求。后端一般是按照“旧的refreshToken换新的accessToken”的逻辑处理第一个请求成功刷新了tokenrefreshToken轮换失效后面两个请求再拿旧的refreshToken去换就会失败。更麻烦的是三个刷新请求各自异步返回最后写回内存里token的时间点不一样谁后返回谁覆盖很容易把新token又盖成旧token。第二个是边界模糊。会话续期不只是“刷新token”这一个动作它还要考虑什么时候该静默续期、什么时候用户长时间无操作后需要重新登录、网页从后台切回前台时token还剩多久、多标签页同时运行时A标签页刷新了token但B标签页不知道。这些逻辑如果都散落在拦截器里代码必然越堆越乱最后变成一团理不清的if else。第三个是状态不可观测。用拦截器方案做续期你根本回答不了这些问题所有请求里有多少是续期失败的用户平均在什么时间点触发续期续期失败后用户重新登录的比例是多少会话时长分布是怎样的传统方案里这些都没有答案后续优化只能靠猜。2.2 “数据面”和“控制面”的划分方式控制面这个概念在网络领域很常见数据面处理具体的业务报文控制面决定路由策略、转发规则。放到前端会话续期场景里可以把整个登录态体系分成两层数据面Data Plane业务请求层。页面里每个调用API的地方只负责发出请求、处理业务数据、展示错误信息。不关心token怎么来的、快不快过期、要不要刷新。控制面Control Plane会话管理层。负责统一回答“当前会话是否有效”、“什么时间刷新token”、“刷新失败之后是先重试还是强制登录”、“多实例之间如何同步状态”这些策略性问题。这个划分的收益在于边界清楚。业务代码里不需要再出现“token快过期了我先调一下刷新接口再发业务请求”这种逻辑。所有续期相关的东西全部收敛到控制面内部对外只暴露极简的接口申请访问凭证、上报会话失效、订阅会话状态变化。那本文要做的“可收敛的控制面”就是一套只做三件事的模块检查一句话token是否快过期、刷新一个凭证访问token续期、广播一个状态会话生命周期事件。加上一个并发合并器保证同一时刻只有一个刷新动作在跑。2.3 为什么需要可“收敛”“收敛”这个词我特别想强调。它包含三层意思第一是请求收敛。同一时刻无论有多少个业务请求发现token过期对后端发出的刷新请求有且只有一个。其他请求全部挂起等待这个刷新请求返回。第二是状态收敛。整个应用只有一个token的读写下发中心所有模块拿token必须通过这个中心所有token更新也只从中心发出不会出现某个模块缓存了旧token、某个模块已经用了新token的割裂状态。第三是策略收敛。要不要静默续期、提前多少秒续期、刷新失败重试几次、失败后是弹登录框还是跳登录页这些全部由控制面统一配置。产品经理改需求时改配置即可不需要翻遍全部代码改业务请求。3. 控制面的核心设计从触发到收敛的完整链路3.1 前置检查什么时候才算“需要续期”很多人写会话续期只处理“收到401再刷新”这种事后方案。但更优雅的做法是事前预防在请求发出之前先检查token的剩余有效时间如果低于某个阈值就先把token刷新好再发业务请求。这里涉及一个关键参数刷新阈值refreshThreshold。它表示剩余有效时间低于多少秒时触发刷新。阈值的大小需要根据实际场景计算不能拍脑袋。一个常见的公式是refreshThreshold 预期最长请求耗时 网络波动时间 时钟偏移余量 页面切换缓冲举个例子中后台系统里最耗时的接口是报表导出平均耗时15秒最坏情况30秒正常网络波动预留15秒客户端和服务端时钟可能存在10秒左右的偏差用户切换浏览器标签页回来后需要一点时间恢复页面状态预留10秒。那么建议阈值就是 30 15 10 10 65秒。这个阈值不宜太小。如果设置成5秒很可能请求刚发出去token就过期了后端还是返回401然后又要走事后刷新流程相当于事前检查白做了。也不宜太大。如果设置成10分钟用户登录后不久就会开始大量触发静默刷新后端刷新接口压力大而且每次续期之后refreshToken本身也会更新频繁更新反而增加refreshToken被截获的风险窗口。我一般建议后台系统设置60秒到120秒之间。交互不频繁的内容站可以把这个数值压到30秒。要求高可用、弱网用户多的移动端H5建议放宽到180秒。3.2 被动刷新与主动刷新两种触发模式必须共存控制面要同时支持两种刷新触发方式缺一不可。被动刷新是底牌请求发出去后端返回401说明当前token确实失效了这时候触发刷新动作刷新成功后重放原请求。这是兜底逻辑保证即使事前检查漏掉了请求也能被救回来。主动刷新是主力通过前置检查发现token剩余时间低于阈值主动刷新让业务请求尽量带着有效token发出从源头减少401的发生。理论上如果主动刷新策略覆盖到位被动刷新的触发频率应该非常低。如果在监控里发现被动刷新触发占比超过所有请求的5%说明阈值设置不合理或者前端计时逻辑有问题。为什么两者必须共存不能只做一个只做主动刷新可能遇到刷新接口本身失败的情况比如网络抖动、服务端临时故障此时token未续期成功后续请求必然401没有被动刷新就无法自愈。只做被动刷新则大量请求会在后端白白走一圈再重放既增加网络开销又让部分写操作在重放时需要额外处理幂等逻辑。两套一起上主动为主、被动兜底才算完整。3.3 刷新动作的并发合并把N次刷新收敛成1次这是整个控制面里最关键的机制也是实现“收敛”的核心枢纽。需求很简单在任意一个时间窗口内无论有多少个业务请求发现token需要刷新可能是主动检查触发的也可能是被动401触发的最终向refreshToken接口发起的请求只能有一个。其余的请求必须等待同一个刷新Promise的结果。实现上用JavaScript的Promise复用特性很容易做到let refreshPromise null; function refreshTokenWithLock() { if (!refreshPromise) { refreshPromise requestRefreshToken() .finally(() { // 无论成功失败最后都要重置保证下次刷新能重新发起 refreshPromise null; }); } return refreshPromise; }这段代码的关键点是finally里重置锁。成功刷新后后续请求直接使用新token不需要再复用旧刷新请求的结果。刷新失败后锁也要释放否则后续业务请求的401都拿不到同一个失败的Promise连锁反应会全部卡死。刷新请求得到了新token之后怎么通知所有挂起的业务请求呢核心做法是让业务请求先进入等待队列在拿到新token之后统一重放。我习惯称这个过程为“同一刷新批次”的概念一次刷新动作会带动一批请求恢复执行控制面需要有批处理意识而不是让每个请求各自重放、各自竞争。3.4 token的存储与广播内存态优先持久化辅助控制面里面必须有一个单一的token状态中心。它的最小结构包含四样东西accessToken当前有效的访问令牌。refreshToken用于换取新accessToken的刷新令牌。expiresAtaccessToken的过期时间戳前端判断剩余时间全靠它。状态机当前会话处于“有效”、“刷新中”、“已失效”中的哪种状态。在组件运行期间token必须放在内存变量中不能只放localStorage。原因在于多标签页场景下localStorage的写入事件能同步但JavaScript层面的并发读取和状态判断是滞后的。如果所有模块每次都从localStorage读token容易出现A模块读到旧值、B模块读到新值的情况。控制面要保证应用实例内所有请求从同一个内存状态中心取token不同标签页之间通过localStorage的storage事件做次级的同步。那为什么还要往localStorage写一份为了页面刷新后快速恢复会话。用户F5刷新页面内存态丢失控制面启动时需要从localStorage或sessionStorage恢复token信息判断剩余有效期决定直接进入有效态、静默续期还是强制登录。选用哪个存储有个原则sessionStorage比localStorage安全关闭浏览器就自动清理适合session级别的会话。但sessionStorage在多标签页间不共享A标签页登录后打开B标签页B标签页拿不到sessionStorage需要后端配合分布式会话存储才能避免重复登录。localStorage全局共享多标签页体验更一致但XSS攻击下被窃取的风险更高。权衡下来企业级中后台我倾向于登录态持久化用localStorage同时配合服务端做关键操作的二次校验降低风险。高安全场景则建议用sessionStorage加短时refreshToken。3.5 失效与重登控制面必须掌握的最终裁决权控制面不只是管“刷新”还要管“刷新不动了怎么办”。刷新Token也过期、用户被强制下线、服务端撤销了会话——这些情况下刷新接口会返回明确的会话失效码此时控制面需要立即广播失效事件通知所有模块清理登录态跳转登录页或弹出重新登录框。这里必须设计一个细节会话失效通知是全应用级的而不是某个请求自己跳登录。否则就会出现一个页面上A区域弹了“登录过期”B区域还在正常显示数据用户视角极其分裂。控制面内部维护一个会话状态变更事件所有UI模块、路由守卫、全局错误处理都订阅这个事件。一旦裁决失效统一执行清理、跳转、提示。另外一个容易被忽略的问题是失效后用户重新登录成功控制面需要把登录态重新初始化同时把所有因失效而挂起的请求处理掉。这个直接做“清空等待队列”即可不要尝试自动重放——用户重新登录后原本的请求是否还有业务意义控制面不应该做这个决策。4. 实操实现把控制面从设计图变成代码4.1 控制面的核心类骨架先给一套可以直接抄的代码骨架。我用TypeScript写方便定义状态类型纯JavaScript项目去掉类型注解就行。下面是控制面核心类 SessionControllertype SessionStatus valid | refreshing | expired; type SessionEventListener (status: SessionStatus, reason?: string) void; interface TokenBundle { accessToken: string; refreshToken: string; expiresAt: number; } interface RefreshOptions { threshold: number; // 剩余多少秒触发主动刷新 onRefresh?: () PromiseTokenBundle; // 实际调用后端刷新接口的函数 onSessionExpired?: (reason: string) void; // 会话彻底失效的处理 } class SessionController { private bundle: TokenBundle | null null; private status: SessionStatus idle; private refreshPromise: PromiseTokenBundle | null null; private options: RefreshOptions; private listeners: SetSessionEventListener new Set(); constructor(options: RefreshOptions) { this.options options; } init(bundle: TokenBundle) { this.bundle bundle; this.status valid; this.emit(); } getToken(): string { if (!this.bundle) { throw new Error(会话未初始化); } return this.bundle.accessToken; } // 业务请求发出前调用决定是否进入刷新流程 async ensureFreshToken(): Promisestring { if (!this.bundle) { throw new Error(会话未初始化); } if (this.status refreshing) { await this.refreshPromise; return this.bundle!.accessToken; } const remainMs this.bundle.expiresAt - Date.now(); if (remainMs this.options.threshold * 1000) { await this.refreshNow(); } return this.bundle!.accessToken; } // 收到401后调用被动刷新入口 async recoverFromUnauthorized(): Promisestring { if (this.status refreshing) { await this.refreshPromise; return this.bundle!.accessToken; } await this.refreshNow(); return this.bundle!.accessToken; } private async refreshNow(): Promisevoid { const previousStatus this.status; this.status refreshing; this.emit(); try { const oldBundle this.bundle; this.refreshPromise this.options .onRefresh() .then((newBundle) { this.bundle newBundle; this.status valid; return newBundle; }) .catch((error) { this.status expired; const reason this.extractExpiredReason(error); this.options.onSessionExpired?.(reason); throw error; }) .finally(() { this.refreshPromise null; }); await this.refreshPromise; } finally { // 状态变更已经在上面的then/catch中处理过 this.emit(); } } private extractExpiredReason(error: any): string { if (error error.code REFRESH_EXPIRED) return 登录状态已过期请重新登录; if (error error.code FORCE_OFFLINE) return 账号在其他设备登录; return 会话续期失败请重新登录; } onStatusChange(listener: SessionEventListener) { this.listeners.add(listener); listener(this.status); return () this.listeners.delete(listener); } private emit() { this.listeners.forEach((listener) listener(this.status)); } clear() { this.bundle null; this.status idle; this.refreshPromise null; this.listeners.clear(); } }这个类就是整个控制面的核心。它不绑定任何请求库axios、fetch、自己封装的原生XMLHttpRequest都可以接进来。核心思路是把“token怎么拿”和“token什么时候刷新”彻底从业务请求中剥离出来。4.2 接入axios拦截器的完整配置接下来看怎么把SessionController接到axios上。完整配置分三块请求拦截器处理“拿token 前置检查”响应拦截器处理“401被动刷新 重放”还有一套“本地锁定 跨标签页同步”的辅助逻辑。import axios, { AxiosError, InternalAxiosRequestConfig } from axios; import { SessionController } from ./session-controller; import { eventBus } from ./event-bus; // 轻量事件总线用于跨模块通信 const session new SessionController({ threshold: 90, onRefresh: async () { const refreshToken localStorage.getItem(refreshToken); const response await axios.post(/api/auth/refresh, { refreshToken }); const { accessToken, refreshToken: newRefreshToken, expiresIn } response.data; const expiresAt Date.now() expiresIn * 1000; localStorage.setItem(accessToken, accessToken); localStorage.setItem(refreshToken, newRefreshToken); localStorage.setItem(expiresAt, String(expiresAt)); return { accessToken, refreshToken: newRefreshToken, expiresAt }; }, onSessionExpired: (reason) { localStorage.removeItem(accessToken); localStorage.removeItem(refreshToken); localStorage.removeItem(expiresAt); eventBus.emit(session:expired, reason); // 路由跳转或弹出登录框由UI层订阅处理 }, }); // 请求拦截器 axios.interceptors.request.use(async (config) { const token await session.ensureFreshToken(); config.headers.Authorization Bearer ${token}; return config; }); // 响应拦截器处理401被动刷新 axios.interceptors.response.use( (response) response, async (error: AxiosError) { const config error.config as InternalAxiosRequestConfig { _retried?: boolean }; if (error.response?.status 401 !config._retried) { // 防止同一个请求无限重放 config._retried true; try { const newToken await session.recoverFromUnauthorized(); config.headers.Authorization Bearer ${newToken}; return axios(config); } catch (refreshError) { return Promise.reject(refreshError); } } return Promise.reject(error); } ); // 启动时从存储恢复会话 const storedAccessToken localStorage.getItem(accessToken); const storedRefreshToken localStorage.getItem(refreshToken); const storedExpiresAt Number(localStorage.getItem(expiresAt) || 0); if (storedAccessToken storedRefreshToken storedExpiresAt 0) { session.init({ accessToken: storedAccessToken, refreshToken: storedRefreshToken, expiresAt: storedExpiresAt, }); }这套代码解决了几个关键问题。_retried标记防止死循环重放。所有401都在同一个通道处理不会出现多个请求同时触发刷新。前置检查通过ensureFreshToken提前把token刷新好业务接口真正发出的时候过期概率大幅降低。4.3 请求排队与批次重放避免三叉戟式的乱序恢复上面的代码解决了“只发一个刷新请求”但还没有解决“刷新成功后多个请求怎么恢复”的秩序问题。试想请求A、B、C同时卡在等待刷新刷新成功后如果A、B、C同时重发后端可能会因为它们携带的时间戳不一致而出现并发冲突。更稳的做法是引入“批次”概念。所有因为同一轮刷新而等待的请求在刷新成功后按发起顺序依次重放重放之间不严格串行但需要保证它们都拿到了刷新后的token。上面拦截器方案里每个请求在recoverFromUnauthorized拿到新token后立即调用axios(config)重放这已经满足了“使用新token”这个基本要求。但在极端情况下控制面还需要考虑刷新接口本身很慢比如网络耗时5秒此时可能有20个请求同时挂在等待队列里。axios方案中每个请求各自持有config对象刷新成功后各自重放这会造成后端瞬间收到并发浪涌。对于内部后台系统20个并发请求后端完全扛得住但一旦业务量级变大这里就需要升级成队列调度器等待窗口期waitWindow刷新成功后的50ms内把新进入的401请求都收进同一批次。批次重放窗口结束后统一按顺序重放本批次的所有请求。实现不复杂但能显著降低尖峰流量。如果你们的系统日均请求量很低第一批直接抄axios拦截器版本就够了。如果要做成公用基础设施建议再加上批次调度。4.4 多标签页同步让A标签页刷新B标签页“知道”用户同时开了两个后台标签页是再常见不过的场景。A标签页的会话过期了控制面自动刷新了token。B标签页如果不知道仍然拿着旧token请求就会得到401。B标签页的拦截器接到401后也会自动刷新这没问题。但还有更微妙的情况A标签页拿到了新token而B标签页状态显示还是“有效”它就不会主动检查直到某个请求打到后端才发现401。这种做法虽然能工作但体验是割裂的B标签页会在用户无感知的情况下多等一次401刷新的网络往返。更糟糕的是如果后端对refreshToken做了轮换策略A标签页刷新后旧refreshToken作废B标签页缓存里还是旧的B触发刷新时会失败直接判定会话失效弹登录框而用户其实明明在A标签页还处于登录状态。所以多标签页同步必须做。实现方案用localStorage的storage事件这套机制天然支持跨标签页广播window.addEventListener(storage, (event) { if (event.key accessToken event.newValue) { const newExpiresAt Number(localStorage.getItem(expiresAt) || 0); session.init({ accessToken: event.newValue as string, refreshToken: localStorage.getItem(refreshToken) || , expiresAt: newExpiresAt, }); eventBus.emit(session:restored, { source: storage }); } if (event.key forceLogout) { session.clear(); eventBus.emit(session:expired, 账号在其他标签页被登出); } });通过storage事件A标签页写入新token时B标签页能立即感知并同步。这样B标签页后续请求会直接使用新token不再经历401→刷新→重放的绕路。这里有一个坑storage事件只在“其他标签页”触发当前标签页自己写入localStorage不会触发回调。所以当前标签页的token更新逻辑必须在SessionController内部自行处理不能依赖这个事件。4.5 前端时钟不可信为什么不能只靠本地时间判断前置检查依赖 expiresAt - Date.now() 计算剩余时间但用户设备时间可能不准。调快5分钟token明明还有3分钟寿命前端会认为已经过期提前发起刷新无端增加刷新频率。调慢5分钟token实际已经过期前端却认为还有3分钟寿命直到请求发出被后端打回401。两种偏差方向都影响体验。解决方案是引入时间漂移校准。在后端刷新接口的响应里除了返回token和有效期再附带一个服务端当前时间戳。控制面记录本地时间与服务端时间的差值后续所有剩余时间计算都基于“服务端时间 本地时间 偏移量”这个公式。实现也很简单在onRefresh接口里多返回一个字段serverTimeconst timeOffsetMs serverTime - Date.now(); await session.init({ ...bundle, timeOffsetMs });然后在ensureFreshToken里计算剩余时间时用校准后的时间const serverNow Date.now() this.timeOffsetMs; const remainMs this.bundle.expiresAt - serverNow;实际项目中设备时间偏差超过5分钟的用户比例不低这个校准值得做。5. 常见故障排查与实操心得5.1 刷新后偶发401问题十有八九出在token竞争很多团队上线会话续期后反馈“刷新完token业务请求还是偶发401”。排查思路不要先怀疑后端先看前端有没有并发刷新覆盖问题。一个典型的现场是用户发起了一个会触发前置检查的请求发现需要刷新同时另一个请求正好返回401也触发了被动刷新。如果前置检查使用的是refreshTokenWithLock而被动刷新走的是另一个独立的刷新函数那同一时刻就发出了两个刷新请求刷新令牌被轮换其中一个失败是必然的。解决办法是让所有刷新入口都走同一个带锁的方法。我看到过很多项目里主动刷新和被动刷新各写了一套这是最隐蔽的坑。5.2 刷新失败后页面卡死检查是否有请求陷入“永久等待”控制面里如果刷新Promise被永久pending所有依赖ensureFreshToken的请求都会卡住。最常见的原因是刷新接口本身没有超时时间。后端接口挂了前端axios默认没有超时设置请求挂起半小时用户界面假死半小时。在业务系统里必须给刷新接口单独设置短超时建议5秒到8秒超过这个时间直接判定刷新失败走会话失效流程。这样用户虽然会被登出但至少能得到明确的提示而不是页面无响应。5.3 请求重放导致重复数据写操作必须带幂等键被动刷新后重放请求本质上是在重复执行一次业务操作。如果这个操作是“提交订单”、“更新用户资料”这类写操作第二次执行可能产生重复数据或错误状态。这不是控制面的bug而是整个续期机制必然会带来的副作用。正确做法是所有写操作在请求头或请求体里带上幂等键Idempotency-Key后端根据幂等键判断是否已经处理过处理过直接返回第一次的结果。在控制面层面重放时保持幂等键不变即可。5.4 会话续期监控指标控制面必须可观测控制面建好了不上监控等于白做。建议从上线第一天就采集这些指标指标名称说明目标值主动刷新次数前置检查触发的刷新次数越高说明阈值策略生效被动刷新次数401触发的刷新次数占比越低越好刷新失败率刷新接口失败的次数/总次数小于1%会话失效率因刷新失败被强制登出的会话占比越低越好平均会话时长用户从登录到失效或被新登录替换的平均时长根据业务目标定义重放请求量因401而重放的请求数量越低越好这些数据上报到前端监控平台后能精准定位“用户到底是在第几分钟掉的线”、“是卡在哪个接口上”、“是全局性问题还是特定设备问题”。我在实际项目里靠这套指标发现过一次后端网关偶发丢弃刷新请求的问题而这个问题在传统“登录过期就跳登录页”的方案里永远发现不了。5.5 一个容易踩的隐藏坑线上日志打印token排查问题时大家习惯在控制台打日志console.log(get token, token)。一旦上线这些日志会完整暴露在用户浏览器的DevTools里。如果前端代码被XSS注入恶意脚本可以读取console输出token泄露风险极高。控制面的所有日志都必须脱敏只打印“token存在/不存在、过期时间、刷新状态”绝不打印token明文。6. 从控制面到更大地图会话续期还能延伸出什么做完了会话续期的控制面之后这套思想可以直接复制到其他前端基础设施场景。一个典型方向是“登录态风险控制”。控制面里可以增加设备指纹、IP变化检测发现异常环境变化时自动要求二次验证而不是仅仅刷新token。另一个方向是“请求优先级调度”。控制面已经掌握全部请求的等待状态可以在此基础上做高优请求插队、低优请求延迟发送等能力。在复杂后台系统里导出任务、批量操作这类请求往往需要占用大量带宽控制面可以主动把它们调度到空闲时段。还有一个很有意思的方向是“多实例会话状态协同”。现在很多中后台系统嵌入了iframe主应用和子系统各自有独立的会话。把每个子应用的会话控制器注册到主应用控制面里统一编排续期与失效广播能让用户在跨子应用跳转时完全无感。我最近在做的低代码平台就是这个架构收获非常大。回到最初那个场景。用户填了半小时报表回来点保存这次不再是恐怖的白屏跳登录而是控制面早就提前判断token剩余时间不足静默完成了续期。保存请求带着新鲜有效的token正常提交用户全程无感。这才是登录后那一个小时里前端会话续期该有的样子不是一个个请求的将就而是一个可收敛、可观测、可扩展的控制面。