refactor(data): merge DTO models into schemas

This commit is contained in:
2026-07-17 12:47:18 +08:00
parent 2796010971
commit eac3d8f0a7
274 changed files with 1466 additions and 1956 deletions
@@ -0,0 +1,42 @@
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);
});
});
@@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import {
FacebookIdentityResponse,
FacebookPsidLoginResponse,
LoginRequest,
} 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({
fbAsid: "asid-1",
fbPsid: "psid-1",
facebookBinding: {
conflicts: [],
bound: [{ field: "psid" }],
},
});
expect(response.toJson()).toMatchObject({
fbAsid: "asid-1",
fbPsid: "psid-1",
facebookBinding: {
conflicts: [],
bound: [{ field: "psid" }],
},
});
});
it("parses facebook psid login responses for real users and guests", () => {
expect(
FacebookPsidLoginResponse.fromJson({
token: "login-token",
refreshToken: "refresh-token",
matchedBy: "psid",
fbAsid: "asid-1",
fbPsid: "psid-1",
hasCompleteFacebookIdentity: true,
isGuest: false,
user: { id: "user-1", username: "Elio" },
}).toJson(),
).toMatchObject({
isGuest: false,
hasCompleteFacebookIdentity: true,
user: { id: "user-1" },
});
expect(
FacebookPsidLoginResponse.fromJson({
token: "guest-token",
matchedBy: "guest",
fbPsid: "psid-1",
hasCompleteFacebookIdentity: false,
isGuest: true,
userId: "guest-1",
}).toJson(),
).toMatchObject({
refreshToken: "",
isGuest: true,
userId: "guest-1",
user: null,
});
});
it("keeps psid in request serialization without a public field declaration", () => {
const request = LoginRequest.from({
email: "user@example.com",
password: "password",
platform: "web",
guestId: "",
psid: "psid-1",
});
expect(request.toJson()).toMatchObject({ psid: "psid-1" });
});
it("accepts facebook identity fields in user schema", () => {
expect(
UserSchema.parse({
id: "user-1",
username: "Elio",
fbAsid: "asid-1",
fbPsid: "psid-1",
}),
).toMatchObject({
fbAsid: "asid-1",
fbPsid: "psid-1",
});
});
});
@@ -17,3 +17,28 @@ export const FacebookUserDataSchema = z.object({
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);
}
}
+1
View File
@@ -3,6 +3,7 @@
*/
export * from "./facebook_user_data";
export * from "./login_status";
export * from "./request/facebook_identity_request";
export * from "./request/facebook_login_request";
export * from "./request/facebook_psid_login_request";
+10
View File
@@ -0,0 +1,10 @@
/** Login status persisted by the auth data layer. */
export const LoginStatus = {
NotLoggedIn: "notLoggedIn",
Guest: "guest",
Facebook: "facebook",
Google: "google",
Email: "email",
} as const;
export type LoginStatus = (typeof LoginStatus)[keyof typeof LoginStatus];
@@ -3,6 +3,11 @@
*/
import { z } from "zod";
import {
createSchemaModel,
type SchemaModel,
} from "../../schema_model";
import { stringOrEmpty } from "../../nullable-defaults";
export const FacebookIdentityRequestSchema = z.object({
@@ -16,3 +21,10 @@ export type FacebookIdentityRequestInput = z.input<
export type FacebookIdentityRequestData = z.output<
typeof FacebookIdentityRequestSchema
>;
export type FacebookIdentityRequest = SchemaModel<
typeof FacebookIdentityRequestSchema
>;
export const FacebookIdentityRequest = createSchemaModel(
FacebookIdentityRequestSchema,
);
@@ -16,3 +16,28 @@ export const FacebookLoginRequestSchema = z.object({
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);
}
}
@@ -3,6 +3,11 @@
*/
import { z } from "zod";
import {
createSchemaModel,
type SchemaModel,
} from "../../schema_model";
import { booleanOrTrue, stringOrEmpty } from "../../nullable-defaults";
export const FacebookPsidLoginRequestSchema = z.object({
@@ -17,3 +22,10 @@ export type FacebookPsidLoginRequestInput = z.input<
export type FacebookPsidLoginRequestData = z.output<
typeof FacebookPsidLoginRequestSchema
>;
export type FacebookPsidLoginRequest = SchemaModel<
typeof FacebookPsidLoginRequestSchema
>;
export const FacebookPsidLoginRequest = createSchemaModel(
FacebookPsidLoginRequestSchema,
);
@@ -15,3 +15,27 @@ export const FbIdLoginRequestSchema = z.object({
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);
}
}
@@ -16,3 +16,28 @@ export const GoogleLoginRequestSchema = z.object({
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);
}
}
@@ -13,3 +13,26 @@ export const GuestLoginRequestSchema = z.object({
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);
}
}
@@ -18,3 +18,30 @@ export const LoginRequestSchema = z.object({
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);
}
}
@@ -4,9 +4,23 @@
*/
import { z } from "zod";
import {
createSchemaModel,
type SchemaModel,
} from "../../schema_model";
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 const RefreshTokenRequest = createSchemaModel<
typeof RefreshTokenRequestSchema,
"refreshToken"
>(RefreshTokenRequestSchema);
@@ -17,3 +17,30 @@ export const RegisterRequestSchema = z.object({
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);
}
}
@@ -3,6 +3,11 @@
*/
import { z } from "zod";
import {
createSchemaModel,
type SchemaModel,
} from "../../schema_model";
import { arrayOrEmpty, stringOrEmpty } from "../../nullable-defaults";
export const FacebookIdentityBindingSchema = z.object({
@@ -25,3 +30,10 @@ export type FacebookIdentityResponseInput = z.input<
export type FacebookIdentityResponseData = z.output<
typeof FacebookIdentityResponseSchema
>;
export type FacebookIdentityResponse = SchemaModel<
typeof FacebookIdentityResponseSchema
>;
export const FacebookIdentityResponse = createSchemaModel(
FacebookIdentityResponseSchema,
);
@@ -28,3 +28,27 @@ export type FacebookPsidLoginResponseInput = z.input<
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);
}
}
@@ -5,7 +5,7 @@
import { z } from "zod";
import { booleanOrTrue, numberOr, schemaOrNull } from "../../nullable-defaults";
import { UserSchema } from "../../user/user";
import { User, UserSchema } from "../../user/user";
export const GuestLoginResponseSchema = z.object({
token: z.string(),
@@ -18,3 +18,31 @@ export const GuestLoginResponseSchema = z.object({
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;
}
}
@@ -5,7 +5,7 @@
import { z } from "zod";
import { stringOrEmpty } from "../../nullable-defaults";
import { UserSchema } from "../../user/user";
import { User, UserSchema } from "../../user/user";
export const LoginResponseSchema = z.object({
user: UserSchema,
@@ -15,3 +15,30 @@ export const LoginResponseSchema = z.object({
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);
}
}
@@ -4,6 +4,11 @@
*/
import { z } from "zod";
import {
createSchemaModel,
type SchemaModel,
} from "../../schema_model";
import { booleanOrTrue } from "../../nullable-defaults";
export const LogoutResponseSchema = z.object({
@@ -12,3 +17,12 @@ export const LogoutResponseSchema = z.object({
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);
@@ -12,3 +12,26 @@ export const RefreshTokenResponseSchema = z.object({
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);
}
}
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { Character } from "@/data/schemas/character";
describe("Character", () => {
it("keeps only the four public character fields", () => {
const character = Character.fromJson({
id: "character_elio",
slug: "elio",
displayName: " Elio Silvestri ",
avatarUrl: "https://cdn.example.com/elio.jpg",
enabled: true,
sortOrder: 1,
});
expect(character.toJson()).toEqual({
id: "character_elio",
slug: "elio",
displayName: "Elio Silvestri",
avatarUrl: "https://cdn.example.com/elio.jpg",
});
});
it("rejects unsafe slugs and non-HTTPS avatars", () => {
expect(() =>
Character.from({
id: "character_aria",
slug: "Aria Profile",
displayName: "Aria",
avatarUrl: "https://cdn.example.com/aria.jpg",
}),
).toThrow();
expect(() =>
Character.from({
id: "character_aria",
slug: "aria",
displayName: "Aria",
avatarUrl: "http://cdn.example.com/aria.jpg",
}),
).toThrow();
});
});
+24
View File
@@ -11,3 +11,27 @@ export const CharacterSchema = z.object({
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);
}
}
@@ -1,6 +1,6 @@
import { z } from "zod";
import { CharacterSchema } from "./character";
import { Character, CharacterSchema } from "./character";
export const CharactersResponseSchema = z.object({
items: z.array(CharacterSchema).default([]),
@@ -8,3 +8,29 @@ export const CharactersResponseSchema = z.object({
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()),
});
}
}
+4
View File
@@ -1,2 +1,6 @@
/**
* @file Automatically generated by barrelsby.
*/
export * from "./character";
export * from "./characters_response";
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { ChatHistoryResponse } from "@/data/schemas/chat";
describe("ChatHistoryResponse", () => {
it("exposes the pagination total and limit used by chat history", () => {
const response = ChatHistoryResponse.from({
messages: [],
total: 120,
limit: 50,
offset: 0,
});
expect(response.total).toBe(120);
expect(response.limit).toBe(50);
});
});
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import { UnlockPrivateRequest } from "@/data/schemas/chat";
describe("UnlockPrivateRequest", () => {
it("serializes an existing backend message unlock", () => {
expect(
UnlockPrivateRequest.from({
characterId: DEFAULT_CHARACTER_ID,
messageId: "message-1",
}).toJson(),
).toEqual({
characterId: DEFAULT_CHARACTER_ID,
messageId: "message-1",
});
});
it("serializes a temporary promotion lock without a fake message id", () => {
expect(
UnlockPrivateRequest.from({
characterId: DEFAULT_CHARACTER_ID,
lockType: "voice_message",
clientLockId: "promotion-1",
}).toJson(),
).toEqual({
characterId: DEFAULT_CHARACTER_ID,
lockType: "voice_message",
clientLockId: "promotion-1",
});
});
});
@@ -0,0 +1,132 @@
import { describe, expect, it } from "vitest";
import { UnlockPrivateResponse } from "@/data/schemas/chat";
describe("UnlockPrivateResponse", () => {
it("parses a successful single-message unlock response", () => {
const response = UnlockPrivateResponse.from({
unlocked: true,
content: "完整消息内容",
audioUrl: "https://example.com/unlocked-voice.mp3",
reason: "ok",
creditBalance: 9180,
creditsCharged: 10,
requiredCredits: 10,
shortfallCredits: 0,
});
expect(response.unlocked).toBe(true);
expect(response.content).toBe("完整消息内容");
expect(response.audioUrl).toBe("https://example.com/unlocked-voice.mp3");
expect(response.reason).toBe("ok");
expect(response.creditBalance).toBe(9180);
expect(response.creditsCharged).toBe(10);
expect(response.requiredCredits).toBe(10);
expect(response.shortfallCredits).toBe(0);
});
it("parses canonical ids, audio, and unlocked images", () => {
const response = UnlockPrivateResponse.from({
unlocked: true,
messageId: "backend-message-1",
clientLockId: "promotion-1",
lockType: "image_paywall",
content: "Unlocked reply",
audioUrl: "https://example.com/audio.mp3",
image: {
type: "promotion",
url: "https://example.com/image.jpg",
},
});
expect(response.messageId).toBe("backend-message-1");
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({
clientLockId: "promotion-1",
lockType: "image_paywall",
});
});
it("parses an insufficient-credits unlock response", () => {
const response = UnlockPrivateResponse.from({
unlocked: false,
content: "",
reason: "insufficient_credits",
creditBalance: 5,
creditsCharged: 0,
requiredCredits: 10,
shortfallCredits: 5,
});
expect(response.unlocked).toBe(false);
expect(response.reason).toBe("insufficient_credits");
expect(response.creditBalance).toBe(5);
expect(response.creditsCharged).toBe(0);
expect(response.requiredCredits).toBe(10);
expect(response.shortfallCredits).toBe(5);
});
it("defaults nullable content to an empty string", () => {
const response = UnlockPrivateResponse.from({
unlocked: false,
content: null,
audioUrl: null,
reason: "insufficient_credits",
});
expect(response.content).toBe("");
expect(response.audioUrl).toBe("");
});
it("defaults nullable credit fields to zero", () => {
const response = UnlockPrivateResponse.from({
unlocked: false,
content: "",
reason: "insufficient_credits",
creditBalance: null,
creditsCharged: null,
requiredCredits: null,
shortfallCredits: null,
});
expect(response.creditBalance).toBe(0);
expect(response.creditsCharged).toBe(0);
expect(response.requiredCredits).toBe(0);
expect(response.shortfallCredits).toBe(0);
});
it("strips legacy lock detail fields", () => {
const response = UnlockPrivateResponse.fromJson({
unlocked: false,
lockDetail: {
locked: true,
reason: "private_message",
showContent: false,
showUpgrade: true,
hint: "Legacy hint",
detail: { messageId: "legacy-message" },
},
});
expect(response.toJson()).not.toHaveProperty("lockDetail");
expect(response).not.toHaveProperty("lockDetail");
});
it("does not fall back to removed response aliases", () => {
const response = UnlockPrivateResponse.fromJson({
unlocked: true,
message_id: "legacy-message",
reply: "Legacy reply",
audio_url: "https://example.com/legacy.mp3",
});
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");
});
});
+1
View File
@@ -0,0 +1 @@
export type ChatMediaKind = "image" | "audio";
+35 -1
View File
@@ -5,7 +5,12 @@
import { z } from "zod";
import { stringOr, stringOrEmpty, stringOrNull } from "../nullable-defaults";
import { ChatImageSchema, ChatLockDetailSchema } from "./chat_payloads";
import {
ChatImageSchema,
ChatLockDetailSchema,
type ChatImageData,
type ChatLockDetailData,
} from "./chat_payloads";
export const ChatMessageSchema = z
.object({
@@ -26,3 +31,32 @@ export const ChatMessageSchema = z
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);
}
}
+2
View File
@@ -3,9 +3,11 @@
*/
export * from "./chat_lock_type";
export * from "./chat_media";
export * from "./chat_message";
export * from "./chat_payloads";
export * from "./request/send_message_request";
export * from "./request/unlock_history_request";
export * from "./request/unlock_private_request";
export * from "./response/chat_history_response";
export * from "./response/chat_send_response";
@@ -25,3 +25,33 @@ export const SendMessageRequestSchema = z.object({
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);
}
}
@@ -10,3 +10,20 @@ export type UnlockHistoryRequestInput = z.input<
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);
}
}
@@ -22,3 +22,28 @@ export type UnlockPrivateRequestInput = z.input<
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);
}
}
@@ -9,7 +9,7 @@ import {
booleanOrFalse,
numberOrZero,
} from "../../nullable-defaults";
import { ChatMessageSchema } from "../chat_message";
import { ChatMessage, ChatMessageSchema } from "../chat_message";
export const ChatHistoryResponseSchema = z.object({
messages: arrayOrEmpty(ChatMessageSchema),
@@ -24,3 +24,30 @@ export const ChatHistoryResponseSchema = z.object({
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);
}
}
@@ -10,7 +10,12 @@ import {
numberOrZero,
stringOrEmpty,
} from "../../nullable-defaults";
import { ChatImageSchema, ChatLockDetailSchema } from "../chat_payloads";
import {
ChatImageSchema,
ChatLockDetailSchema,
type ChatImageData,
type ChatLockDetailData,
} from "../chat_payloads";
export const ChatSendResponseSchema = z.object({
reply: stringOrEmpty,
@@ -30,3 +35,35 @@ export const ChatSendResponseSchema = z.object({
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);
}
}
@@ -41,3 +41,27 @@ export type UnlockHistoryResponseInput = z.input<
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);
}
}
@@ -6,7 +6,7 @@ import {
stringOrEmpty,
stringOrNull,
} from "../../nullable-defaults";
import { ChatImageSchema } from "../chat_payloads";
import { ChatImageSchema, type ChatImageData } from "../chat_payloads";
import { ChatLockTypeSchema } from "../chat_lock_type";
/**
@@ -36,3 +36,37 @@ export type UnlockPrivateResponseInput = z.input<
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);
}
}
@@ -0,0 +1,22 @@
export const FEEDBACK_CATEGORIES = [
"problem",
"suggestion",
"payment",
"other",
] as const;
export type FeedbackCategory = (typeof FEEDBACK_CATEGORIES)[number];
export interface FeedbackContext {
appVersion: string;
platform: string;
browser: string;
viewport: string;
}
export interface SubmitFeedbackInput {
category: FeedbackCategory;
content: string;
context: FeedbackContext;
images: readonly File[];
}
+5
View File
@@ -1 +1,6 @@
/**
* @file Automatically generated by barrelsby.
*/
export * from "./feedback_submission";
export * from "./response";
@@ -1,5 +1,10 @@
import { z } from "zod";
import {
createSchemaModel,
type SchemaModel,
} from "../../schema_model";
export const FeedbackSubmitResponseSchema = z.object({
feedbackId: z.string().min(1),
});
@@ -10,3 +15,12 @@ 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);
@@ -1 +1,5 @@
/**
* @file Automatically generated by barrelsby.
*/
export * from "./feedback_submit_response";
@@ -4,6 +4,11 @@
*/
import { z } from "zod";
import {
createSchemaModel,
type SchemaModel,
} from "../../schema_model";
import { stringOrEmpty } from "../../nullable-defaults";
export const AppEventSchema = z.object({
@@ -14,3 +19,10 @@ export const AppEventSchema = z.object({
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);
@@ -4,6 +4,11 @@
*/
import { z } from "zod";
import {
createSchemaModel,
type SchemaModel,
} from "../../schema_model";
import {
booleanOrFalse,
numberOrZero,
@@ -20,3 +25,15 @@ export const PwaEventSchema = z.object({
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);
@@ -0,0 +1,191 @@
import { describe, expect, it } from "vitest";
import { z } from "zod";
import {
PaymentPlan,
PaymentPlansResponse,
TipPaymentPlansResponse,
} from "@/data/schemas/payment";
describe("PaymentPlan", () => {
it("parses the camelCase payment plan shape", () => {
const plan = PaymentPlan.from({
planId: "vip_monthly",
planName: "VIP Monthly",
orderType: "vip_monthly",
vipDays: 30,
dolAmount: null,
creditBalance: 3000,
amountCents: 1999,
originalAmountCents: 2499,
dailyPriceCents: 66,
currency: "JPY",
mostPopular: true,
});
expect(plan.planId).toBe("vip_monthly");
expect(plan.creditBalance).toBe(3000);
expect(plan.mostPopular).toBe(true);
expect(plan.toJson()).toEqual({
planId: "vip_monthly",
planName: "VIP Monthly",
orderType: "vip_monthly",
vipDays: 30,
dolAmount: null,
creditBalance: 3000,
amountCents: 1999,
originalAmountCents: 2499,
dailyPriceCents: 66,
currency: "JPY",
isFirstRechargeOffer: false,
mostPopular: true,
firstRechargeDiscountPercent: null,
promotionType: null,
});
});
it("rejects the legacy snake_case payment plan shape", () => {
expect(() =>
PaymentPlan.fromJson({
plan_id: "vip_monthly",
plan_name: "VIP Monthly",
order_type: "vip_monthly",
amount_cents: 1999,
original_amount_cents: 2499,
daily_price_cents: 66,
currency: "JPY",
vip_days: 30,
dol_amount: null,
}),
).toThrow(z.ZodError);
});
it("defaults mostPopular to false when the backend omits it", () => {
const plan = PaymentPlan.from({
planId: "vip_monthly",
planName: "VIP Monthly",
orderType: "vip_monthly",
vipDays: 30,
dolAmount: null,
creditBalance: 3000,
amountCents: 1999,
originalAmountCents: null,
dailyPriceCents: null,
currency: "USD",
});
expect(plan.mostPopular).toBe(false);
});
});
describe("PaymentPlansResponse", () => {
it("parses plans from the new backend response data shape", () => {
const response = PaymentPlansResponse.from({
plans: [
{
planId: "dol_1000",
planName: "1000 Credits",
orderType: "dol",
vipDays: null,
dolAmount: 1000,
creditBalance: 1000,
amountCents: 990,
originalAmountCents: null,
dailyPriceCents: null,
currency: "USD",
},
],
});
expect(response.plans).toHaveLength(1);
expect(response.plans[0]?.dolAmount).toBe(1000);
});
it("parses first recharge offer metadata", () => {
const response = PaymentPlansResponse.from({
isFirstRecharge: true,
firstRechargeOffer: {
enabled: true,
type: "first_recharge_half_price",
discountPercent: 50,
},
plans: [
{
planId: "vip_monthly",
planName: "VIP Monthly",
orderType: "vip_monthly",
vipDays: 30,
dolAmount: null,
creditBalance: 3000,
amountCents: 995,
originalAmountCents: 1990,
dailyPriceCents: null,
currency: "USD",
isFirstRechargeOffer: true,
firstRechargeDiscountPercent: 50,
promotionType: "first_recharge_half_price",
},
],
});
expect(response.isFirstRecharge).toBe(true);
expect(response.firstRechargeOffer?.discountPercent).toBe(50);
expect(response.plans[0]).toMatchObject({
amountCents: 995,
originalAmountCents: 1990,
isFirstRechargeOffer: true,
firstRechargeDiscountPercent: 50,
promotionType: "first_recharge_half_price",
});
});
it("defaults nullable first recharge offer metadata", () => {
const response = PaymentPlansResponse.from({
isFirstRecharge: true,
firstRechargeOffer: {
enabled: null,
type: null,
discountPercent: null,
},
plans: [],
});
expect(response.firstRechargeOffer).toEqual({
enabled: false,
type: "",
discountPercent: 0,
});
});
});
describe("TipPaymentPlansResponse", () => {
it("keeps only fields used by the tip payment flow", () => {
const response = TipPaymentPlansResponse.fromJson({
plans: [
{
planId: "tip_coffee_usd_4_99",
planName: "Small Coffee",
orderType: "tip",
tipType: "coffee_small",
description: "Buy Elio a small coffee",
amountCents: 499,
currency: "USD",
autoRenew: false,
isFirstRechargeOffer: false,
firstRechargeDiscountPercent: 0,
},
],
});
expect(response.toJson()).toEqual({
plans: [
{
planId: "tip_coffee_usd_4_99",
planName: "Small Coffee",
amountCents: 499,
currency: "USD",
},
],
});
});
});
+34
View File
@@ -28,3 +28,37 @@ export const PaymentPlanSchema = z.object({
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);
}
}
@@ -18,3 +18,31 @@ export type CreatePaymentOrderRequestInput = z.input<
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);
}
}
@@ -55,3 +55,30 @@ export type CreatePaymentOrderResponseInput = z.input<
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);
}
}
@@ -19,3 +19,29 @@ export type PaymentOrderStatusResponseInput = z.input<
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);
}
}
@@ -10,7 +10,7 @@ import {
schemaOrNull,
stringOrEmpty,
} from "../../nullable-defaults";
import { PaymentPlanSchema } from "../payment_plan";
import { PaymentPlan, PaymentPlanSchema } from "../payment_plan";
export const FirstRechargeOfferSchema = z.object({
enabled: booleanOrFalse,
@@ -31,3 +31,37 @@ export type PaymentPlansResponseData = z.output<
typeof PaymentPlansResponseSchema
>;
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;
@@ -1,7 +1,7 @@
import { z } from "zod";
import { arrayOrEmpty } from "../../nullable-defaults";
import { TipPaymentPlanSchema } from "../tip_payment_plan";
import { TipPaymentPlan, TipPaymentPlanSchema } from "../tip_payment_plan";
export const TipPaymentPlansResponseSchema = z.object({
plans: arrayOrEmpty(TipPaymentPlanSchema),
@@ -13,3 +13,31 @@ export type TipPaymentPlansResponseInput = z.input<
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()),
};
}
}
@@ -9,3 +9,28 @@ export const TipPaymentPlanSchema = z.object({
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);
}
}
@@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";
import {
PrivateAlbumsResponse,
PrivateAlbumUnlockResponse,
UnlockPrivateAlbumRequest,
} from "@/data/schemas/private-room";
import { ApiPath } from "@/data/services/api";
const ALBUM_ID = "a1b2c3d4-0000-0000-0000-000000000000";
const COVER_URL =
"https://dbapi.banlv-ai.com/storage/v1/object/public/elio-schedules/01.jpg";
const lockedAlbum = {
albumId: ALBUM_ID,
momentId: `album:${ALBUM_ID}`,
characterId: "elio",
collectionKey: "manila_202607",
title: "Private Manila set",
content: null,
previewText: "Only for you.",
imageCount: 8,
mediaCount: 8,
images: [{ url: COVER_URL, type: "image", locked: true, index: 0 }],
locked: true,
unlocked: false,
unlockCost: 320,
requiredCredits: 320,
creditCostPerImage: 40,
currency: "credits",
canUnlockWithCredits: false,
publishedAt: "2026-07-13T00:00:00+00:00",
lockDetail: {
locked: true,
showContent: false,
showUpgrade: true,
reason: "private_album",
},
};
describe("Private Album schema models", () => {
it("keeps a locked album cover URL while preserving the lock state", () => {
const response = PrivateAlbumsResponse.fromJson({
items: [lockedAlbum],
creditBalance: 100,
nextCursor: null,
hasMore: false,
});
expect(response.items[0]?.locked).toBe(true);
expect(response.items[0]?.unlocked).toBe(false);
expect(response.items[0]?.images[0]?.url).toBe(COVER_URL);
expect(response.items[0]?.lockDetail).toEqual({ locked: true });
});
it("strips response fields that are not used by the frontend", () => {
const response = PrivateAlbumsResponse.fromJson({
items: [lockedAlbum],
creditBalance: 100,
packageOptions: [{ imageCount: 8, creditCost: 320 }],
});
const albumJson = response.items[0]?.toJson();
expect(albumJson).not.toHaveProperty("momentId");
expect(albumJson).not.toHaveProperty("characterId");
expect(albumJson).not.toHaveProperty("collectionKey");
expect(response.toJson()).not.toHaveProperty("packageOptions");
});
it("parses an unlock response with all album images", () => {
const response = PrivateAlbumUnlockResponse.fromJson({
albumId: ALBUM_ID,
locked: false,
unlocked: true,
reason: "ok",
unlockCost: 320,
creditBalance: 180,
images: Array.from({ length: 8 }, (_, index) => ({
url: `${COVER_URL}?image=${index}`,
locked: false,
index,
})),
creditsCharged: 320,
});
expect(response.unlocked).toBe(true);
expect(response.images).toHaveLength(8);
expect(response.creditBalance).toBe(180);
expect(response.toJson()).not.toHaveProperty("creditsCharged");
});
it("serializes the expected unlock cost", () => {
expect(
UnlockPrivateAlbumRequest.from({ expectedCost: 320 }).toJson(),
).toEqual({ expectedCost: 320 });
});
it("encodes the album id in the unlock path", () => {
expect(ApiPath.privateRoomAlbumUnlock("album:91")).toBe(
"/api/private-room/albums/album%3A91/unlock",
);
});
});
@@ -36,3 +36,35 @@ export const PrivateAlbumSchema = z.object({
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);
}
}
@@ -12,3 +12,19 @@ export type UnlockPrivateAlbumRequestInput = z.input<
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);
}
}
@@ -40,3 +40,35 @@ export type PrivateAlbumUnlockResponseInput = z.input<
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);
}
}
@@ -1,7 +1,7 @@
import { z } from "zod";
import { arrayOrEmpty, numberOrZero } from "../../nullable-defaults";
import { PrivateAlbumSchema } from "../private_album";
import { PrivateAlbum, PrivateAlbumSchema } from "../private_album";
export const PrivateAlbumsResponseSchema = z.object({
items: arrayOrEmpty(PrivateAlbumSchema),
@@ -14,3 +14,32 @@ export type PrivateAlbumsResponseInput = z.input<
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,
};
}
}
+53
View File
@@ -0,0 +1,53 @@
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
>;
}
@@ -0,0 +1,168 @@
import { describe, expect, it } from "vitest";
import { UserEntitlements } from "@/data/schemas/user";
import { User } from "@/data/schemas/user/user";
describe("User", () => {
it("parses the latest backend user payload", () => {
const user = User.fromJson({
id: "d7bb2b3d-e9c5-474c-85fe-f503048f30dd",
username: "guest_bbc90a38",
email: "device_bbc90a38b996187348f54cf2cb0b789e@guest.local",
platform: "web",
country: "Hong Kong",
countryCode: "HK",
intimacy: 0,
dolBalance: 0,
creditBalance: 0,
dailyFreeChatLimit: 30,
dailyFreeChatUsed: 1,
dailyFreeChatRemaining: 29,
dailyFreePrivateLimit: 2,
dailyFreePrivateUsed: 0,
dailyFreePrivateRemaining: 2,
dailyQuotas: {
chat: {
limit: 30,
used: 1,
remaining: 29,
},
privateMessage: {
limit: 2,
used: 0,
remaining: 2,
},
},
personalityTraits: {
caring: 0.5,
playful: 0.5,
serious: 0.5,
cheerful: 0.5,
romantic: 0.5,
},
preferredLanguage: "zh",
createdAt: "2026-06-12T08:08:27.378706+00:00",
lastMessageAt: "2026-06-24T11:00:23.156561+00:00",
});
expect(user.toJson()).toEqual({
id: "d7bb2b3d-e9c5-474c-85fe-f503048f30dd",
username: "guest_bbc90a38",
email: "device_bbc90a38b996187348f54cf2cb0b789e@guest.local",
platform: "web",
country: "Hong Kong",
countryCode: "HK",
fbAsid: "",
fbPsid: "",
intimacy: 0,
dolBalance: 0,
creditBalance: 0,
dailyFreeChatLimit: 30,
dailyFreeChatUsed: 1,
dailyFreeChatRemaining: 29,
dailyFreePrivateLimit: 2,
dailyFreePrivateUsed: 0,
dailyFreePrivateRemaining: 2,
personalityTraits: {
caring: 0.5,
playful: 0.5,
serious: 0.5,
cheerful: 0.5,
romantic: 0.5,
},
preferredLanguage: "zh",
createdAt: "2026-06-12T08:08:27.378706+00:00",
lastMessageAt: "2026-06-24T11:00:23.156561+00:00",
});
expect("dailyQuotas" in user.toJson()).toBe(false);
});
it("defaults nullable nested user fields through schema helpers", () => {
const user = User.fromJson({
id: "user-1",
username: "guest_user",
personalityTraits: null,
});
expect(user.personalityTraits.toJson()).toEqual({
caring: 0.5,
playful: 0.5,
serious: 0.5,
cheerful: 0.5,
romantic: 0.5,
});
});
it("parses the latest backend entitlements payload", () => {
const entitlements = UserEntitlements.fromJson({
userId: "user-1",
isGuest: false,
isVip: true,
vipExpiresAt: "2026-07-26T00:00:00+00:00",
creditBalance: 120,
dolBalance: 120,
policy: {
membershipState: "vip",
nonVipEntitlementsShared: true,
guestSameAsLoggedInNonVip: true,
refreshAfterPayment: "GET /api/user/entitlements",
},
costs: {
normal_message: 2,
private_message: 10,
voice_message: 20,
photo: 40,
voice_call_minute: 50,
private_album_10: 300,
private_album_20: 600,
},
quotas: {
normalChatFreeDaily: 30,
privateUnlockFreeDaily: 1,
voiceMessageFreeDaily: 0,
photoFreeDaily: 0,
},
historyUnlock: {
enabled: true,
order: "oldest_first",
chargeMode: "highest_cost_per_locked_message",
insufficientBalanceBehavior: "no_deduction",
costs: {
private_message: 10,
voice_message: 20,
photo: 40,
},
},
});
expect(entitlements.isVip).toBe(true);
expect(entitlements.creditBalance).toBe(120);
expect(entitlements.historyUnlock.costs.photo).toBe(40);
});
it("defaults nullable entitlement sections through schema helpers", () => {
const entitlements = UserEntitlements.fromJson({
userId: "user-1",
policy: null,
costs: null,
quotas: null,
historyUnlock: null,
});
expect(entitlements.policy.membershipState).toBe("");
expect(entitlements.costs.private_message).toBe(0);
expect(entitlements.quotas.normalChatFreeDaily).toBe(0);
expect(entitlements.historyUnlock).toMatchObject({
enabled: false,
order: "",
chargeMode: "",
insufficientBalanceBehavior: "",
costs: {
private_message: 0,
voice_message: 0,
photo: 0,
},
});
});
});
+23
View File
@@ -13,3 +13,26 @@ export const AvatarDataSchema = z.object({
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);
}
}
@@ -24,3 +24,29 @@ export const PersonalityTraitsSchema = z.object({
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);
}
}
+25
View File
@@ -15,3 +15,28 @@ export const RecentMemorySchema = z.object({
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);
}
}
+60 -1
View File
@@ -3,7 +3,11 @@
*
*/
import { z } from "zod";
import { PersonalityTraitsSchema, PERSONALITY_TRAITS_DEFAULTS } from "./personality_traits";
import {
PersonalityTraits,
PersonalityTraitsSchema,
PERSONALITY_TRAITS_DEFAULTS,
} from "./personality_traits";
import {
numberOrZero,
schemaOr,
@@ -37,3 +41,58 @@ export const UserSchema = z.object({
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;
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(),
});
}
}
@@ -118,3 +118,34 @@ export const UserEntitlementsSchema = z.object({
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);
}
}