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.
This commit is contained in:
2026-06-16 11:10:21 +08:00
parent 00651861ce
commit 29776272ad
2 changed files with 53 additions and 22 deletions
+29 -18
View File
@@ -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("还没发过消息"),保持 nullableUser 类声明 `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<typeof UserSchema>;
export type UserData = z.output<typeof UserSchema>;