From df3f914eb13c83ba7028e6a5e3f2303ce363c26f Mon Sep 17 00:00:00 2001 From: duxingchen Date: Mon, 21 Sep 2026 13:47:31 +0800 Subject: [PATCH] =?UTF-8?q?fix(audit):=20=E4=BF=AE=E5=A4=8D=E3=80=8C?= =?UTF-8?q?=E9=80=80=E5=87=BA=E7=99=BB=E5=BD=95=E3=80=8D=E6=97=A0=E6=97=A5?= =?UTF-8?q?=E5=BF=97=EF=BC=88PC=20+=20=E7=A7=BB=E5=8A=A8=E7=AB=AF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现象:审计日志里 logout 记录数恒为 0,退出动作完全不可见。 根因有两处,缺一不可: 1. 移动端(App)根本没有发起过上报 settings.vue 的 handleLogout 只做 removeStorageSync + reLaunch, 一个请求都没发。而 uni.reLaunch 会销毁页面上下文、直接掐断未完成的 uni.request —— 所以必须「先 await 上报、再清 token 与跳转」。 2. PC 端存在时序竞态 axios 的请求拦截器在微任务里执行、现读 localStorage 取 token; 而原实现同步清空 localStorage 并立刻 navigate,拦截器跑到时 token 已经没了 → 请求不带 Authorization → 后端只能记成「未认证」。 注:只改 AuthContext 不够,调用方 AdminLayout / ProfilePage 原来是 `logout(); navigate(...)`,不等就跳转照样会掐断请求 —— 三处都得改。 改动: - 移动端 handleLogout 改 async,await post("/auth/logout") 后再清 token - AuthContext.logout 改 async(类型同步为 () => Promise), 先 await 上报再清 token;失败静默,绝不阻断退出 - AdminLayout / ProfilePage 两个调用点补 await - 全部用 try-catch 兜住:断网/超时也照退不误(JWT 无状态, 服务端本就不需要它成功) --- .../src/components/layout/AdminLayout.tsx | 6 +++-- frontend/src/contexts/AuthContext.tsx | 22 +++++++++++++------ frontend/src/pages/ProfilePage.tsx | 6 +++-- frontend/src/services/authApi.ts | 7 +++--- track-uniapp/src/pages/profile/settings.vue | 21 ++++++++++++++++-- 5 files changed, 46 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/layout/AdminLayout.tsx b/frontend/src/components/layout/AdminLayout.tsx index f4197c1..8bcbcc7 100644 --- a/frontend/src/components/layout/AdminLayout.tsx +++ b/frontend/src/components/layout/AdminLayout.tsx @@ -72,8 +72,10 @@ export default function AdminLayout() { return ; } - function handleLogout() { - logout(); + async function handleLogout() { + // 必须 await:logout() 要先完成审计上报再清 token, + // 提前 navigate 会把请求掐断,退出就留不下痕 + await logout(); navigate("/admin/login", { replace: true }); } diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index aa4ef32..4e46b3c 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -28,7 +28,8 @@ interface AuthState { interface AuthContextValue extends AuthState { login: (username: string, password: string) => Promise; - logout: () => void; + /** 登出。async 是因为必须先 await 审计上报、再清 token —— 顺序反了会丢日志 */ + logout: () => Promise; isAuthenticated: boolean; } @@ -113,12 +114,19 @@ export function AuthProvider({ children }: { children: ReactNode }) { setState({ user, token: accessToken, loading: false }); }, []); - const logout = useCallback(() => { - // 先上报登出(审计留痕),再做本地清理 —— 顺序不能反: - // api 的请求拦截器是从 localStorage 取 token 的,清掉之后就发不出这个请求了。 - // 刻意不 await、失败也不阻断:JWT 无状态,服务端本就不需要它成功, - // 而用户点了「退出」必须退得掉,不能因为一次上报失败就卡住。 - logoutApi().catch(() => {}); + const logout = useCallback(async () => { + // ⚠️ 必须【先 await 上报、再清 token】。两边顺序反了或不等,退出就留不下痕: + // 1) axios 的请求拦截器是在微任务里执行的,它去 localStorage 读 token 时, + // 同步的 logoutInternal() 早已把 token 清掉 → 请求不带 Authorization + // → 后端只能记成「未认证」,退出归因不到人; + // 2) 调用方点完退出还会立刻 navigate 到登录页,进一步压缩执行窗口。 + // 所以这里(async) + 调用方(await) 两处都得改,只改一处等于没改。 + // 失败绝不影响退出:JWT 无状态,服务端本就不需要它成功。 + try { + await logoutApi(); + } catch { + /* 静默:断网/超时也照退不误 */ + } logoutInternal(); }, []); diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index dae3df8..a946fc3 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -15,8 +15,10 @@ export default function ProfilePage() { const displayName = user?.display_name || user?.username || "未知用户"; const avatarChar = displayName.charAt(0); - function handleLogout() { - logout(); + async function handleLogout() { + // 必须 await:logout() 要先完成审计上报再清 token, + // 提前 navigate 会把请求掐断,退出就留不下痕 + await logout(); navigate("/admin/login", { replace: true }); } diff --git a/frontend/src/services/authApi.ts b/frontend/src/services/authApi.ts index fab47e8..90b86c6 100644 --- a/frontend/src/services/authApi.ts +++ b/frontend/src/services/authApi.ts @@ -60,9 +60,10 @@ export async function getMe(token: string): Promise { * JWT 无状态,服务端不会(也无法)吊销令牌,本地清 token 就是登出。 * 但没有这个请求,前端的「退出」动作在审计里完全不可见,所以必须上报一次。 * - * 刻意走 axios 实例(而非上面几个函数的裸 fetch):它的请求拦截器会从 - * localStorage 自动注入 Authorization,从而让后端记录到真实操作人。 - * ⚠️ 因此调用方必须在清除本地 token 【之前】调用本函数。 + * ⚠️ 调用方必须 **await 本函数之后**才清 localStorage 与跳转: + * axios 的请求拦截器在微任务里执行、需要现读 localStorage 取 token。 + * 若不等就同步清空并 navigate,请求会不带 Authorization(或直接被掐断), + * 后端只能记成「未认证」,退出归因不到人 —— 实测审计里 logout 记录为 0。 */ export async function logout(): Promise { await api.post("/auth/logout"); diff --git a/track-uniapp/src/pages/profile/settings.vue b/track-uniapp/src/pages/profile/settings.vue index 5d9d9dc..64b981f 100644 --- a/track-uniapp/src/pages/profile/settings.vue +++ b/track-uniapp/src/pages/profile/settings.vue @@ -38,6 +38,7 @@