diff --git a/frontend/src/App.test.ts b/frontend/src/App.test.ts index a4e1b20c..ea70528c 100644 --- a/frontend/src/App.test.ts +++ b/frontend/src/App.test.ts @@ -4,10 +4,20 @@ import { createMemoryHistory, createRouter } from "vue-router"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import App from "./App.vue"; -vi.mock("./authSession", () => ({ +const mocks = vi.hoisted(() => ({ clearSession: vi.fn(), onSessionChange: vi.fn(() => () => undefined), - readAuth: vi.fn(() => null), + readAuth: vi.fn(() => null as unknown), + saveAuth: vi.fn(), + refreshAuthSession: vi.fn(), + logoutCurrentSession: vi.fn(), +})); + +vi.mock("./authSession", () => ({ + clearSession: mocks.clearSession, + onSessionChange: mocks.onSessionChange, + readAuth: mocks.readAuth, + saveAuth: mocks.saveAuth, })); vi.mock("./composables/useTheme", () => ({ @@ -15,7 +25,10 @@ vi.mock("./composables/useTheme", () => ({ })); vi.mock("./apiClient", () => ({ - apiClient: { logoutCurrentSession: vi.fn() }, + apiClient: { + logoutCurrentSession: mocks.logoutCurrentSession, + refreshAuthSession: mocks.refreshAuthSession, + }, })); vi.mock("./components/shell/AppShell.vue", () => ({ @@ -25,6 +38,12 @@ vi.mock("./components/shell/AppShell.vue", () => ({ let mountedApp: VueApp | null = null; beforeEach(() => { + mocks.readAuth.mockReset(); + mocks.readAuth.mockReturnValue(null); + mocks.saveAuth.mockReset(); + mocks.refreshAuthSession.mockReset(); + mocks.onSessionChange.mockReset(); + mocks.onSessionChange.mockReturnValue(() => undefined); vi.stubGlobal( "fetch", vi.fn(async () => ({ ok: true })), @@ -77,4 +96,62 @@ describe("应用会话恢复", () => { }); expect(protectedMounted).not.toHaveBeenCalled(); }); + + it("启动时从服务端刷新缓存角色,避免展示过期社团身份", async () => { + const staleAuth = { + token: "stale-token", + user: { id: 8, username: "club_admin", realName: "王夫人", accountStatus: "normal" }, + roles: [ + { + id: 6, + code: "ADVISOR", + name: "指导老师", + displayName: "图灵社指导老师", + scope: "club", + clubId: 1000003, + clubIds: [1000003], + permissions: ["club:internal:view"], + }, + ], + permissions: ["club:internal:view"], + }; + const currentAuth = { + ...staleAuth, + token: "current-token", + roles: [ + { + ...staleAuth.roles[0], + code: "CLUB_ADMIN", + name: "社团管理员", + displayName: "社团管理员", + scope: "system", + clubId: null, + clubIds: [], + permissions: ["club:review"], + }, + ], + permissions: ["club:review"], + }; + mocks.readAuth.mockReturnValue(staleAuth); + mocks.refreshAuthSession.mockResolvedValue(currentAuth); + + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: "/dashboard", component: { template: "
" } }], + }); + await router.push("/dashboard"); + await router.isReady(); + + const host = document.createElement("div"); + document.body.appendChild(host); + mountedApp = createApp(App); + mountedApp.use(ElementPlus); + mountedApp.use(router); + mountedApp.mount(host); + + await vi.waitFor(() => { + expect(mocks.refreshAuthSession).toHaveBeenCalledOnce(); + expect(mocks.saveAuth).toHaveBeenCalledWith(currentAuth); + }); + }); }); diff --git a/frontend/src/App.vue b/frontend/src/App.vue index e7d67057..6d51942b 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -3,7 +3,13 @@ import { computed, onMounted, onUnmounted, ref } from "vue"; import { useRoute, useRouter } from "vue-router"; import zhCn from "element-plus/es/locale/lang/zh-cn"; import { ElMessage } from "element-plus"; -import { type AuthResponse, clearSession, onSessionChange, readAuth } from "./authSession"; +import { + type AuthResponse, + clearSession, + onSessionChange, + readAuth, + saveAuth, +} from "./authSession"; import { apiClient } from "./apiClient"; import { buildNavigationGroups, resolveActiveNavigation } from "./navigation"; import { initializeTheme } from "./composables/useTheme"; @@ -49,6 +55,23 @@ function refreshSession() { sessionResolved.value = true; } +async function refreshStoredSession() { + if (!readAuth()) { + refreshSession(); + return; + } + + try { + const refreshedAuth = await apiClient.refreshAuthSession(); + saveAuth(refreshedAuth); + } catch { + // Keep a cached session during a transient network failure. A 401 response + // is handled by apiClient and clears the session before this fallback runs. + } + + refreshSession(); +} + async function checkHealth() { healthChecking.value = true; try { @@ -73,9 +96,9 @@ async function logout() { } onMounted(() => { - refreshSession(); - checkHealth(); stopSessionListener = onSessionChange(refreshSession); + void refreshStoredSession(); + checkHealth(); }); onUnmounted(() => {