refactor(data): replace schema classes with readonly models

This commit is contained in:
2026-07-17 13:21:40 +08:00
parent 3437312167
commit ae97366a4a
103 changed files with 1220 additions and 2117 deletions
@@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest";
import { FacebookIdentityResponseSchema } from "@/data/schemas/auth";
import { ChatHistoryResponseSchema } from "@/data/schemas/chat";
import { PaymentPlansResponseSchema } from "@/data/schemas/payment";
import { PrivateAlbumsResponseSchema } from "@/data/schemas/private-room";
import { UserSchema } from "@/data/schemas/user";
describe("immutable schemas", () => {
it("freezes nested user data", () => {
const user = UserSchema.parse({
id: "user-1",
username: "Ada",
personalityTraits: null,
});
expectDeepFrozen(user);
});
it("freezes chat history messages and payloads", () => {
const response = ChatHistoryResponseSchema.parse({
messages: [
{
id: "message-1",
role: "assistant",
content: "Hello",
image: { type: "jpg", url: "https://example.com/image.jpg" },
lockDetail: { locked: false, reason: null },
},
],
});
expectDeepFrozen(response);
});
it("freezes payment and private-room collections", () => {
const plans = PaymentPlansResponseSchema.parse({
firstRechargeOffer: {
enabled: true,
type: "half_price",
discountPercent: 50,
},
plans: [
{
planId: "vip_monthly",
planName: "Monthly VIP",
orderType: "vip_monthly",
creditBalance: 0,
amountCents: 999,
currency: "USD",
},
],
});
const albums = PrivateAlbumsResponseSchema.parse({
items: [
{
albumId: "album-1",
title: "Private album",
images: [{ url: "https://example.com/image.jpg", index: 0 }],
lockDetail: { locked: true },
},
],
});
expectDeepFrozen(plans);
expectDeepFrozen(albums);
});
it("freezes containers created from missing defaults", () => {
const response = FacebookIdentityResponseSchema.parse({});
expectDeepFrozen(response);
expect(response.facebookBinding).toEqual({ conflicts: [], bound: [] });
});
});
function expectDeepFrozen(value: unknown): void {
if (value === null || typeof value !== "object") return;
expect(Object.isFrozen(value)).toBe(true);
for (const child of Object.values(value)) {
expectDeepFrozen(child);
}
}
@@ -1,42 +0,0 @@
import { describe, expect, it } from "vitest";
import { z } from "zod";
import {
createSchemaModel,
type SchemaModel,
} from "../schema_model";
const ExampleSchema = z.object({
id: z.string(),
traceId: z.string().default("generated-trace"),
});
type ExampleModel = SchemaModel<typeof ExampleSchema, "id">;
const ExampleModel = createSchemaModel<typeof ExampleSchema, "id">(
ExampleSchema,
);
describe("createSchemaModel", () => {
it("parses, freezes and serializes through the source schema", () => {
const model: ExampleModel = ExampleModel.from({ id: "item-1" });
expect(model).toBeInstanceOf(ExampleModel);
expect(Object.isFrozen(model)).toBe(true);
expect(model.id).toBe("item-1");
expect(model.toJson()).toEqual({
id: "item-1",
traceId: "generated-trace",
});
});
it("keeps non-public schema fields out of the declared model interface", () => {
const model = ExampleModel.fromJson({ id: "item-1", traceId: "trace-1" });
// @ts-expect-error traceId is serialized but not part of the public API.
expect(model.traceId).toBe("trace-1");
});
it("preserves schema validation errors", () => {
expect(() => ExampleModel.fromJson({ id: 1 })).toThrow(z.ZodError);
});
});
@@ -1,15 +1,15 @@
import { describe, expect, it } from "vitest";
import {
FacebookIdentityResponse,
FacebookPsidLoginResponse,
LoginRequest,
FacebookIdentityResponseSchema,
FacebookPsidLoginResponseSchema,
LoginRequestSchema,
} from "@/data/schemas/auth";
import { UserSchema } from "@/data/schemas/user/user";
describe("facebook identity schema models", () => {
it("parses facebook identity binding responses", () => {
const response = FacebookIdentityResponse.fromJson({
const response = FacebookIdentityResponseSchema.parse({
fbAsid: "asid-1",
fbPsid: "psid-1",
facebookBinding: {
@@ -18,7 +18,7 @@ describe("facebook identity schema models", () => {
},
});
expect(response.toJson()).toMatchObject({
expect(response).toMatchObject({
fbAsid: "asid-1",
fbPsid: "psid-1",
facebookBinding: {
@@ -30,7 +30,7 @@ describe("facebook identity schema models", () => {
it("parses facebook psid login responses for real users and guests", () => {
expect(
FacebookPsidLoginResponse.fromJson({
FacebookPsidLoginResponseSchema.parse({
token: "login-token",
refreshToken: "refresh-token",
matchedBy: "psid",
@@ -39,7 +39,7 @@ describe("facebook identity schema models", () => {
hasCompleteFacebookIdentity: true,
isGuest: false,
user: { id: "user-1", username: "Elio" },
}).toJson(),
}),
).toMatchObject({
isGuest: false,
hasCompleteFacebookIdentity: true,
@@ -47,14 +47,14 @@ describe("facebook identity schema models", () => {
});
expect(
FacebookPsidLoginResponse.fromJson({
FacebookPsidLoginResponseSchema.parse({
token: "guest-token",
matchedBy: "guest",
fbPsid: "psid-1",
hasCompleteFacebookIdentity: false,
isGuest: true,
userId: "guest-1",
}).toJson(),
}),
).toMatchObject({
refreshToken: "",
isGuest: true,
@@ -64,7 +64,7 @@ describe("facebook identity schema models", () => {
});
it("keeps psid in request serialization without a public field declaration", () => {
const request = LoginRequest.from({
const request = LoginRequestSchema.parse({
email: "user@example.com",
password: "password",
platform: "web",
@@ -72,7 +72,7 @@ describe("facebook identity schema models", () => {
psid: "psid-1",
});
expect(request.toJson()).toMatchObject({ psid: "psid-1" });
expect(request).toMatchObject({ psid: "psid-1" });
});
it("accepts facebook identity fields in user schema", () => {
+10 -31
View File
@@ -1,6 +1,6 @@
/**
* Facebook 用户数据 schema
*
*
*
* 字段对齐 Dart 端 `String?` 可空语义:缺字段时存 `null`。
*/
@@ -8,37 +8,16 @@ import { z } from "zod";
import { stringOrNull } from "../nullable-defaults";
export const FacebookUserDataSchema = z.object({
id: stringOrNull,
name: stringOrNull,
email: stringOrNull,
pictureUrl: stringOrNull,
});
export const FacebookUserDataSchema = z
.object({
id: stringOrNull,
name: stringOrNull,
email: stringOrNull,
pictureUrl: stringOrNull,
})
.readonly();
export type FacebookUserDataInput = z.input<typeof FacebookUserDataSchema>;
export type FacebookUserDataData = z.output<typeof FacebookUserDataSchema>;
export class FacebookUserData {
declare readonly id: string | null;
declare readonly name: string | null;
declare readonly email: string | null;
declare readonly pictureUrl: string | null;
private constructor(input: FacebookUserDataInput) {
const data = FacebookUserDataSchema.parse(input);
Object.assign(this, data);
Object.freeze(this);
}
static from(input: FacebookUserDataInput): FacebookUserData {
return new FacebookUserData(input);
}
static fromJson(json: unknown): FacebookUserData {
return FacebookUserData.from(json as FacebookUserDataInput);
}
toJson(): FacebookUserDataData {
return FacebookUserDataSchema.parse(this);
}
}
export type FacebookUserData = FacebookUserDataData;
@@ -3,17 +3,14 @@
*/
import { z } from "zod";
import {
createSchemaModel,
type SchemaModel,
} from "../../schema_model";
import { stringOrEmpty } from "../../nullable-defaults";
export const FacebookIdentityRequestSchema = z.object({
asid: stringOrEmpty,
psid: stringOrEmpty,
});
export const FacebookIdentityRequestSchema = z
.object({
asid: stringOrEmpty,
psid: stringOrEmpty,
})
.readonly();
export type FacebookIdentityRequestInput = z.input<
typeof FacebookIdentityRequestSchema
@@ -21,10 +18,4 @@ export type FacebookIdentityRequestInput = z.input<
export type FacebookIdentityRequestData = z.output<
typeof FacebookIdentityRequestSchema
>;
export type FacebookIdentityRequest = SchemaModel<
typeof FacebookIdentityRequestSchema
>;
export const FacebookIdentityRequest = createSchemaModel(
FacebookIdentityRequestSchema,
);
export type FacebookIdentityRequest = FacebookIdentityRequestData;
@@ -1,43 +1,26 @@
/**
* Facebook 登录请求
*
*
*/
import { z } from "zod";
import { booleanOrFalse, stringOrEmpty } from "../../nullable-defaults";
export const FacebookLoginRequestSchema = z.object({
accessToken: z.string(),
platform: z.string(),
guestId: stringOrEmpty,
psid: stringOrEmpty,
isTestAccount: booleanOrFalse,
});
export const FacebookLoginRequestSchema = z
.object({
accessToken: z.string(),
platform: z.string(),
guestId: stringOrEmpty,
psid: stringOrEmpty,
isTestAccount: booleanOrFalse,
})
.readonly();
export type FacebookLoginRequestInput = z.input<typeof FacebookLoginRequestSchema>;
export type FacebookLoginRequestData = z.output<typeof FacebookLoginRequestSchema>;
export type FacebookLoginRequestInput = z.input<
typeof FacebookLoginRequestSchema
>;
export type FacebookLoginRequestData = z.output<
typeof FacebookLoginRequestSchema
>;
export class FacebookLoginRequest {
declare readonly accessToken: string;
declare readonly platform: string;
declare readonly guestId: string;
declare readonly isTestAccount: boolean;
private constructor(input: FacebookLoginRequestInput) {
const data = FacebookLoginRequestSchema.parse(input);
Object.assign(this, data);
Object.freeze(this);
}
static from(input: FacebookLoginRequestInput): FacebookLoginRequest {
return new FacebookLoginRequest(input);
}
static fromJson(json: unknown): FacebookLoginRequest {
return FacebookLoginRequest.from(json as FacebookLoginRequestInput);
}
toJson(): FacebookLoginRequestData {
return FacebookLoginRequestSchema.parse(this);
}
}
export type FacebookLoginRequest = FacebookLoginRequestData;
@@ -3,18 +3,15 @@
*/
import { z } from "zod";
import {
createSchemaModel,
type SchemaModel,
} from "../../schema_model";
import { booleanOrTrue, stringOrEmpty } from "../../nullable-defaults";
export const FacebookPsidLoginRequestSchema = z.object({
psid: z.string(),
deviceId: stringOrEmpty,
bindToGuest: booleanOrTrue,
});
export const FacebookPsidLoginRequestSchema = z
.object({
psid: z.string(),
deviceId: stringOrEmpty,
bindToGuest: booleanOrTrue,
})
.readonly();
export type FacebookPsidLoginRequestInput = z.input<
typeof FacebookPsidLoginRequestSchema
@@ -22,10 +19,4 @@ export type FacebookPsidLoginRequestInput = z.input<
export type FacebookPsidLoginRequestData = z.output<
typeof FacebookPsidLoginRequestSchema
>;
export type FacebookPsidLoginRequest = SchemaModel<
typeof FacebookPsidLoginRequestSchema
>;
export const FacebookPsidLoginRequest = createSchemaModel(
FacebookPsidLoginRequestSchema,
);
export type FacebookPsidLoginRequest = FacebookPsidLoginRequestData;
@@ -1,41 +1,21 @@
/**
* Facebook ID 登录请求
*
*
*/
import { z } from "zod";
import { booleanOrFalse, stringOrEmpty } from "../../nullable-defaults";
export const FbIdLoginRequestSchema = z.object({
fbId: z.string(),
avatarUrl: stringOrEmpty,
psid: stringOrEmpty,
isTestAccount: booleanOrFalse,
});
export const FbIdLoginRequestSchema = z
.object({
fbId: z.string(),
avatarUrl: stringOrEmpty,
psid: stringOrEmpty,
isTestAccount: booleanOrFalse,
})
.readonly();
export type FbIdLoginRequestInput = z.input<typeof FbIdLoginRequestSchema>;
export type FbIdLoginRequestData = z.output<typeof FbIdLoginRequestSchema>;
export class FbIdLoginRequest {
declare readonly fbId: string;
declare readonly avatarUrl: string;
declare readonly isTestAccount: boolean;
private constructor(input: FbIdLoginRequestInput) {
const data = FbIdLoginRequestSchema.parse(input);
Object.assign(this, data);
Object.freeze(this);
}
static from(input: FbIdLoginRequestInput): FbIdLoginRequest {
return new FbIdLoginRequest(input);
}
static fromJson(json: unknown): FbIdLoginRequest {
return FbIdLoginRequest.from(json as FbIdLoginRequestInput);
}
toJson(): FbIdLoginRequestData {
return FbIdLoginRequestSchema.parse(this);
}
}
export type FbIdLoginRequest = FbIdLoginRequestData;
@@ -1,43 +1,22 @@
/**
* Google 登录请求
*
*
*/
import { z } from "zod";
import { booleanOrFalse, stringOrEmpty } from "../../nullable-defaults";
export const GoogleLoginRequestSchema = z.object({
idToken: z.string(),
platform: z.string(),
guestId: stringOrEmpty,
psid: stringOrEmpty,
isTestAccount: booleanOrFalse,
});
export const GoogleLoginRequestSchema = z
.object({
idToken: z.string(),
platform: z.string(),
guestId: stringOrEmpty,
psid: stringOrEmpty,
isTestAccount: booleanOrFalse,
})
.readonly();
export type GoogleLoginRequestInput = z.input<typeof GoogleLoginRequestSchema>;
export type GoogleLoginRequestData = z.output<typeof GoogleLoginRequestSchema>;
export class GoogleLoginRequest {
declare readonly idToken: string;
declare readonly platform: string;
declare readonly guestId: string;
declare readonly isTestAccount: boolean;
private constructor(input: GoogleLoginRequestInput) {
const data = GoogleLoginRequestSchema.parse(input);
Object.assign(this, data);
Object.freeze(this);
}
static from(input: GoogleLoginRequestInput): GoogleLoginRequest {
return new GoogleLoginRequest(input);
}
static fromJson(json: unknown): GoogleLoginRequest {
return GoogleLoginRequest.from(json as GoogleLoginRequestInput);
}
toJson(): GoogleLoginRequestData {
return GoogleLoginRequestSchema.parse(this);
}
}
export type GoogleLoginRequest = GoogleLoginRequestData;
@@ -1,38 +1,19 @@
/**
* 游客登录请求
*
*
*/
import { z } from "zod";
import { booleanOrFalse } from "../../nullable-defaults";
export const GuestLoginRequestSchema = z.object({
deviceId: z.string(),
isTestAccount: booleanOrFalse,
});
export const GuestLoginRequestSchema = z
.object({
deviceId: z.string(),
isTestAccount: booleanOrFalse,
})
.readonly();
export type GuestLoginRequestInput = z.input<typeof GuestLoginRequestSchema>;
export type GuestLoginRequestData = z.output<typeof GuestLoginRequestSchema>;
export class GuestLoginRequest {
declare readonly deviceId: string;
declare readonly isTestAccount: boolean;
private constructor(input: GuestLoginRequestInput) {
const data = GuestLoginRequestSchema.parse(input);
Object.assign(this, data);
Object.freeze(this);
}
static from(input: GuestLoginRequestInput): GuestLoginRequest {
return new GuestLoginRequest(input);
}
static fromJson(json: unknown): GuestLoginRequest {
return GuestLoginRequest.from(json as GuestLoginRequestInput);
}
toJson(): GuestLoginRequestData {
return GuestLoginRequestSchema.parse(this);
}
}
export type GuestLoginRequest = GuestLoginRequestData;
+13 -36
View File
@@ -1,47 +1,24 @@
/**
* 登录请求
*
*
*/
import { z } from "zod";
import { booleanOrFalse, stringOrEmpty } from "../../nullable-defaults";
export const LoginRequestSchema = z.object({
email: stringOrEmpty,
username: stringOrEmpty,
password: z.string(),
platform: z.string(),
guestId: stringOrEmpty,
psid: stringOrEmpty,
isTestAccount: booleanOrFalse,
});
export const LoginRequestSchema = z
.object({
email: stringOrEmpty,
username: stringOrEmpty,
password: z.string(),
platform: z.string(),
guestId: stringOrEmpty,
psid: stringOrEmpty,
isTestAccount: booleanOrFalse,
})
.readonly();
export type LoginRequestInput = z.input<typeof LoginRequestSchema>;
export type LoginRequestData = z.output<typeof LoginRequestSchema>;
export class LoginRequest {
declare readonly email: string;
declare readonly username: string;
declare readonly password: string;
declare readonly platform: string;
declare readonly guestId: string;
declare readonly isTestAccount: boolean;
private constructor(input: LoginRequestInput) {
const data = LoginRequestSchema.parse(input);
Object.assign(this, data);
Object.freeze(this);
}
static from(input: LoginRequestInput): LoginRequest {
return new LoginRequest(input);
}
static fromJson(json: unknown): LoginRequest {
return LoginRequest.from(json as LoginRequestInput);
}
toJson(): LoginRequestData {
return LoginRequestSchema.parse(this);
}
}
export type LoginRequest = LoginRequestData;
@@ -1,26 +1,19 @@
/**
* 刷新 Token 请求
*
*
*/
import { z } from "zod";
import {
createSchemaModel,
type SchemaModel,
} from "../../schema_model";
export const RefreshTokenRequestSchema = z
.object({
refreshToken: z.string(),
})
.readonly();
export const RefreshTokenRequestSchema = z.object({
refreshToken: z.string(),
});
export type RefreshTokenRequestInput = z.input<typeof RefreshTokenRequestSchema>;
export type RefreshTokenRequestData = z.output<typeof RefreshTokenRequestSchema>;
export type RefreshTokenRequest = SchemaModel<
typeof RefreshTokenRequestSchema,
"refreshToken"
export type RefreshTokenRequestInput = z.input<
typeof RefreshTokenRequestSchema
>;
export const RefreshTokenRequest = createSchemaModel<
typeof RefreshTokenRequestSchema,
"refreshToken"
>(RefreshTokenRequestSchema);
export type RefreshTokenRequestData = z.output<
typeof RefreshTokenRequestSchema
>;
export type RefreshTokenRequest = RefreshTokenRequestData;
@@ -1,46 +1,23 @@
/**
* 注册请求
*
*
*/
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: stringOrEmpty,
isTestAccount: booleanOrFalse,
});
export const RegisterRequestSchema = z
.object({
username: z.string(),
email: z.string(),
password: z.string(),
platform: z.string(),
guestId: stringOrEmpty,
isTestAccount: booleanOrFalse,
})
.readonly();
export type RegisterRequestInput = z.input<typeof RegisterRequestSchema>;
export type RegisterRequestData = z.output<typeof RegisterRequestSchema>;
export class RegisterRequest {
declare readonly username: string;
declare readonly email: string;
declare readonly password: string;
declare readonly platform: string;
declare readonly guestId: string;
declare readonly isTestAccount: boolean;
private constructor(input: RegisterRequestInput) {
const data = RegisterRequestSchema.parse(input);
Object.assign(this, data);
Object.freeze(this);
}
static from(input: RegisterRequestInput): RegisterRequest {
return new RegisterRequest(input);
}
static fromJson(json: unknown): RegisterRequest {
return RegisterRequest.from(json as RegisterRequestInput);
}
toJson(): RegisterRequestData {
return RegisterRequestSchema.parse(this);
}
}
export type RegisterRequest = RegisterRequestData;
@@ -3,26 +3,25 @@
*/
import { z } from "zod";
import {
createSchemaModel,
type SchemaModel,
} from "../../schema_model";
import { arrayOrEmpty, stringOrEmpty } from "../../nullable-defaults";
export const FacebookIdentityBindingSchema = z.object({
conflicts: arrayOrEmpty(z.unknown()),
bound: arrayOrEmpty(z.unknown()),
});
export const FacebookIdentityBindingSchema = z
.object({
conflicts: arrayOrEmpty(z.unknown()),
bound: arrayOrEmpty(z.unknown()),
})
.readonly();
export const FacebookIdentityResponseSchema = z.object({
fbAsid: stringOrEmpty,
fbPsid: stringOrEmpty,
facebookBinding: FacebookIdentityBindingSchema.default({
conflicts: [],
bound: [],
}),
});
export const FacebookIdentityResponseSchema = z
.object({
fbAsid: stringOrEmpty,
fbPsid: stringOrEmpty,
facebookBinding: FacebookIdentityBindingSchema.prefault({
conflicts: [],
bound: [],
}),
})
.readonly();
export type FacebookIdentityResponseInput = z.input<
typeof FacebookIdentityResponseSchema
@@ -30,10 +29,4 @@ export type FacebookIdentityResponseInput = z.input<
export type FacebookIdentityResponseData = z.output<
typeof FacebookIdentityResponseSchema
>;
export type FacebookIdentityResponse = SchemaModel<
typeof FacebookIdentityResponseSchema
>;
export const FacebookIdentityResponse = createSchemaModel(
FacebookIdentityResponseSchema,
);
export type FacebookIdentityResponse = FacebookIdentityResponseData;
@@ -10,17 +10,19 @@ import {
} from "../../nullable-defaults";
import { UserSchema } from "../../user/user";
export const FacebookPsidLoginResponseSchema = z.object({
token: z.string(),
refreshToken: stringOrEmpty,
matchedBy: stringOrEmpty,
fbAsid: stringOrEmpty,
fbPsid: stringOrEmpty,
hasCompleteFacebookIdentity: booleanOrFalse,
isGuest: booleanOrFalse,
user: schemaOrNull(UserSchema),
userId: stringOrEmpty,
});
export const FacebookPsidLoginResponseSchema = z
.object({
token: z.string(),
refreshToken: stringOrEmpty,
matchedBy: stringOrEmpty,
fbAsid: stringOrEmpty,
fbPsid: stringOrEmpty,
hasCompleteFacebookIdentity: booleanOrFalse,
isGuest: booleanOrFalse,
user: schemaOrNull(UserSchema),
userId: stringOrEmpty,
})
.readonly();
export type FacebookPsidLoginResponseInput = z.input<
typeof FacebookPsidLoginResponseSchema
@@ -29,26 +31,4 @@ export type FacebookPsidLoginResponseData = z.output<
typeof FacebookPsidLoginResponseSchema
>;
export class FacebookPsidLoginResponse {
private constructor(input: FacebookPsidLoginResponseInput) {
const data = FacebookPsidLoginResponseSchema.parse(input);
Object.assign(this, data);
Object.freeze(this);
}
static from(
input: FacebookPsidLoginResponseInput,
): FacebookPsidLoginResponse {
return new FacebookPsidLoginResponse(input);
}
static fromJson(json: unknown): FacebookPsidLoginResponse {
return FacebookPsidLoginResponse.from(
json as FacebookPsidLoginResponseInput,
);
}
toJson(): FacebookPsidLoginResponseData {
return FacebookPsidLoginResponseSchema.parse(this);
}
}
export type FacebookPsidLoginResponse = FacebookPsidLoginResponseData;
@@ -1,48 +1,24 @@
/**
* 游客登录响应
*
*
*/
import { z } from "zod";
import { booleanOrTrue, numberOr, schemaOrNull } from "../../nullable-defaults";
import { User, UserSchema } from "../../user/user";
import { booleanOrTrue, numberOr, schemaOr } from "../../nullable-defaults";
import { EMPTY_USER, UserSchema } from "../../user/user";
export const GuestLoginResponseSchema = z.object({
token: z.string(),
deviceId: z.string(),
userId: z.string(),
isDeviceUser: booleanOrTrue,
expiresIn: numberOr(2592000),
user: schemaOrNull(UserSchema),
});
export const GuestLoginResponseSchema = z
.object({
token: z.string(),
deviceId: z.string(),
userId: z.string(),
isDeviceUser: booleanOrTrue,
expiresIn: numberOr(2592000),
user: schemaOr(UserSchema, EMPTY_USER),
})
.readonly();
export type GuestLoginResponseInput = z.input<typeof GuestLoginResponseSchema>;
export type GuestLoginResponseData = z.output<typeof GuestLoginResponseSchema>;
export class GuestLoginResponse {
declare readonly token: string;
declare readonly userId: string;
declare readonly user: User;
private constructor(input: GuestLoginResponseInput) {
const parsed = GuestLoginResponseSchema.parse(input);
Object.assign(this, {
...parsed,
user: parsed.user ? User.fromJson(parsed.user) : User.empty(),
});
Object.freeze(this);
}
static from(input: GuestLoginResponseInput): GuestLoginResponse {
return new GuestLoginResponse(input);
}
static fromJson(json: unknown): GuestLoginResponse {
return GuestLoginResponse.from(json as GuestLoginResponseInput);
}
toJson(): GuestLoginResponseData {
const data = GuestLoginResponseSchema.parse(this);
return data;
}
}
export type GuestLoginResponse = GuestLoginResponseData;
@@ -1,44 +1,21 @@
/**
* 登录响应
*
*
*/
import { z } from "zod";
import { stringOrEmpty } from "../../nullable-defaults";
import { User, UserSchema } from "../../user/user";
import { UserSchema } from "../../user/user";
export const LoginResponseSchema = z.object({
user: UserSchema,
token: z.string(),
refreshToken: stringOrEmpty,
});
export const LoginResponseSchema = z
.object({
user: UserSchema,
token: z.string(),
refreshToken: stringOrEmpty,
})
.readonly();
export type LoginResponseInput = z.input<typeof LoginResponseSchema>;
export type LoginResponseData = z.output<typeof LoginResponseSchema>;
export class LoginResponse {
declare readonly user: User;
declare readonly token: string;
declare readonly refreshToken: string;
private constructor(input: LoginResponseInput) {
const data = LoginResponseSchema.parse(input);
Object.assign(this, {
...data,
user: User.fromJson(data.user),
});
Object.freeze(this);
}
static from(input: LoginResponseInput): LoginResponse {
return new LoginResponse(input);
}
static fromJson(json: unknown): LoginResponse {
return LoginResponse.from(json as LoginResponseInput);
}
toJson(): LoginResponseData {
return LoginResponseSchema.parse(this);
}
}
export type LoginResponse = LoginResponseData;
@@ -1,28 +1,17 @@
/**
* 退出登录响应
*
*
*/
import { z } from "zod";
import {
createSchemaModel,
type SchemaModel,
} from "../../schema_model";
import { booleanOrTrue } from "../../nullable-defaults";
export const LogoutResponseSchema = z.object({
success: booleanOrTrue,
});
export const LogoutResponseSchema = z
.object({
success: booleanOrTrue,
})
.readonly();
export type LogoutResponseInput = z.input<typeof LogoutResponseSchema>;
export type LogoutResponseData = z.output<typeof LogoutResponseSchema>;
export type LogoutResponse = SchemaModel<
typeof LogoutResponseSchema,
"success"
>;
export const LogoutResponse = createSchemaModel<
typeof LogoutResponseSchema,
"success"
>(LogoutResponseSchema);
export type LogoutResponse = LogoutResponseData;
@@ -1,37 +1,22 @@
/**
* 刷新 Token 响应
*
*
*/
import { z } from "zod";
export const RefreshTokenResponseSchema = z.object({
token: z.string(),
refreshToken: z.string(),
userId: z.string(),
});
export const RefreshTokenResponseSchema = z
.object({
token: z.string(),
refreshToken: z.string(),
userId: z.string(),
})
.readonly();
export type RefreshTokenResponseInput = z.input<typeof RefreshTokenResponseSchema>;
export type RefreshTokenResponseData = z.output<typeof RefreshTokenResponseSchema>;
export type RefreshTokenResponseInput = z.input<
typeof RefreshTokenResponseSchema
>;
export type RefreshTokenResponseData = z.output<
typeof RefreshTokenResponseSchema
>;
export class RefreshTokenResponse {
declare readonly token: string;
declare readonly refreshToken: string;
private constructor(input: RefreshTokenResponseInput) {
const data = RefreshTokenResponseSchema.parse(input);
Object.assign(this, data);
Object.freeze(this);
}
static from(input: RefreshTokenResponseInput): RefreshTokenResponse {
return new RefreshTokenResponse(input);
}
static fromJson(json: unknown): RefreshTokenResponse {
return RefreshTokenResponse.from(json as RefreshTokenResponseInput);
}
toJson(): RefreshTokenResponseData {
return RefreshTokenResponseSchema.parse(this);
}
}
export type RefreshTokenResponse = RefreshTokenResponseData;
@@ -1,10 +1,10 @@
import { describe, expect, it } from "vitest";
import { Character } from "@/data/schemas/character";
import { CharacterSchema } from "@/data/schemas/character";
describe("Character", () => {
it("keeps only the four public character fields", () => {
const character = Character.fromJson({
const character = CharacterSchema.parse({
id: "character_elio",
slug: "elio",
displayName: " Elio Silvestri ",
@@ -13,7 +13,7 @@ describe("Character", () => {
sortOrder: 1,
});
expect(character.toJson()).toEqual({
expect(character).toEqual({
id: "character_elio",
slug: "elio",
displayName: "Elio Silvestri",
@@ -23,7 +23,7 @@ describe("Character", () => {
it("rejects unsafe slugs and non-HTTPS avatars", () => {
expect(() =>
Character.from({
CharacterSchema.parse({
id: "character_aria",
slug: "Aria Profile",
displayName: "Aria",
@@ -31,7 +31,7 @@ describe("Character", () => {
}),
).toThrow();
expect(() =>
Character.from({
CharacterSchema.parse({
id: "character_aria",
slug: "aria",
displayName: "Aria",
+11 -31
View File
@@ -1,37 +1,17 @@
import { z } from "zod";
export const CharacterSchema = z.object({
id: z.string().min(1).max(64),
slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
displayName: z.string().trim().min(1).max(80),
avatarUrl: z.url().refine((url) => url.startsWith("https://"), {
message: "avatarUrl must use HTTPS",
}),
});
export const CharacterSchema = z
.object({
id: z.string().min(1).max(64),
slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
displayName: z.string().trim().min(1).max(80),
avatarUrl: z.url().refine((url) => url.startsWith("https://"), {
message: "avatarUrl must use HTTPS",
}),
})
.readonly();
export type CharacterInput = z.input<typeof CharacterSchema>;
export type CharacterData = z.output<typeof CharacterSchema>;
export class Character {
declare readonly id: string;
declare readonly slug: string;
declare readonly displayName: string;
declare readonly avatarUrl: string;
private constructor(input: CharacterInput) {
Object.assign(this, CharacterSchema.parse(input));
Object.freeze(this);
}
static from(input: CharacterInput): Character {
return new Character(input);
}
static fromJson(json: unknown): Character {
return Character.from(json as CharacterInput);
}
toJson(): CharacterData {
return CharacterSchema.parse(this);
}
}
export type Character = CharacterData;
@@ -1,36 +1,17 @@
import { z } from "zod";
import { Character, CharacterSchema } from "./character";
import { CharacterSchema } from "./character";
export const CharactersResponseSchema = z.object({
items: z.array(CharacterSchema).default([]),
});
export const CharactersResponseSchema = z
.object({
items: z
.array(CharacterSchema)
.default(() => [])
.readonly(),
})
.readonly();
export type CharactersResponseInput = z.input<typeof CharactersResponseSchema>;
export type CharactersResponseData = z.output<typeof CharactersResponseSchema>;
export class CharactersResponse {
declare readonly items: Character[];
private constructor(input: CharactersResponseInput) {
const data = CharactersResponseSchema.parse(input);
Object.assign(this, {
items: data.items.map((item) => Character.from(item)),
});
Object.freeze(this);
}
static from(input: CharactersResponseInput): CharactersResponse {
return new CharactersResponse(input);
}
static fromJson(json: unknown): CharactersResponse {
return CharactersResponse.from(json as CharactersResponseInput);
}
toJson(): CharactersResponseData {
return CharactersResponseSchema.parse({
items: this.items.map((item) => item.toJson()),
});
}
}
export type CharactersResponse = CharactersResponseData;
@@ -1,10 +1,10 @@
import { describe, expect, it } from "vitest";
import { ChatHistoryResponse } from "@/data/schemas/chat";
import { ChatHistoryResponseSchema } from "@/data/schemas/chat";
describe("ChatHistoryResponse", () => {
it("exposes the pagination total and limit used by chat history", () => {
const response = ChatHistoryResponse.from({
const response = ChatHistoryResponseSchema.parse({
messages: [],
total: 120,
limit: 50,
@@ -1,15 +1,15 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import { UnlockPrivateRequest } from "@/data/schemas/chat";
import { UnlockPrivateRequestSchema } from "@/data/schemas/chat";
describe("UnlockPrivateRequest", () => {
it("serializes an existing backend message unlock", () => {
expect(
UnlockPrivateRequest.from({
UnlockPrivateRequestSchema.parse({
characterId: DEFAULT_CHARACTER_ID,
messageId: "message-1",
}).toJson(),
}),
).toEqual({
characterId: DEFAULT_CHARACTER_ID,
messageId: "message-1",
@@ -18,11 +18,11 @@ describe("UnlockPrivateRequest", () => {
it("serializes a temporary promotion lock without a fake message id", () => {
expect(
UnlockPrivateRequest.from({
UnlockPrivateRequestSchema.parse({
characterId: DEFAULT_CHARACTER_ID,
lockType: "voice_message",
clientLockId: "promotion-1",
}).toJson(),
}),
).toEqual({
characterId: DEFAULT_CHARACTER_ID,
lockType: "voice_message",
@@ -1,10 +1,10 @@
import { describe, expect, it } from "vitest";
import { UnlockPrivateResponse } from "@/data/schemas/chat";
import { UnlockPrivateResponseSchema } from "@/data/schemas/chat";
describe("UnlockPrivateResponse", () => {
it("parses a successful single-message unlock response", () => {
const response = UnlockPrivateResponse.from({
const response = UnlockPrivateResponseSchema.parse({
unlocked: true,
content: "完整消息内容",
audioUrl: "https://example.com/unlocked-voice.mp3",
@@ -26,7 +26,7 @@ describe("UnlockPrivateResponse", () => {
});
it("parses canonical ids, audio, and unlocked images", () => {
const response = UnlockPrivateResponse.from({
const response = UnlockPrivateResponseSchema.parse({
unlocked: true,
messageId: "backend-message-1",
clientLockId: "promotion-1",
@@ -43,14 +43,14 @@ describe("UnlockPrivateResponse", () => {
expect(response.content).toBe("Unlocked reply");
expect(response.audioUrl).toBe("https://example.com/audio.mp3");
expect(response.image.url).toBe("https://example.com/image.jpg");
expect(response.toJson()).toMatchObject({
expect(response).toMatchObject({
clientLockId: "promotion-1",
lockType: "image_paywall",
});
});
it("parses an insufficient-credits unlock response", () => {
const response = UnlockPrivateResponse.from({
const response = UnlockPrivateResponseSchema.parse({
unlocked: false,
content: "",
reason: "insufficient_credits",
@@ -69,7 +69,7 @@ describe("UnlockPrivateResponse", () => {
});
it("defaults nullable content to an empty string", () => {
const response = UnlockPrivateResponse.from({
const response = UnlockPrivateResponseSchema.parse({
unlocked: false,
content: null,
audioUrl: null,
@@ -81,7 +81,7 @@ describe("UnlockPrivateResponse", () => {
});
it("defaults nullable credit fields to zero", () => {
const response = UnlockPrivateResponse.from({
const response = UnlockPrivateResponseSchema.parse({
unlocked: false,
content: "",
reason: "insufficient_credits",
@@ -98,7 +98,7 @@ describe("UnlockPrivateResponse", () => {
});
it("strips legacy lock detail fields", () => {
const response = UnlockPrivateResponse.fromJson({
const response = UnlockPrivateResponseSchema.parse({
unlocked: false,
lockDetail: {
locked: true,
@@ -110,12 +110,12 @@ describe("UnlockPrivateResponse", () => {
},
});
expect(response.toJson()).not.toHaveProperty("lockDetail");
expect(response).not.toHaveProperty("lockDetail");
expect(response).not.toHaveProperty("lockDetail");
});
it("does not fall back to removed response aliases", () => {
const response = UnlockPrivateResponse.fromJson({
const response = UnlockPrivateResponseSchema.parse({
unlocked: true,
message_id: "legacy-message",
reply: "Legacy reply",
@@ -125,8 +125,8 @@ describe("UnlockPrivateResponse", () => {
expect(response.messageId).toBe("");
expect(response.content).toBe("");
expect(response.audioUrl).toBe("");
expect(response.toJson()).not.toHaveProperty("message_id");
expect(response.toJson()).not.toHaveProperty("reply");
expect(response.toJson()).not.toHaveProperty("audio_url");
expect(response).not.toHaveProperty("message_id");
expect(response).not.toHaveProperty("reply");
expect(response).not.toHaveProperty("audio_url");
});
});
+5 -36
View File
@@ -1,16 +1,11 @@
/**
* 聊天历史中的单条消息
*
*
*/
import { z } from "zod";
import { stringOr, stringOrEmpty, stringOrNull } from "../nullable-defaults";
import {
ChatImageSchema,
ChatLockDetailSchema,
type ChatImageData,
type ChatLockDetailData,
} from "./chat_payloads";
import { ChatImageSchema, ChatLockDetailSchema } from "./chat_payloads";
export const ChatMessageSchema = z
.object({
@@ -27,36 +22,10 @@ export const ChatMessageSchema = z
.transform(({ created_at, ...data }) => ({
...data,
createdAt: data.createdAt || created_at || "",
}));
}))
.readonly();
export type ChatMessageInput = z.input<typeof ChatMessageSchema>;
export type ChatMessageData = z.output<typeof ChatMessageSchema>;
export class ChatMessage {
declare readonly role: string;
declare readonly type: string;
declare readonly content: string;
declare readonly id: string;
declare readonly createdAt: string;
declare readonly audioUrl: string | null;
declare readonly image: ChatImageData;
declare readonly lockDetail: ChatLockDetailData;
private constructor(input: ChatMessageInput) {
const data = ChatMessageSchema.parse(input);
Object.assign(this, data);
Object.freeze(this);
}
static from(input: ChatMessageInput): ChatMessage {
return new ChatMessage(input);
}
static fromJson(json: unknown): ChatMessage {
return ChatMessage.from(json as ChatMessageInput);
}
toJson(): ChatMessageData {
return ChatMessageSchema.parse(this);
}
}
export type ChatMessage = ChatMessageData;
+3 -7
View File
@@ -3,11 +3,7 @@
*/
import { z } from "zod";
import {
booleanOrFalse,
schemaOr,
stringOrNull,
} from "../nullable-defaults";
import { booleanOrFalse, schemaOr, stringOrNull } from "../nullable-defaults";
const CHAT_IMAGE_DEFAULTS = { type: null, url: null } as const;
@@ -22,7 +18,7 @@ export const ChatImageSchema = schemaOr(
url: stringOrNull,
}),
CHAT_IMAGE_DEFAULTS,
);
).readonly();
export const ChatLockDetailSchema = schemaOr(
z.object({
@@ -30,7 +26,7 @@ export const ChatLockDetailSchema = schemaOr(
reason: stringOrNull,
}),
CHAT_LOCK_DETAIL_DEFAULTS,
);
).readonly();
export type ChatImageInput = z.input<typeof ChatImageSchema>;
export type ChatImageData = z.output<typeof ChatImageSchema>;
@@ -1,6 +1,6 @@
/**
* 发送消息请求
*
*
*/
import { z } from "zod";
@@ -10,48 +10,22 @@ import {
stringOrEmpty,
} from "../../nullable-defaults";
export const SendMessageRequestSchema = z.object({
characterId: z.string().min(1),
message: stringOrEmpty,
image: stringOrEmpty,
imageId: stringOrEmpty,
imageThumbUrl: stringOrEmpty,
imageMediumUrl: stringOrEmpty,
imageOriginalUrl: stringOrEmpty,
imageWidth: numberOrZero,
imageHeight: numberOrZero,
useWebSocket: booleanOrFalse,
});
export const SendMessageRequestSchema = z
.object({
characterId: z.string().min(1),
message: stringOrEmpty,
image: stringOrEmpty,
imageId: stringOrEmpty,
imageThumbUrl: stringOrEmpty,
imageMediumUrl: stringOrEmpty,
imageOriginalUrl: stringOrEmpty,
imageWidth: numberOrZero,
imageHeight: numberOrZero,
useWebSocket: booleanOrFalse,
})
.readonly();
export type SendMessageRequestInput = z.input<typeof SendMessageRequestSchema>;
export type SendMessageRequestData = z.output<typeof SendMessageRequestSchema>;
export class SendMessageRequest {
declare readonly characterId: string;
declare readonly message: string;
declare readonly image: string;
declare readonly imageId: string;
declare readonly imageThumbUrl: string;
declare readonly imageMediumUrl: string;
declare readonly imageOriginalUrl: string;
declare readonly imageWidth: number;
declare readonly imageHeight: number;
private constructor(input: SendMessageRequestInput) {
const data = SendMessageRequestSchema.parse(input);
Object.assign(this, data);
Object.freeze(this);
}
static from(input: SendMessageRequestInput): SendMessageRequest {
return new SendMessageRequest(input);
}
static fromJson(json: unknown): SendMessageRequest {
return SendMessageRequest.from(json as SendMessageRequestInput);
}
toJson(): SendMessageRequestData {
return SendMessageRequestSchema.parse(this);
}
}
export type SendMessageRequest = SendMessageRequestData;
@@ -1,8 +1,10 @@
import { z } from "zod";
export const UnlockHistoryRequestSchema = z.object({
characterId: z.string().min(1),
});
export const UnlockHistoryRequestSchema = z
.object({
characterId: z.string().min(1),
})
.readonly();
export type UnlockHistoryRequestInput = z.input<
typeof UnlockHistoryRequestSchema
@@ -11,19 +13,4 @@ export type UnlockHistoryRequestData = z.output<
typeof UnlockHistoryRequestSchema
>;
export class UnlockHistoryRequest {
declare readonly characterId: string;
private constructor(input: UnlockHistoryRequestInput) {
Object.assign(this, UnlockHistoryRequestSchema.parse(input));
Object.freeze(this);
}
static from(input: UnlockHistoryRequestInput): UnlockHistoryRequest {
return new UnlockHistoryRequest(input);
}
toJson(): UnlockHistoryRequestData {
return UnlockHistoryRequestSchema.parse(this);
}
}
export type UnlockHistoryRequest = UnlockHistoryRequestData;
@@ -14,7 +14,8 @@ export const UnlockPrivateRequestSchema = z
})
.refine((value) => value.messageId || value.lockType, {
message: "messageId or lockType is required",
});
})
.readonly();
export type UnlockPrivateRequestInput = z.input<
typeof UnlockPrivateRequestSchema
@@ -23,27 +24,4 @@ export type UnlockPrivateRequestData = z.output<
typeof UnlockPrivateRequestSchema
>;
export class UnlockPrivateRequest {
declare readonly characterId: string;
declare readonly messageId?: string;
declare readonly lockType?: UnlockPrivateRequestData["lockType"];
declare readonly clientLockId?: string;
private constructor(input: UnlockPrivateRequestInput) {
const data = UnlockPrivateRequestSchema.parse(input);
Object.assign(this, data);
Object.freeze(this);
}
static from(input: UnlockPrivateRequestInput): UnlockPrivateRequest {
return new UnlockPrivateRequest(input);
}
static fromJson(json: unknown): UnlockPrivateRequest {
return UnlockPrivateRequest.from(json as UnlockPrivateRequestInput);
}
toJson(): UnlockPrivateRequestData {
return UnlockPrivateRequestSchema.parse(this);
}
}
export type UnlockPrivateRequest = UnlockPrivateRequestData;
@@ -1,6 +1,6 @@
/**
* 聊天历史响应
*
*
*/
import { z } from "zod";
@@ -9,45 +9,26 @@ import {
booleanOrFalse,
numberOrZero,
} from "../../nullable-defaults";
import { ChatMessage, ChatMessageSchema } from "../chat_message";
import { ChatMessageSchema } from "../chat_message";
export const ChatHistoryResponseSchema = z.object({
messages: arrayOrEmpty(ChatMessageSchema),
total: numberOrZero,
limit: numberOrZero,
offset: numberOrZero,
isVip: booleanOrFalse,
privateFreeLimit: numberOrZero,
privateUsedToday: numberOrZero,
privateCanViewFree: booleanOrFalse,
});
export const ChatHistoryResponseSchema = z
.object({
messages: arrayOrEmpty(ChatMessageSchema),
total: numberOrZero,
limit: numberOrZero,
offset: numberOrZero,
isVip: booleanOrFalse,
privateFreeLimit: numberOrZero,
privateUsedToday: numberOrZero,
privateCanViewFree: booleanOrFalse,
})
.readonly();
export type ChatHistoryResponseInput = z.input<typeof ChatHistoryResponseSchema>;
export type ChatHistoryResponseData = z.output<typeof ChatHistoryResponseSchema>;
export type ChatHistoryResponseInput = z.input<
typeof ChatHistoryResponseSchema
>;
export type ChatHistoryResponseData = z.output<
typeof ChatHistoryResponseSchema
>;
export class ChatHistoryResponse {
declare readonly messages: ChatMessage[];
declare readonly total: number;
declare readonly limit: number;
private constructor(input: ChatHistoryResponseInput) {
const data = ChatHistoryResponseSchema.parse(input);
Object.assign(this, {
...data,
messages: data.messages.map((m) => ChatMessage.from(m)),
});
Object.freeze(this);
}
static from(input: ChatHistoryResponseInput): ChatHistoryResponse {
return new ChatHistoryResponse(input);
}
static fromJson(json: unknown): ChatHistoryResponse {
return ChatHistoryResponse.from(json as ChatHistoryResponseInput);
}
toJson(): ChatHistoryResponseData {
return ChatHistoryResponseSchema.parse(this);
}
}
export type ChatHistoryResponse = ChatHistoryResponseData;
@@ -1,6 +1,6 @@
/**
* 发送消息响应
*
*
*/
import { z } from "zod";
import {
@@ -10,60 +10,27 @@ import {
numberOrZero,
stringOrEmpty,
} from "../../nullable-defaults";
import {
ChatImageSchema,
ChatLockDetailSchema,
type ChatImageData,
type ChatLockDetailData,
} from "../chat_payloads";
import { ChatImageSchema, ChatLockDetailSchema } from "../chat_payloads";
export const ChatSendResponseSchema = z.object({
reply: stringOrEmpty,
audioUrl: stringOrEmpty,
messageId: stringOrEmpty,
isGuest: booleanOrFalse,
// 函数式 default —— 每次 parse 时重新调 Date.now()(后端不返回 timestamp 时用当下时间)
timestamp: numberOrLazy(() => Date.now()),
image: ChatImageSchema,
lockDetail: ChatLockDetailSchema,
canSendMessage: booleanOrTrue,
creditBalance: numberOrZero,
creditsCharged: numberOrZero,
requiredCredits: numberOrZero,
shortfallCredits: numberOrZero,
});
export const ChatSendResponseSchema = z
.object({
reply: stringOrEmpty,
audioUrl: stringOrEmpty,
messageId: stringOrEmpty,
isGuest: booleanOrFalse,
// 函数式 default —— 每次 parse 时重新调 Date.now()(后端不返回 timestamp 时用当下时间)
timestamp: numberOrLazy(() => Date.now()),
image: ChatImageSchema,
lockDetail: ChatLockDetailSchema,
canSendMessage: booleanOrTrue,
creditBalance: numberOrZero,
creditsCharged: numberOrZero,
requiredCredits: numberOrZero,
shortfallCredits: numberOrZero,
})
.readonly();
export type ChatSendResponseInput = z.input<typeof ChatSendResponseSchema>;
export type ChatSendResponseData = z.output<typeof ChatSendResponseSchema>;
export class ChatSendResponse {
declare readonly reply: string;
declare readonly audioUrl: string;
declare readonly messageId: string;
declare readonly timestamp: number;
declare readonly image: ChatImageData;
declare readonly lockDetail: ChatLockDetailData;
declare readonly canSendMessage: boolean;
declare readonly creditBalance: number;
declare readonly creditsCharged: number;
declare readonly requiredCredits: number;
declare readonly shortfallCredits: number;
private constructor(input: ChatSendResponseInput) {
const data = ChatSendResponseSchema.parse(input);
Object.assign(this, data);
Object.freeze(this);
}
static from(input: ChatSendResponseInput): ChatSendResponse {
return new ChatSendResponse(input);
}
static fromJson(json: unknown): ChatSendResponse {
return ChatSendResponse.from(json as ChatSendResponseInput);
}
toJson(): ChatSendResponseData {
return ChatSendResponseSchema.parse(this);
}
}
export type ChatSendResponse = ChatSendResponseData;
@@ -18,21 +18,23 @@ export const UnlockHistoryReasonSchema = z.enum([
"no_locked_messages",
]);
export const UnlockHistoryResponseSchema = z.object({
unlocked: booleanOrFalse,
reason: UnlockHistoryReasonSchema,
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 const UnlockHistoryResponseSchema = z
.object({
unlocked: booleanOrFalse,
reason: UnlockHistoryReasonSchema,
totalLocked: numberOrZero,
unlockedCount: numberOrZero,
privateCount: numberOrZero,
imageCount: numberOrZero,
voiceCount: numberOrZero,
requiredCredits: numberOrZero,
currentCredits: numberOrZero,
remainingCredits: numberOrZero,
shortfallCredits: numberOrZero,
costsByMessage: UnlockHistoryCostsByMessageSchema,
messageIds: arrayOrEmpty(z.string()),
})
.readonly();
export type UnlockHistoryReason = z.output<typeof UnlockHistoryReasonSchema>;
export type UnlockHistoryResponseInput = z.input<
@@ -42,26 +44,4 @@ export type UnlockHistoryResponseData = z.output<
typeof UnlockHistoryResponseSchema
>;
export class UnlockHistoryResponse {
declare readonly unlocked: boolean;
declare readonly reason: UnlockHistoryReason;
declare readonly shortfallCredits: number;
private constructor(input: UnlockHistoryResponseInput) {
const data = UnlockHistoryResponseSchema.parse(input);
Object.assign(this, data);
Object.freeze(this);
}
static from(input: UnlockHistoryResponseInput): UnlockHistoryResponse {
return new UnlockHistoryResponse(input);
}
static fromJson(json: unknown): UnlockHistoryResponse {
return UnlockHistoryResponse.from(json as UnlockHistoryResponseInput);
}
toJson(): UnlockHistoryResponseData {
return UnlockHistoryResponseSchema.parse(this);
}
}
export type UnlockHistoryResponse = UnlockHistoryResponseData;
@@ -6,28 +6,30 @@ import {
stringOrEmpty,
stringOrNull,
} from "../../nullable-defaults";
import { ChatImageSchema, type ChatImageData } from "../chat_payloads";
import { ChatLockTypeSchema } from "../chat_lock_type";
import { ChatImageSchema } from "../chat_payloads";
/**
* 单条历史付费 / 私密消息解锁响应。
*/
export const UnlockPrivateReasonSchema = stringOrEmpty;
export const UnlockPrivateResponseSchema = z.object({
unlocked: booleanOrFalse,
content: stringOrEmpty,
messageId: stringOrEmpty,
clientLockId: stringOrNull,
lockType: ChatLockTypeSchema.nullable().default(null),
audioUrl: stringOrEmpty,
image: ChatImageSchema,
reason: UnlockPrivateReasonSchema,
creditBalance: numberOrZero,
creditsCharged: numberOrZero,
requiredCredits: numberOrZero,
shortfallCredits: numberOrZero,
});
export const UnlockPrivateResponseSchema = z
.object({
unlocked: booleanOrFalse,
content: stringOrEmpty,
messageId: stringOrEmpty,
clientLockId: stringOrNull,
lockType: ChatLockTypeSchema.nullable().default(null),
audioUrl: stringOrEmpty,
image: ChatImageSchema,
reason: UnlockPrivateReasonSchema,
creditBalance: numberOrZero,
creditsCharged: numberOrZero,
requiredCredits: numberOrZero,
shortfallCredits: numberOrZero,
})
.readonly();
export type UnlockPrivateReason = z.output<typeof UnlockPrivateReasonSchema>;
export type UnlockPrivateResponseInput = z.input<
@@ -37,36 +39,4 @@ export type UnlockPrivateResponseData = z.output<
typeof UnlockPrivateResponseSchema
>;
/**
* 单条历史付费 / 私密消息解锁响应数据类。
*/
export class UnlockPrivateResponse {
declare readonly unlocked: boolean;
declare readonly messageId: string;
declare readonly content: string;
declare readonly audioUrl: string;
declare readonly image: ChatImageData;
declare readonly reason: UnlockPrivateReason;
declare readonly creditBalance: number;
declare readonly creditsCharged: number;
declare readonly requiredCredits: number;
declare readonly shortfallCredits: number;
private constructor(input: UnlockPrivateResponseInput) {
const data = UnlockPrivateResponseSchema.parse(input);
Object.assign(this, data);
Object.freeze(this);
}
static from(input: UnlockPrivateResponseInput): UnlockPrivateResponse {
return new UnlockPrivateResponse(input);
}
static fromJson(json: unknown): UnlockPrivateResponse {
return UnlockPrivateResponse.from(json as UnlockPrivateResponseInput);
}
toJson(): UnlockPrivateResponseData {
return UnlockPrivateResponseSchema.parse(this);
}
}
export type UnlockPrivateResponse = UnlockPrivateResponseData;
@@ -1,13 +1,10 @@
import { z } from "zod";
import {
createSchemaModel,
type SchemaModel,
} from "../../schema_model";
export const FeedbackSubmitResponseSchema = z.object({
feedbackId: z.string().min(1),
});
export const FeedbackSubmitResponseSchema = z
.object({
feedbackId: z.string().min(1),
})
.readonly();
export type FeedbackSubmitResponseInput = z.input<
typeof FeedbackSubmitResponseSchema
@@ -15,12 +12,4 @@ export type FeedbackSubmitResponseInput = z.input<
export type FeedbackSubmitResponseData = z.output<
typeof FeedbackSubmitResponseSchema
>;
export type FeedbackSubmitResponse = SchemaModel<
typeof FeedbackSubmitResponseSchema,
"feedbackId"
>;
export const FeedbackSubmitResponse = createSchemaModel<
typeof FeedbackSubmitResponseSchema,
"feedbackId"
>(FeedbackSubmitResponseSchema);
export type FeedbackSubmitResponse = FeedbackSubmitResponseData;
+9 -18
View File
@@ -1,28 +1,19 @@
/**
* 应用事件上报请求
*
*
*/
import { z } from "zod";
import {
createSchemaModel,
type SchemaModel,
} from "../../schema_model";
import { stringOrEmpty } from "../../nullable-defaults";
export const AppEventSchema = z.object({
userId: stringOrEmpty,
browser: stringOrEmpty,
userAgent: stringOrEmpty,
});
export const AppEventSchema = z
.object({
userId: stringOrEmpty,
browser: stringOrEmpty,
userAgent: stringOrEmpty,
})
.readonly();
export type AppEventInput = z.input<typeof AppEventSchema>;
export type AppEventData = z.output<typeof AppEventSchema>;
type AppEventPublicKey = "userId" | "browser" | "userAgent";
export type AppEvent = SchemaModel<typeof AppEventSchema, AppEventPublicKey>;
export const AppEvent = createSchemaModel<
typeof AppEventSchema,
AppEventPublicKey
>(AppEventSchema);
export type AppEvent = AppEventData;
+11 -25
View File
@@ -1,39 +1,25 @@
/**
* PWA 事件上报请求
*
*
*/
import { z } from "zod";
import {
createSchemaModel,
type SchemaModel,
} from "../../schema_model";
import {
booleanOrFalse,
numberOrZero,
stringOrEmpty,
} from "../../nullable-defaults";
export const PwaEventSchema = z.object({
deviceId: stringOrEmpty,
deviceType: stringOrEmpty,
timestamp: numberOrZero,
pwaInstalled: booleanOrFalse,
pwaSupported: booleanOrFalse,
});
export const PwaEventSchema = z
.object({
deviceId: stringOrEmpty,
deviceType: stringOrEmpty,
timestamp: numberOrZero,
pwaInstalled: booleanOrFalse,
pwaSupported: booleanOrFalse,
})
.readonly();
export type PwaEventInput = z.input<typeof PwaEventSchema>;
export type PwaEventData = z.output<typeof PwaEventSchema>;
type PwaEventPublicKey =
| "deviceId"
| "deviceType"
| "timestamp"
| "pwaInstalled"
| "pwaSupported";
export type PwaEvent = SchemaModel<typeof PwaEventSchema, PwaEventPublicKey>;
export const PwaEvent = createSchemaModel<
typeof PwaEventSchema,
PwaEventPublicKey
>(PwaEventSchema);
export type PwaEvent = PwaEventData;
+6 -4
View File
@@ -91,7 +91,8 @@ export function arrayOrEmpty<T extends z.ZodType>(schema: T) {
.array(schema)
.nullable()
.transform((v) => v ?? [])
.default(() => []);
.default(() => [])
.readonly();
}
/** `Record<string, T> | null | undefined` → `Record<string, T>`(默认空对象) */
@@ -100,7 +101,8 @@ export function recordOrEmpty<T extends z.ZodType>(schema: T) {
.record(z.string(), schema)
.nullable()
.transform((v) => v ?? {})
.default(() => ({}));
.default(() => ({}))
.readonly();
}
/** `Record<string, unknown> | null | undefined` → `Record<string, unknown>` */
@@ -129,10 +131,10 @@ export function schemaOr<T extends z.ZodType>(
);
if (typeof defaultValue === "function") {
return preprocessed.default(defaultValue as () => SchemaDefault<T>);
return preprocessed.prefault(defaultValue as () => SchemaDefault<T>);
}
return preprocessed.default(defaultValue);
return preprocessed.prefault(defaultValue);
}
/** `T | null | undefined` → `T | null`(默认 null */
@@ -2,14 +2,14 @@ import { describe, expect, it } from "vitest";
import { z } from "zod";
import {
PaymentPlan,
PaymentPlansResponse,
TipPaymentPlansResponse,
PaymentPlanSchema,
PaymentPlansResponseSchema,
TipPaymentPlansResponseSchema,
} from "@/data/schemas/payment";
describe("PaymentPlan", () => {
it("parses the camelCase payment plan shape", () => {
const plan = PaymentPlan.from({
const plan = PaymentPlanSchema.parse({
planId: "vip_monthly",
planName: "VIP Monthly",
orderType: "vip_monthly",
@@ -26,7 +26,7 @@ describe("PaymentPlan", () => {
expect(plan.planId).toBe("vip_monthly");
expect(plan.creditBalance).toBe(3000);
expect(plan.mostPopular).toBe(true);
expect(plan.toJson()).toEqual({
expect(plan).toEqual({
planId: "vip_monthly",
planName: "VIP Monthly",
orderType: "vip_monthly",
@@ -46,7 +46,7 @@ describe("PaymentPlan", () => {
it("rejects the legacy snake_case payment plan shape", () => {
expect(() =>
PaymentPlan.fromJson({
PaymentPlanSchema.parse({
plan_id: "vip_monthly",
plan_name: "VIP Monthly",
order_type: "vip_monthly",
@@ -61,7 +61,7 @@ describe("PaymentPlan", () => {
});
it("defaults mostPopular to false when the backend omits it", () => {
const plan = PaymentPlan.from({
const plan = PaymentPlanSchema.parse({
planId: "vip_monthly",
planName: "VIP Monthly",
orderType: "vip_monthly",
@@ -80,7 +80,7 @@ describe("PaymentPlan", () => {
describe("PaymentPlansResponse", () => {
it("parses plans from the new backend response data shape", () => {
const response = PaymentPlansResponse.from({
const response = PaymentPlansResponseSchema.parse({
plans: [
{
planId: "dol_1000",
@@ -102,7 +102,7 @@ describe("PaymentPlansResponse", () => {
});
it("parses first recharge offer metadata", () => {
const response = PaymentPlansResponse.from({
const response = PaymentPlansResponseSchema.parse({
isFirstRecharge: true,
firstRechargeOffer: {
enabled: true,
@@ -140,7 +140,7 @@ describe("PaymentPlansResponse", () => {
});
it("defaults nullable first recharge offer metadata", () => {
const response = PaymentPlansResponse.from({
const response = PaymentPlansResponseSchema.parse({
isFirstRecharge: true,
firstRechargeOffer: {
enabled: null,
@@ -160,7 +160,7 @@ describe("PaymentPlansResponse", () => {
describe("TipPaymentPlansResponse", () => {
it("keeps only fields used by the tip payment flow", () => {
const response = TipPaymentPlansResponse.fromJson({
const response = TipPaymentPlansResponseSchema.parse({
plans: [
{
planId: "tip_coffee_usd_4_99",
@@ -177,7 +177,7 @@ describe("TipPaymentPlansResponse", () => {
],
});
expect(response.toJson()).toEqual({
expect(response).toEqual({
plans: [
{
planId: "tip_coffee_usd_4_99",
+19 -49
View File
@@ -9,56 +9,26 @@ import {
stringOrNull,
} from "../nullable-defaults";
export const PaymentPlanSchema = z.object({
planId: z.string(),
planName: z.string(),
orderType: z.string(),
vipDays: numberOrNull,
dolAmount: numberOrNull,
creditBalance: z.number(),
amountCents: z.number(),
originalAmountCents: numberOrNull,
dailyPriceCents: numberOrNull,
currency: z.string(),
isFirstRechargeOffer: booleanOrFalse,
mostPopular: booleanOrFalse,
firstRechargeDiscountPercent: numberOrNull,
promotionType: stringOrNull,
});
export const PaymentPlanSchema = z
.object({
planId: z.string(),
planName: z.string(),
orderType: z.string(),
vipDays: numberOrNull,
dolAmount: numberOrNull,
creditBalance: z.number(),
amountCents: z.number(),
originalAmountCents: numberOrNull,
dailyPriceCents: numberOrNull,
currency: z.string(),
isFirstRechargeOffer: booleanOrFalse,
mostPopular: booleanOrFalse,
firstRechargeDiscountPercent: numberOrNull,
promotionType: stringOrNull,
})
.readonly();
export type PaymentPlanInput = z.input<typeof PaymentPlanSchema>;
export type PaymentPlanData = z.output<typeof PaymentPlanSchema>;
export class PaymentPlan {
declare readonly planId: string;
declare readonly planName: string;
declare readonly orderType: string;
declare readonly vipDays: number | null;
declare readonly dolAmount: number | null;
declare readonly creditBalance: number;
declare readonly amountCents: number;
declare readonly originalAmountCents: number | null;
declare readonly currency: string;
declare readonly isFirstRechargeOffer: boolean;
declare readonly mostPopular: boolean;
declare readonly firstRechargeDiscountPercent: number | null;
declare readonly promotionType: string | null;
private constructor(input: PaymentPlanInput) {
const data = PaymentPlanSchema.parse(input);
Object.assign(this, data);
Object.freeze(this);
}
static from(input: PaymentPlanInput): PaymentPlan {
return new PaymentPlan(input);
}
static fromJson(json: unknown): PaymentPlan {
return PaymentPlan.from(json as PaymentPlanInput);
}
toJson(): PaymentPlanData {
return PaymentPlanSchema.parse(this);
}
}
export type PaymentPlan = PaymentPlanData;
@@ -5,11 +5,13 @@ import { z } from "zod";
export const PayChannelSchema = z.enum(["stripe", "ezpay"]);
export const CreatePaymentOrderRequestSchema = z.object({
planId: z.string(),
payChannel: PayChannelSchema,
autoRenew: z.boolean(),
});
export const CreatePaymentOrderRequestSchema = z
.object({
planId: z.string(),
payChannel: PayChannelSchema,
autoRenew: z.boolean(),
})
.readonly();
export type PayChannel = z.output<typeof PayChannelSchema>;
export type CreatePaymentOrderRequestInput = z.input<
@@ -19,30 +21,4 @@ export type CreatePaymentOrderRequestData = z.output<
typeof CreatePaymentOrderRequestSchema
>;
export class CreatePaymentOrderRequest {
declare readonly planId: string;
declare readonly payChannel: PayChannel;
declare readonly autoRenew: boolean;
private constructor(input: CreatePaymentOrderRequestInput) {
const data = CreatePaymentOrderRequestSchema.parse(input);
Object.assign(this, data);
Object.freeze(this);
}
static from(
input: CreatePaymentOrderRequestInput,
): CreatePaymentOrderRequest {
return new CreatePaymentOrderRequest(input);
}
static fromJson(json: unknown): CreatePaymentOrderRequest {
return CreatePaymentOrderRequest.from(
json as CreatePaymentOrderRequestInput,
);
}
toJson(): CreatePaymentOrderRequestData {
return CreatePaymentOrderRequestSchema.parse(this);
}
}
export type CreatePaymentOrderRequest = CreatePaymentOrderRequestData;
@@ -19,35 +19,42 @@ const paymentUrlKeys = [
"url",
] as const;
export const CreatePaymentOrderResponseSchema = z.object({
orderId: z.string(),
payParams: z.record(z.string(), z.unknown()),
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 };
export const CreatePaymentOrderResponseSchema = z
.object({
orderId: z.string(),
payParams: z.record(z.string(), z.unknown()).readonly(),
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 };
for (const key of paymentUrlKeys) {
const value = data[key];
if (typeof value === "string" && value.length > 0 && !(key in payParams)) {
payParams[key] = value;
for (const key of paymentUrlKeys) {
const value = data[key];
if (
typeof value === "string" &&
value.length > 0 &&
!(key in payParams)
) {
payParams[key] = value;
}
}
}
return {
orderId: data.orderId,
payParams,
};
});
return {
orderId: data.orderId,
payParams,
};
})
.readonly();
export type CreatePaymentOrderResponseInput = z.input<
typeof CreatePaymentOrderResponseSchema
@@ -56,29 +63,4 @@ export type CreatePaymentOrderResponseData = z.output<
typeof CreatePaymentOrderResponseSchema
>;
export class CreatePaymentOrderResponse {
declare readonly orderId: string;
declare readonly payParams: Record<string, unknown>;
private constructor(input: CreatePaymentOrderResponseInput) {
const data = CreatePaymentOrderResponseSchema.parse(input);
Object.assign(this, data);
Object.freeze(this);
}
static from(
input: CreatePaymentOrderResponseInput,
): CreatePaymentOrderResponse {
return new CreatePaymentOrderResponse(input);
}
static fromJson(json: unknown): CreatePaymentOrderResponse {
return CreatePaymentOrderResponse.from(
json as CreatePaymentOrderResponseInput,
);
}
toJson(): CreatePaymentOrderResponseData {
return CreatePaymentOrderResponseSchema.parse(this);
}
}
export type CreatePaymentOrderResponse = CreatePaymentOrderResponseData;
@@ -5,12 +5,14 @@ import { z } from "zod";
export const PaymentOrderStatusSchema = z.enum(["pending", "paid", "failed"]);
export const PaymentOrderStatusResponseSchema = z.object({
orderId: z.string(),
status: PaymentOrderStatusSchema,
orderType: z.string(),
planId: z.string(),
});
export const PaymentOrderStatusResponseSchema = z
.object({
orderId: z.string(),
status: PaymentOrderStatusSchema,
orderType: z.string(),
planId: z.string(),
})
.readonly();
export type PaymentOrderStatus = z.output<typeof PaymentOrderStatusSchema>;
export type PaymentOrderStatusResponseInput = z.input<
@@ -20,28 +22,4 @@ export type PaymentOrderStatusResponseData = z.output<
typeof PaymentOrderStatusResponseSchema
>;
export class PaymentOrderStatusResponse {
declare readonly status: PaymentOrderStatus;
private constructor(input: PaymentOrderStatusResponseInput) {
const data = PaymentOrderStatusResponseSchema.parse(input);
Object.assign(this, data);
Object.freeze(this);
}
static from(
input: PaymentOrderStatusResponseInput,
): PaymentOrderStatusResponse {
return new PaymentOrderStatusResponse(input);
}
static fromJson(json: unknown): PaymentOrderStatusResponse {
return PaymentOrderStatusResponse.from(
json as PaymentOrderStatusResponseInput,
);
}
toJson(): PaymentOrderStatusResponseData {
return PaymentOrderStatusResponseSchema.parse(this);
}
}
export type PaymentOrderStatusResponse = PaymentOrderStatusResponseData;
@@ -10,19 +10,23 @@ import {
schemaOrNull,
stringOrEmpty,
} from "../../nullable-defaults";
import { PaymentPlan, PaymentPlanSchema } from "../payment_plan";
import { PaymentPlanSchema } from "../payment_plan";
export const FirstRechargeOfferSchema = z.object({
enabled: booleanOrFalse,
type: stringOrEmpty,
discountPercent: numberOrZero,
});
export const FirstRechargeOfferSchema = z
.object({
enabled: booleanOrFalse,
type: stringOrEmpty,
discountPercent: numberOrZero,
})
.readonly();
export const PaymentPlansResponseSchema = z.object({
isFirstRecharge: booleanOrFalse,
firstRechargeOffer: schemaOrNull(FirstRechargeOfferSchema),
plans: arrayOrEmpty(PaymentPlanSchema),
});
export const PaymentPlansResponseSchema = z
.object({
isFirstRecharge: booleanOrFalse,
firstRechargeOffer: schemaOrNull(FirstRechargeOfferSchema),
plans: arrayOrEmpty(PaymentPlanSchema),
})
.readonly();
export type PaymentPlansResponseInput = z.input<
typeof PaymentPlansResponseSchema
@@ -32,36 +36,6 @@ export type PaymentPlansResponseData = z.output<
>;
export type FirstRechargeOfferData = z.output<typeof FirstRechargeOfferSchema>;
export class PaymentPlansResponse {
declare readonly isFirstRecharge: boolean;
declare readonly firstRechargeOffer: FirstRechargeOfferData | null;
declare readonly plans: PaymentPlan[];
private constructor(input: PaymentPlansResponseInput) {
const data = PaymentPlansResponseSchema.parse(input);
Object.assign(this, {
isFirstRecharge: data.isFirstRecharge,
firstRechargeOffer: data.firstRechargeOffer,
plans: data.plans.map((plan) => PaymentPlan.from(plan)),
});
Object.freeze(this);
}
static from(input: PaymentPlansResponseInput): PaymentPlansResponse {
return new PaymentPlansResponse(input);
}
static fromJson(json: unknown): PaymentPlansResponse {
return PaymentPlansResponse.from(json as PaymentPlansResponseInput);
}
toJson(): PaymentPlansResponseData {
return {
isFirstRecharge: this.isFirstRecharge,
firstRechargeOffer: this.firstRechargeOffer,
plans: this.plans.map((plan) => plan.toJson()),
};
}
}
export type FirstRechargeOffer = FirstRechargeOfferData;
export type PaymentPlansResponse = PaymentPlansResponseData;
@@ -1,11 +1,13 @@
import { z } from "zod";
import { arrayOrEmpty } from "../../nullable-defaults";
import { TipPaymentPlan, TipPaymentPlanSchema } from "../tip_payment_plan";
import { TipPaymentPlanSchema } from "../tip_payment_plan";
export const TipPaymentPlansResponseSchema = z.object({
plans: arrayOrEmpty(TipPaymentPlanSchema),
});
export const TipPaymentPlansResponseSchema = z
.object({
plans: arrayOrEmpty(TipPaymentPlanSchema),
})
.readonly();
export type TipPaymentPlansResponseInput = z.input<
typeof TipPaymentPlansResponseSchema
@@ -14,30 +16,4 @@ export type TipPaymentPlansResponseData = z.output<
typeof TipPaymentPlansResponseSchema
>;
export class TipPaymentPlansResponse {
declare readonly plans: TipPaymentPlan[];
private constructor(input: TipPaymentPlansResponseInput) {
const data = TipPaymentPlansResponseSchema.parse(input);
Object.assign(this, {
plans: data.plans.map((plan) => TipPaymentPlan.from(plan)),
});
Object.freeze(this);
}
static from(input: TipPaymentPlansResponseInput): TipPaymentPlansResponse {
return new TipPaymentPlansResponse(input);
}
static fromJson(json: unknown): TipPaymentPlansResponse {
return TipPaymentPlansResponse.from(
json as TipPaymentPlansResponseInput,
);
}
toJson(): TipPaymentPlansResponseData {
return {
plans: this.plans.map((plan) => plan.toJson()),
};
}
}
export type TipPaymentPlansResponse = TipPaymentPlansResponseData;
+9 -30
View File
@@ -1,36 +1,15 @@
import { z } from "zod";
export const TipPaymentPlanSchema = z.object({
planId: z.string(),
planName: z.string(),
amountCents: z.number(),
currency: z.string(),
});
export const TipPaymentPlanSchema = z
.object({
planId: z.string(),
planName: z.string(),
amountCents: z.number(),
currency: z.string(),
})
.readonly();
export type TipPaymentPlanInput = z.input<typeof TipPaymentPlanSchema>;
export type TipPaymentPlanData = z.output<typeof TipPaymentPlanSchema>;
export class TipPaymentPlan {
declare readonly planId: string;
declare readonly planName: string;
declare readonly amountCents: number;
declare readonly currency: string;
private constructor(input: TipPaymentPlanInput) {
const data = TipPaymentPlanSchema.parse(input);
Object.assign(this, data);
Object.freeze(this);
}
static from(input: TipPaymentPlanInput): TipPaymentPlan {
return new TipPaymentPlan(input);
}
static fromJson(json: unknown): TipPaymentPlan {
return TipPaymentPlan.from(json as TipPaymentPlanInput);
}
toJson(): TipPaymentPlanData {
return TipPaymentPlanSchema.parse(this);
}
}
export type TipPaymentPlan = TipPaymentPlanData;
@@ -1,9 +1,9 @@
import { describe, expect, it } from "vitest";
import {
PrivateAlbumsResponse,
PrivateAlbumUnlockResponse,
UnlockPrivateAlbumRequest,
PrivateAlbumUnlockResponseSchema,
PrivateAlbumsResponseSchema,
UnlockPrivateAlbumRequestSchema,
} from "@/data/schemas/private-room";
import { ApiPath } from "@/data/services/api";
@@ -40,7 +40,7 @@ const lockedAlbum = {
describe("Private Album schema models", () => {
it("keeps a locked album cover URL while preserving the lock state", () => {
const response = PrivateAlbumsResponse.fromJson({
const response = PrivateAlbumsResponseSchema.parse({
items: [lockedAlbum],
creditBalance: 100,
nextCursor: null,
@@ -54,21 +54,21 @@ describe("Private Album schema models", () => {
});
it("strips response fields that are not used by the frontend", () => {
const response = PrivateAlbumsResponse.fromJson({
const response = PrivateAlbumsResponseSchema.parse({
items: [lockedAlbum],
creditBalance: 100,
packageOptions: [{ imageCount: 8, creditCost: 320 }],
});
const albumJson = response.items[0]?.toJson();
const albumJson = response.items[0];
expect(albumJson).not.toHaveProperty("momentId");
expect(albumJson).not.toHaveProperty("characterId");
expect(albumJson).not.toHaveProperty("collectionKey");
expect(response.toJson()).not.toHaveProperty("packageOptions");
expect(response).not.toHaveProperty("packageOptions");
});
it("parses an unlock response with all album images", () => {
const response = PrivateAlbumUnlockResponse.fromJson({
const response = PrivateAlbumUnlockResponseSchema.parse({
albumId: ALBUM_ID,
locked: false,
unlocked: true,
@@ -86,12 +86,12 @@ describe("Private Album schema models", () => {
expect(response.unlocked).toBe(true);
expect(response.images).toHaveLength(8);
expect(response.creditBalance).toBe(180);
expect(response.toJson()).not.toHaveProperty("creditsCharged");
expect(response).not.toHaveProperty("creditsCharged");
});
it("serializes the expected unlock cost", () => {
expect(
UnlockPrivateAlbumRequest.from({ expectedCost: 320 }).toJson(),
UnlockPrivateAlbumRequestSchema.parse({ expectedCost: 320 }),
).toEqual({ expectedCost: 320 });
});
+28 -52
View File
@@ -9,62 +9,38 @@ import {
stringOrNull,
} from "../nullable-defaults";
export const PrivateAlbumImageSchema = z.object({
url: stringOrEmpty,
locked: booleanOrFalse,
index: numberOrZero,
});
export const PrivateAlbumImageSchema = z
.object({
url: stringOrEmpty,
locked: booleanOrFalse,
index: numberOrZero,
})
.readonly();
export const PrivateAlbumLockDetailSchema = z.object({
locked: booleanOrFalse,
});
export const PrivateAlbumLockDetailSchema = z
.object({
locked: booleanOrFalse,
})
.readonly();
export const PrivateAlbumSchema = z.object({
albumId: stringOrEmpty,
title: stringOrEmpty,
content: stringOrNull,
previewText: stringOrEmpty,
imageCount: numberOrZero,
images: arrayOrEmpty(PrivateAlbumImageSchema),
locked: booleanOrFalse,
unlocked: booleanOrFalse,
unlockCost: numberOrZero,
publishedAt: stringOrNull,
lockDetail: schemaOr(PrivateAlbumLockDetailSchema, { locked: false }),
});
export const PrivateAlbumSchema = z
.object({
albumId: stringOrEmpty,
title: stringOrEmpty,
content: stringOrNull,
previewText: stringOrEmpty,
imageCount: numberOrZero,
images: arrayOrEmpty(PrivateAlbumImageSchema),
locked: booleanOrFalse,
unlocked: booleanOrFalse,
unlockCost: numberOrZero,
publishedAt: stringOrNull,
lockDetail: schemaOr(PrivateAlbumLockDetailSchema, { locked: false }),
})
.readonly();
export type PrivateAlbumImageData = z.output<typeof PrivateAlbumImageSchema>;
export type PrivateAlbumInput = z.input<typeof PrivateAlbumSchema>;
export type PrivateAlbumData = z.output<typeof PrivateAlbumSchema>;
export class PrivateAlbum {
declare readonly albumId: string;
declare readonly title: string;
declare readonly content: string | null;
declare readonly previewText: string;
declare readonly imageCount: number;
declare readonly images: PrivateAlbumData["images"];
declare readonly locked: boolean;
declare readonly unlocked: boolean;
declare readonly unlockCost: number;
declare readonly publishedAt: string | null;
declare readonly lockDetail: PrivateAlbumData["lockDetail"];
private constructor(input: PrivateAlbumInput) {
const data = PrivateAlbumSchema.parse(input);
Object.assign(this, data);
Object.freeze(this);
}
static from(input: PrivateAlbumInput): PrivateAlbum {
return new PrivateAlbum(input);
}
static fromJson(json: unknown): PrivateAlbum {
return PrivateAlbum.from(json as PrivateAlbumInput);
}
toJson(): PrivateAlbumData {
return PrivateAlbumSchema.parse(this);
}
}
export type PrivateAlbum = PrivateAlbumData;
@@ -2,9 +2,11 @@ import { z } from "zod";
import { numberOrZero } from "../../nullable-defaults";
export const UnlockPrivateAlbumRequestSchema = z.object({
expectedCost: numberOrZero,
});
export const UnlockPrivateAlbumRequestSchema = z
.object({
expectedCost: numberOrZero,
})
.readonly();
export type UnlockPrivateAlbumRequestInput = z.input<
typeof UnlockPrivateAlbumRequestSchema
@@ -13,18 +15,4 @@ export type UnlockPrivateAlbumRequestData = z.output<
typeof UnlockPrivateAlbumRequestSchema
>;
export class UnlockPrivateAlbumRequest {
private constructor(input: UnlockPrivateAlbumRequestInput) {
const data = UnlockPrivateAlbumRequestSchema.parse(input);
Object.assign(this, data);
Object.freeze(this);
}
static from(input: UnlockPrivateAlbumRequestInput): UnlockPrivateAlbumRequest {
return new UnlockPrivateAlbumRequest(input);
}
toJson(): UnlockPrivateAlbumRequestData {
return UnlockPrivateAlbumRequestSchema.parse(this);
}
}
export type UnlockPrivateAlbumRequest = UnlockPrivateAlbumRequestData;
@@ -19,17 +19,19 @@ export const PrivateAlbumUnlockReasonSchema = z.enum([
"not_found",
]);
export const PrivateAlbumUnlockResponseSchema = z.object({
albumId: stringOrEmpty,
locked: booleanOrFalse,
unlocked: booleanOrFalse,
reason: PrivateAlbumUnlockReasonSchema.or(stringOrEmpty),
unlockCost: numberOrZero,
requiredCredits: numberOrZero,
creditBalance: numberOrZero,
shortfallCredits: numberOrZero,
images: arrayOrEmpty(PrivateAlbumImageSchema),
});
export const PrivateAlbumUnlockResponseSchema = z
.object({
albumId: stringOrEmpty,
locked: booleanOrFalse,
unlocked: booleanOrFalse,
reason: PrivateAlbumUnlockReasonSchema.or(stringOrEmpty),
unlockCost: numberOrZero,
requiredCredits: numberOrZero,
creditBalance: numberOrZero,
shortfallCredits: numberOrZero,
images: arrayOrEmpty(PrivateAlbumImageSchema),
})
.readonly();
export type PrivateAlbumUnlockReason = z.output<
typeof PrivateAlbumUnlockReasonSchema
@@ -41,34 +43,4 @@ export type PrivateAlbumUnlockResponseData = z.output<
typeof PrivateAlbumUnlockResponseSchema
>;
export class PrivateAlbumUnlockResponse {
declare readonly albumId: string;
declare readonly locked: boolean;
declare readonly unlocked: boolean;
declare readonly reason: PrivateAlbumUnlockReason | string;
declare readonly unlockCost: number;
declare readonly requiredCredits: number;
declare readonly creditBalance: number;
declare readonly shortfallCredits: number;
declare readonly images: PrivateAlbumUnlockResponseData["images"];
private constructor(input: PrivateAlbumUnlockResponseInput) {
const data = PrivateAlbumUnlockResponseSchema.parse(input);
Object.assign(this, data);
Object.freeze(this);
}
static from(input: PrivateAlbumUnlockResponseInput): PrivateAlbumUnlockResponse {
return new PrivateAlbumUnlockResponse(input);
}
static fromJson(json: unknown): PrivateAlbumUnlockResponse {
return PrivateAlbumUnlockResponse.from(
json as PrivateAlbumUnlockResponseInput,
);
}
toJson(): PrivateAlbumUnlockResponseData {
return PrivateAlbumUnlockResponseSchema.parse(this);
}
}
export type PrivateAlbumUnlockResponse = PrivateAlbumUnlockResponseData;
@@ -1,12 +1,14 @@
import { z } from "zod";
import { arrayOrEmpty, numberOrZero } from "../../nullable-defaults";
import { PrivateAlbum, PrivateAlbumSchema } from "../private_album";
import { PrivateAlbumSchema } from "../private_album";
export const PrivateAlbumsResponseSchema = z.object({
items: arrayOrEmpty(PrivateAlbumSchema),
creditBalance: numberOrZero,
});
export const PrivateAlbumsResponseSchema = z
.object({
items: arrayOrEmpty(PrivateAlbumSchema),
creditBalance: numberOrZero,
})
.readonly();
export type PrivateAlbumsResponseInput = z.input<
typeof PrivateAlbumsResponseSchema
@@ -15,31 +17,4 @@ export type PrivateAlbumsResponseData = z.output<
typeof PrivateAlbumsResponseSchema
>;
export class PrivateAlbumsResponse {
declare readonly items: PrivateAlbum[];
declare readonly creditBalance: number;
private constructor(input: PrivateAlbumsResponseInput) {
const data = PrivateAlbumsResponseSchema.parse(input);
Object.assign(this, {
items: data.items.map((item) => PrivateAlbum.from(item)),
creditBalance: data.creditBalance,
});
Object.freeze(this);
}
static from(input: PrivateAlbumsResponseInput): PrivateAlbumsResponse {
return new PrivateAlbumsResponse(input);
}
static fromJson(json: unknown): PrivateAlbumsResponse {
return PrivateAlbumsResponse.from(json as PrivateAlbumsResponseInput);
}
toJson(): PrivateAlbumsResponseData {
return {
items: this.items.map((item) => item.toJson()),
creditBalance: this.creditBalance,
};
}
}
export type PrivateAlbumsResponse = PrivateAlbumsResponseData;
-53
View File
@@ -1,53 +0,0 @@
import type { z } from "zod";
export type SchemaModel<
TSchema extends z.ZodType,
TPublicKey extends keyof z.output<TSchema> = never,
> = Readonly<Pick<z.output<TSchema>, TPublicKey>> & {
toJson(): z.output<TSchema>;
};
export interface SchemaModelFactory<
TSchema extends z.ZodType,
TPublicKey extends keyof z.output<TSchema> = never,
> {
readonly prototype: SchemaModel<TSchema, TPublicKey>;
[Symbol.hasInstance](value: unknown): boolean;
from(input: z.input<TSchema>): SchemaModel<TSchema, TPublicKey>;
fromJson(json: unknown): SchemaModel<TSchema, TPublicKey>;
}
/** Creates a shallow-frozen data class backed by a Zod schema. */
export function createSchemaModel<
TSchema extends z.ZodType,
TPublicKey extends keyof z.output<TSchema> = never,
>(schema: TSchema): SchemaModelFactory<TSchema, TPublicKey> {
class GeneratedSchemaModel {
private constructor(input: z.input<TSchema>) {
Object.assign(this, schema.parse(input) as object);
Object.freeze(this);
}
static from(
input: z.input<TSchema>,
): SchemaModel<TSchema, TPublicKey> {
return new GeneratedSchemaModel(input) as SchemaModel<
TSchema,
TPublicKey
>;
}
static fromJson(json: unknown): SchemaModel<TSchema, TPublicKey> {
return GeneratedSchemaModel.from(json as z.input<TSchema>);
}
toJson(): z.output<TSchema> {
return schema.parse(this);
}
}
return GeneratedSchemaModel as unknown as SchemaModelFactory<
TSchema,
TPublicKey
>;
}
+9 -9
View File
@@ -1,11 +1,11 @@
import { describe, expect, it } from "vitest";
import { UserEntitlements } from "@/data/schemas/user";
import { User } from "@/data/schemas/user/user";
import { UserEntitlementsSchema } from "@/data/schemas/user";
import { UserSchema } from "@/data/schemas/user/user";
describe("User", () => {
it("parses the latest backend user payload", () => {
const user = User.fromJson({
const user = UserSchema.parse({
id: "d7bb2b3d-e9c5-474c-85fe-f503048f30dd",
username: "guest_bbc90a38",
email: "device_bbc90a38b996187348f54cf2cb0b789e@guest.local",
@@ -45,7 +45,7 @@ describe("User", () => {
lastMessageAt: "2026-06-24T11:00:23.156561+00:00",
});
expect(user.toJson()).toEqual({
expect(user).toEqual({
id: "d7bb2b3d-e9c5-474c-85fe-f503048f30dd",
username: "guest_bbc90a38",
email: "device_bbc90a38b996187348f54cf2cb0b789e@guest.local",
@@ -75,17 +75,17 @@ describe("User", () => {
lastMessageAt: "2026-06-24T11:00:23.156561+00:00",
});
expect("dailyQuotas" in user.toJson()).toBe(false);
expect("dailyQuotas" in user).toBe(false);
});
it("defaults nullable nested user fields through schema helpers", () => {
const user = User.fromJson({
const user = UserSchema.parse({
id: "user-1",
username: "guest_user",
personalityTraits: null,
});
expect(user.personalityTraits.toJson()).toEqual({
expect(user.personalityTraits).toEqual({
caring: 0.5,
playful: 0.5,
serious: 0.5,
@@ -95,7 +95,7 @@ describe("User", () => {
});
it("parses the latest backend entitlements payload", () => {
const entitlements = UserEntitlements.fromJson({
const entitlements = UserEntitlementsSchema.parse({
userId: "user-1",
isGuest: false,
isVip: true,
@@ -142,7 +142,7 @@ describe("User", () => {
});
it("defaults nullable entitlement sections through schema helpers", () => {
const entitlements = UserEntitlements.fromJson({
const entitlements = UserEntitlementsSchema.parse({
userId: "user-1",
policy: null,
costs: null,
+8 -27
View File
@@ -1,38 +1,19 @@
/**
* 头像数据模型
*
*
*/
import { z } from "zod";
import { stringOrEmpty } from "../nullable-defaults";
export const AvatarDataSchema = z.object({
avatarUrl: stringOrEmpty,
userId: stringOrEmpty,
});
export const AvatarDataSchema = z
.object({
avatarUrl: stringOrEmpty,
userId: stringOrEmpty,
})
.readonly();
export type AvatarDataInput = z.input<typeof AvatarDataSchema>;
export type AvatarDataData = z.output<typeof AvatarDataSchema>;
export class AvatarData {
declare readonly avatarUrl: string;
declare readonly userId: string;
private constructor(input: AvatarDataInput) {
const data = AvatarDataSchema.parse(input);
Object.assign(this, data);
Object.freeze(this);
}
static from(input: AvatarDataInput): AvatarData {
return new AvatarData(input);
}
static fromJson(json: unknown): AvatarData {
return AvatarData.from(json as AvatarDataInput);
}
toJson(): AvatarDataData {
return AvatarDataSchema.parse(this);
}
}
export type AvatarData = AvatarDataData;
+11 -9
View File
@@ -8,15 +8,17 @@ import { z } from "zod";
import { numberOrZero, stringOrEmpty } from "../nullable-defaults";
export const PersistedUserSchema = z.object({
id: stringOrEmpty,
username: stringOrEmpty,
countryCode: stringOrEmpty,
dailyFreeChatLimit: numberOrZero,
dailyFreeChatRemaining: numberOrZero,
dailyFreePrivateLimit: numberOrZero,
dailyFreePrivateRemaining: numberOrZero,
});
export const PersistedUserSchema = z
.object({
id: stringOrEmpty,
username: stringOrEmpty,
countryCode: stringOrEmpty,
dailyFreeChatLimit: numberOrZero,
dailyFreeChatRemaining: numberOrZero,
dailyFreePrivateLimit: numberOrZero,
dailyFreePrivateRemaining: numberOrZero,
})
.readonly();
export type PersistedUserInput = z.input<typeof PersistedUserSchema>;
export type PersistedUserData = z.output<typeof PersistedUserSchema>;
+11 -33
View File
@@ -1,6 +1,6 @@
/**
* 个性特征模型
*
*
*/
import { z } from "zod";
@@ -14,39 +14,17 @@ export const PERSONALITY_TRAITS_DEFAULTS = {
romantic: 0.5,
} as const;
export const PersonalityTraitsSchema = z.object({
cheerful: numberOr(0.5),
caring: numberOr(0.5),
playful: numberOr(0.5),
serious: numberOr(0.5),
romantic: numberOr(0.5),
});
export const PersonalityTraitsSchema = z
.object({
cheerful: numberOr(0.5),
caring: numberOr(0.5),
playful: numberOr(0.5),
serious: numberOr(0.5),
romantic: numberOr(0.5),
})
.readonly();
export type PersonalityTraitsInput = z.input<typeof PersonalityTraitsSchema>;
export type PersonalityTraitsData = z.output<typeof PersonalityTraitsSchema>;
export class PersonalityTraits {
declare readonly cheerful: number;
declare readonly caring: number;
declare readonly playful: number;
declare readonly serious: number;
declare readonly romantic: number;
private constructor(input: PersonalityTraitsInput) {
const data = PersonalityTraitsSchema.parse(input);
Object.assign(this, data);
Object.freeze(this);
}
static from(input: PersonalityTraitsInput): PersonalityTraits {
return new PersonalityTraits(input);
}
static fromJson(json: unknown): PersonalityTraits {
return PersonalityTraits.from(json as PersonalityTraitsInput);
}
toJson(): PersonalityTraitsData {
return PersonalityTraitsSchema.parse(this);
}
}
export type PersonalityTraits = PersonalityTraitsData;
+10 -31
View File
@@ -1,42 +1,21 @@
/**
* 最近记忆模型
*
*
*/
import { z } from "zod";
import { numberOrZero, stringOrEmpty } from "../nullable-defaults";
export const RecentMemorySchema = z.object({
type: stringOrEmpty,
content: stringOrEmpty,
importance: numberOrZero,
createdAt: stringOrEmpty,
});
export const RecentMemorySchema = z
.object({
type: stringOrEmpty,
content: stringOrEmpty,
importance: numberOrZero,
createdAt: stringOrEmpty,
})
.readonly();
export type RecentMemoryInput = z.input<typeof RecentMemorySchema>;
export type RecentMemoryData = z.output<typeof RecentMemorySchema>;
export class RecentMemory {
declare readonly type: string;
declare readonly content: string;
declare readonly importance: number;
declare readonly createdAt: string;
private constructor(input: RecentMemoryInput) {
const data = RecentMemorySchema.parse(input);
Object.assign(this, data);
Object.freeze(this);
}
static from(input: RecentMemoryInput): RecentMemory {
return new RecentMemory(input);
}
static fromJson(json: unknown): RecentMemory {
return RecentMemory.from(json as RecentMemoryInput);
}
toJson(): RecentMemoryData {
return RecentMemorySchema.parse(this);
}
}
export type RecentMemory = RecentMemoryData;
+37 -85
View File
@@ -1,98 +1,50 @@
/**
* 用户模型
*
*
*/
import { z } from "zod";
import { numberOrZero, schemaOr, stringOrEmpty } from "../nullable-defaults";
import {
PersonalityTraits,
PersonalityTraitsSchema,
PERSONALITY_TRAITS_DEFAULTS,
PersonalityTraitsSchema,
} from "./personality_traits";
import {
numberOrZero,
schemaOr,
stringOrEmpty,
} from "../nullable-defaults";
export const UserSchema = z.object({
id: stringOrEmpty, // 兜底:理论 id 不会 null,但保持防御
username: stringOrEmpty,
email: stringOrEmpty,
platform: stringOrEmpty,
country: stringOrEmpty,
countryCode: stringOrEmpty,
fbAsid: stringOrEmpty,
fbPsid: stringOrEmpty,
intimacy: numberOrZero,
dolBalance: numberOrZero,
creditBalance: numberOrZero,
dailyFreeChatLimit: numberOrZero,
dailyFreeChatUsed: numberOrZero,
dailyFreeChatRemaining: numberOrZero,
dailyFreePrivateLimit: numberOrZero,
dailyFreePrivateUsed: numberOrZero,
dailyFreePrivateRemaining: numberOrZero,
personalityTraits: schemaOr(PersonalityTraitsSchema, PERSONALITY_TRAITS_DEFAULTS),
preferredLanguage: stringOrEmpty,
createdAt: stringOrEmpty,
// 后端新游客返回 null("还没发过消息"),前端统一收敛为空字符串。
lastMessageAt: stringOrEmpty,
});
export const UserSchema = z
.object({
id: stringOrEmpty, // 兜底:理论 id 不会 null,但保持防御
username: stringOrEmpty,
email: stringOrEmpty,
platform: stringOrEmpty,
country: stringOrEmpty,
countryCode: stringOrEmpty,
fbAsid: stringOrEmpty,
fbPsid: stringOrEmpty,
intimacy: numberOrZero,
dolBalance: numberOrZero,
creditBalance: numberOrZero,
dailyFreeChatLimit: numberOrZero,
dailyFreeChatUsed: numberOrZero,
dailyFreeChatRemaining: numberOrZero,
dailyFreePrivateLimit: numberOrZero,
dailyFreePrivateUsed: numberOrZero,
dailyFreePrivateRemaining: numberOrZero,
personalityTraits: schemaOr(
PersonalityTraitsSchema,
PERSONALITY_TRAITS_DEFAULTS,
),
preferredLanguage: stringOrEmpty,
createdAt: stringOrEmpty,
// 后端新游客返回 null("还没发过消息"),前端统一收敛为空字符串。
lastMessageAt: stringOrEmpty,
})
.readonly();
export type UserInput = z.input<typeof UserSchema>;
export type UserData = z.output<typeof UserSchema>;
export class User {
declare readonly id: string;
declare readonly username: string;
declare readonly email: string;
declare readonly platform: string;
declare readonly country: string;
declare readonly countryCode: string;
declare readonly fbAsid: string;
declare readonly fbPsid: string;
declare readonly intimacy: number;
declare readonly dolBalance: number;
declare readonly creditBalance: number;
declare readonly dailyFreeChatLimit: number;
declare readonly dailyFreeChatUsed: number;
declare readonly dailyFreeChatRemaining: number;
declare readonly dailyFreePrivateLimit: number;
declare readonly dailyFreePrivateUsed: number;
declare readonly dailyFreePrivateRemaining: number;
declare readonly personalityTraits: PersonalityTraits;
declare readonly preferredLanguage: string;
declare readonly createdAt: string;
declare readonly lastMessageAt: string;
export type User = UserData;
private constructor(input: UserInput) {
const data = UserSchema.parse(input);
Object.assign(this, {
...data,
personalityTraits: PersonalityTraits.from(data.personalityTraits),
});
Object.freeze(this);
}
static from(input: UserInput): User {
return new User(input);
}
static fromJson(json: unknown): User {
return User.from(json as UserInput);
}
/**
* 创建一个具有空 id/username 的 User 实例(用于可选 user 字段的默认占位)
*/
static empty(): User {
return new User({ id: "", username: "" });
}
toJson(): UserData {
return UserSchema.parse({
...this,
personalityTraits: this.personalityTraits.toJson(),
});
}
}
export const EMPTY_USER: User = UserSchema.parse({
id: "",
username: "",
});
@@ -8,10 +8,12 @@ import { z } from "zod";
import { booleanOrFalse, numberOrZero } from "../nullable-defaults";
export const UserEntitlementSnapshotSchema = z.object({
isVip: booleanOrFalse,
creditBalance: numberOrZero,
});
export const UserEntitlementSnapshotSchema = z
.object({
isVip: booleanOrFalse,
creditBalance: numberOrZero,
})
.readonly();
export type UserEntitlementSnapshotInput = z.input<
typeof UserEntitlementSnapshotSchema
+28 -50
View File
@@ -56,7 +56,8 @@ export const UserEntitlementsPolicySchema = z
guestSameAsLoggedInNonVip: booleanOrFalse,
refreshAfterPayment: stringOrEmpty,
})
.passthrough();
.passthrough()
.readonly();
export const UserEntitlementsCostsSchema = z
.object({
@@ -68,7 +69,8 @@ export const UserEntitlementsCostsSchema = z
private_album_10: numberOrZero,
private_album_20: numberOrZero,
})
.passthrough();
.passthrough()
.readonly();
export const UserEntitlementsQuotasSchema = z
.object({
@@ -77,7 +79,8 @@ export const UserEntitlementsQuotasSchema = z
voiceMessageFreeDaily: numberOrZero,
photoFreeDaily: numberOrZero,
})
.passthrough();
.passthrough()
.readonly();
export const UserEntitlementsHistoryUnlockCostsSchema = z
.object({
@@ -85,7 +88,8 @@ export const UserEntitlementsHistoryUnlockCostsSchema = z
voice_message: numberOrZero,
photo: numberOrZero,
})
.passthrough();
.passthrough()
.readonly();
export const UserEntitlementsHistoryUnlockSchema = z
.object({
@@ -98,54 +102,28 @@ export const UserEntitlementsHistoryUnlockSchema = z
HISTORY_UNLOCK_COSTS_DEFAULTS,
),
})
.passthrough();
.passthrough()
.readonly();
export const UserEntitlementsSchema = z.object({
userId: stringOrEmpty,
isGuest: booleanOrFalse,
isVip: booleanOrFalse,
vipExpiresAt: stringOrNull,
creditBalance: numberOrZero,
dolBalance: numberOrZero,
policy: schemaOr(UserEntitlementsPolicySchema, POLICY_DEFAULTS),
costs: schemaOr(UserEntitlementsCostsSchema, COSTS_DEFAULTS),
quotas: schemaOr(UserEntitlementsQuotasSchema, QUOTAS_DEFAULTS),
historyUnlock: schemaOr(
UserEntitlementsHistoryUnlockSchema,
HISTORY_UNLOCK_DEFAULTS,
),
});
export const UserEntitlementsSchema = z
.object({
userId: stringOrEmpty,
isGuest: booleanOrFalse,
isVip: booleanOrFalse,
vipExpiresAt: stringOrNull,
creditBalance: numberOrZero,
dolBalance: numberOrZero,
policy: schemaOr(UserEntitlementsPolicySchema, POLICY_DEFAULTS),
costs: schemaOr(UserEntitlementsCostsSchema, COSTS_DEFAULTS),
quotas: schemaOr(UserEntitlementsQuotasSchema, QUOTAS_DEFAULTS),
historyUnlock: schemaOr(
UserEntitlementsHistoryUnlockSchema,
HISTORY_UNLOCK_DEFAULTS,
),
})
.readonly();
export type UserEntitlementsInput = z.input<typeof UserEntitlementsSchema>;
export type UserEntitlementsData = z.output<typeof UserEntitlementsSchema>;
export class UserEntitlements {
declare readonly userId: string;
declare readonly isGuest: boolean;
declare readonly isVip: boolean;
declare readonly vipExpiresAt: string | null;
declare readonly creditBalance: number;
declare readonly dolBalance: number;
declare readonly policy: UserEntitlementsData["policy"];
declare readonly costs: UserEntitlementsData["costs"];
declare readonly quotas: UserEntitlementsData["quotas"];
declare readonly historyUnlock: UserEntitlementsData["historyUnlock"];
private constructor(input: UserEntitlementsInput) {
const data = UserEntitlementsSchema.parse(input);
Object.assign(this, data);
Object.freeze(this);
}
static from(input: UserEntitlementsInput): UserEntitlements {
return new UserEntitlements(input);
}
static fromJson(json: unknown): UserEntitlements {
return UserEntitlements.from(json as UserEntitlementsInput);
}
toJson(): UserEntitlementsData {
return UserEntitlementsSchema.parse(this);
}
}
export type UserEntitlements = UserEntitlementsData;