From 8f17ea9c9f53b887fa12bd1797db69b5e14899b4 Mon Sep 17 00:00:00 2001 From: chenhang Date: Fri, 3 Jul 2026 16:14:04 +0800 Subject: [PATCH] fix(auth): return to guest session on logout --- src/app/sidebar/sidebar-screen.tsx | 1 + src/data/repositories/auth_repository.ts | 40 +++++++++++++++++++ .../interfaces/iauth_repository.ts | 5 +++ src/data/storage/auth/auth_storage.ts | 24 +++++------ src/data/storage/auth/iauth_storage.ts | 2 + src/lib/auth/__tests__/auth_session.test.ts | 1 + .../auth/__tests__/auth-machine.test.ts | 7 ++-- src/stores/auth/auth-actors.ts | 6 ++- src/stores/auth/auth-machine.ts | 9 +++-- .../chat-machine.transitions.test.ts | 24 +++++++++-- src/stores/chat/chat-machine.ts | 4 ++ 11 files changed, 97 insertions(+), 26 deletions(-) diff --git a/src/app/sidebar/sidebar-screen.tsx b/src/app/sidebar/sidebar-screen.tsx index 8e3b8dc0..0b08eee4 100644 --- a/src/app/sidebar/sidebar-screen.tsx +++ b/src/app/sidebar/sidebar-screen.tsx @@ -44,6 +44,7 @@ export function SidebarScreen() { userDispatch({ type: "UserClearLocal" }); authDispatch({ type: "AuthLogoutSubmitted" }); void signOut({ redirect: false }); + navigator.openChat({ replace: true }); }; // 状态派生:未登录 / 已登录未 VIP / 已登录 VIP diff --git a/src/data/repositories/auth_repository.ts b/src/data/repositories/auth_repository.ts index d28b68f8..bea081a3 100644 --- a/src/data/repositories/auth_repository.ts +++ b/src/data/repositories/auth_repository.ts @@ -116,6 +116,46 @@ export class AuthRepository implements IAuthRepository { } } + /** + * 退出真实用户登录并恢复游客态。 + * + * 与 `logout()` 不同,这里不会删除 guestToken / deviceId。退出后会尝试 + * 重新跑游客登录,以便恢复游客 userId;如果游客登录接口失败但本地仍有 + * guestToken,则降级回本地游客态,避免用户从聊天页被打回 splash。 + */ + async logoutToGuest(deviceId: string): Promise> { + try { + await this.api.logout(); + } catch (e) { + log.warn("[AuthRepository] logout API failed, restoring guest anyway", e); + } + + try { + await this.storage.clearBusinessAuthData(); + await this.userStorage.clearUserData(); + } catch (e) { + return Result.err(e); + } + + const guestResult = await this.guestLogin(deviceId); + if (Result.isOk(guestResult)) return Result.ok(undefined); + + const hasGuestToken = await this.storage.hasGuestToken(); + if (hasGuestToken.success && hasGuestToken.data) { + const providerResult = await this.storage.setLoginProvider( + LoginStatus.Guest, + ); + if (Result.isErr(providerResult)) return providerResult; + log.warn( + "[AuthRepository] guest login failed after logout, falling back to local guest token", + guestResult.error, + ); + return Result.ok(undefined); + } + + return Result.err(guestResult.error); + } + /** * 游客登录:使用 deviceId 换取 guest token。 * 成功后写 guest token + deviceId + userId。 diff --git a/src/data/repositories/interfaces/iauth_repository.ts b/src/data/repositories/interfaces/iauth_repository.ts index 70d28a20..3643a370 100644 --- a/src/data/repositories/interfaces/iauth_repository.ts +++ b/src/data/repositories/interfaces/iauth_repository.ts @@ -46,6 +46,11 @@ export interface IAuthRepository { */ logout(): Promise>; + /** + * 退出真实用户登录并恢复游客态。 + */ + logoutToGuest(deviceId: string): Promise>; + /** 游客登录:使用 deviceId 换取 guest token。 */ guestLogin(deviceId: string): Promise>; diff --git a/src/data/storage/auth/auth_storage.ts b/src/data/storage/auth/auth_storage.ts index a330dea0..01e9183a 100644 --- a/src/data/storage/auth/auth_storage.ts +++ b/src/data/storage/auth/auth_storage.ts @@ -1,19 +1,5 @@ "use client"; -/** - * AuthStorage 完整实现(基于 unstorage 抽象) - * - * SECURITY: 当前与 Dart 端行为一致,refresh_token 以明文存于 localStorage。 - * 后续轮次将升级到 HttpOnly Cookie 方案,参见 `clearAuthData` 处的 TODO 标记。 - * - * 设计说明: - * - 内部使用 `SpAsyncUtil` 静态类(@/utils/storage) - * - 浏览器 → `localStorage` driver;SSR → memory driver(unstorage 自动) - * - 单例挂在 class 静态字段,规避 HMR 复用污染 - * - `hasXxx` = `token != null && token.length > 0`,对齐 Dart 端 `isNotEmpty` 语义 - * - `clearAuthData` 顺序清理:loginToken → loginProvider → guestToken → refreshToken,任一失败立即返回 - */ - import { LoginStatus, type LoginStatus as LoginStatusT } from "@/data/dto/auth"; import { Result, type Result as ResultT, SpAsyncUtil } from "@/utils"; import { StorageKeys } from "../storage_keys"; @@ -117,6 +103,16 @@ export class AuthStorage implements IAuthStorage { // ---- bulk clear ---- + async clearBusinessAuthData(): Promise> { + const r1 = await this.clearLoginToken(); + if (!r1.success) return r1; + const r2 = await this.clearRefreshToken(); + if (!r2.success) return r2; + const r3 = await this.clearFacebookId(); + if (!r3.success) return r3; + return this.clearLoginProvider(); + } + async clearAuthData(): Promise> { const r1 = await this.clearLoginToken(); if (!r1.success) return r1; diff --git a/src/data/storage/auth/iauth_storage.ts b/src/data/storage/auth/iauth_storage.ts index da95fbc0..be6adf5e 100644 --- a/src/data/storage/auth/iauth_storage.ts +++ b/src/data/storage/auth/iauth_storage.ts @@ -6,6 +6,7 @@ * 对齐 Dart 端 `IAuthStorage`(lib/data/services/storage/iauth_storage.dart): * - loginToken / loginProvider / guestToken / deviceId / refreshToken / facebookId 字段的 CRUD * - `clearAuthData()` 批量清空登录态 + * - `clearBusinessAuthData()` 仅清空真实用户登录态,保留 guest token / deviceId * - 所有方法返回 `Promise>`,与 Dart `Future>` 对齐 */ @@ -38,5 +39,6 @@ export interface IAuthStorage { setFacebookId(id: string): Promise>; clearFacebookId(): Promise>; + clearBusinessAuthData(): Promise>; clearAuthData(): Promise>; } diff --git a/src/lib/auth/__tests__/auth_session.test.ts b/src/lib/auth/__tests__/auth_session.test.ts index 8188d6ac..a47b68d9 100644 --- a/src/lib/auth/__tests__/auth_session.test.ts +++ b/src/lib/auth/__tests__/auth_session.test.ts @@ -35,6 +35,7 @@ function makeAuthStorageMock(input: { getFacebookId: vi.fn(async () => Result.ok(null)), setFacebookId: vi.fn(async () => Result.ok(undefined)), clearFacebookId: vi.fn(async () => Result.ok(undefined)), + clearBusinessAuthData: vi.fn(async () => Result.ok(undefined)), clearAuthData: vi.fn(async () => Result.ok(undefined)), }; } diff --git a/src/stores/auth/__tests__/auth-machine.test.ts b/src/stores/auth/__tests__/auth-machine.test.ts index 473d3cc0..a142cef3 100644 --- a/src/stores/auth/__tests__/auth-machine.test.ts +++ b/src/stores/auth/__tests__/auth-machine.test.ts @@ -49,8 +49,9 @@ function createTestAuthMachine( LoginStatus, { accessToken: string } >(async () => "facebook"), - logout: fromPromise(async () => { + logout: fromPromise(async () => { logoutSpy(); + return "guest"; }), }, }); @@ -125,7 +126,7 @@ describe("authMachine", () => { actor.stop(); }); - it("logout resets auth state and keeps initialization completed", async () => { + it("logout transitions authenticated users back to guest", async () => { const logoutSpy = vi.fn<() => void>(); const actor = createActor(createTestAuthMachine({ logoutSpy })).start(); @@ -141,7 +142,7 @@ describe("authMachine", () => { const context = actor.getSnapshot().context; expect(logoutSpy).toHaveBeenCalledOnce(); - expect(context.loginStatus).toBe("notLoggedIn"); + expect(context.loginStatus).toBe("guest"); expect(context.hasInitialized).toBe(true); actor.stop(); diff --git a/src/stores/auth/auth-actors.ts b/src/stores/auth/auth-actors.ts index 8d9fc7b4..5e351458 100644 --- a/src/stores/auth/auth-actors.ts +++ b/src/stores/auth/auth-actors.ts @@ -94,10 +94,12 @@ export const oauthSignInActor = fromPromise(async ({ input } } }); -export const logoutActor = fromPromise(async () => { +export const logoutActor = fromPromise(async () => { const authRepo = getAuthRepository(); - const result = await authRepo.logout(); + const deviceId = await deviceIdentifier.getDeviceId(); + const result = await authRepo.logoutToGuest(deviceId); if (Result.isErr(result)) throw result.error; + return "guest" as LoginStatus; }); // ========== OAuth token → 后端业务 token sync actors ========== diff --git a/src/stores/auth/auth-machine.ts b/src/stores/auth/auth-machine.ts index 8af4f397..9461fbc7 100644 --- a/src/stores/auth/auth-machine.ts +++ b/src/stores/auth/auth-machine.ts @@ -241,15 +241,16 @@ export const authMachine = setup({ src: "logout", onDone: { target: "idle", - actions: assign(() => ({ - ...initialState, + actions: assign({ + loginStatus: ({ event }) => event.output, hasInitialized: true, - })), + errorMessage: null, + }), }, onError: { target: "idle", actions: assign(({ event }) => ({ - ...initialState, + loginStatus: "guest", hasInitialized: true, errorMessage: toAuthErrorMessage(event.error), })), diff --git a/src/stores/chat/__tests__/chat-machine.transitions.test.ts b/src/stores/chat/__tests__/chat-machine.transitions.test.ts index 6e2c6912..2b860a62 100644 --- a/src/stores/chat/__tests__/chat-machine.transitions.test.ts +++ b/src/stores/chat/__tests__/chat-machine.transitions.test.ts @@ -164,7 +164,7 @@ function createLoadHistoryCallback( } describe("chatMachine transitions", () => { - it("keeps guest logout disabled while allowing upgrade to user login", async () => { + it("keeps guest logout disabled and supports user to guest transition", async () => { const actor = createActor(createTestChatMachine()).start(); actor.send({ type: "ChatGuestLogin" }); @@ -180,6 +180,22 @@ describe("chatMachine transitions", () => { snapshot.matches({ userSession: "ready" }), ); + actor.send({ type: "ChatGuestLogin" }); + await waitFor(actor, (snapshot) => + snapshot.matches({ guestSession: "ready" }), + ); + + actor.stop(); + }); + + it("allows explicit logout from user session", async () => { + const actor = createActor(createTestChatMachine()).start(); + + actor.send({ type: "ChatUserLogin", token: "token" }); + await waitFor(actor, (snapshot) => + snapshot.matches({ userSession: "ready" }), + ); + actor.send({ type: "ChatLogout" }); expect(actor.getSnapshot().matches("idle")).toBe(true); @@ -270,7 +286,7 @@ describe("chatMachine transitions", () => { actor.stop(); }); - it("ignores guest login while an authenticated user session is active", async () => { + it("switches to guest session when guest login arrives during user session", async () => { const actor = createActor(createTestChatMachine()).start(); actor.send({ type: "ChatUserLogin", token: "token" }); @@ -279,7 +295,9 @@ describe("chatMachine transitions", () => { ); actor.send({ type: "ChatGuestLogin" }); - expect(actor.getSnapshot().matches({ userSession: "ready" })).toBe(true); + await waitFor(actor, (snapshot) => + snapshot.matches({ guestSession: "ready" }), + ); actor.stop(); }); diff --git a/src/stores/chat/chat-machine.ts b/src/stores/chat/chat-machine.ts index 5a7bcc66..5e3a77b7 100644 --- a/src/stores/chat/chat-machine.ts +++ b/src/stores/chat/chat-machine.ts @@ -235,6 +235,10 @@ export const chatMachine = setup({ }, ], on: { + ChatGuestLogin: { + target: "#chat.guestSession", + actions: "startGuestSession", + }, ChatLogout: { target: "#chat.idle", actions: "clearChatSession",