From 29776272adcaab3788b2d6194be6671aa7847017 Mon Sep 17 00:00:00 2001 From: chenhang Date: Tue, 16 Jun 2026 11:10:21 +0800 Subject: [PATCH] fix(auth): handle null backend values and surface login errors on splash Two related fixes for OAuth login failures: - `UserSchema`: backend (Facebook/Apple) returns `null` for fields like `email`; Zod's `.default("")` rejects `null`, causing silent login failures. Introduce `stringOrEmpty` / `numberOrZero` / `booleanOrFalse` helpers (`nullable().transform(v => v ?? x).default(x)`) and apply across the schema so input accepts `string | null | undefined` while output stays the typed primitive. - `SplashScreen`: render `state.errorMessage` (set by auth state machine `onError`, e.g. Facebook sync / schema mismatch) so users no longer get stuck on splash without feedback. --- src/app/splash/components/splash-screen.tsx | 28 ++++++++++-- src/data/schemas/user/user.ts | 47 +++++++++++++-------- 2 files changed, 53 insertions(+), 22 deletions(-) diff --git a/src/app/splash/components/splash-screen.tsx b/src/app/splash/components/splash-screen.tsx index 92d7a1b1..7c58ed73 100644 --- a/src/app/splash/components/splash-screen.tsx +++ b/src/app/splash/components/splash-screen.tsx @@ -24,7 +24,7 @@ export function SplashScreen() { // ───────────────────────────────────────────────────────────── // useEffect ① 启动时检查 localStorage 里的 loginToken: // - loginToken(OAuth/email sync 写下的)→ 视为"已登录"跳 /chat - // - **不**查 guestToken —— 游客用户**停留 splash**(按你要求) + // - 不查 guestToken —— 游客用户停留 splash(按你要求) // - 都没有 → 留 splash // proxy 读不到 localStorage(Edge runtime),所以这条 fallback 必须在 Client 侧。 // ───────────────────────────────────────────────────────────── @@ -43,11 +43,11 @@ export function SplashScreen() { }, [router]); // ───────────────────────────────────────────────────────────── - // useEffect ② 按钮**刚**派了事件(pendingRedirect=true) + + // useEffect ② 按钮刚派了事件(pendingRedirect=true) + // loginStatus 是非 notLoggedIn → 跳 /chat // - // **关键**:`pendingRedirect` 是 auth context 里的**持久**状态(不是 ref)—— - // re-mount 时**不**重置,区分"刚按了"和"历史登录"准确无误。 + // 关键:`pendingRedirect` 是 auth context 里的持久状态(不是 ref)—— + // re-mount 时不重置,区分"刚按了"和"历史登录"准确无误。 // 跳完调 `AuthClearPendingRedirect` 置 false(一次性信号)。 // ───────────────────────────────────────────────────────────── useEffect(() => { @@ -88,6 +88,26 @@ export function SplashScreen() {
+ {/* + * 登录错误提示 —— auth state machine 的 onError 会设 errorMessage + * (如 Facebook 同步后端失败 / 后端返回数据 schema 不匹配等)。 + * 之前没显示,导致用户卡在 splash 看不到原因。 + */} + {state.errorMessage ? ( +

+ {state.errorMessage} +

+ ) : null}

Elio Silvestri, Your exclusive AI boyfriend
diff --git a/src/data/schemas/user/user.ts b/src/data/schemas/user/user.ts index d7651361..b2c9f9d8 100644 --- a/src/data/schemas/user/user.ts +++ b/src/data/schemas/user/user.ts @@ -5,32 +5,43 @@ import { z } from "zod"; import { PersonalityTraitsSchema, PERSONALITY_TRAITS_DEFAULTS } from "./personality_traits"; +/** + * 防御性解析:后端对 Facebook / Apple 等 OAuth 用户可能返回 `null`(如 `email: null`), + * Zod 默认的 `.default("")` 只接受 `undefined`,遇到 `null` 会抛 ZodError → 登录静默失败。 + * 用 `z.string().nullable().transform(v => v ?? "").default("")` 模式: + * - input 接受 `string | null | undefined`(保持 optional) + * - output 永远是 `string`(User 类的 `email: string` 声明不会破) + * + * 这是前端防御性解析(不要求后端修改)—— 和后端对齐更稳。 + */ +const stringOrEmpty = z.string().nullable().transform((v) => v ?? "").default(""); +const numberOrZero = z.number().nullable().transform((v) => v ?? 0).default(0); +const booleanOrFalse = z.boolean().nullable().transform((v) => v ?? false).default(false); + export const UserSchema = z.object({ - id: z.string(), - username: z.string(), - email: z.string().default(""), - platform: z.string().default("web"), - intimacy: z.number().default(0), - dolBalance: z.number().default(0), - relationshipStage: z.string().default("密友"), - currentMood: z.string().default("happy"), + id: stringOrEmpty, // 兜底:理论 id 不会 null,但保持防御 + username: stringOrEmpty, + email: stringOrEmpty, + platform: stringOrEmpty, + intimacy: numberOrZero, + dolBalance: numberOrZero, + relationshipStage: stringOrEmpty, + currentMood: stringOrEmpty, personalityTraits: PersonalityTraitsSchema.default(PERSONALITY_TRAITS_DEFAULTS), - preferredLanguage: z.string().default(""), - createdAt: z.string().default(""), - // 后端新游客返回 null("还没发过消息"),`.default("")` 不兜 null —— 需 nullable + preferredLanguage: stringOrEmpty, + createdAt: stringOrEmpty, + // 后端新游客返回 null("还没发过消息"),保持 nullable(User 类声明 `string | null`) lastMessageAt: z.string().nullable().default(null), - avatarUrl: z.string().default(""), - loginProvider: z.string().default("email"), - isGuest: z.boolean().default(false), + avatarUrl: stringOrEmpty, + loginProvider: stringOrEmpty, + isGuest: booleanOrFalse, /** 是否为 VIP 会员(订阅中或未过期) —— Stripe webhook 更新 */ - isVip: z.boolean().default(false), + isVip: booleanOrFalse, /** 语音聊天剩余分钟数(订阅生效后每天补充;用完为 0 不变) */ - voiceMinutesRemaining: z.number().default(0), + voiceMinutesRemaining: numberOrZero, /** Stripe Customer ID —— 首次 Checkout 完成后由 webhook 设置,用于 Customer Portal 自助管理 */ stripeCustomerId: z.string().nullable().default(null), }); - - export type UserInput = z.input; export type UserData = z.output;