diff --git a/web/src/services/request.ts b/web/src/services/request.ts index 606e156..ec14187 100644 --- a/web/src/services/request.ts +++ b/web/src/services/request.ts @@ -55,12 +55,14 @@ async function request(url: string, options: RequestInit = {}): Promise { const headers: Record = { ...((options.headers as Record) || {}), } + // 记录本次请求实际使用的令牌,避免旧请求响应覆盖新会话。 + const requestToken = token // FormData 时不要手动设置 Content-Type,由浏览器带 boundary const isForm = typeof FormData !== 'undefined' && options.body instanceof FormData if (!isForm && !headers['Content-Type']) { headers['Content-Type'] = 'application/json' } - if (token) headers['Authorization'] = `Bearer ${token}` + if (requestToken) headers['Authorization'] = `Bearer ${requestToken}` const res = await fetch(`${BASE}${url}`, { ...options, headers }) const nextPermissionVersion = res.headers.get('X-Permission-Version') || '' @@ -73,17 +75,16 @@ async function request(url: string, options: RequestInit = {}): Promise { } // 服务端滑动续期:静默替换本地令牌 const renewed = res.headers.get('X-Renewed-Token') - if (renewed && renewed !== token) { + if (renewed && requestToken === token && renewed !== token) { setToken(renewed) tokenRenewedListener?.(renewed) } const json = await res.json() - if (json.code === 401) { - // 登录/续期请求本身的 401 交给调用方提示,不触发全局过期 - if (!url.startsWith('/login') && !url.startsWith('/auth/refresh')) { - notifySessionExpired() - } + // 只处理当前会话令牌的 401;登录失败和已经被新令牌替换的旧请求不触发全局登出。 + if ((res.status === 401 || json.code === 401) && url !== '/login' && + requestToken !== '' && requestToken === token) { + notifySessionExpired() } if (json.code !== 0) { diff --git a/web/src/stores/auth.tsx b/web/src/stores/auth.tsx index 6ee9f1e..b40717d 100644 --- a/web/src/stores/auth.tsx +++ b/web/src/stores/auth.tsx @@ -1,6 +1,6 @@ import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react' import { - PERMISSIONS_CHANGED_EVENT, SESSION_EXPIRED_EVENT, onTokenRenewed, setToken, resetSessionExpiredFlag, + PERMISSIONS_CHANGED_EVENT, SESSION_EXPIRED_EVENT, getToken, onTokenRenewed, setToken, resetSessionExpiredFlag, } from '@/services/request' import { getMe, @@ -141,28 +141,34 @@ export function AuthProvider({ children }: { children: ReactNode }) { } }, []) - // 服务端滑动续期(X-Renewed-Token):同步更新内存与 localStorage 中的令牌 - useEffect(() => { - return onTokenRenewed(nextToken => { - setUser(prev => { - if (!prev) return prev - const updated = { ...prev, token: nextToken } - persistUser(updated) - return updated - }) + // 所有续期入口共用:同步请求模块、React 状态和 localStorage 中的令牌 + const applyRenewedToken = useCallback((nextToken: string) => { + if (!nextToken) return + setToken(nextToken) + setUser(prev => { + if (!prev || prev.token === nextToken) return prev + const updated = { ...prev, token: nextToken } + persistUser(updated) + return updated }) }, []) + // 服务端滑动续期(X-Renewed-Token) + useEffect(() => onTokenRenewed(applyRenewedToken), [applyRenewedToken]) + // 定时续期:长挂机场景下每 10 分钟主动换新令牌,避免 WS 重连/请求时已过期 useEffect(() => { if (!user?.token) return + const sessionToken = user.token const timer = window.setInterval(() => { void refreshSession().then(res => { - if (res.data?.token) setToken(res.data.token) + // 请求期间已退出、重新登录或由其它响应完成续期时,不再应用旧响应。 + if (getToken() !== sessionToken) return + applyRenewedToken(res.data?.token || '') }).catch(() => { /* 401 已由 request 层统一处理;网络错误等待下一轮 */ }) }, 10 * 60 * 1000) return () => window.clearInterval(timer) - }, [user?.token]) + }, [applyRenewedToken, user?.token]) // 会话过期(所有 401 的统一出口):清理登录态并回到登录页 useEffect(() => {