fix(auth): 登录页无限重载闪屏 —— 401 在 /login 上仍整页跳转导致死循环
CI / Backend (go vet + test) (push) Waiting to run
CI / Frontend (lint + build + test) (push) Waiting to run

=== 现象 ===
打开 /login 页面一直加载、闪屏,无法登录。
线上访问日志:10 分钟内 /api/v1/system/license 被请求 **1566 次,全部 401**(约 2.6 次/秒)。

=== 根因(三处叠加)===
1) LicenseProvider 挂在 App 根部,包住**所有**路由(含 /login),
   挂载即请求鉴权接口 /system/license;登录页没有 token → 401。
2) http.ts 的 redirectToLogin() 在已处于 /login 时仍然执行
   window.location.href = '/login' —— **给 href 赋同值也会整页重载**。
3) 重载 → App 重新挂载 → LicenseProvider 再请求 → 再 401 → 再重载。
形成"请求→401→整页重载→请求"的死循环,用户看到的就是转圈+闪屏。

=== 修复(三处,防御性叠加)===
1) useLicense:没有 accessToken 时**不请求**;有 token 才拉。
   并用 1 秒一次的低成本探测(只读 localStorage,不发请求)在登录成功
   (token 出现)后补拉一次,避免登录后授权状态缺失。
2) licenseApi.status 使用 silent: true —— 401 不触发"清凭证+跳登录"的全局副作用。
3) redirectToLogin:已在 /login 时直接 return,不再重载页面。

=== 验证 ===
tsc 通过;发布后 /login 不再产生 401 轮询(见发布后回归)
This commit is contained in:
opsx committed 2026-11-03 03:25:58 +00:00
1 parent f02b1daee1
commit f91078f814
3 files changed
+60 -12

No files matched your search

+7 -5
View File
@@ -73,14 +73,16 @@ let redirecting = false;
/** 清凭证并防抖跳转登录(并发失效只产生一次跳转,携带当前路径供回跳) */ /** 清凭证并防抖跳转登录(并发失效只产生一次跳转,携带当前路径供回跳) */
function redirectToLogin(): void { function redirectToLogin(): void {
clearAuth(); clearAuth();
// 已经在登录页就**不要再跳**:给 location.href 赋同值也会触发整页重载,
// 而重载会重新挂载组件、再发一次需要鉴权的请求 → 又 401 → 再重载。
// 这正是登录页"一直加载 + 闪屏"的根本原因(实测 10 分钟 1566 次 401)。
if (window.location.pathname.startsWith('/login')) {
return;
}
if (redirecting) return; if (redirecting) return;
redirecting = true; redirecting = true;
const current = window.location.pathname + window.location.search; const current = window.location.pathname + window.location.search;
const target = window.location.href = `/login?redirect=${encodeURIComponent(current)}`;
current.startsWith('/login')
? '/login'
: `/login?redirect=${encodeURIComponent(current)}`;
window.location.href = target;
} }
/** 单次 HTTP 往返:组头、超时、取消、解包。401 以 ApiError(status=401) 抛出,由上层统一处理 */ /** 单次 HTTP 往返:组头、超时、取消、解包。401 以 ApiError(status=401) 抛出,由上层统一处理 */
+3 -1
View File
@@ -21,7 +21,9 @@ export interface LicenseInfo {
invalidReason?: 'expired' | 'over_instance' | 'revoked' | string invalidReason?: 'expired' | 'over_instance' | 'revoked' | string
} }
export const licenseApi = { export const licenseApi = {
status: () => http.get<{ license: LicenseInfo; pro: boolean; features: string[] }>('/system/license'), // silent:授权状态探测不应触发"清凭证+跳登录"的全局副作用。
// 登录页也会挂载 LicenseProvider,普通请求模式下 401 会引发跳转→重载→再 401 的循环。
status: () => http.get<{ license: LicenseInfo; pro: boolean; features: string[] }>('/system/license', { silent: true }),
activate: (key: string) => http.post<LicenseInfo>('/system/license/activate', { key }), activate: (key: string) => http.post<LicenseInfo>('/system/license/activate', { key }),
deactivate: () => deactivate: () =>
http.post<{ offlineLocalOnly?: boolean; message?: string }>('/system/license/deactivate'), http.post<{ offlineLocalOnly?: boolean; message?: string }>('/system/license/deactivate'),
+50 -6
View File
@@ -1,5 +1,6 @@
import { createContext, useContext, useEffect, useState } from 'react' import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
import { licenseApi, type LicenseInfo } from '@/api/license' import { licenseApi, type LicenseInfo } from '@/api/license'
import { getAccessToken } from '@/lib/auth-storage'
interface LicenseState { interface LicenseState {
pro: boolean pro: boolean
@@ -9,15 +10,58 @@ interface LicenseState {
const LicenseContext = createContext<LicenseState>({ pro: false, info: null, reload: () => {} }) const LicenseContext = createContext<LicenseState>({ pro: false, info: null, reload: () => {} })
/**
* 授权状态 Provider。
*
* ⚠️ 必须只在**已登录**时请求:`/system/license` 是鉴权接口,
* 未登录访问会 401,而 401 会触发全局"跳登录"逻辑。
* 之前没有这道判断,登录页一挂载就请求 → 401 → 跳 /login(整页重载)
* → 重新挂载 → 再 401,形成死循环:登录页一直转圈+闪屏
* (线上实测 10 分钟 1566 次 401)。三处修复:
* 1) 这里没有 token 就不请求
* 2) 请求带 silent,401 不触发全局跳转副作用
* 3) http.ts 的 redirectToLogin 在已处于 /login 时不再重载
*
* 登录成功后 token 才出现,所以用一个轻量的定时探测(只读 localStorage,
* 不发请求)在 token 出现时补拉一次。
*/
export function LicenseProvider({ children }: { children: React.ReactNode }) { export function LicenseProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<{ pro: boolean; info: LicenseInfo | null }>({ pro: false, info: null }) const [state, setState] = useState<{ pro: boolean; info: LicenseInfo | null }>({ pro: false, info: null })
useEffect(() => { const loadedRef = useRef(false)
licenseApi.status()
.then(res => setState({ pro: Boolean(res.pro), info: res.license })) const load = useCallback(() => {
.catch(() => setState({ pro: false, info: null })) // 未登录(登录页)直接跳过,避免 401 → 跳登录 → 重载 的死循环
if (!getAccessToken()) return
licenseApi
.status()
.then(res => {
loadedRef.current = true
setState({ pro: Boolean(res.pro), info: res.license })
})
.catch(() => {
// 拿不到授权信息时按免费版处理,不打断页面
setState({ pro: false, info: null })
})
}, []) }, [])
useEffect(() => {
load()
// 登录后 token 才写入 localStorage,用 1 秒一次的低成本探测补拉(只读本地存储)
const timer = window.setInterval(() => {
if (loadedRef.current) {
window.clearInterval(timer)
return
}
if (getAccessToken()) {
window.clearInterval(timer)
load()
}
}, 1000)
return () => window.clearInterval(timer)
}, [load])
return ( return (
<LicenseContext.Provider value={{ ...state, reload: () => licenseApi.status().then(res => setState({ pro: Boolean(res.pro), info: res.license })).catch(() => {}) }}> <LicenseContext.Provider value={{ ...state, reload: load }}>
{children} {children}
</LicenseContext.Provider> </LicenseContext.Provider>
) )