diff --git a/src/app/chat/chat-screen.tsx b/src/app/chat/chat-screen.tsx index 3695b75d..94a6d8e9 100644 --- a/src/app/chat/chat-screen.tsx +++ b/src/app/chat/chat-screen.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from "react"; import Image from "next/image"; -import { useAuthState } from "@/stores/auth/auth-context"; +import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context"; import { useChatDispatch, useChatState } from "@/stores/chat/chat-context"; import { useUserState } from "@/stores/user/user-context"; import { ROUTES } from "@/router/routes"; @@ -42,6 +42,7 @@ export function ChatScreen() { const state = useChatState(); const chatDispatch = useChatDispatch(); const authState = useAuthState(); + const authDispatch = useAuthDispatch(); const userState = useUserState(); const navigator = useAppNavigator(); const [showExternalBrowserDialog, setShowExternalBrowserDialog] = @@ -70,6 +71,25 @@ export function ChatScreen() { const shouldShowPwaInstall = state.historyLoaded && state.messages.length >= 10; const externalBrowserPromptShownRef = useRef(false); + const guestLoginRequestedRef = useRef(false); + + useEffect(() => { + if (!authState.hasInitialized || authState.isLoading) return; + + if (authState.loginStatus !== "notLoggedIn") { + guestLoginRequestedRef.current = false; + return; + } + + if (guestLoginRequestedRef.current) return; + guestLoginRequestedRef.current = true; + authDispatch({ type: "AuthGuestLoginSubmitted" }); + }, [ + authDispatch, + authState.hasInitialized, + authState.isLoading, + authState.loginStatus, + ]); useEffect(() => { if ( diff --git a/src/app/splash/components/splash-button.tsx b/src/app/splash/components/splash-button.tsx index 6af03253..e5ae41d1 100644 --- a/src/app/splash/components/splash-button.tsx +++ b/src/app/splash/components/splash-button.tsx @@ -12,7 +12,7 @@ export interface SplashButtonProps { export function SplashButton({ onStartChat }: SplashButtonProps) { const state = useAuthState(); - const isLoading = state.isLoading; + const isLoading = !state.hasInitialized || state.isLoading; return (
diff --git a/src/router/__tests__/navigation-resolver.test.ts b/src/router/__tests__/navigation-resolver.test.ts index 70d61e3e..5c6ee8b4 100644 --- a/src/router/__tests__/navigation-resolver.test.ts +++ b/src/router/__tests__/navigation-resolver.test.ts @@ -33,12 +33,21 @@ describe("navigation resolver", () => { ).toBe("/subscription?type=topup"); }); - it("redirects not logged in session routes to splash", () => { + it("allows not logged in users to enter chat for guest bootstrap", () => { expect( resolveRouteGuardRedirect({ loginStatus: "notLoggedIn", pathname: ROUTES.chat, }), + ).toBeNull(); + }); + + it("redirects not logged in session routes except chat to splash", () => { + expect( + resolveRouteGuardRedirect({ + loginStatus: "notLoggedIn", + pathname: ROUTES.sidebar, + }), ).toBe(ROUTES.splash); }); diff --git a/src/router/route-meta.ts b/src/router/route-meta.ts index e7b592fb..127a0425 100644 --- a/src/router/route-meta.ts +++ b/src/router/route-meta.ts @@ -1,12 +1,17 @@ import { ROUTES } from "./routes"; -export type RouteAccess = "public" | "session" | "realUser" | "authOnly"; +export type RouteAccess = + | "public" + | "guestEntry" + | "session" + | "realUser" + | "authOnly"; const STATIC_ROUTE_ACCESS: Partial> = { [ROUTES.root]: "public", [ROUTES.splash]: "authOnly", [ROUTES.auth]: "authOnly", - [ROUTES.chat]: "session", + [ROUTES.chat]: "guestEntry", [ROUTES.sidebar]: "session", [ROUTES.subscription]: "realUser", [ROUTES.coinsRules]: "public", diff --git a/src/stores/auth/__tests__/auth-machine.test.ts b/src/stores/auth/__tests__/auth-machine.test.ts index a142cef3..8fe5d6c6 100644 --- a/src/stores/auth/__tests__/auth-machine.test.ts +++ b/src/stores/auth/__tests__/auth-machine.test.ts @@ -95,13 +95,31 @@ describe("authMachine", () => { actor.stop(); }); - it("automatically guest logs in when initialization finds no login state", async () => { + it("initializes as notLoggedIn without automatically guest logging in", async () => { const guestLoginSpy = vi.fn<() => void>(); const actor = createActor(createTestAuthMachine({ guestLoginSpy })).start(); actor.send({ type: "AuthInit" }); await waitFor(actor, (snapshot) => snapshot.matches("idle")); + const context = actor.getSnapshot().context; + expect(context.loginStatus).toBe("notLoggedIn"); + expect(context.hasInitialized).toBe(true); + expect(guestLoginSpy).not.toHaveBeenCalled(); + + actor.stop(); + }); + + it("guest logs in only after explicit guest login submission", async () => { + const guestLoginSpy = vi.fn<() => void>(); + const actor = createActor(createTestAuthMachine({ guestLoginSpy })).start(); + + actor.send({ type: "AuthInit" }); + await waitFor(actor, (snapshot) => snapshot.matches("idle")); + + actor.send({ type: "AuthGuestLoginSubmitted" }); + await waitFor(actor, (snapshot) => snapshot.matches("idle")); + const context = actor.getSnapshot().context; expect(context.loginStatus).toBe("guest"); expect(context.hasInitialized).toBe(true); diff --git a/src/stores/auth/auth-context.tsx b/src/stores/auth/auth-context.tsx index ac70c41f..db41d2ef 100644 --- a/src/stores/auth/auth-context.tsx +++ b/src/stores/auth/auth-context.tsx @@ -58,7 +58,7 @@ export function AuthProvider({ children }: AuthProviderProps) { username: state.context.username, confirmPassword: state.context.confirmPassword, // isLoading 覆盖邮箱登录 / 邮箱注册 / OAuth 跳转(NextAuth 重定向期间)/ - // OAuth 回调后端 sync / 自动游客登录 + // OAuth 回调后端 sync / 显式游客登录 isLoading: state.matches("loadingEmailLogin") || state.matches("loadingEmailRegister") || diff --git a/src/stores/auth/auth-events.ts b/src/stores/auth/auth-events.ts index d77a1b7a..63dc4973 100644 --- a/src/stores/auth/auth-events.ts +++ b/src/stores/auth/auth-events.ts @@ -15,6 +15,7 @@ export type AuthEvent = /** App 启动时一次性派发 —— 读 storage 把 loginStatus 同步到状态机 */ | { type: "AuthInit" } // 业务事件(提交) + | { type: "AuthGuestLoginSubmitted" } | { type: "AuthEmailLoginSubmitted"; email: string; password: string } | { type: "AuthEmailRegisterSubmitted"; diff --git a/src/stores/auth/auth-machine.ts b/src/stores/auth/auth-machine.ts index 9461fbc7..aed10667 100644 --- a/src/stores/auth/auth-machine.ts +++ b/src/stores/auth/auth-machine.ts @@ -32,7 +32,7 @@ const toAuthErrorMessage = (error: unknown): string => // // 设计:所有登录/同步流跑完都回 `idle`(带正确 `loginStatus`)。 // -// 未找到任何登录态时,初始化流程会自动进入 guest login,补齐游客会话。 +// 初始化只恢复已有登录态;游客登录必须由用户明确操作触发。 // ============================================================ export const authMachine = setup({ types: { @@ -49,6 +49,13 @@ export const authMachine = setup({ checkAuthStatus: checkAuthStatusActor, logout: logoutActor, }, + guards: { + canSubmitGuestLogin: ({ context }) => + context.loginStatus === "notLoggedIn", + canLogoutToGuest: ({ context }) => + context.loginStatus !== "notLoggedIn" && + context.loginStatus !== "guest", + }, }).createMachine({ /** @xstate-layout N4IgpgJg5mDOIC5QEMCuAXAFgOgJYQBswBiAQQ0wAVkA7MAgWQHsIwBhTWmCAbQAYAuolAAHJrFzpcTGsJAAPRABYA7NgAc6gEzqAjCvUBOLbvUqlWgDQgAnohWnsAZgCsSpXyeHdTgGy6lXwBfIOs0LDxCEnIsZlYOLkh+ISQQMQkpGTlFBF81Y1MzX3UvdT5NaztcrS1sD10tQ1dvPi0+YNCQcJx8IjIKADEmACcAWzYiZGGkwTl0yWlZVJzzbHMarSUvNpctYsrEXd86pT0SvfULXyUQsIpIvpjMACU4MHRkufEFrOXEXT4fGwvkMLicmxUTT4qj4hgOCC2QKUoL4KkaShc5ycty69160QoAEkaJJPql5pklqAcl4nGtDCCtGDdqotCp4YY9MC0Xw3CpfH5Gi4cd0HgSsABxVBwdAAGSYUFwNAAyqgAEajSToGYpUTfSnZRC0+mM5kQtnwlzlDQuQynEpOJz6BoqEV4qL9LAAUVGyFwBHlipV6s16G1vFm5P1i0NCGNkNNrnN7Ns9gx2D2LhUDjcl0BN06ovxnswPr9BFeitg2uGqo1Wp1XwyMb+caaJuMZtZKaquhBQKMsOhHiafY6dwixaeEqYCqIgaVddD4bJeubv2pRvbCc7Se78P0SjqYLaewuTtObsnHqeA2QAGMwGrZwBrBfB+thxtR9dUhRbukdyZPc0R7RAaiPUEnRKREjH0dQrx6G8KFIEQRHnBVFxDBsI11NJow3f8EVqVRfCzJ14L4Qp4UdI83F0Bkz3BJxtEQsUSxnOcwGVGwaHvJccNXfDf1jeMGV3FlQIPDEj1OQITAYjwijYqdBgfJ9Xx4viBK-XCmx+P8aW3cTgMki1UwQBpHFRB1IQMdRMXHXFr0eCgJjAKZKDAGgICVKBXl86Z7w+SM1wM2MLGwQFopimKAnhLQWOwTFNF2JxAWuDwtBUkkpGQAhcAALz84gIBkMA8BoAA3JgXwqotctwfKir8hAlRq+9kEpZIhIpFtNwRAVkt8JkdCaXxWhMeFNHUNZTEMTwXExaEXF0HKFma4qaCgYgwGGYYRmwdCuoAMxGUZsAajaCq2qA2uqphOu6wReoIwzlGubAFoFUw3BMEpQXhSEXDWMiVDSlkJtdQt7gIJhkF87apRld9SvKyqarqy7YfhxGoGR6t33ujqusWHrQuE8LWytIFwT0fxAW0F14SUXQQe+x0HDZRy7TYuGEb8gm5Uwmhdv2w7jvQM6xmxiJ+bxoWifax7SZkcm8L6wicitXRuQYq1-C2cwXBomovrBX7jDaVonNFeW-LLf1UbKugMdq+qcYF7bHYDEXiZV56BFekTqdaPXxs5PJdGj+EyNqJ0z08Cx6b53GHd9J2RbFg7hiOghTvO2WcHt72M99oN-aesmXopzX3oQK0jyolxrkS05JuzS1bWcW1Am8Jw0SdW3Pbxn3K1was9rR13laxu209L8tx8n4ZK9Vmh1f0g1WwsXWLacEcjAPxuD1tfIB-B8FPBUcFsphuWF6gMewCrGts4l-OpcL+evafsvl5rGvQOwcqYDQsLUMwjcBQGxbmBSyjcvqXAmgxTkAQDCp1-gAeSeNPCqs8PYPywU8IB1cg61zehFUEyUDCmD8M6TQ-IErg2PEPCwB9QQmAwXjbBFB3650ltLC6P9uHEOVlXNWNcNYUJ3gyL6-1NCGAMDTPw0097RVWrvCEAQ2KwF4veQWs4oBEAAEIPjqj5XBbs573F0XxAxXFTH3nMRAEhEiyFSJDmArYUU2Y-VMCCTQugDx0OSheEEnIHKeDvhOHAtj9FI0MSYsx3kIB8LzgXGWoo4n2KMWARxzjXEb0kVvfqRF3BHmaBYVEo4TYWQaMUahJRTAGEaIUHRei-J3kfM+Wq+SUmWPwUXbA2TtpdI0r05JPlCmbx-KAspexnANHIiYTkqIBQHiWdgJZ-jszgnWO0uxoz1I9JfH0ixe0c7pK-pkmxHSjndNfGclxYj14zLCtvMBbJgQtzZA5PQ0cmgHiiVsnkrhYHFE5CEToNAWBwDkN0EpWtEAAFp2aGHRRizF6L1C+HhMi4eLkwCIvrpsowlxsxgjRAEUwqjDDOFRBoxKI1ygIXvj0Rqm0-LEtjAEnudo-DjQ8CNVRQJo43zWQKR0pguGC2lITEW3LWy8sqQKvwQqrAWU5vS1QrNEo6BvgS4uj8fbvkVQNZVvdVXXHaBqqo5E5oAl8GDXkvyZWL39AAvaZqiIWv5SCNVNrT4MWcHQ8iQ5mluqgDwrA3qci+q2P661wq6lh30E68pmh3CbGxGy4Zdz8aJLyZMiAsbEDxqteqg8OhajGE5OYWEA8jDQxiXmw5UAxknKeaW3InI+UJsFYGlNgRkqJVtOUJRE1WUhCAA */ id: "auth", @@ -78,10 +85,17 @@ export const authMachine = setup({ errorMessage: null, }), }, - AuthLogoutSubmitted: "loggingOut", + AuthLogoutSubmitted: { + guard: "canLogoutToGuest", + target: "loggingOut", + }, // 启动一次性 init:从 storage 同步 loginStatus 到状态机 —— 由 派发 AuthInit: "initializing", + AuthGuestLoginSubmitted: { + guard: "canSubmitGuestLogin", + target: "loadingGuestLogin", + }, AuthEmailLoginSubmitted: "loadingEmailLogin", AuthEmailRegisterSubmitted: "loadingEmailRegister", AuthGoogleLoginSubmitted: "loadingOAuth", @@ -102,26 +116,14 @@ export const authMachine = setup({ entry: assign({ errorMessage: null }), invoke: { src: "checkAuthStatus", - onDone: [ - { - guard: ({ event }) => event.output === "notLoggedIn", - target: "loadingGuestLogin", - actions: assign({ - loginStatus: "notLoggedIn", - // 自动游客登录仍属于初始化流程,完成前不标记 initialized。 - hasInitialized: false, - errorMessage: null, - }), - }, - { - target: "idle", // ← init 完回 idle(从 storage 拿到 loginStatus 后落地) - actions: assign({ - loginStatus: ({ event }) => event.output, - hasInitialized: true, - errorMessage: null, - }), - }, - ], + onDone: { + target: "idle", // ← init 完回 idle(从 storage 拿到 loginStatus 后落地) + actions: assign({ + loginStatus: ({ event }) => event.output, + hasInitialized: true, + errorMessage: null, + }), + }, onError: { target: "idle", actions: assign({