From 473f6a3726eee0c1dcb106a48bb46e094075c13a Mon Sep 17 00:00:00 2001 From: chenhang Date: Mon, 6 Jul 2026 11:14:56 +0800 Subject: [PATCH] refactor(dto): centralize backend schema defaults --- .../__tests__/unlock_private_response.test.ts | 18 +++ src/data/dto/user/__tests__/user.test.ts | 41 ++++++ src/data/schemas/auth/apple_login_request.ts | 4 +- .../schemas/auth/facebook_login_request.ts | 6 +- src/data/schemas/auth/facebook_user_data.ts | 10 +- src/data/schemas/auth/fb_id_login_request.ts | 6 +- src/data/schemas/auth/google_login_request.ts | 6 +- src/data/schemas/auth/guest_login_request.ts | 4 +- src/data/schemas/auth/guest_login_response.ts | 8 +- src/data/schemas/auth/login_request.ts | 10 +- src/data/schemas/auth/login_response.ts | 4 +- src/data/schemas/auth/logout_response.ts | 4 +- src/data/schemas/auth/register_request.ts | 6 +- .../schemas/chat/chat_history_response.ts | 18 +-- src/data/schemas/chat/chat_message.ts | 16 ++- src/data/schemas/chat/chat_payloads.ts | 60 +++++---- src/data/schemas/chat/chat_send_response.ts | 19 ++- src/data/schemas/chat/chat_sync_data.ts | 6 +- .../schemas/chat/image_upload_response.ts | 18 +-- src/data/schemas/chat/send_message_request.ts | 20 +-- src/data/schemas/chat/stt_data.ts | 4 +- src/data/schemas/chat/sync_message.ts | 8 +- .../schemas/chat/unlock_history_response.ts | 28 ++-- .../schemas/chat/unlock_private_response.ts | 6 +- src/data/schemas/metrics/app_event.ts | 8 +- src/data/schemas/metrics/pwa_event.ts | 16 ++- src/data/schemas/nullable-defaults.ts | 125 ++++++++++++++++-- .../payment/create_payment_order_response.ts | 24 ++-- src/data/schemas/payment/payment_plan.ts | 20 ++- .../schemas/payment/payment_plans_response.ts | 14 +- .../payment/payment_vip_status_response.ts | 6 +- src/data/schemas/user/avatar_data.ts | 6 +- src/data/schemas/user/credits_data.ts | 6 +- src/data/schemas/user/credits_history_data.ts | 10 +- src/data/schemas/user/personality_traits.ts | 12 +- src/data/schemas/user/recent_memory.ts | 10 +- .../schemas/user/update_profile_request.ts | 10 +- src/data/schemas/user/user.ts | 3 +- src/data/schemas/user/user_entitlements.ts | 105 ++++++++------- src/data/schemas/user/user_stats_response.ts | 39 +++--- 40 files changed, 513 insertions(+), 231 deletions(-) diff --git a/src/data/dto/chat/__tests__/unlock_private_response.test.ts b/src/data/dto/chat/__tests__/unlock_private_response.test.ts index b9ad2fa7..0c8e9178 100644 --- a/src/data/dto/chat/__tests__/unlock_private_response.test.ts +++ b/src/data/dto/chat/__tests__/unlock_private_response.test.ts @@ -104,4 +104,22 @@ describe("UnlockPrivateResponse", () => { expect(response.requiredCredits).toBe(0); expect(response.shortfallCredits).toBe(0); }); + + it("defaults nullable lock detail through schema helpers", () => { + const response = UnlockPrivateResponse.from({ + unlocked: false, + content: "", + reason: "insufficient_balance", + lockDetail: null, + }); + + expect(response.lockDetail).toEqual({ + locked: false, + showContent: true, + showUpgrade: false, + reason: null, + hint: null, + detail: null, + }); + }); }); diff --git a/src/data/dto/user/__tests__/user.test.ts b/src/data/dto/user/__tests__/user.test.ts index 7f70f7d0..58ffe730 100644 --- a/src/data/dto/user/__tests__/user.test.ts +++ b/src/data/dto/user/__tests__/user.test.ts @@ -76,6 +76,22 @@ describe("User", () => { expect("dailyQuotas" in user.toJson()).toBe(false); }); + it("defaults nullable nested user fields through schema helpers", () => { + const user = User.fromJson({ + id: "user-1", + username: "guest_user", + personalityTraits: null, + }); + + expect(user.personalityTraits.toJson()).toEqual({ + caring: 0.5, + playful: 0.5, + serious: 0.5, + cheerful: 0.5, + romantic: 0.5, + }); + }); + it("parses the latest backend entitlements payload", () => { const entitlements = UserEntitlements.fromJson({ userId: "user-1", @@ -122,4 +138,29 @@ describe("User", () => { expect(entitlements.creditBalance).toBe(120); expect(entitlements.historyUnlock.costs.photo).toBe(40); }); + + it("defaults nullable entitlement sections through schema helpers", () => { + const entitlements = UserEntitlements.fromJson({ + userId: "user-1", + policy: null, + costs: null, + quotas: null, + historyUnlock: null, + }); + + expect(entitlements.policy.membershipState).toBe(""); + expect(entitlements.costs.private_message).toBe(0); + expect(entitlements.quotas.normalChatFreeDaily).toBe(0); + expect(entitlements.historyUnlock).toMatchObject({ + enabled: false, + order: "", + chargeMode: "", + insufficientBalanceBehavior: "", + costs: { + private_message: 0, + voice_message: 0, + photo: 0, + }, + }); + }); }); diff --git a/src/data/schemas/auth/apple_login_request.ts b/src/data/schemas/auth/apple_login_request.ts index c3a58b40..21360bd3 100644 --- a/src/data/schemas/auth/apple_login_request.ts +++ b/src/data/schemas/auth/apple_login_request.ts @@ -4,10 +4,12 @@ */ import { z } from "zod"; +import { booleanOrFalse } from "../nullable-defaults"; + export const AppleLoginRequestSchema = z.object({ identityToken: z.string(), platform: z.string(), - isTestAccount: z.boolean().default(false), + isTestAccount: booleanOrFalse, }); export type AppleLoginRequestInput = z.input; diff --git a/src/data/schemas/auth/facebook_login_request.ts b/src/data/schemas/auth/facebook_login_request.ts index df82c4ac..4f1e6cc5 100644 --- a/src/data/schemas/auth/facebook_login_request.ts +++ b/src/data/schemas/auth/facebook_login_request.ts @@ -4,11 +4,13 @@ */ import { z } from "zod"; +import { booleanOrFalse, stringOrEmpty } from "../nullable-defaults"; + export const FacebookLoginRequestSchema = z.object({ accessToken: z.string(), platform: z.string(), - guestId: z.string().default(""), - isTestAccount: z.boolean().default(false), + guestId: stringOrEmpty, + isTestAccount: booleanOrFalse, }); export type FacebookLoginRequestInput = z.input; diff --git a/src/data/schemas/auth/facebook_user_data.ts b/src/data/schemas/auth/facebook_user_data.ts index 4365c35c..1672a849 100644 --- a/src/data/schemas/auth/facebook_user_data.ts +++ b/src/data/schemas/auth/facebook_user_data.ts @@ -6,11 +6,13 @@ */ import { z } from "zod"; +import { stringOrNull } from "../nullable-defaults"; + export const FacebookUserDataSchema = z.object({ - id: z.string().nullable().default(null), - name: z.string().nullable().default(null), - email: z.string().nullable().default(null), - pictureUrl: z.string().nullable().default(null), + id: stringOrNull, + name: stringOrNull, + email: stringOrNull, + pictureUrl: stringOrNull, }); export type FacebookUserDataInput = z.input; diff --git a/src/data/schemas/auth/fb_id_login_request.ts b/src/data/schemas/auth/fb_id_login_request.ts index c551b641..631e3dd1 100644 --- a/src/data/schemas/auth/fb_id_login_request.ts +++ b/src/data/schemas/auth/fb_id_login_request.ts @@ -4,10 +4,12 @@ */ import { z } from "zod"; +import { booleanOrFalse, stringOrEmpty } from "../nullable-defaults"; + export const FbIdLoginRequestSchema = z.object({ fbId: z.string(), - avatarUrl: z.string().default(""), - isTestAccount: z.boolean().default(false), + avatarUrl: stringOrEmpty, + isTestAccount: booleanOrFalse, }); export type FbIdLoginRequestInput = z.input; diff --git a/src/data/schemas/auth/google_login_request.ts b/src/data/schemas/auth/google_login_request.ts index c0d9ebb1..c33b84ee 100644 --- a/src/data/schemas/auth/google_login_request.ts +++ b/src/data/schemas/auth/google_login_request.ts @@ -4,11 +4,13 @@ */ import { z } from "zod"; +import { booleanOrFalse, stringOrEmpty } from "../nullable-defaults"; + export const GoogleLoginRequestSchema = z.object({ idToken: z.string(), platform: z.string(), - guestId: z.string().default(""), - isTestAccount: z.boolean().default(false), + guestId: stringOrEmpty, + isTestAccount: booleanOrFalse, }); export type GoogleLoginRequestInput = z.input; diff --git a/src/data/schemas/auth/guest_login_request.ts b/src/data/schemas/auth/guest_login_request.ts index d7c7dcf6..53e43e4c 100644 --- a/src/data/schemas/auth/guest_login_request.ts +++ b/src/data/schemas/auth/guest_login_request.ts @@ -4,9 +4,11 @@ */ import { z } from "zod"; +import { booleanOrFalse } from "../nullable-defaults"; + export const GuestLoginRequestSchema = z.object({ deviceId: z.string(), - isTestAccount: z.boolean().default(false), + isTestAccount: booleanOrFalse, }); export type GuestLoginRequestInput = z.input; diff --git a/src/data/schemas/auth/guest_login_response.ts b/src/data/schemas/auth/guest_login_response.ts index 667609dc..9cd28c23 100644 --- a/src/data/schemas/auth/guest_login_response.ts +++ b/src/data/schemas/auth/guest_login_response.ts @@ -3,15 +3,17 @@ * */ import { z } from "zod"; + +import { booleanOrTrue, numberOr, schemaOrNull } from "../nullable-defaults"; import { UserSchema } from "../user/user"; export const GuestLoginResponseSchema = z.object({ token: z.string(), deviceId: z.string(), userId: z.string(), - isDeviceUser: z.boolean().default(true), - expiresIn: z.number().default(2592000), - user: UserSchema.optional(), + isDeviceUser: booleanOrTrue, + expiresIn: numberOr(2592000), + user: schemaOrNull(UserSchema), }); export type GuestLoginResponseInput = z.input; diff --git a/src/data/schemas/auth/login_request.ts b/src/data/schemas/auth/login_request.ts index aeb67e08..5dcb8649 100644 --- a/src/data/schemas/auth/login_request.ts +++ b/src/data/schemas/auth/login_request.ts @@ -4,13 +4,15 @@ */ import { z } from "zod"; +import { booleanOrFalse, stringOrEmpty } from "../nullable-defaults"; + export const LoginRequestSchema = z.object({ - email: z.string().default(""), - username: z.string().default(""), + email: stringOrEmpty, + username: stringOrEmpty, password: z.string(), platform: z.string(), - guestId: z.string().default(""), - isTestAccount: z.boolean().default(false), + guestId: stringOrEmpty, + isTestAccount: booleanOrFalse, }); export type LoginRequestInput = z.input; diff --git a/src/data/schemas/auth/login_response.ts b/src/data/schemas/auth/login_response.ts index 7231abcf..cb04a96f 100644 --- a/src/data/schemas/auth/login_response.ts +++ b/src/data/schemas/auth/login_response.ts @@ -3,12 +3,14 @@ * */ import { z } from "zod"; + +import { stringOrEmpty } from "../nullable-defaults"; import { UserSchema } from "../user/user"; export const LoginResponseSchema = z.object({ user: UserSchema, token: z.string(), - refreshToken: z.string().default(""), + refreshToken: stringOrEmpty, }); export type LoginResponseInput = z.input; diff --git a/src/data/schemas/auth/logout_response.ts b/src/data/schemas/auth/logout_response.ts index c200d606..66711122 100644 --- a/src/data/schemas/auth/logout_response.ts +++ b/src/data/schemas/auth/logout_response.ts @@ -4,8 +4,10 @@ */ import { z } from "zod"; +import { booleanOrTrue } from "../nullable-defaults"; + export const LogoutResponseSchema = z.object({ - success: z.boolean().default(true), + success: booleanOrTrue, }); export type LogoutResponseInput = z.input; diff --git a/src/data/schemas/auth/register_request.ts b/src/data/schemas/auth/register_request.ts index ad594ba3..fb1d4841 100644 --- a/src/data/schemas/auth/register_request.ts +++ b/src/data/schemas/auth/register_request.ts @@ -4,13 +4,15 @@ */ import { z } from "zod"; +import { booleanOrFalse, stringOrEmpty } from "../nullable-defaults"; + export const RegisterRequestSchema = z.object({ username: z.string(), email: z.string(), password: z.string(), platform: z.string(), - guestId: z.string().default(""), - isTestAccount: z.boolean().default(false), + guestId: stringOrEmpty, + isTestAccount: booleanOrFalse, }); export type RegisterRequestInput = z.input; diff --git a/src/data/schemas/chat/chat_history_response.ts b/src/data/schemas/chat/chat_history_response.ts index ad812a51..749b9b53 100644 --- a/src/data/schemas/chat/chat_history_response.ts +++ b/src/data/schemas/chat/chat_history_response.ts @@ -3,17 +3,19 @@ * */ import { z } from "zod"; + +import { arrayOrEmpty, booleanOrFalse, numberOrZero } from "../nullable-defaults"; import { ChatMessageSchema } from "./chat_message"; export const ChatHistoryResponseSchema = z.object({ - messages: z.array(ChatMessageSchema), - total: z.number().default(0), - limit: z.number(), - offset: z.number(), - isVip: z.boolean().default(false), - privateFreeLimit: z.number().default(0), - privateUsedToday: z.number().default(0), - privateCanViewFree: z.boolean().default(false), + messages: arrayOrEmpty(ChatMessageSchema), + total: numberOrZero, + limit: numberOrZero, + offset: numberOrZero, + isVip: booleanOrFalse, + privateFreeLimit: numberOrZero, + privateUsedToday: numberOrZero, + privateCanViewFree: booleanOrFalse, }); export type ChatHistoryResponseInput = z.input; diff --git a/src/data/schemas/chat/chat_message.ts b/src/data/schemas/chat/chat_message.ts index 7a212f9b..0d6dbb18 100644 --- a/src/data/schemas/chat/chat_message.ts +++ b/src/data/schemas/chat/chat_message.ts @@ -3,17 +3,19 @@ * */ import { z } from "zod"; + +import { stringOr, stringOrEmpty, stringOrNull } from "../nullable-defaults"; import { ChatImageSchema, ChatLockDetailSchema } from "./chat_payloads"; export const ChatMessageSchema = z .object({ - role: z.string(), - type: z.string().default("text"), - content: z.string(), - id: z.string().default(""), - createdAt: z.string().default(""), - created_at: z.string().optional(), - audioUrl: z.string().nullable().default(null), + role: stringOrEmpty, + type: stringOr("text"), + content: stringOrEmpty, + id: stringOrEmpty, + createdAt: stringOrEmpty, + created_at: stringOrNull, + audioUrl: stringOrNull, image: ChatImageSchema, lockDetail: ChatLockDetailSchema, }) diff --git a/src/data/schemas/chat/chat_payloads.ts b/src/data/schemas/chat/chat_payloads.ts index edc9d868..29714241 100644 --- a/src/data/schemas/chat/chat_payloads.ts +++ b/src/data/schemas/chat/chat_payloads.ts @@ -3,30 +3,44 @@ */ import { z } from "zod"; -export const ChatImageSchema = z - .object({ - type: z.string().nullable().default(null), - url: z.string().nullable().default(null), - }) - .default({ type: null, url: null }); +import { + booleanOrFalse, + booleanOrTrue, + schemaOr, + stringOrNull, + unknownRecordOrNull, +} from "../nullable-defaults"; -export const ChatLockDetailSchema = z - .object({ - locked: z.boolean().default(false), - showContent: z.boolean().default(true), - showUpgrade: z.boolean().default(false), - reason: z.string().nullable().default(null), - hint: z.string().nullable().default(null), - detail: z.record(z.string(), z.unknown()).nullable().default(null), - }) - .default({ - locked: false, - showContent: true, - showUpgrade: false, - reason: null, - hint: null, - detail: null, - }); +const CHAT_IMAGE_DEFAULTS = { type: null, url: null } as const; + +const CHAT_LOCK_DETAIL_DEFAULTS = { + locked: false, + showContent: true, + showUpgrade: false, + reason: null, + hint: null, + detail: null, +} as const; + +export const ChatImageSchema = schemaOr( + z.object({ + type: stringOrNull, + url: stringOrNull, + }), + CHAT_IMAGE_DEFAULTS, +); + +export const ChatLockDetailSchema = schemaOr( + z.object({ + locked: booleanOrFalse, + showContent: booleanOrTrue, + showUpgrade: booleanOrFalse, + reason: stringOrNull, + hint: stringOrNull, + detail: unknownRecordOrNull, + }), + CHAT_LOCK_DETAIL_DEFAULTS, +); export type ChatImageInput = z.input; export type ChatImageData = z.output; diff --git a/src/data/schemas/chat/chat_send_response.ts b/src/data/schemas/chat/chat_send_response.ts index 419ee7ab..41012113 100644 --- a/src/data/schemas/chat/chat_send_response.ts +++ b/src/data/schemas/chat/chat_send_response.ts @@ -3,19 +3,26 @@ * */ import { z } from "zod"; -import { booleanOrFalse, numberOrZero, stringOrNull } from "../nullable-defaults"; +import { + booleanOrFalse, + booleanOrTrue, + numberOrLazy, + numberOrZero, + stringOrEmpty, + stringOrNull, +} from "../nullable-defaults"; import { ChatImageSchema, ChatLockDetailSchema } from "./chat_payloads"; export const ChatSendResponseSchema = z.object({ - reply: z.string(), - audioUrl: z.string().default(""), - messageId: z.string(), + reply: stringOrEmpty, + audioUrl: stringOrEmpty, + messageId: stringOrEmpty, isGuest: booleanOrFalse, // 函数式 default —— 每次 parse 时重新调 Date.now()(后端不返回 timestamp 时用当下时间) - timestamp: z.number().default(() => Date.now()), + timestamp: numberOrLazy(() => Date.now()), image: ChatImageSchema, lockDetail: ChatLockDetailSchema, - canSendMessage: z.boolean().default(true), + canSendMessage: booleanOrTrue, cannotSendReason: stringOrNull, creditBalance: numberOrZero, creditsCharged: numberOrZero, diff --git a/src/data/schemas/chat/chat_sync_data.ts b/src/data/schemas/chat/chat_sync_data.ts index 2929eb10..ce985140 100644 --- a/src/data/schemas/chat/chat_sync_data.ts +++ b/src/data/schemas/chat/chat_sync_data.ts @@ -4,9 +4,11 @@ */ import { z } from "zod"; +import { numberOrZero } from "../nullable-defaults"; + export const ChatSyncDataSchema = z.object({ - syncedCount: z.number(), - totalHistory: z.number(), + syncedCount: numberOrZero, + totalHistory: numberOrZero, }); export type ChatSyncDataInput = z.input; diff --git a/src/data/schemas/chat/image_upload_response.ts b/src/data/schemas/chat/image_upload_response.ts index ba220375..dcaf3760 100644 --- a/src/data/schemas/chat/image_upload_response.ts +++ b/src/data/schemas/chat/image_upload_response.ts @@ -4,15 +4,17 @@ */ import { z } from "zod"; +import { booleanOrTrue, numberOrZero, stringOrEmpty } from "../nullable-defaults"; + export const ImageUploadResponseSchema = z.object({ - success: z.boolean().default(true), - imageId: z.string(), - thumbUrl: z.string(), - mediumUrl: z.string(), - originalUrl: z.string(), - width: z.number(), - height: z.number(), - bytes: z.number(), + success: booleanOrTrue, + imageId: stringOrEmpty, + thumbUrl: stringOrEmpty, + mediumUrl: stringOrEmpty, + originalUrl: stringOrEmpty, + width: numberOrZero, + height: numberOrZero, + bytes: numberOrZero, }); export type ImageUploadResponseInput = z.input; diff --git a/src/data/schemas/chat/send_message_request.ts b/src/data/schemas/chat/send_message_request.ts index c0d0df6f..bfa41464 100644 --- a/src/data/schemas/chat/send_message_request.ts +++ b/src/data/schemas/chat/send_message_request.ts @@ -4,16 +4,18 @@ */ import { z } from "zod"; +import { booleanOrFalse, numberOrZero, stringOrEmpty } from "../nullable-defaults"; + export const SendMessageRequestSchema = z.object({ - message: z.string().default(""), - image: z.string().default(""), - imageId: z.string().default(""), - imageThumbUrl: z.string().default(""), - imageMediumUrl: z.string().default(""), - imageOriginalUrl: z.string().default(""), - imageWidth: z.number().default(0), - imageHeight: z.number().default(0), - useWebSocket: z.boolean().default(false), + message: stringOrEmpty, + image: stringOrEmpty, + imageId: stringOrEmpty, + imageThumbUrl: stringOrEmpty, + imageMediumUrl: stringOrEmpty, + imageOriginalUrl: stringOrEmpty, + imageWidth: numberOrZero, + imageHeight: numberOrZero, + useWebSocket: booleanOrFalse, }); export type SendMessageRequestInput = z.input; diff --git a/src/data/schemas/chat/stt_data.ts b/src/data/schemas/chat/stt_data.ts index f542642b..365cc83c 100644 --- a/src/data/schemas/chat/stt_data.ts +++ b/src/data/schemas/chat/stt_data.ts @@ -4,8 +4,10 @@ */ import { z } from "zod"; +import { stringOrEmpty } from "../nullable-defaults"; + export const SttDataSchema = z.object({ - text: z.string(), + text: stringOrEmpty, }); export type SttDataInput = z.input; diff --git a/src/data/schemas/chat/sync_message.ts b/src/data/schemas/chat/sync_message.ts index 8237547e..6343dfe5 100644 --- a/src/data/schemas/chat/sync_message.ts +++ b/src/data/schemas/chat/sync_message.ts @@ -4,10 +4,12 @@ */ import { z } from "zod"; +import { stringOrEmpty } from "../nullable-defaults"; + export const SyncMessageSchema = z.object({ - role: z.string(), - content: z.string(), - timestamp: z.string(), + role: stringOrEmpty, + content: stringOrEmpty, + timestamp: stringOrEmpty, }); export type SyncMessageInput = z.input; diff --git a/src/data/schemas/chat/unlock_history_response.ts b/src/data/schemas/chat/unlock_history_response.ts index 89c8d2ea..13291d27 100644 --- a/src/data/schemas/chat/unlock_history_response.ts +++ b/src/data/schemas/chat/unlock_history_response.ts @@ -3,7 +3,9 @@ */ import { z } from "zod"; -export const UnlockHistoryCostsByMessageSchema = z.record(z.string(), z.number()); +import { arrayOrEmpty, booleanOrFalse, numberOrZero, recordOrEmpty } from "../nullable-defaults"; + +export const UnlockHistoryCostsByMessageSchema = recordOrEmpty(z.number()); export const UnlockHistoryReasonSchema = z.enum([ "ok", @@ -12,19 +14,19 @@ export const UnlockHistoryReasonSchema = z.enum([ ]); export const UnlockHistoryResponseSchema = z.object({ - unlocked: z.boolean(), + unlocked: booleanOrFalse, reason: UnlockHistoryReasonSchema, - totalLocked: z.number().default(0), - unlockedCount: z.number().default(0), - privateCount: z.number().default(0), - imageCount: z.number().default(0), - voiceCount: z.number().default(0), - requiredCredits: z.number().default(0), - currentCredits: z.number().default(0), - remainingCredits: z.number().default(0), - shortfallCredits: z.number().default(0), - costsByMessage: UnlockHistoryCostsByMessageSchema.default({}), - messageIds: z.array(z.string()).default([]), + totalLocked: numberOrZero, + unlockedCount: numberOrZero, + privateCount: numberOrZero, + imageCount: numberOrZero, + voiceCount: numberOrZero, + requiredCredits: numberOrZero, + currentCredits: numberOrZero, + remainingCredits: numberOrZero, + shortfallCredits: numberOrZero, + costsByMessage: UnlockHistoryCostsByMessageSchema, + messageIds: arrayOrEmpty(z.string()), }); export type UnlockHistoryReason = z.output; diff --git a/src/data/schemas/chat/unlock_private_response.ts b/src/data/schemas/chat/unlock_private_response.ts index 08f74ab3..5aa954b6 100644 --- a/src/data/schemas/chat/unlock_private_response.ts +++ b/src/data/schemas/chat/unlock_private_response.ts @@ -1,15 +1,15 @@ import { z } from "zod"; -import { numberOrZero, stringOrEmpty } from "../nullable-defaults"; +import { booleanOrFalse, numberOrZero, stringOrEmpty } from "../nullable-defaults"; import { ChatLockDetailSchema } from "./chat_payloads"; /** * 单条历史付费 / 私密消息解锁响应。 */ -export const UnlockPrivateReasonSchema = z.string().default(""); +export const UnlockPrivateReasonSchema = stringOrEmpty; export const UnlockPrivateResponseSchema = z.object({ - unlocked: z.boolean(), + unlocked: booleanOrFalse, content: stringOrEmpty, reason: UnlockPrivateReasonSchema, creditBalance: numberOrZero, diff --git a/src/data/schemas/metrics/app_event.ts b/src/data/schemas/metrics/app_event.ts index 731bebf3..76290e35 100644 --- a/src/data/schemas/metrics/app_event.ts +++ b/src/data/schemas/metrics/app_event.ts @@ -4,10 +4,12 @@ */ import { z } from "zod"; +import { stringOrEmpty } from "../nullable-defaults"; + export const AppEventSchema = z.object({ - userId: z.string(), - browser: z.string(), - userAgent: z.string(), + userId: stringOrEmpty, + browser: stringOrEmpty, + userAgent: stringOrEmpty, }); export type AppEventInput = z.input; diff --git a/src/data/schemas/metrics/pwa_event.ts b/src/data/schemas/metrics/pwa_event.ts index 4610f775..a050ebad 100644 --- a/src/data/schemas/metrics/pwa_event.ts +++ b/src/data/schemas/metrics/pwa_event.ts @@ -4,12 +4,18 @@ */ import { z } from "zod"; +import { + booleanOrFalse, + numberOrZero, + stringOrEmpty, +} from "../nullable-defaults"; + export const PwaEventSchema = z.object({ - deviceId: z.string(), - deviceType: z.string(), - timestamp: z.number(), - pwaInstalled: z.boolean().default(false), - pwaSupported: z.boolean().default(false), + deviceId: stringOrEmpty, + deviceType: stringOrEmpty, + timestamp: numberOrZero, + pwaInstalled: booleanOrFalse, + pwaSupported: booleanOrFalse, }); export type PwaEventInput = z.input; diff --git a/src/data/schemas/nullable-defaults.ts b/src/data/schemas/nullable-defaults.ts index aa205769..03fed6a8 100644 --- a/src/data/schemas/nullable-defaults.ts +++ b/src/data/schemas/nullable-defaults.ts @@ -1,5 +1,5 @@ /** - * Zod 默认值辅助器(处理 `null | undefined` → 标量默认) + * Zod 默认值辅助器(处理 `null | undefined` → 默认值) * * 防御性解析:后端对 Facebook / Apple 等 OAuth 用户可能返回 `null`(如 `email: null`), * Zod 默认的 `.default("")` 只接受 `undefined`,遇到 `null` 会抛 ZodError → 登录静默失败。 @@ -11,26 +11,131 @@ */ import { z } from "zod"; +type SchemaDefault = Exclude, undefined>; + +/** `string | null | undefined` → `string` */ +export function stringOr(defaultValue: string) { + return z + .string() + .nullable() + .transform((v) => v ?? defaultValue) + .default(defaultValue); +} + /** `string | null | undefined` → `string`(默认空串) */ -export const stringOrEmpty = z +export const stringOrEmpty = stringOr(""); + +/** required `string | null` → `string`(缺字段仍报错,null 收敛为空串) */ +export const requiredStringOrEmpty = z .string() .nullable() - .transform((v) => v ?? "") - .default(""); + .transform((v) => v ?? ""); /** `string | null | undefined` → `string | null`(默认 null) */ export const stringOrNull = z.string().nullable().default(null); +/** `number | null | undefined` → `number` */ +export function numberOr(defaultValue: number) { + return z + .number() + .nullable() + .transform((v) => v ?? defaultValue) + .default(defaultValue); +} + +/** `number | null | undefined` → `number`,默认值在 parse 时惰性计算 */ +export function numberOrLazy(defaultValue: () => number) { + return z + .number() + .nullable() + .transform((v) => v ?? defaultValue()) + .default(defaultValue); +} + /** `number | null | undefined` → `number`(默认 0) */ -export const numberOrZero = z +export const numberOrZero = numberOr(0); + +/** required `number | null` → `number`(缺字段仍报错,null 收敛为 0) */ +export const requiredNumberOrZero = z .number() .nullable() - .transform((v) => v ?? 0) - .default(0); + .transform((v) => v ?? 0); + +/** `number | null | undefined` → `number | null`(默认 null) */ +export const numberOrNull = z.number().nullable().default(null); + +/** `boolean | null | undefined` → `boolean` */ +export function booleanOr(defaultValue: boolean) { + return z + .boolean() + .nullable() + .transform((v) => v ?? defaultValue) + .default(defaultValue); +} /** `boolean | null | undefined` → `boolean`(默认 false) */ -export const booleanOrFalse = z +export const booleanOrFalse = booleanOr(false); + +/** `boolean | null | undefined` → `boolean`(默认 true) */ +export const booleanOrTrue = booleanOr(true); + +/** required `boolean | null` → `boolean`(缺字段仍报错,null 收敛为 false) */ +export const requiredBooleanOrFalse = z .boolean() .nullable() - .transform((v) => v ?? false) - .default(false); + .transform((v) => v ?? false); + +/** `T[] | null | undefined` → `T[]`(默认空数组) */ +export function arrayOrEmpty(schema: T) { + return z + .array(schema) + .nullable() + .transform((v) => v ?? []) + .default(() => []); +} + +/** `Record | null | undefined` → `Record`(默认空对象) */ +export function recordOrEmpty(schema: T) { + return z + .record(z.string(), schema) + .nullable() + .transform((v) => v ?? {}) + .default(() => ({})); +} + +/** `Record | null | undefined` → `Record` */ +export const unknownRecordOrEmpty = recordOrEmpty(z.unknown()); + +/** `Record | null | undefined` → `Record | null` */ +export const unknownRecordOrNull = z + .record(z.string(), z.unknown()) + .nullable() + .default(null); + +function resolveDefault(defaultValue: T | (() => T)): T { + return typeof defaultValue === "function" + ? (defaultValue as () => T)() + : defaultValue; +} + +/** `T | null | undefined` → `T`(默认对象会继续经过 schema 解析) */ +export function schemaOr( + schema: T, + defaultValue: SchemaDefault | (() => SchemaDefault), +) { + const preprocessed = z.preprocess( + (value) => value ?? resolveDefault(defaultValue), + schema, + ); + + if (typeof defaultValue === "function") { + return preprocessed.default(defaultValue as () => SchemaDefault); + } + + return preprocessed.default(defaultValue); +} + +/** `T | null | undefined` → `T | null`(默认 null) */ +export function schemaOrNull(schema: T) { + return schema.nullable().default(null); +} diff --git a/src/data/schemas/payment/create_payment_order_response.ts b/src/data/schemas/payment/create_payment_order_response.ts index 4b5475ab..63ce8572 100644 --- a/src/data/schemas/payment/create_payment_order_response.ts +++ b/src/data/schemas/payment/create_payment_order_response.ts @@ -3,6 +3,8 @@ */ import { z } from "zod"; +import { stringOrNull } from "../nullable-defaults"; + const paymentUrlKeys = [ "cashierUrl", "cashier_url", @@ -20,17 +22,17 @@ const paymentUrlKeys = [ export const CreatePaymentOrderResponseSchema = z.object({ orderId: z.string(), payParams: z.record(z.string(), z.unknown()), - cashierUrl: z.string().optional(), - cashier_url: z.string().optional(), - checkoutUrl: z.string().optional(), - checkout_url: z.string().optional(), - paymentUrl: z.string().optional(), - payment_url: z.string().optional(), - approvalUrl: z.string().optional(), - approval_url: z.string().optional(), - redirectUrl: z.string().optional(), - redirect_url: z.string().optional(), - url: z.string().optional(), + cashierUrl: stringOrNull, + cashier_url: stringOrNull, + checkoutUrl: stringOrNull, + checkout_url: stringOrNull, + paymentUrl: stringOrNull, + payment_url: stringOrNull, + approvalUrl: stringOrNull, + approval_url: stringOrNull, + redirectUrl: stringOrNull, + redirect_url: stringOrNull, + url: stringOrNull, }).transform((data) => { const payParams = { ...data.payParams }; diff --git a/src/data/schemas/payment/payment_plan.ts b/src/data/schemas/payment/payment_plan.ts index 17ee8b68..d6c8aef0 100644 --- a/src/data/schemas/payment/payment_plan.ts +++ b/src/data/schemas/payment/payment_plan.ts @@ -3,20 +3,26 @@ */ import { z } from "zod"; +import { + booleanOrFalse, + numberOrNull, + stringOrNull, +} from "../nullable-defaults"; + export const PaymentPlanSchema = z.object({ planId: z.string(), planName: z.string(), orderType: z.string(), - vipDays: z.number().nullable(), - dolAmount: z.number().nullable(), + vipDays: numberOrNull, + dolAmount: numberOrNull, creditBalance: z.number(), amountCents: z.number(), - originalAmountCents: z.number().nullable().default(null), - dailyPriceCents: z.number().nullable().default(null), + originalAmountCents: numberOrNull, + dailyPriceCents: numberOrNull, currency: z.string(), - isFirstRechargeOffer: z.boolean().default(false), - firstRechargeDiscountPercent: z.number().nullable().default(null), - promotionType: z.string().nullable().default(null), + isFirstRechargeOffer: booleanOrFalse, + firstRechargeDiscountPercent: numberOrNull, + promotionType: stringOrNull, }); export type PaymentPlanInput = z.input; diff --git a/src/data/schemas/payment/payment_plans_response.ts b/src/data/schemas/payment/payment_plans_response.ts index d0c87242..7145739d 100644 --- a/src/data/schemas/payment/payment_plans_response.ts +++ b/src/data/schemas/payment/payment_plans_response.ts @@ -3,7 +3,13 @@ */ import { z } from "zod"; -import { booleanOrFalse, numberOrZero, stringOrEmpty } from "../nullable-defaults"; +import { + arrayOrEmpty, + booleanOrFalse, + numberOrZero, + schemaOrNull, + stringOrEmpty, +} from "../nullable-defaults"; import { PaymentPlanSchema } from "./payment_plan"; export const FirstRechargeOfferSchema = z.object({ @@ -13,9 +19,9 @@ export const FirstRechargeOfferSchema = z.object({ }); export const PaymentPlansResponseSchema = z.object({ - isFirstRecharge: z.boolean().default(false), - firstRechargeOffer: FirstRechargeOfferSchema.nullable().default(null), - plans: z.array(PaymentPlanSchema), + isFirstRecharge: booleanOrFalse, + firstRechargeOffer: schemaOrNull(FirstRechargeOfferSchema), + plans: arrayOrEmpty(PaymentPlanSchema), }); export type PaymentPlansResponseInput = z.input< diff --git a/src/data/schemas/payment/payment_vip_status_response.ts b/src/data/schemas/payment/payment_vip_status_response.ts index 65a31fc4..9232837d 100644 --- a/src/data/schemas/payment/payment_vip_status_response.ts +++ b/src/data/schemas/payment/payment_vip_status_response.ts @@ -3,9 +3,11 @@ */ import { z } from "zod"; +import { booleanOrFalse, stringOrNull } from "../nullable-defaults"; + export const PaymentVipStatusResponseSchema = z.object({ - isVip: z.boolean(), - vipExpiresAt: z.string().nullable(), + isVip: booleanOrFalse, + vipExpiresAt: stringOrNull, }); export type PaymentVipStatusResponseInput = z.input< diff --git a/src/data/schemas/user/avatar_data.ts b/src/data/schemas/user/avatar_data.ts index 1d00b7b3..65f18c85 100644 --- a/src/data/schemas/user/avatar_data.ts +++ b/src/data/schemas/user/avatar_data.ts @@ -4,9 +4,11 @@ */ import { z } from "zod"; +import { stringOrEmpty } from "../nullable-defaults"; + export const AvatarDataSchema = z.object({ - avatarUrl: z.string().default(""), - userId: z.string(), + avatarUrl: stringOrEmpty, + userId: stringOrEmpty, }); export type AvatarDataInput = z.input; diff --git a/src/data/schemas/user/credits_data.ts b/src/data/schemas/user/credits_data.ts index e72fa12c..dc672733 100644 --- a/src/data/schemas/user/credits_data.ts +++ b/src/data/schemas/user/credits_data.ts @@ -4,9 +4,11 @@ */ import { z } from "zod"; +import { numberOrZero, stringOrEmpty } from "../nullable-defaults"; + export const CreditsDataSchema = z.object({ - userId: z.string(), - dolBalance: z.number(), + userId: stringOrEmpty, + dolBalance: numberOrZero, }); export type CreditsDataInput = z.input; diff --git a/src/data/schemas/user/credits_history_data.ts b/src/data/schemas/user/credits_history_data.ts index b81f3bdf..4ce3c98f 100644 --- a/src/data/schemas/user/credits_history_data.ts +++ b/src/data/schemas/user/credits_history_data.ts @@ -4,11 +4,13 @@ */ import { z } from "zod"; +import { arrayOrEmpty, numberOrZero } from "../nullable-defaults"; + export const CreditsHistoryDataSchema = z.object({ - records: z.array(z.record(z.string(), z.unknown())), - total: z.number(), - limit: z.number(), - offset: z.number(), + records: arrayOrEmpty(z.record(z.string(), z.unknown())), + total: numberOrZero, + limit: numberOrZero, + offset: numberOrZero, }); export type CreditsHistoryDataInput = z.input; diff --git a/src/data/schemas/user/personality_traits.ts b/src/data/schemas/user/personality_traits.ts index 955a9160..488bdee7 100644 --- a/src/data/schemas/user/personality_traits.ts +++ b/src/data/schemas/user/personality_traits.ts @@ -4,6 +4,8 @@ */ import { z } from "zod"; +import { numberOr } from "../nullable-defaults"; + export const PERSONALITY_TRAITS_DEFAULTS = { cheerful: 0.5, caring: 0.5, @@ -13,11 +15,11 @@ export const PERSONALITY_TRAITS_DEFAULTS = { } as const; export const PersonalityTraitsSchema = z.object({ - cheerful: z.number().default(0.5), - caring: z.number().default(0.5), - playful: z.number().default(0.5), - serious: z.number().default(0.5), - romantic: z.number().default(0.5), + cheerful: numberOr(0.5), + caring: numberOr(0.5), + playful: numberOr(0.5), + serious: numberOr(0.5), + romantic: numberOr(0.5), }); export type PersonalityTraitsInput = z.input; diff --git a/src/data/schemas/user/recent_memory.ts b/src/data/schemas/user/recent_memory.ts index 130fb74b..0a2eebb1 100644 --- a/src/data/schemas/user/recent_memory.ts +++ b/src/data/schemas/user/recent_memory.ts @@ -4,11 +4,13 @@ */ import { z } from "zod"; +import { numberOrZero, stringOrEmpty } from "../nullable-defaults"; + export const RecentMemorySchema = z.object({ - type: z.string(), - content: z.string(), - importance: z.number(), - createdAt: z.string().default(""), + type: stringOrEmpty, + content: stringOrEmpty, + importance: numberOrZero, + createdAt: stringOrEmpty, }); export type RecentMemoryInput = z.input; diff --git a/src/data/schemas/user/update_profile_request.ts b/src/data/schemas/user/update_profile_request.ts index c9c6e64d..5d0566a4 100644 --- a/src/data/schemas/user/update_profile_request.ts +++ b/src/data/schemas/user/update_profile_request.ts @@ -4,11 +4,13 @@ */ import { z } from "zod"; +import { stringOrEmpty } from "../nullable-defaults"; + export const UpdateProfileRequestSchema = z.object({ - username: z.string().default(""), - nickname: z.string().default(""), - preferredLanguage: z.string().default(""), - currentMood: z.string().default(""), + username: stringOrEmpty, + nickname: stringOrEmpty, + preferredLanguage: stringOrEmpty, + currentMood: stringOrEmpty, }); export type UpdateProfileRequestInput = z.input; diff --git a/src/data/schemas/user/user.ts b/src/data/schemas/user/user.ts index bcd5b894..0a485315 100644 --- a/src/data/schemas/user/user.ts +++ b/src/data/schemas/user/user.ts @@ -6,6 +6,7 @@ import { z } from "zod"; import { PersonalityTraitsSchema, PERSONALITY_TRAITS_DEFAULTS } from "./personality_traits"; import { numberOrZero, + schemaOr, stringOrEmpty, } from "../nullable-defaults"; @@ -25,7 +26,7 @@ export const UserSchema = z.object({ dailyFreePrivateLimit: numberOrZero, dailyFreePrivateUsed: numberOrZero, dailyFreePrivateRemaining: numberOrZero, - personalityTraits: PersonalityTraitsSchema.default(PERSONALITY_TRAITS_DEFAULTS), + personalityTraits: schemaOr(PersonalityTraitsSchema, PERSONALITY_TRAITS_DEFAULTS), preferredLanguage: stringOrEmpty, createdAt: stringOrEmpty, // 后端新游客返回 null("还没发过消息"),前端统一收敛为空字符串。 diff --git a/src/data/schemas/user/user_entitlements.ts b/src/data/schemas/user/user_entitlements.ts index 3524221d..caaf855b 100644 --- a/src/data/schemas/user/user_entitlements.ts +++ b/src/data/schemas/user/user_entitlements.ts @@ -6,9 +6,49 @@ import { z } from "zod"; import { booleanOrFalse, numberOrZero, + schemaOr, stringOrEmpty, + stringOrNull, } from "../nullable-defaults"; +const POLICY_DEFAULTS = { + membershipState: "", + nonVipEntitlementsShared: false, + guestSameAsLoggedInNonVip: false, + refreshAfterPayment: "", +} as const; + +const COSTS_DEFAULTS = { + normal_message: 0, + private_message: 0, + voice_message: 0, + photo: 0, + voice_call_minute: 0, + private_album_10: 0, + private_album_20: 0, +} as const; + +const QUOTAS_DEFAULTS = { + normalChatFreeDaily: 0, + privateUnlockFreeDaily: 0, + voiceMessageFreeDaily: 0, + photoFreeDaily: 0, +} as const; + +const HISTORY_UNLOCK_COSTS_DEFAULTS = { + private_message: 0, + voice_message: 0, + photo: 0, +} as const; + +const HISTORY_UNLOCK_DEFAULTS = { + enabled: false, + order: "", + chargeMode: "", + insufficientBalanceBehavior: "", + costs: HISTORY_UNLOCK_COSTS_DEFAULTS, +} as const; + export const UserEntitlementsPolicySchema = z .object({ membershipState: stringOrEmpty, @@ -39,24 +79,24 @@ export const UserEntitlementsQuotasSchema = z }) .passthrough(); +export const UserEntitlementsHistoryUnlockCostsSchema = z + .object({ + private_message: numberOrZero, + voice_message: numberOrZero, + photo: numberOrZero, + }) + .passthrough(); + export const UserEntitlementsHistoryUnlockSchema = z .object({ enabled: booleanOrFalse, order: stringOrEmpty, chargeMode: stringOrEmpty, insufficientBalanceBehavior: stringOrEmpty, - costs: z - .object({ - private_message: numberOrZero, - voice_message: numberOrZero, - photo: numberOrZero, - }) - .passthrough() - .default(() => ({ - private_message: 0, - voice_message: 0, - photo: 0, - })), + costs: schemaOr( + UserEntitlementsHistoryUnlockCostsSchema, + HISTORY_UNLOCK_COSTS_DEFAULTS, + ), }) .passthrough(); @@ -64,41 +104,16 @@ export const UserEntitlementsSchema = z.object({ userId: stringOrEmpty, isGuest: booleanOrFalse, isVip: booleanOrFalse, - vipExpiresAt: z.string().nullable().default(null), + vipExpiresAt: stringOrNull, creditBalance: numberOrZero, dolBalance: numberOrZero, - policy: UserEntitlementsPolicySchema.default(() => ({ - membershipState: "", - nonVipEntitlementsShared: false, - guestSameAsLoggedInNonVip: false, - refreshAfterPayment: "", - })), - costs: UserEntitlementsCostsSchema.default(() => ({ - normal_message: 0, - private_message: 0, - voice_message: 0, - photo: 0, - voice_call_minute: 0, - private_album_10: 0, - private_album_20: 0, - })), - quotas: UserEntitlementsQuotasSchema.default(() => ({ - normalChatFreeDaily: 0, - privateUnlockFreeDaily: 0, - voiceMessageFreeDaily: 0, - photoFreeDaily: 0, - })), - historyUnlock: UserEntitlementsHistoryUnlockSchema.default(() => ({ - enabled: false, - order: "", - chargeMode: "", - insufficientBalanceBehavior: "", - costs: { - private_message: 0, - voice_message: 0, - photo: 0, - }, - })), + policy: schemaOr(UserEntitlementsPolicySchema, POLICY_DEFAULTS), + costs: schemaOr(UserEntitlementsCostsSchema, COSTS_DEFAULTS), + quotas: schemaOr(UserEntitlementsQuotasSchema, QUOTAS_DEFAULTS), + historyUnlock: schemaOr( + UserEntitlementsHistoryUnlockSchema, + HISTORY_UNLOCK_DEFAULTS, + ), }); export type UserEntitlementsInput = z.input; diff --git a/src/data/schemas/user/user_stats_response.ts b/src/data/schemas/user/user_stats_response.ts index a1fce600..e42bb2cc 100644 --- a/src/data/schemas/user/user_stats_response.ts +++ b/src/data/schemas/user/user_stats_response.ts @@ -3,26 +3,33 @@ * */ import { z } from "zod"; + +import { + arrayOrEmpty, + numberOrZero, + schemaOr, + stringOrEmpty, +} from "../nullable-defaults"; import { PersonalityTraitsSchema, PERSONALITY_TRAITS_DEFAULTS } from "./personality_traits"; import { RecentMemorySchema } from "./recent_memory"; export const UserStatsResponseSchema = z.object({ - userId: z.string(), - username: z.string(), - platform: z.string(), - intimacy: z.number(), - relationshipStage: z.string(), - dolBalance: z.number(), - totalMessages: z.number(), - lastMessageAt: z.string().default(""), - accountCreatedAt: z.string().default(""), - currentMood: z.string().default(""), - personalityTraits: PersonalityTraitsSchema.default(PERSONALITY_TRAITS_DEFAULTS), - recentMemories: z.array(RecentMemorySchema).default([]), - promptTokensTotal: z.number().default(0), - completionTokensTotal: z.number().default(0), - cacheHitTokensTotal: z.number().default(0), - tokensTotal: z.number().default(0), + userId: stringOrEmpty, + username: stringOrEmpty, + platform: stringOrEmpty, + intimacy: numberOrZero, + relationshipStage: stringOrEmpty, + dolBalance: numberOrZero, + totalMessages: numberOrZero, + lastMessageAt: stringOrEmpty, + accountCreatedAt: stringOrEmpty, + currentMood: stringOrEmpty, + personalityTraits: schemaOr(PersonalityTraitsSchema, PERSONALITY_TRAITS_DEFAULTS), + recentMemories: arrayOrEmpty(RecentMemorySchema), + promptTokensTotal: numberOrZero, + completionTokensTotal: numberOrZero, + cacheHitTokensTotal: numberOrZero, + tokensTotal: numberOrZero, }); export type UserStatsResponseInput = z.input;