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({