refactor(dto): centralize backend schema defaults

This commit is contained in:
2026-07-06 11:14:56 +08:00
parent 2d736f00ba
commit 473f6a3726
40 changed files with 513 additions and 231 deletions
@@ -104,4 +104,22 @@ describe("UnlockPrivateResponse", () => {
expect(response.requiredCredits).toBe(0); expect(response.requiredCredits).toBe(0);
expect(response.shortfallCredits).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,
});
});
}); });
+41
View File
@@ -76,6 +76,22 @@ describe("User", () => {
expect("dailyQuotas" in user.toJson()).toBe(false); 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", () => { it("parses the latest backend entitlements payload", () => {
const entitlements = UserEntitlements.fromJson({ const entitlements = UserEntitlements.fromJson({
userId: "user-1", userId: "user-1",
@@ -122,4 +138,29 @@ describe("User", () => {
expect(entitlements.creditBalance).toBe(120); expect(entitlements.creditBalance).toBe(120);
expect(entitlements.historyUnlock.costs.photo).toBe(40); 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,
},
});
});
}); });
+3 -1
View File
@@ -4,10 +4,12 @@
*/ */
import { z } from "zod"; import { z } from "zod";
import { booleanOrFalse } from "../nullable-defaults";
export const AppleLoginRequestSchema = z.object({ export const AppleLoginRequestSchema = z.object({
identityToken: z.string(), identityToken: z.string(),
platform: z.string(), platform: z.string(),
isTestAccount: z.boolean().default(false), isTestAccount: booleanOrFalse,
}); });
export type AppleLoginRequestInput = z.input<typeof AppleLoginRequestSchema>; export type AppleLoginRequestInput = z.input<typeof AppleLoginRequestSchema>;
@@ -4,11 +4,13 @@
*/ */
import { z } from "zod"; import { z } from "zod";
import { booleanOrFalse, stringOrEmpty } from "../nullable-defaults";
export const FacebookLoginRequestSchema = z.object({ export const FacebookLoginRequestSchema = z.object({
accessToken: z.string(), accessToken: z.string(),
platform: z.string(), platform: z.string(),
guestId: z.string().default(""), guestId: stringOrEmpty,
isTestAccount: z.boolean().default(false), isTestAccount: booleanOrFalse,
}); });
export type FacebookLoginRequestInput = z.input<typeof FacebookLoginRequestSchema>; export type FacebookLoginRequestInput = z.input<typeof FacebookLoginRequestSchema>;
+6 -4
View File
@@ -6,11 +6,13 @@
*/ */
import { z } from "zod"; import { z } from "zod";
import { stringOrNull } from "../nullable-defaults";
export const FacebookUserDataSchema = z.object({ export const FacebookUserDataSchema = z.object({
id: z.string().nullable().default(null), id: stringOrNull,
name: z.string().nullable().default(null), name: stringOrNull,
email: z.string().nullable().default(null), email: stringOrNull,
pictureUrl: z.string().nullable().default(null), pictureUrl: stringOrNull,
}); });
export type FacebookUserDataInput = z.input<typeof FacebookUserDataSchema>; export type FacebookUserDataInput = z.input<typeof FacebookUserDataSchema>;
+4 -2
View File
@@ -4,10 +4,12 @@
*/ */
import { z } from "zod"; import { z } from "zod";
import { booleanOrFalse, stringOrEmpty } from "../nullable-defaults";
export const FbIdLoginRequestSchema = z.object({ export const FbIdLoginRequestSchema = z.object({
fbId: z.string(), fbId: z.string(),
avatarUrl: z.string().default(""), avatarUrl: stringOrEmpty,
isTestAccount: z.boolean().default(false), isTestAccount: booleanOrFalse,
}); });
export type FbIdLoginRequestInput = z.input<typeof FbIdLoginRequestSchema>; export type FbIdLoginRequestInput = z.input<typeof FbIdLoginRequestSchema>;
@@ -4,11 +4,13 @@
*/ */
import { z } from "zod"; import { z } from "zod";
import { booleanOrFalse, stringOrEmpty } from "../nullable-defaults";
export const GoogleLoginRequestSchema = z.object({ export const GoogleLoginRequestSchema = z.object({
idToken: z.string(), idToken: z.string(),
platform: z.string(), platform: z.string(),
guestId: z.string().default(""), guestId: stringOrEmpty,
isTestAccount: z.boolean().default(false), isTestAccount: booleanOrFalse,
}); });
export type GoogleLoginRequestInput = z.input<typeof GoogleLoginRequestSchema>; export type GoogleLoginRequestInput = z.input<typeof GoogleLoginRequestSchema>;
+3 -1
View File
@@ -4,9 +4,11 @@
*/ */
import { z } from "zod"; import { z } from "zod";
import { booleanOrFalse } from "../nullable-defaults";
export const GuestLoginRequestSchema = z.object({ export const GuestLoginRequestSchema = z.object({
deviceId: z.string(), deviceId: z.string(),
isTestAccount: z.boolean().default(false), isTestAccount: booleanOrFalse,
}); });
export type GuestLoginRequestInput = z.input<typeof GuestLoginRequestSchema>; export type GuestLoginRequestInput = z.input<typeof GuestLoginRequestSchema>;
@@ -3,15 +3,17 @@
* *
*/ */
import { z } from "zod"; import { z } from "zod";
import { booleanOrTrue, numberOr, schemaOrNull } from "../nullable-defaults";
import { UserSchema } from "../user/user"; import { UserSchema } from "../user/user";
export const GuestLoginResponseSchema = z.object({ export const GuestLoginResponseSchema = z.object({
token: z.string(), token: z.string(),
deviceId: z.string(), deviceId: z.string(),
userId: z.string(), userId: z.string(),
isDeviceUser: z.boolean().default(true), isDeviceUser: booleanOrTrue,
expiresIn: z.number().default(2592000), expiresIn: numberOr(2592000),
user: UserSchema.optional(), user: schemaOrNull(UserSchema),
}); });
export type GuestLoginResponseInput = z.input<typeof GuestLoginResponseSchema>; export type GuestLoginResponseInput = z.input<typeof GuestLoginResponseSchema>;
+6 -4
View File
@@ -4,13 +4,15 @@
*/ */
import { z } from "zod"; import { z } from "zod";
import { booleanOrFalse, stringOrEmpty } from "../nullable-defaults";
export const LoginRequestSchema = z.object({ export const LoginRequestSchema = z.object({
email: z.string().default(""), email: stringOrEmpty,
username: z.string().default(""), username: stringOrEmpty,
password: z.string(), password: z.string(),
platform: z.string(), platform: z.string(),
guestId: z.string().default(""), guestId: stringOrEmpty,
isTestAccount: z.boolean().default(false), isTestAccount: booleanOrFalse,
}); });
export type LoginRequestInput = z.input<typeof LoginRequestSchema>; export type LoginRequestInput = z.input<typeof LoginRequestSchema>;
+3 -1
View File
@@ -3,12 +3,14 @@
* *
*/ */
import { z } from "zod"; import { z } from "zod";
import { stringOrEmpty } from "../nullable-defaults";
import { UserSchema } from "../user/user"; import { UserSchema } from "../user/user";
export const LoginResponseSchema = z.object({ export const LoginResponseSchema = z.object({
user: UserSchema, user: UserSchema,
token: z.string(), token: z.string(),
refreshToken: z.string().default(""), refreshToken: stringOrEmpty,
}); });
export type LoginResponseInput = z.input<typeof LoginResponseSchema>; export type LoginResponseInput = z.input<typeof LoginResponseSchema>;
+3 -1
View File
@@ -4,8 +4,10 @@
*/ */
import { z } from "zod"; import { z } from "zod";
import { booleanOrTrue } from "../nullable-defaults";
export const LogoutResponseSchema = z.object({ export const LogoutResponseSchema = z.object({
success: z.boolean().default(true), success: booleanOrTrue,
}); });
export type LogoutResponseInput = z.input<typeof LogoutResponseSchema>; export type LogoutResponseInput = z.input<typeof LogoutResponseSchema>;
+4 -2
View File
@@ -4,13 +4,15 @@
*/ */
import { z } from "zod"; import { z } from "zod";
import { booleanOrFalse, stringOrEmpty } from "../nullable-defaults";
export const RegisterRequestSchema = z.object({ export const RegisterRequestSchema = z.object({
username: z.string(), username: z.string(),
email: z.string(), email: z.string(),
password: z.string(), password: z.string(),
platform: z.string(), platform: z.string(),
guestId: z.string().default(""), guestId: stringOrEmpty,
isTestAccount: z.boolean().default(false), isTestAccount: booleanOrFalse,
}); });
export type RegisterRequestInput = z.input<typeof RegisterRequestSchema>; export type RegisterRequestInput = z.input<typeof RegisterRequestSchema>;
+10 -8
View File
@@ -3,17 +3,19 @@
* *
*/ */
import { z } from "zod"; import { z } from "zod";
import { arrayOrEmpty, booleanOrFalse, numberOrZero } from "../nullable-defaults";
import { ChatMessageSchema } from "./chat_message"; import { ChatMessageSchema } from "./chat_message";
export const ChatHistoryResponseSchema = z.object({ export const ChatHistoryResponseSchema = z.object({
messages: z.array(ChatMessageSchema), messages: arrayOrEmpty(ChatMessageSchema),
total: z.number().default(0), total: numberOrZero,
limit: z.number(), limit: numberOrZero,
offset: z.number(), offset: numberOrZero,
isVip: z.boolean().default(false), isVip: booleanOrFalse,
privateFreeLimit: z.number().default(0), privateFreeLimit: numberOrZero,
privateUsedToday: z.number().default(0), privateUsedToday: numberOrZero,
privateCanViewFree: z.boolean().default(false), privateCanViewFree: booleanOrFalse,
}); });
export type ChatHistoryResponseInput = z.input<typeof ChatHistoryResponseSchema>; export type ChatHistoryResponseInput = z.input<typeof ChatHistoryResponseSchema>;
+9 -7
View File
@@ -3,17 +3,19 @@
* *
*/ */
import { z } from "zod"; import { z } from "zod";
import { stringOr, stringOrEmpty, stringOrNull } from "../nullable-defaults";
import { ChatImageSchema, ChatLockDetailSchema } from "./chat_payloads"; import { ChatImageSchema, ChatLockDetailSchema } from "./chat_payloads";
export const ChatMessageSchema = z export const ChatMessageSchema = z
.object({ .object({
role: z.string(), role: stringOrEmpty,
type: z.string().default("text"), type: stringOr("text"),
content: z.string(), content: stringOrEmpty,
id: z.string().default(""), id: stringOrEmpty,
createdAt: z.string().default(""), createdAt: stringOrEmpty,
created_at: z.string().optional(), created_at: stringOrNull,
audioUrl: z.string().nullable().default(null), audioUrl: stringOrNull,
image: ChatImageSchema, image: ChatImageSchema,
lockDetail: ChatLockDetailSchema, lockDetail: ChatLockDetailSchema,
}) })
+31 -17
View File
@@ -3,30 +3,44 @@
*/ */
import { z } from "zod"; import { z } from "zod";
export const ChatImageSchema = z import {
.object({ booleanOrFalse,
type: z.string().nullable().default(null), booleanOrTrue,
url: z.string().nullable().default(null), schemaOr,
}) stringOrNull,
.default({ type: null, url: null }); unknownRecordOrNull,
} from "../nullable-defaults";
export const ChatLockDetailSchema = z const CHAT_IMAGE_DEFAULTS = { type: null, url: null } as const;
.object({
locked: z.boolean().default(false), const CHAT_LOCK_DETAIL_DEFAULTS = {
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, locked: false,
showContent: true, showContent: true,
showUpgrade: false, showUpgrade: false,
reason: null, reason: null,
hint: null, hint: null,
detail: 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<typeof ChatImageSchema>; export type ChatImageInput = z.input<typeof ChatImageSchema>;
export type ChatImageData = z.output<typeof ChatImageSchema>; export type ChatImageData = z.output<typeof ChatImageSchema>;
+13 -6
View File
@@ -3,19 +3,26 @@
* *
*/ */
import { z } from "zod"; 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"; import { ChatImageSchema, ChatLockDetailSchema } from "./chat_payloads";
export const ChatSendResponseSchema = z.object({ export const ChatSendResponseSchema = z.object({
reply: z.string(), reply: stringOrEmpty,
audioUrl: z.string().default(""), audioUrl: stringOrEmpty,
messageId: z.string(), messageId: stringOrEmpty,
isGuest: booleanOrFalse, isGuest: booleanOrFalse,
// 函数式 default —— 每次 parse 时重新调 Date.now()(后端不返回 timestamp 时用当下时间) // 函数式 default —— 每次 parse 时重新调 Date.now()(后端不返回 timestamp 时用当下时间)
timestamp: z.number().default(() => Date.now()), timestamp: numberOrLazy(() => Date.now()),
image: ChatImageSchema, image: ChatImageSchema,
lockDetail: ChatLockDetailSchema, lockDetail: ChatLockDetailSchema,
canSendMessage: z.boolean().default(true), canSendMessage: booleanOrTrue,
cannotSendReason: stringOrNull, cannotSendReason: stringOrNull,
creditBalance: numberOrZero, creditBalance: numberOrZero,
creditsCharged: numberOrZero, creditsCharged: numberOrZero,
+4 -2
View File
@@ -4,9 +4,11 @@
*/ */
import { z } from "zod"; import { z } from "zod";
import { numberOrZero } from "../nullable-defaults";
export const ChatSyncDataSchema = z.object({ export const ChatSyncDataSchema = z.object({
syncedCount: z.number(), syncedCount: numberOrZero,
totalHistory: z.number(), totalHistory: numberOrZero,
}); });
export type ChatSyncDataInput = z.input<typeof ChatSyncDataSchema>; export type ChatSyncDataInput = z.input<typeof ChatSyncDataSchema>;
+10 -8
View File
@@ -4,15 +4,17 @@
*/ */
import { z } from "zod"; import { z } from "zod";
import { booleanOrTrue, numberOrZero, stringOrEmpty } from "../nullable-defaults";
export const ImageUploadResponseSchema = z.object({ export const ImageUploadResponseSchema = z.object({
success: z.boolean().default(true), success: booleanOrTrue,
imageId: z.string(), imageId: stringOrEmpty,
thumbUrl: z.string(), thumbUrl: stringOrEmpty,
mediumUrl: z.string(), mediumUrl: stringOrEmpty,
originalUrl: z.string(), originalUrl: stringOrEmpty,
width: z.number(), width: numberOrZero,
height: z.number(), height: numberOrZero,
bytes: z.number(), bytes: numberOrZero,
}); });
export type ImageUploadResponseInput = z.input<typeof ImageUploadResponseSchema>; export type ImageUploadResponseInput = z.input<typeof ImageUploadResponseSchema>;
+11 -9
View File
@@ -4,16 +4,18 @@
*/ */
import { z } from "zod"; import { z } from "zod";
import { booleanOrFalse, numberOrZero, stringOrEmpty } from "../nullable-defaults";
export const SendMessageRequestSchema = z.object({ export const SendMessageRequestSchema = z.object({
message: z.string().default(""), message: stringOrEmpty,
image: z.string().default(""), image: stringOrEmpty,
imageId: z.string().default(""), imageId: stringOrEmpty,
imageThumbUrl: z.string().default(""), imageThumbUrl: stringOrEmpty,
imageMediumUrl: z.string().default(""), imageMediumUrl: stringOrEmpty,
imageOriginalUrl: z.string().default(""), imageOriginalUrl: stringOrEmpty,
imageWidth: z.number().default(0), imageWidth: numberOrZero,
imageHeight: z.number().default(0), imageHeight: numberOrZero,
useWebSocket: z.boolean().default(false), useWebSocket: booleanOrFalse,
}); });
export type SendMessageRequestInput = z.input<typeof SendMessageRequestSchema>; export type SendMessageRequestInput = z.input<typeof SendMessageRequestSchema>;
+3 -1
View File
@@ -4,8 +4,10 @@
*/ */
import { z } from "zod"; import { z } from "zod";
import { stringOrEmpty } from "../nullable-defaults";
export const SttDataSchema = z.object({ export const SttDataSchema = z.object({
text: z.string(), text: stringOrEmpty,
}); });
export type SttDataInput = z.input<typeof SttDataSchema>; export type SttDataInput = z.input<typeof SttDataSchema>;
+5 -3
View File
@@ -4,10 +4,12 @@
*/ */
import { z } from "zod"; import { z } from "zod";
import { stringOrEmpty } from "../nullable-defaults";
export const SyncMessageSchema = z.object({ export const SyncMessageSchema = z.object({
role: z.string(), role: stringOrEmpty,
content: z.string(), content: stringOrEmpty,
timestamp: z.string(), timestamp: stringOrEmpty,
}); });
export type SyncMessageInput = z.input<typeof SyncMessageSchema>; export type SyncMessageInput = z.input<typeof SyncMessageSchema>;
@@ -3,7 +3,9 @@
*/ */
import { z } from "zod"; 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([ export const UnlockHistoryReasonSchema = z.enum([
"ok", "ok",
@@ -12,19 +14,19 @@ export const UnlockHistoryReasonSchema = z.enum([
]); ]);
export const UnlockHistoryResponseSchema = z.object({ export const UnlockHistoryResponseSchema = z.object({
unlocked: z.boolean(), unlocked: booleanOrFalse,
reason: UnlockHistoryReasonSchema, reason: UnlockHistoryReasonSchema,
totalLocked: z.number().default(0), totalLocked: numberOrZero,
unlockedCount: z.number().default(0), unlockedCount: numberOrZero,
privateCount: z.number().default(0), privateCount: numberOrZero,
imageCount: z.number().default(0), imageCount: numberOrZero,
voiceCount: z.number().default(0), voiceCount: numberOrZero,
requiredCredits: z.number().default(0), requiredCredits: numberOrZero,
currentCredits: z.number().default(0), currentCredits: numberOrZero,
remainingCredits: z.number().default(0), remainingCredits: numberOrZero,
shortfallCredits: z.number().default(0), shortfallCredits: numberOrZero,
costsByMessage: UnlockHistoryCostsByMessageSchema.default({}), costsByMessage: UnlockHistoryCostsByMessageSchema,
messageIds: z.array(z.string()).default([]), messageIds: arrayOrEmpty(z.string()),
}); });
export type UnlockHistoryReason = z.output<typeof UnlockHistoryReasonSchema>; export type UnlockHistoryReason = z.output<typeof UnlockHistoryReasonSchema>;
@@ -1,15 +1,15 @@
import { z } from "zod"; import { z } from "zod";
import { numberOrZero, stringOrEmpty } from "../nullable-defaults"; import { booleanOrFalse, numberOrZero, stringOrEmpty } from "../nullable-defaults";
import { ChatLockDetailSchema } from "./chat_payloads"; import { ChatLockDetailSchema } from "./chat_payloads";
/** /**
* 单条历史付费 / 私密消息解锁响应。 * 单条历史付费 / 私密消息解锁响应。
*/ */
export const UnlockPrivateReasonSchema = z.string().default(""); export const UnlockPrivateReasonSchema = stringOrEmpty;
export const UnlockPrivateResponseSchema = z.object({ export const UnlockPrivateResponseSchema = z.object({
unlocked: z.boolean(), unlocked: booleanOrFalse,
content: stringOrEmpty, content: stringOrEmpty,
reason: UnlockPrivateReasonSchema, reason: UnlockPrivateReasonSchema,
creditBalance: numberOrZero, creditBalance: numberOrZero,
+5 -3
View File
@@ -4,10 +4,12 @@
*/ */
import { z } from "zod"; import { z } from "zod";
import { stringOrEmpty } from "../nullable-defaults";
export const AppEventSchema = z.object({ export const AppEventSchema = z.object({
userId: z.string(), userId: stringOrEmpty,
browser: z.string(), browser: stringOrEmpty,
userAgent: z.string(), userAgent: stringOrEmpty,
}); });
export type AppEventInput = z.input<typeof AppEventSchema>; export type AppEventInput = z.input<typeof AppEventSchema>;
+11 -5
View File
@@ -4,12 +4,18 @@
*/ */
import { z } from "zod"; import { z } from "zod";
import {
booleanOrFalse,
numberOrZero,
stringOrEmpty,
} from "../nullable-defaults";
export const PwaEventSchema = z.object({ export const PwaEventSchema = z.object({
deviceId: z.string(), deviceId: stringOrEmpty,
deviceType: z.string(), deviceType: stringOrEmpty,
timestamp: z.number(), timestamp: numberOrZero,
pwaInstalled: z.boolean().default(false), pwaInstalled: booleanOrFalse,
pwaSupported: z.boolean().default(false), pwaSupported: booleanOrFalse,
}); });
export type PwaEventInput = z.input<typeof PwaEventSchema>; export type PwaEventInput = z.input<typeof PwaEventSchema>;
+118 -13
View File
@@ -1,5 +1,5 @@
/** /**
* Zod 默认值辅助器(处理 `null | undefined` → 标量默认) * Zod 默认值辅助器(处理 `null | undefined` → 默认
* *
* 防御性解析:后端对 Facebook / Apple 等 OAuth 用户可能返回 `null`(如 `email: null`), * 防御性解析:后端对 Facebook / Apple 等 OAuth 用户可能返回 `null`(如 `email: null`),
* Zod 默认的 `.default("")` 只接受 `undefined`,遇到 `null` 会抛 ZodError → 登录静默失败。 * Zod 默认的 `.default("")` 只接受 `undefined`,遇到 `null` 会抛 ZodError → 登录静默失败。
@@ -11,26 +11,131 @@
*/ */
import { z } from "zod"; import { z } from "zod";
/** `string | null | undefined` → `string`(默认空串) */ type SchemaDefault<T extends z.ZodType> = Exclude<z.output<T>, undefined>;
export const stringOrEmpty = z
/** `string | null | undefined` → `string` */
export function stringOr(defaultValue: string) {
return z
.string() .string()
.nullable() .nullable()
.transform((v) => v ?? "") .transform((v) => v ?? defaultValue)
.default(""); .default(defaultValue);
}
/** `string | null | undefined` → `string`(默认空串) */
export const stringOrEmpty = stringOr("");
/** required `string | null` → `string`(缺字段仍报错,null 收敛为空串) */
export const requiredStringOrEmpty = z
.string()
.nullable()
.transform((v) => v ?? "");
/** `string | null | undefined` → `string | null`(默认 null */ /** `string | null | undefined` → `string | null`(默认 null */
export const stringOrNull = z.string().nullable().default(null); export const stringOrNull = z.string().nullable().default(null);
/** `number | null | undefined` → `number`(默认 0 */ /** `number | null | undefined` → `number` */
export const numberOrZero = z export function numberOr(defaultValue: number) {
return z
.number() .number()
.nullable() .nullable()
.transform((v) => v ?? 0) .transform((v) => v ?? defaultValue)
.default(0); .default(defaultValue);
}
/** `boolean | null | undefined` → `boolean`(默认 false */ /** `number | null | undefined` → `number`,默认值在 parse 时惰性计算 */
export const booleanOrFalse = z export function numberOrLazy(defaultValue: () => number) {
return z
.number()
.nullable()
.transform((v) => v ?? defaultValue())
.default(defaultValue);
}
/** `number | null | undefined` → `number`(默认 0 */
export const numberOrZero = numberOr(0);
/** required `number | null` → `number`(缺字段仍报错,null 收敛为 0) */
export const requiredNumberOrZero = z
.number()
.nullable()
.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() .boolean()
.nullable() .nullable()
.transform((v) => v ?? false) .transform((v) => v ?? defaultValue)
.default(false); .default(defaultValue);
}
/** `boolean | null | undefined` → `boolean`(默认 false */
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);
/** `T[] | null | undefined` → `T[]`(默认空数组) */
export function arrayOrEmpty<T extends z.ZodType>(schema: T) {
return z
.array(schema)
.nullable()
.transform((v) => v ?? [])
.default(() => []);
}
/** `Record<string, T> | null | undefined` → `Record<string, T>`(默认空对象) */
export function recordOrEmpty<T extends z.ZodType>(schema: T) {
return z
.record(z.string(), schema)
.nullable()
.transform((v) => v ?? {})
.default(() => ({}));
}
/** `Record<string, unknown> | null | undefined` → `Record<string, unknown>` */
export const unknownRecordOrEmpty = recordOrEmpty(z.unknown());
/** `Record<string, unknown> | null | undefined` → `Record<string, unknown> | null` */
export const unknownRecordOrNull = z
.record(z.string(), z.unknown())
.nullable()
.default(null);
function resolveDefault<T>(defaultValue: T | (() => T)): T {
return typeof defaultValue === "function"
? (defaultValue as () => T)()
: defaultValue;
}
/** `T | null | undefined` → `T`(默认对象会继续经过 schema 解析) */
export function schemaOr<T extends z.ZodType>(
schema: T,
defaultValue: SchemaDefault<T> | (() => SchemaDefault<T>),
) {
const preprocessed = z.preprocess(
(value) => value ?? resolveDefault(defaultValue),
schema,
);
if (typeof defaultValue === "function") {
return preprocessed.default(defaultValue as () => SchemaDefault<T>);
}
return preprocessed.default(defaultValue);
}
/** `T | null | undefined` → `T | null`(默认 null */
export function schemaOrNull<T extends z.ZodType>(schema: T) {
return schema.nullable().default(null);
}
@@ -3,6 +3,8 @@
*/ */
import { z } from "zod"; import { z } from "zod";
import { stringOrNull } from "../nullable-defaults";
const paymentUrlKeys = [ const paymentUrlKeys = [
"cashierUrl", "cashierUrl",
"cashier_url", "cashier_url",
@@ -20,17 +22,17 @@ const paymentUrlKeys = [
export const CreatePaymentOrderResponseSchema = z.object({ export const CreatePaymentOrderResponseSchema = z.object({
orderId: z.string(), orderId: z.string(),
payParams: z.record(z.string(), z.unknown()), payParams: z.record(z.string(), z.unknown()),
cashierUrl: z.string().optional(), cashierUrl: stringOrNull,
cashier_url: z.string().optional(), cashier_url: stringOrNull,
checkoutUrl: z.string().optional(), checkoutUrl: stringOrNull,
checkout_url: z.string().optional(), checkout_url: stringOrNull,
paymentUrl: z.string().optional(), paymentUrl: stringOrNull,
payment_url: z.string().optional(), payment_url: stringOrNull,
approvalUrl: z.string().optional(), approvalUrl: stringOrNull,
approval_url: z.string().optional(), approval_url: stringOrNull,
redirectUrl: z.string().optional(), redirectUrl: stringOrNull,
redirect_url: z.string().optional(), redirect_url: stringOrNull,
url: z.string().optional(), url: stringOrNull,
}).transform((data) => { }).transform((data) => {
const payParams = { ...data.payParams }; const payParams = { ...data.payParams };
+13 -7
View File
@@ -3,20 +3,26 @@
*/ */
import { z } from "zod"; import { z } from "zod";
import {
booleanOrFalse,
numberOrNull,
stringOrNull,
} from "../nullable-defaults";
export const PaymentPlanSchema = z.object({ export const PaymentPlanSchema = z.object({
planId: z.string(), planId: z.string(),
planName: z.string(), planName: z.string(),
orderType: z.string(), orderType: z.string(),
vipDays: z.number().nullable(), vipDays: numberOrNull,
dolAmount: z.number().nullable(), dolAmount: numberOrNull,
creditBalance: z.number(), creditBalance: z.number(),
amountCents: z.number(), amountCents: z.number(),
originalAmountCents: z.number().nullable().default(null), originalAmountCents: numberOrNull,
dailyPriceCents: z.number().nullable().default(null), dailyPriceCents: numberOrNull,
currency: z.string(), currency: z.string(),
isFirstRechargeOffer: z.boolean().default(false), isFirstRechargeOffer: booleanOrFalse,
firstRechargeDiscountPercent: z.number().nullable().default(null), firstRechargeDiscountPercent: numberOrNull,
promotionType: z.string().nullable().default(null), promotionType: stringOrNull,
}); });
export type PaymentPlanInput = z.input<typeof PaymentPlanSchema>; export type PaymentPlanInput = z.input<typeof PaymentPlanSchema>;
@@ -3,7 +3,13 @@
*/ */
import { z } from "zod"; 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"; import { PaymentPlanSchema } from "./payment_plan";
export const FirstRechargeOfferSchema = z.object({ export const FirstRechargeOfferSchema = z.object({
@@ -13,9 +19,9 @@ export const FirstRechargeOfferSchema = z.object({
}); });
export const PaymentPlansResponseSchema = z.object({ export const PaymentPlansResponseSchema = z.object({
isFirstRecharge: z.boolean().default(false), isFirstRecharge: booleanOrFalse,
firstRechargeOffer: FirstRechargeOfferSchema.nullable().default(null), firstRechargeOffer: schemaOrNull(FirstRechargeOfferSchema),
plans: z.array(PaymentPlanSchema), plans: arrayOrEmpty(PaymentPlanSchema),
}); });
export type PaymentPlansResponseInput = z.input< export type PaymentPlansResponseInput = z.input<
@@ -3,9 +3,11 @@
*/ */
import { z } from "zod"; import { z } from "zod";
import { booleanOrFalse, stringOrNull } from "../nullable-defaults";
export const PaymentVipStatusResponseSchema = z.object({ export const PaymentVipStatusResponseSchema = z.object({
isVip: z.boolean(), isVip: booleanOrFalse,
vipExpiresAt: z.string().nullable(), vipExpiresAt: stringOrNull,
}); });
export type PaymentVipStatusResponseInput = z.input< export type PaymentVipStatusResponseInput = z.input<
+4 -2
View File
@@ -4,9 +4,11 @@
*/ */
import { z } from "zod"; import { z } from "zod";
import { stringOrEmpty } from "../nullable-defaults";
export const AvatarDataSchema = z.object({ export const AvatarDataSchema = z.object({
avatarUrl: z.string().default(""), avatarUrl: stringOrEmpty,
userId: z.string(), userId: stringOrEmpty,
}); });
export type AvatarDataInput = z.input<typeof AvatarDataSchema>; export type AvatarDataInput = z.input<typeof AvatarDataSchema>;
+4 -2
View File
@@ -4,9 +4,11 @@
*/ */
import { z } from "zod"; import { z } from "zod";
import { numberOrZero, stringOrEmpty } from "../nullable-defaults";
export const CreditsDataSchema = z.object({ export const CreditsDataSchema = z.object({
userId: z.string(), userId: stringOrEmpty,
dolBalance: z.number(), dolBalance: numberOrZero,
}); });
export type CreditsDataInput = z.input<typeof CreditsDataSchema>; export type CreditsDataInput = z.input<typeof CreditsDataSchema>;
@@ -4,11 +4,13 @@
*/ */
import { z } from "zod"; import { z } from "zod";
import { arrayOrEmpty, numberOrZero } from "../nullable-defaults";
export const CreditsHistoryDataSchema = z.object({ export const CreditsHistoryDataSchema = z.object({
records: z.array(z.record(z.string(), z.unknown())), records: arrayOrEmpty(z.record(z.string(), z.unknown())),
total: z.number(), total: numberOrZero,
limit: z.number(), limit: numberOrZero,
offset: z.number(), offset: numberOrZero,
}); });
export type CreditsHistoryDataInput = z.input<typeof CreditsHistoryDataSchema>; export type CreditsHistoryDataInput = z.input<typeof CreditsHistoryDataSchema>;
+7 -5
View File
@@ -4,6 +4,8 @@
*/ */
import { z } from "zod"; import { z } from "zod";
import { numberOr } from "../nullable-defaults";
export const PERSONALITY_TRAITS_DEFAULTS = { export const PERSONALITY_TRAITS_DEFAULTS = {
cheerful: 0.5, cheerful: 0.5,
caring: 0.5, caring: 0.5,
@@ -13,11 +15,11 @@ export const PERSONALITY_TRAITS_DEFAULTS = {
} as const; } as const;
export const PersonalityTraitsSchema = z.object({ export const PersonalityTraitsSchema = z.object({
cheerful: z.number().default(0.5), cheerful: numberOr(0.5),
caring: z.number().default(0.5), caring: numberOr(0.5),
playful: z.number().default(0.5), playful: numberOr(0.5),
serious: z.number().default(0.5), serious: numberOr(0.5),
romantic: z.number().default(0.5), romantic: numberOr(0.5),
}); });
export type PersonalityTraitsInput = z.input<typeof PersonalityTraitsSchema>; export type PersonalityTraitsInput = z.input<typeof PersonalityTraitsSchema>;
+6 -4
View File
@@ -4,11 +4,13 @@
*/ */
import { z } from "zod"; import { z } from "zod";
import { numberOrZero, stringOrEmpty } from "../nullable-defaults";
export const RecentMemorySchema = z.object({ export const RecentMemorySchema = z.object({
type: z.string(), type: stringOrEmpty,
content: z.string(), content: stringOrEmpty,
importance: z.number(), importance: numberOrZero,
createdAt: z.string().default(""), createdAt: stringOrEmpty,
}); });
export type RecentMemoryInput = z.input<typeof RecentMemorySchema>; export type RecentMemoryInput = z.input<typeof RecentMemorySchema>;
@@ -4,11 +4,13 @@
*/ */
import { z } from "zod"; import { z } from "zod";
import { stringOrEmpty } from "../nullable-defaults";
export const UpdateProfileRequestSchema = z.object({ export const UpdateProfileRequestSchema = z.object({
username: z.string().default(""), username: stringOrEmpty,
nickname: z.string().default(""), nickname: stringOrEmpty,
preferredLanguage: z.string().default(""), preferredLanguage: stringOrEmpty,
currentMood: z.string().default(""), currentMood: stringOrEmpty,
}); });
export type UpdateProfileRequestInput = z.input<typeof UpdateProfileRequestSchema>; export type UpdateProfileRequestInput = z.input<typeof UpdateProfileRequestSchema>;
+2 -1
View File
@@ -6,6 +6,7 @@ import { z } from "zod";
import { PersonalityTraitsSchema, PERSONALITY_TRAITS_DEFAULTS } from "./personality_traits"; import { PersonalityTraitsSchema, PERSONALITY_TRAITS_DEFAULTS } from "./personality_traits";
import { import {
numberOrZero, numberOrZero,
schemaOr,
stringOrEmpty, stringOrEmpty,
} from "../nullable-defaults"; } from "../nullable-defaults";
@@ -25,7 +26,7 @@ export const UserSchema = z.object({
dailyFreePrivateLimit: numberOrZero, dailyFreePrivateLimit: numberOrZero,
dailyFreePrivateUsed: numberOrZero, dailyFreePrivateUsed: numberOrZero,
dailyFreePrivateRemaining: numberOrZero, dailyFreePrivateRemaining: numberOrZero,
personalityTraits: PersonalityTraitsSchema.default(PERSONALITY_TRAITS_DEFAULTS), personalityTraits: schemaOr(PersonalityTraitsSchema, PERSONALITY_TRAITS_DEFAULTS),
preferredLanguage: stringOrEmpty, preferredLanguage: stringOrEmpty,
createdAt: stringOrEmpty, createdAt: stringOrEmpty,
// 后端新游客返回 null("还没发过消息"),前端统一收敛为空字符串。 // 后端新游客返回 null("还没发过消息"),前端统一收敛为空字符串。
+60 -45
View File
@@ -6,9 +6,49 @@ import { z } from "zod";
import { import {
booleanOrFalse, booleanOrFalse,
numberOrZero, numberOrZero,
schemaOr,
stringOrEmpty, stringOrEmpty,
stringOrNull,
} from "../nullable-defaults"; } 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 export const UserEntitlementsPolicySchema = z
.object({ .object({
membershipState: stringOrEmpty, membershipState: stringOrEmpty,
@@ -39,24 +79,24 @@ export const UserEntitlementsQuotasSchema = z
}) })
.passthrough(); .passthrough();
export const UserEntitlementsHistoryUnlockCostsSchema = z
.object({
private_message: numberOrZero,
voice_message: numberOrZero,
photo: numberOrZero,
})
.passthrough();
export const UserEntitlementsHistoryUnlockSchema = z export const UserEntitlementsHistoryUnlockSchema = z
.object({ .object({
enabled: booleanOrFalse, enabled: booleanOrFalse,
order: stringOrEmpty, order: stringOrEmpty,
chargeMode: stringOrEmpty, chargeMode: stringOrEmpty,
insufficientBalanceBehavior: stringOrEmpty, insufficientBalanceBehavior: stringOrEmpty,
costs: z costs: schemaOr(
.object({ UserEntitlementsHistoryUnlockCostsSchema,
private_message: numberOrZero, HISTORY_UNLOCK_COSTS_DEFAULTS,
voice_message: numberOrZero, ),
photo: numberOrZero,
})
.passthrough()
.default(() => ({
private_message: 0,
voice_message: 0,
photo: 0,
})),
}) })
.passthrough(); .passthrough();
@@ -64,41 +104,16 @@ export const UserEntitlementsSchema = z.object({
userId: stringOrEmpty, userId: stringOrEmpty,
isGuest: booleanOrFalse, isGuest: booleanOrFalse,
isVip: booleanOrFalse, isVip: booleanOrFalse,
vipExpiresAt: z.string().nullable().default(null), vipExpiresAt: stringOrNull,
creditBalance: numberOrZero, creditBalance: numberOrZero,
dolBalance: numberOrZero, dolBalance: numberOrZero,
policy: UserEntitlementsPolicySchema.default(() => ({ policy: schemaOr(UserEntitlementsPolicySchema, POLICY_DEFAULTS),
membershipState: "", costs: schemaOr(UserEntitlementsCostsSchema, COSTS_DEFAULTS),
nonVipEntitlementsShared: false, quotas: schemaOr(UserEntitlementsQuotasSchema, QUOTAS_DEFAULTS),
guestSameAsLoggedInNonVip: false, historyUnlock: schemaOr(
refreshAfterPayment: "", UserEntitlementsHistoryUnlockSchema,
})), HISTORY_UNLOCK_DEFAULTS,
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,
},
})),
}); });
export type UserEntitlementsInput = z.input<typeof UserEntitlementsSchema>; export type UserEntitlementsInput = z.input<typeof UserEntitlementsSchema>;
+23 -16
View File
@@ -3,26 +3,33 @@
* *
*/ */
import { z } from "zod"; import { z } from "zod";
import {
arrayOrEmpty,
numberOrZero,
schemaOr,
stringOrEmpty,
} from "../nullable-defaults";
import { PersonalityTraitsSchema, PERSONALITY_TRAITS_DEFAULTS } from "./personality_traits"; import { PersonalityTraitsSchema, PERSONALITY_TRAITS_DEFAULTS } from "./personality_traits";
import { RecentMemorySchema } from "./recent_memory"; import { RecentMemorySchema } from "./recent_memory";
export const UserStatsResponseSchema = z.object({ export const UserStatsResponseSchema = z.object({
userId: z.string(), userId: stringOrEmpty,
username: z.string(), username: stringOrEmpty,
platform: z.string(), platform: stringOrEmpty,
intimacy: z.number(), intimacy: numberOrZero,
relationshipStage: z.string(), relationshipStage: stringOrEmpty,
dolBalance: z.number(), dolBalance: numberOrZero,
totalMessages: z.number(), totalMessages: numberOrZero,
lastMessageAt: z.string().default(""), lastMessageAt: stringOrEmpty,
accountCreatedAt: z.string().default(""), accountCreatedAt: stringOrEmpty,
currentMood: z.string().default(""), currentMood: stringOrEmpty,
personalityTraits: PersonalityTraitsSchema.default(PERSONALITY_TRAITS_DEFAULTS), personalityTraits: schemaOr(PersonalityTraitsSchema, PERSONALITY_TRAITS_DEFAULTS),
recentMemories: z.array(RecentMemorySchema).default([]), recentMemories: arrayOrEmpty(RecentMemorySchema),
promptTokensTotal: z.number().default(0), promptTokensTotal: numberOrZero,
completionTokensTotal: z.number().default(0), completionTokensTotal: numberOrZero,
cacheHitTokensTotal: z.number().default(0), cacheHitTokensTotal: numberOrZero,
tokensTotal: z.number().default(0), tokensTotal: numberOrZero,
}); });
export type UserStatsResponseInput = z.input<typeof UserStatsResponseSchema>; export type UserStatsResponseInput = z.input<typeof UserStatsResponseSchema>;