From f91078f81418cc193af294a0475966db73eebfa0 Mon Sep 17 00:00:00 2001 From: opsx Date: Tue, 3 Nov 2026 03:22:02 +0000 Subject: [PATCH] =?UTF-8?q?fix(auth):=20=E7=99=BB=E5=BD=95=E9=A1=B5?= =?UTF-8?q?=E6=97=A0=E9=99=90=E9=87=8D=E8=BD=BD=E9=97=AA=E5=B1=8F=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20401=20=E5=9C=A8=20/login=20=E4=B8=8A?= =?UTF-8?q?=E4=BB=8D=E6=95=B4=E9=A1=B5=E8=B7=B3=E8=BD=AC=E5=AF=BC=E8=87=B4?= =?UTF-8?q?=E6=AD=BB=E5=BE=AA=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit === 现象 === 打开 /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 轮询(见发布后回归) --- frontend/src/api/http.ts | 12 ++++--- frontend/src/api/license/index.ts | 4 ++- frontend/src/hooks/useLicense.tsx | 56 +++++++++++++++++++++++++++---- 3 files changed, 60 insertions(+), 12 deletions(-) diff --git a/frontend/src/api/http.ts b/frontend/src/api/http.ts index c972f85..bca74d0 100644 --- a/frontend/src/api/http.ts +++ b/frontend/src/api/http.ts @@ -73,14 +73,16 @@ let redirecting = false; /** 清凭证并防抖跳转登录(并发失效只产生一次跳转,携带当前路径供回跳) */ function redirectToLogin(): void { clearAuth(); + // 已经在登录页就**不要再跳**:给 location.href 赋同值也会触发整页重载, + // 而重载会重新挂载组件、再发一次需要鉴权的请求 → 又 401 → 再重载。 + // 这正是登录页"一直加载 + 闪屏"的根本原因(实测 10 分钟 1566 次 401)。 + if (window.location.pathname.startsWith('/login')) { + return; + } if (redirecting) return; redirecting = true; const current = window.location.pathname + window.location.search; - const target = - current.startsWith('/login') - ? '/login' - : `/login?redirect=${encodeURIComponent(current)}`; - window.location.href = target; + window.location.href = `/login?redirect=${encodeURIComponent(current)}`; } /** 单次 HTTP 往返:组头、超时、取消、解包。401 以 ApiError(status=401) 抛出,由上层统一处理 */ diff --git a/frontend/src/api/license/index.ts b/frontend/src/api/license/index.ts index a985667..8ae9a81 100644 --- a/frontend/src/api/license/index.ts +++ b/frontend/src/api/license/index.ts @@ -21,7 +21,9 @@ export interface LicenseInfo { invalidReason?: 'expired' | 'over_instance' | 'revoked' | string } 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('/system/license/activate', { key }), deactivate: () => http.post<{ offlineLocalOnly?: boolean; message?: string }>('/system/license/deactivate'), diff --git a/frontend/src/hooks/useLicense.tsx b/frontend/src/hooks/useLicense.tsx index 001d251..a61c766 100644 --- a/frontend/src/hooks/useLicense.tsx +++ b/frontend/src/hooks/useLicense.tsx @@ -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 { getAccessToken } from '@/lib/auth-storage' interface LicenseState { pro: boolean @@ -9,15 +10,58 @@ interface LicenseState { const LicenseContext = createContext({ 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 }) { const [state, setState] = useState<{ pro: boolean; info: LicenseInfo | null }>({ pro: false, info: null }) - useEffect(() => { - licenseApi.status() - .then(res => setState({ pro: Boolean(res.pro), info: res.license })) - .catch(() => setState({ pro: false, info: null })) + const loadedRef = useRef(false) + + const load = useCallback(() => { + // 未登录(登录页)直接跳过,避免 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 ( - licenseApi.status().then(res => setState({ pro: Boolean(res.pro), info: res.license })).catch(() => {}) }}> + {children} )