在企业级 AI 网关项目中,前端登录态管理不能只做”存 token、带 token”两件事,必须构建一个覆盖存储、注入、失效处理三层能力的闭环:Pinia 负责内存态、localStorage 负责持久化、axios 拦截器负责统一注入与 401 兜底,再叠加双 token 静默续期,才能让十几个页面共享同一份登录状态而不各自维护。我们做 AI 爆款文章创作器时踩过的坑是:SSE 流式请求里的 401 和普通请求不一样,处理逻辑必须单独写。下文给出这套方案的完整落地过程。
一、登录态全局管理的三层结构
全局管理的核心诉求是”一处登录,处处可用”。我们把登录态拆成三个层次,每层职责单一:
| 层次 | 职责 | 落地载体 |
|---|---|---|
| 存储层 | 登录态在内存与磁盘间同步 | Pinia + localStorage |
| 注入层 | 每次请求自动携带凭证 | axios 请求拦截器 |
| 失效层 | 401 统一处理、静默续期、踢回登录页 | axios 响应拦截器 + 路由守卫 |
1.1 存储层:Pinia 管内存,localStorage 管持久化
刷新页面时 Pinia 会被清空,所以 token 必须落盘到 localStorage;但所有业务代码只读写 Pinia,不直接碰 localStorage,避免散落。登录成功后一次性写入两处:
// stores/auth.ts
import { defineStore } from 'pinia'
export const useAuthStore = defineStore('auth', {
state: () => ({
accessToken: localStorage.getItem('access_token') || '',
refreshToken: localStorage.getItem('refresh_token') || '',
user: null as UserInfo | null,
}),
getters: {
isLoggedIn: (s) => !!s.accessToken,
},
actions: {
setLogin({ accessToken, refreshToken, user }) {
this.accessToken = accessToken
this.refreshToken = refreshToken
this.user = user
localStorage.setItem('access_token', accessToken)
localStorage.setItem('refresh_token', refreshToken)
},
logout() {
this.accessToken = ''
this.refreshToken = ''
this.user = null
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
},
},
})
1.2 注入层:请求拦截器统一加认证头
网关后端按 Bearer 协议校验,所有请求(含 SSE)都走同一个 axios 实例。请求拦截器在发出前读取 Pinia,把 token 写进 Authorization 头:
import axios from 'axios'
import { useAuthStore } from '@/stores/auth'
const http = axios.create({ baseURL: '/api', timeout: 30000 })
http.interceptors.request.use((config) => {
const auth = useAuthStore()
if (auth.accessToken) {
config.headers.Authorization = `Bearer ${auth.accessToken}`
}
return config
})
这段代码的好处是:新增一个页面、新增一个接口,登录态自动跟上,不用在业务层重复取 token。
二、失效层:401 拦截与静默续期
access token 生命周期短(我们网关设为 30 分钟),到期后所有请求会返回 401。响应拦截器拦截 401,用 refresh token 换新凭证,再重放原请求,用户全程无感知。
2.1 双 token 机制
网关对短时凭证与长时凭证做了区分:
| 凭证 | 有效期 | 用途 |
|---|---|---|
| access_token | 30 分钟 | 每次请求携带,后端校验身份 |
| refresh_token | 7 天 | 仅在续期接口使用,换取新的双 token |
refresh token 只允许换一次,换完立即作废,即便泄露也只能用一次。这是企业级项目里通行做法,我们照搬到了网关前端。
2.2 并发刷新保护
多个接口同时 401 时,如果每个都去调刷新接口,后端会被打出一堆无效请求。用 isRefreshing 标志加一个失败队列,保证同一时间只有一次刷新,其余请求排队等新 token:
let isRefreshing = false
let pendingQueue: Array<{ resolve: (value: unknown) => void }> = []
http.interceptors.response.use(
(res) => res,
async (error) => {
const { response, config } = error
if (!response || response.status !== 401) return Promise.reject(error)
if (config.url === '/auth/refresh' || config._retried) {
forceLogout()
return Promise.reject(error)
}
const auth = useAuthStore()
if (!auth.refreshToken) {
forceLogout()
return Promise.reject(error)
}
if (isRefreshing) {
// 已有刷新在途,挂到队列等新 token
return new Promise((resolve) => pendingQueue.push({ resolve }))
}
isRefreshing = true
config._retried = true
try {
const { data } = await http.post('/auth/refresh', {
refreshToken: auth.refreshToken,
})
auth.setLogin(data)
pendingQueue.forEach(({ resolve }) => resolve(data.accessToken))
pendingQueue = []
config.headers.Authorization = `Bearer ${data.accessToken}`
return http(config)
} catch (e) {
forceLogout()
pendingQueue = []
return Promise.reject(e)
} finally {
isRefreshing = false
}
},
)
刷新失败说明 refresh token 也失效了,走 forceLogout():清空 Pinia 与 localStorage,跳登录页并携带 redirect 参数,登录成功后跳回原页面。
三、SSE 流式请求的 401 是例外场景
这是我们在 AI 网关项目里单独处理的一环。网关的对话接口走 SSE 长连接,一次流式响应可能持续几十秒。如果 token 恰好在这期间过期,后端会中途断开并返回 401 事件,此时响应头阶段已经结束,拦截器里那套”刷新后重放”的逻辑对长连接无效。
我们的做法是:SSE 请求不走 axios 响应拦截器的自动重试,而是在流式读取循环里识别 401 事件,提示”登录已过期”,引导用户重新登录;同时把对话上下文暂存到本地,登录后恢复现场。对普通请求保留静默续期,对流式请求降级为显式提示,两种策略分开写,谁都不影响谁。
四、登录态管理的完整落地步骤
按下面顺序接入,可以避免”能登录但一刷新就掉线”这类问题:
- 先写 Pinia 的 auth store,包含 setLogin / logout / 双 token 字段,并在 state 初始化时从 localStorage 恢复;
- 封装 axios 实例,加请求拦截器注入 Authorization,加响应拦截器处理 401 与并发刷新;
- 路由守卫里加白名单(login 等免登录页),无 token 访问受限页时跳登录;
- SSE 流式请求单独封装,用 fetch 而非 axios,读流时识别 401 事件走显式提示逻辑;
- 全项目统一通过
useAuthStore()读登录态,禁止业务代码直接操作 localStorage。
五、三个常见的坑
存储方案选错是头号问题:token 放 sessionStorage,刷新标签页就丢;放 cookie 会被每个请求自动带上,且 4KB 上限受限。localStorage 没有这些限制,但 XSS 风险要靠”不落地存储明文敏感信息 + 请求侧 CSP”兜底,风险点在网关后端校验环节控制。
刷新 token 时如果没做并发保护,高峰期会打出几十个无效刷新请求。isRefreshing 加队列这段必须写,否则后端日志里全是刷新接口的重复调用。
跳登录页的逻辑别散落在各个组件里。统一收敛在拦截器的 forceLogout() 与路由守卫两处,出问题排查时只需找这两个点。
常见问题(FAQ)
Q1:token 为什么必须存 localStorage?
刷新页面会清空内存,localStorage 能持久化;再配合 Pinia 读取,两者互补。
Q2:refresh token 泄露了怎么办?
一次性使用、用完即作废,再叠加有效期限制,泄露窗口被压缩到极小。
Q3:SSE 长连接里 token 过期怎么处理?
无法重放请求,只能识别 401 事件提示重登,并暂存对话上下文恢复现场。