diff --git a/src/router/proxy.ts b/src/router/proxy.ts
index 56392ae2..9f09a64d 100644
--- a/src/router/proxy.ts
+++ b/src/router/proxy.ts
@@ -23,11 +23,16 @@
* - 函数导出名建议为 `proxy`(可默认导出)。
*
* 用 `src/` 时 proxy 必须放在 `src/` 内(参见 `src-folder.md`)。
+ *
+ * DEBUG:每条请求都打日志(`hasSession` / `isAuthOnly` / `isProtected` + 决策),
+ * 用于排查"进入 /splash 后仍自动跳 /chat"类路由 bug。bug 修完可考虑保留 INFO 级别 + 删 DEBUG。
*/
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
+import { Logger } from "@/utils/logger";
+
import {
AUTH_ONLY_ROUTES,
PROTECTED_ROUTES,
@@ -40,6 +45,9 @@ const SESSION_COOKIE_NAMES = [
"__Secure-next-auth.session-token",
];
+/** proxy 入口日志(Node.js runtime 跑,pino 兼容) */
+const log = new Logger("Proxy");
+
function isAuthOnlyRoute(pathname: string): boolean {
return (AUTH_ONLY_ROUTES as readonly string[]).includes(pathname);
}
@@ -58,21 +66,38 @@ export function proxy(request: NextRequest) {
const hasSession = SESSION_COOKIE_NAMES.some(
(name) => Boolean(request.cookies.get(name)?.value),
);
+ const isAuthOnly = isAuthOnlyRoute(pathname);
+ const isProtected = isProtectedRoute(pathname);
+
+ // DEBUG:每条请求都打(区分 path 1=proxy 跳 vs path 2=splash useEffect 跳)
+ log.info(
+ { pathname, hasSession, isAuthOnly, isProtected },
+ "[proxy] request",
+ );
// 已登录访问未登录专属页 → /chat
- if (hasSession && isAuthOnlyRoute(pathname)) {
+ if (hasSession && isAuthOnly) {
+ log.info(
+ { pathname, target: ROUTES.chat, reason: "logged-in → auth-only" },
+ "[proxy] redirect",
+ );
const url = request.nextUrl.clone();
url.pathname = ROUTES.chat;
return NextResponse.redirect(url, 308);
}
// 未登录访问登录态专属页 → /splash(首界面)
- if (!hasSession && isProtectedRoute(pathname)) {
+ if (!hasSession && isProtected) {
+ log.info(
+ { pathname, target: ROUTES.splash, reason: "not-logged-in → protected" },
+ "[proxy] redirect",
+ );
const url = request.nextUrl.clone();
url.pathname = ROUTES.splash;
return NextResponse.redirect(url, 308);
}
+ log.info({ pathname }, "[proxy] pass-through");
return NextResponse.next();
}
diff --git a/src/stores/auth/auth-context.tsx b/src/stores/auth/auth-context.tsx
index 39eebd9d..363146ce 100644
--- a/src/stores/auth/auth-context.tsx
+++ b/src/stores/auth/auth-context.tsx
@@ -55,11 +55,12 @@ export function AuthProvider({ children }: AuthProviderProps) {
password: state.context.password,
username: state.context.username,
confirmPassword: state.context.confirmPassword,
- // isLoading 覆盖邮箱登录 / 邮箱注册 / OAuth 跳转(NextAuth 重定向期间)
+ // isLoading 覆盖邮箱登录 / 邮箱注册 / OAuth 跳转(NextAuth 重定向期间)/ 显式游客登录
isLoading:
state.matches("loadingEmailLogin") ||
state.matches("loadingEmailRegister") ||
- state.matches("loadingOAuth"),
+ state.matches("loadingOAuth") ||
+ state.matches("loadingGuestLogin"),
errorMessage: state.context.errorMessage,
isSuccess: state.matches("success"),
loginStatus: state.context.loginStatus,
diff --git a/src/stores/auth/auth-events.ts b/src/stores/auth/auth-events.ts
index e16cac15..9e17c75b 100644
--- a/src/stores/auth/auth-events.ts
+++ b/src/stores/auth/auth-events.ts
@@ -13,8 +13,10 @@ export type AuthEvent =
| { type: "AuthModeChanged"; mode: AuthMode }
| { type: "AuthFormCleared" }
| { type: "AuthReset" }
- /** 启动 / 恢复时检查登录态 —— 拿 deviceId → 查 token → 必要时游客登录 */
+ /** 启动 / 恢复时检查登录态 —— 拿 deviceId → 查 token(**不**自动创建游客账号) */
| { type: "AuthStatusCheckSubmitted" }
+ /** 显式游客登录 —— splash 上点"游客模式"按钮触发(**不**自动) */
+ | { type: "AuthGuestLoginSubmitted" }
// 业务事件(提交)
| { type: "AuthEmailLoginSubmitted"; email: string; password: string }
| {
diff --git a/src/stores/auth/auth-machine.ts b/src/stores/auth/auth-machine.ts
index 8a1537d6..3c2e40ba 100644
--- a/src/stores/auth/auth-machine.ts
+++ b/src/stores/auth/auth-machine.ts
@@ -40,9 +40,6 @@ async function readGuestId(): Promise
{
// ============================================================
// Actors(异步服务)
// ============================================================
-// 邮箱登录 actor 仅调 authRepository.emailLogin 拿业务 token。
-// 本轮起不再调 /api/auth/marker 写 cookie(统一迁到 NextAuth session
-// cookie 检测,邮箱登录 proxy 行为在后续轮恢复)。
const emailLoginActor = fromPromise(
async ({ input }) => {
@@ -79,7 +76,7 @@ const oauthSignInActor = fromPromise(async ({ input }) => {
await signIn(input);
});
-// ========== 新增:OAuth token → 后端业务 token sync actors ==========
+// ========== OAuth token → 后端业务 token sync actors ==========
// 由 在 NextAuth 回调后派发(监听 useSession())。
// 用 OAuth provider 的 idToken / accessToken 调后端换业务 token + user。
// 后端返回的 LoginResponse 已经被 authRepository._saveLoginData 持久化。
@@ -109,24 +106,35 @@ const syncFacebookBackendActor = fromPromise<
return "facebook" as LoginStatus;
});
-// ========== 新增:启动 / 恢复时检查登录态 actor ==========
+// ========== 显式游客登录 actor(opt-in) ==========
+// 由 splash UI 派发 `AuthGuestLoginSubmitted` 触发,**不**自动跑。
+// 取代了之前 checkAuthStatusActor 的 auto-guest-login 行为 —— 避免每次
+// 访 splash 都自动创建游客账号污染后端。
+
+const guestLoginActor = fromPromise(async () => {
+ const deviceId = await deviceIdentifier.getDeviceId();
+ const result = await authRepository.guestLogin(deviceId);
+ if (Result.isErr(result)) throw result.error;
+ return "guest" as LoginStatus;
+});
+
+// ========== 启动 / 恢复时检查登录态 actor(只查不创) ==========
// 由 在 mount 时派发(一次性)。
-// 流程:
+// 流程(**纯只读**):
// 1. deviceIdentifier.getDeviceId() —— 无则通过 fingerprintjs 生成 + 落盘
-// 2. AuthStorage.getLoginToken() —— 有 → "email"(具体 provider 由 OAuthSessionSync 后续覆盖)
+// (给后续 OAuth/email 登录准备 deviceId,**不**在 splash 首次访问就 auto-create 游客)
+// 2. AuthStorage.getLoginToken() —— 有 → "email"
// 3. AuthStorage.getGuestToken() —— 有 → "guest"
-// 4. 都没有 → authRepository.guestLogin(deviceId) 内部自动 setGuestToken + setDeviceId + setUserId
+// 4. 都没有 → "notLoggedIn"(让用户**显式**选 OAuth / Email / Guest 入口)
const checkAuthStatusActor = fromPromise(async () => {
- // 1. 拿 deviceId
- const deviceId = await deviceIdentifier.getDeviceId();
+ // 1. 拿 deviceId(**不**用于 auto-guest-login,仅供后续登录用)
+ await deviceIdentifier.getDeviceId();
const storage = AuthStorage.getInstance();
- // 2. 查 loginToken(OAuth / 邮箱登录后端写下的)
+ // 2. 查 loginToken
const loginTokenR = await storage.getLoginToken();
if (loginTokenR.success && loginTokenR.data) {
- // token 字符串无法区分 google/facebook/email/apple
- // 默认 "email" —— 若实际是 OAuth,OAuthSessionSync 后续会通过 AuthGoogle/FacebookSyncSubmitted 覆盖
return "email" as LoginStatus;
}
@@ -136,10 +144,8 @@ const checkAuthStatusActor = fromPromise(async () => {
return "guest" as LoginStatus;
}
- // 4. 都没有 —— 调游客登录接口(内部自动 setGuestToken + setDeviceId + setUserId)
- const result = await authRepository.guestLogin(deviceId);
- if (Result.isErr(result)) throw result.error;
- return "guest" as LoginStatus;
+ // 4. 都没有 → "notLoggedIn"(保持 splash 页面 + 登录入口)
+ return "notLoggedIn" as LoginStatus;
});
// ============================================================
@@ -156,6 +162,7 @@ export const authMachine = setup({
oauthSignIn: oauthSignInActor,
syncGoogleBackend: syncGoogleBackendActor,
syncFacebookBackend: syncFacebookBackendActor,
+ guestLogin: guestLoginActor,
checkAuthStatus: checkAuthStatusActor,
},
}).createMachine({
@@ -191,6 +198,8 @@ export const authMachine = setup({
},
// 启动 / 恢复时检查登录态 —— 由 派发
AuthStatusCheckSubmitted: "checkingAuthStatus",
+ // 显式游客登录 —— 由 splash UI 派发(**不**自动)
+ AuthGuestLoginSubmitted: "loadingGuestLogin",
AuthEmailLoginSubmitted: "loadingEmailLogin",
AuthEmailRegisterSubmitted: "loadingEmailRegister",
AuthGoogleLoginSubmitted: "loadingOAuth",
@@ -227,6 +236,27 @@ export const authMachine = setup({
},
},
+ loadingGuestLogin: {
+ entry: assign({ errorMessage: null }),
+ invoke: {
+ src: "guestLogin",
+ onDone: {
+ target: "success",
+ actions: assign({
+ loginStatus: ({ event }) => event.output,
+ errorMessage: null,
+ }),
+ },
+ onError: {
+ target: "idle",
+ actions: assign({
+ errorMessage: ({ event }) =>
+ event.error instanceof Error ? event.error.message : String(event.error),
+ }),
+ },
+ },
+ },
+
loadingEmailLogin: {
invoke: {
src: "emailLogin",
@@ -287,14 +317,11 @@ export const authMachine = setup({
invoke: {
src: "oauthSignIn",
input: ({ event }) => {
- // event 来自 idle 跳转(AuthGoogle/FacebookLoginSubmitted)
if (event.type === "AuthGoogleLoginSubmitted") return event.provider;
if (event.type === "AuthFacebookLoginSubmitted") return event.provider;
return "google" as AuthProvider;
},
onDone: {
- // 罕见分支:NextAuth 同步 resolve(实际几乎不会发生,
- // OAuth 流程会通过重定向离开本应用)。
target: "idle",
},
onError: {
@@ -307,13 +334,11 @@ export const authMachine = setup({
},
},
- // ========== 新增:OAuth token → 后端 sync 状态 ==========
syncingGoogleBackend: {
entry: assign({ errorMessage: null }),
invoke: {
src: "syncGoogleBackend",
input: ({ event }) => {
- // event 来自 idle 跳转(AuthGoogleSyncSubmitted)
if (event.type !== "AuthGoogleSyncSubmitted") return { idToken: "" };
return { idToken: event.idToken };
},
diff --git a/src/stores/auth/oauth-session-sync.tsx b/src/stores/auth/oauth-session-sync.tsx
index 8594f07d..88f74544 100644
--- a/src/stores/auth/oauth-session-sync.tsx
+++ b/src/stores/auth/oauth-session-sync.tsx
@@ -10,10 +10,10 @@
* → 业务 token 写入本地 storage
*
* 此组件**无可见 UI**(返回 null),仅作为 NextAuth session 与 auth state
- * machine 之间的桥接器。挂在 RootProviders 内、AuthProvider 之后:
- * ← 提供 useAuthState / useAuthDispatch
- * ← 用上面两个 hook
- *
+ * machine 之间的桥接器。挂在 RootProviders 内、AuthProvider 之后。
+ *
+ * DEBUG:client 端**不**用 Logger(pino 引入会爆 client bundle),改用 console.log
+ * 临时调试,bug 修完即删。
*/
import { useEffect } from "react";
import { useSession } from "next-auth/react";
@@ -26,6 +26,22 @@ export function OAuthSessionSync() {
const { loginStatus } = useAuthState();
useEffect(() => {
+ // DEBUG:每条 session 变化都打(区分 OAuth 残留 session 是否触发 sync)
+ console.log("[OAuthSessionSync] session change", {
+ status,
+ hasSession: !!session,
+ provider: session?.provider,
+ idToken: session?.idToken ? `${session.idToken.slice(0, 8)}...` : null,
+ accessToken: session?.accessToken
+ ? `${session.accessToken.slice(0, 8)}...`
+ : null,
+ loginStatus,
+ willDispatch:
+ status === "authenticated" &&
+ !!session?.provider &&
+ loginStatus !== session.provider,
+ });
+
// 1) NextAuth 还没就绪
if (status !== "authenticated" || !session) return;