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
+29 -40
View File
@@ -12,7 +12,7 @@ description: Sync this Next.js frontend with backend API documentation
当后端 API 新增、删除、字段结构调整、响应语义变化时,使用本 skill 更新:
- 网络层
- schema / 不可变数据
- schema / 不可变数据
- repository
- storage
- XState store
@@ -23,10 +23,10 @@ description: Sync this Next.js frontend with backend API documentation
## 当前项目结构
| 层级 | 路径 | 说明 |
| --- | --- | --- |
| -------------------- | --------------------------------------------------- | ----------------------------------------------- |
| API Path | `src/data/services/api/api_path.ts` | 统一维护接口路径 |
| API Service | `src/data/services/api/*_api.ts` | 调用 `httpClient` 并解析响应 |
| Schema Model | `src/data/schemas/<module>/*.ts` | Zod schema 与不可变数据类,负责解析和序列化 |
| Schema Model | `src/data/schemas/<module>/*.ts` | Zod schema 与不可变数据类,负责解析和归一化 |
| Repository Interface | `src/data/repositories/interfaces/i*_repository.ts` | 仓库接口 |
| Repository | `src/data/repositories/*_repository.ts` | 业务仓库实现 |
| Storage | `src/data/storage/*` | 本地持久化 |
@@ -84,13 +84,16 @@ git status --short
否则 schema 应尽量防御性解析,例如:
```ts
z.string().nullable().transform((v) => v ?? "").default("")
z.string()
.nullable()
.transform((v) => v ?? "")
.default("");
```
项目已有通用 helper
```ts
src/data/schemas/nullable-defaults.ts
src / data / schemas / nullable - defaults.ts;
```
### 3. 更新 API Path
@@ -98,7 +101,7 @@ src/data/schemas/nullable-defaults.ts
新增或修改:
```ts
src/data/services/api/api_path.ts
src / data / services / api / api_path.ts;
```
示例:
@@ -122,46 +125,32 @@ src/data/schemas/<module>/*.ts
- 输入类型用 `z.input<typeof Schema>`
- 对后端可能返回 `null` 的字段做防御性处理。
- 不要在 schema 中写 UI 逻辑。
- Schema 与对应不可变数据类必须定义在同一个文件中
- 数据类的 `declare readonly` 只声明业务代码直接访问的字段
- Schema 输出必须是纯数据,不要定义 class 或工厂对象
- 对象 Schema 使用 `.readonly()`;已知嵌套对象、数组和 Record 也必须逐层 readonly
- transform 应先完成归一化,再在最终输出应用 `.readonly()`
- 容器默认值必须经过 Schema 解析;使用 `prefault` 或在最外层应用 readonly,不能让 `.default()` 返回未冻结对象。
- 后端返回但前端既不读取、也不透传的字段,不要加入 Schema。
- 仅用于请求序列化、响应兼容或内部透传的字段保留在 Schema 中,不声明 class 属性
- 数据类统一使用 `from()``fromJson()``toJson()` `Object.freeze(this)`
- 没有嵌套转换或自定义方法时,使用 `src/data/schemas/schema_model.ts``createSchemaModel()`
- 仅用于请求序列化、响应兼容或内部透传的字段直接保留在 Schema 中。
- 统一使用 `XxxSchema.parse()` 构造请求与解析响应,不增加 `from()``fromJson()` `toJson()` 包装
- 不要再创建独立的数据传输对象层。
示例:
```ts
export const XxxResponseSchema = z.object({
export const XxxResponseSchema = z
.object({
id: z.string(),
tags: z
.array(z.string())
.default(() => [])
.readonly(),
count: z.number().default(0),
});
})
.readonly();
export type XxxResponseInput = z.input<typeof XxxResponseSchema>;
export type XxxResponseData = z.output<typeof XxxResponseSchema>;
export class XxxResponse {
declare readonly id: string;
private constructor(input: XxxResponseInput) {
const data = XxxResponseSchema.parse(input);
Object.assign(this, data);
Object.freeze(this);
}
static from(input: XxxResponseInput): XxxResponse {
return new XxxResponse(input);
}
static fromJson(json: unknown): XxxResponse {
return XxxResponse.from(json as XxxResponseInput);
}
toJson(): XxxResponseData {
return XxxResponseSchema.parse(this);
}
}
export type XxxResponse = XxxResponseData;
```
### 5. 更新 API Service
@@ -177,9 +166,9 @@ src/data/services/api/<module>_api.ts
```ts
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.xxx, {
method: "POST",
body: request.toJson(),
body: request,
});
return XxxResponse.fromJson(unwrap(env) as Record<string, unknown>);
return XxxResponseSchema.parse(unwrap(env));
```
注意:
@@ -207,7 +196,7 @@ async getXxx(): Promise<Result<XxxResponse>> {
Repository 可以做轻量编排,例如:
- request 数据类构造
- request schema 解析
- 多接口组合
- 本地 storage 同步
- 本地模型与接口模型转换
@@ -333,7 +322,7 @@ pnpm exec vitest run <related tests>
1. 文档确认 path / method / payload。
2. `api_path.ts` 新增路径。
3. 在同一文件新增 schema 与不可变数据类
3. 在同一文件新增 readonly schema 与输入/输出类型
4. API service 新增方法。
5. repository interface + implementation 新增方法。
6. 如需要,接入 store actor / event。
@@ -343,7 +332,7 @@ pnpm exec vitest run <related tests>
### 修改响应字段
1. 更新 schema。
2. 更新同文件数据类的公开字段和 `toJson`
2. 更新同文件输入/输出类型及相关 parse 调用
3. 更新 mapper/helper。
4. 更新 UI 或状态机依赖字段。
5. 更新 mock 和测试。
@@ -2,12 +2,12 @@ import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PaymentPlan } from "@/data/schemas/payment";
import { PaymentPlan, PaymentPlanSchema } from "@/data/schemas/payment";
import { behaviorAnalytics } from "@/lib/analytics";
import { usePaymentPlanAnalytics } from "../use-payment-plan-analytics";
const firstPlan = PaymentPlan.from({
const firstPlan = PaymentPlanSchema.parse({
planId: "plan-1",
planName: "Plan One",
orderType: "dol",
@@ -18,7 +18,7 @@ const firstPlan = PaymentPlan.from({
originalAmountCents: null,
currency: "USD",
});
const secondPlan = PaymentPlan.from({
const secondPlan = PaymentPlanSchema.parse({
planId: "plan-2",
planName: "Plan Two",
orderType: "dol",
@@ -43,8 +43,9 @@ describe("usePaymentPlanAnalytics", () => {
let root: Root;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
.IS_REACT_ACT_ENVIRONMENT = true;
(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
@@ -1,16 +1,18 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { PrivateAlbum } from "@/data/schemas/private-room";
import {
PrivateAlbumSchema,
type PrivateAlbum,
type PrivateAlbumInput,
} from "@/data/schemas/private-room";
import { PrivateAlbumCard } from "../private-album-card";
const COVER_URL = "/images/private-room/banner.png";
function makeAlbum(
overrides: Partial<Parameters<typeof PrivateAlbum.from>[0]> = {},
): PrivateAlbum {
return PrivateAlbum.from({
function makeAlbum(overrides: Partial<PrivateAlbumInput> = {}): PrivateAlbum {
return PrivateAlbumSchema.parse({
albumId: "album-1",
title: "Private morning",
content: "A quiet morning by the water.",
@@ -61,9 +63,7 @@ describe("PrivateAlbumCard", () => {
expect(html).toContain("8 photos");
expect(html).toContain("A quiet morning by the water.");
expect(html).toContain('aria-label="Open 8 private album photos"');
expect(html).toContain(
'data-analytics-key="private_album.open_gallery"',
);
expect(html).toContain('data-analytics-key="private_album.open_gallery"');
});
it("uses the backend first image as the locked cover", () => {
@@ -1,6 +1,10 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { PaymentPlan } from "@/data/schemas/payment";
import {
PaymentPlanSchema,
type PaymentPlan,
type PaymentPlanInput,
} from "@/data/schemas/payment";
import {
canChooseSubscriptionPayChannel,
@@ -13,10 +17,8 @@ import {
toVipOfferPlanViews,
} from "../subscription-screen.helpers";
function makePlan(
overrides: Partial<Parameters<typeof PaymentPlan.from>[0]>,
): PaymentPlan {
return PaymentPlan.from({
function makePlan(overrides: Partial<PaymentPlanInput>): PaymentPlan {
return PaymentPlanSchema.parse({
planId: "plan",
planName: "Plan",
orderType: "vip_monthly",
@@ -138,7 +140,9 @@ describe("subscription screen helpers", () => {
originalPrice: "",
},
],
coinPlans: [{ id: "coin_1000", coins: 1000, price: "9.90", currency: "US$" }],
coinPlans: [
{ id: "coin_1000", coins: 1000, price: "9.90", currency: "US$" },
],
});
expect(selected?.id).toBe("coin_1000");
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { PaymentPlan } from "@/data/schemas/payment";
import { PaymentPlan, PaymentPlanSchema } from "@/data/schemas/payment";
import type { PaymentPlanInput } from "@/data/schemas/payment/payment_plan";
import {
@@ -10,7 +10,7 @@ import {
} from "../tip-screen.helpers";
function makePlan(input: Partial<PaymentPlanInput>): PaymentPlan {
return PaymentPlan.from({
return PaymentPlanSchema.parse({
planId: "coins_100",
planName: "Coins",
orderType: "dol",
@@ -1,11 +1,11 @@
import { describe, expect, it, vi } from "vitest";
import { AuthRepository } from "@/data/repositories/auth_repository";
import {
FacebookIdentityResponse,
FacebookPsidLoginResponse,
FacebookIdentityResponseSchema,
FacebookPsidLoginResponseSchema,
LoginStatus,
} from "@/data/schemas/auth";
import { AuthRepository } from "@/data/repositories/auth_repository";
import type { AuthApi } from "@/data/services/api";
import type { IAuthStorage } from "@/data/storage/auth";
import type { IUserStorage } from "@/data/storage/user";
@@ -36,7 +36,7 @@ function createRepository(overrides: Partial<AuthApi>) {
describe("AuthRepository facebook identity", () => {
it("binds facebook identity and stores returned ids", async () => {
const bindFacebookIdentity = vi.fn(async () =>
FacebookIdentityResponse.fromJson({
FacebookIdentityResponseSchema.parse({
fbAsid: "asid-1",
fbPsid: "psid-1",
facebookBinding: { conflicts: [], bound: [] },
@@ -57,7 +57,7 @@ describe("AuthRepository facebook identity", () => {
it("saves real login data from complete psid login responses", async () => {
const facebookPsidLogin = vi.fn(async () =>
FacebookPsidLoginResponse.fromJson({
FacebookPsidLoginResponseSchema.parse({
token: "login-token",
refreshToken: "refresh-token",
matchedBy: "psid",
@@ -88,7 +88,7 @@ describe("AuthRepository facebook identity", () => {
it("saves guest data from incomplete psid login responses", async () => {
const facebookPsidLogin = vi.fn(async () =>
FacebookPsidLoginResponse.fromJson({
FacebookPsidLoginResponseSchema.parse({
token: "guest-token",
matchedBy: "psid_incomplete",
fbPsid: "psid-1",
@@ -116,7 +116,7 @@ describe("AuthRepository facebook identity", () => {
it("does not report guest psid login success when session persistence fails", async () => {
const storageError = new Error("guest token write failed");
const facebookPsidLogin = vi.fn(async () =>
FacebookPsidLoginResponse.fromJson({
FacebookPsidLoginResponseSchema.parse({
token: "guest-token",
matchedBy: "psid_incomplete",
fbPsid: "psid-1",
@@ -1,22 +1,24 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
GuestLoginResponse,
LoginResponse,
LoginStatus,
RefreshTokenResponse,
} from "@/data/schemas/auth";
import { AuthRepository } from "@/data/repositories/auth_repository";
import {
GuestLoginResponseSchema,
LoginResponseSchema,
LoginStatus,
RefreshTokenResponseSchema,
} from "@/data/schemas/auth";
import type { AuthApi } from "@/data/services/api";
import type { IAuthStorage } from "@/data/storage/auth";
import type { IUserStorage } from "@/data/storage/user";
import { Result } from "@/utils/result";
function createRepository(input: {
function createRepository(
input: {
api?: Partial<AuthApi>;
storage?: Partial<IAuthStorage>;
userStorage?: Partial<IUserStorage>;
} = {}) {
} = {},
) {
const storage = {
getRefreshToken: vi.fn(async () => Result.ok("refresh-token")),
setLoginToken: vi.fn(async () => Result.ok(undefined)),
@@ -52,7 +54,7 @@ afterEach(() => {
});
describe("AuthRepository session Result handling", () => {
const loginResponse = LoginResponse.from({
const loginResponse = LoginResponseSchema.parse({
token: "login-token",
refreshToken: "refresh-token",
user: { id: "user-1", username: "Elio" },
@@ -112,7 +114,7 @@ describe("AuthRepository session Result handling", () => {
it("does not report refresh success when a new token cannot be stored", async () => {
const storageError = new Error("login token write failed");
const response = RefreshTokenResponse.from({
const response = RefreshTokenResponseSchema.parse({
token: "new-login-token",
refreshToken: "new-refresh-token",
userId: "user-1",
@@ -135,7 +137,7 @@ describe("AuthRepository session Result handling", () => {
it("does not report guest login success when session persistence fails", async () => {
const storageError = new Error("guest token write failed");
const response = GuestLoginResponse.from({
const response = GuestLoginResponseSchema.parse({
token: "guest-token",
deviceId: "device-1",
userId: "guest-1",
@@ -1,13 +1,13 @@
import { describe, expect, it, vi } from "vitest";
import { ChatMessage } from "@/data/schemas/chat";
import { ChatMessage, ChatMessageSchema } from "@/data/schemas/chat";
import { LocalMessage } from "@/data/storage/chat";
import { Result } from "@/utils/result";
import { ChatLocalMessageStore } from "../chat_local_message_store";
function makeMessage(id: string, content: string): ChatMessage {
return ChatMessage.from({
return ChatMessageSchema.parse({
id,
role: "assistant",
type: "text",
@@ -53,9 +53,8 @@ describe("ChatLocalMessageStore identity isolation", () => {
it("reads, replaces, and clears only the current identity", async () => {
const memory = createMemoryStorage();
let identity = "user:account-a";
const store = new ChatLocalMessageStore(
memory.storage,
async () => Result.ok(identity),
const store = new ChatLocalMessageStore(memory.storage, async () =>
Result.ok(identity),
);
await store.saveMessages([makeMessage("a-1", "account A")]);
@@ -1,7 +1,14 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import { ChatMessage, ChatSendResponse } from "@/data/schemas/chat";
import {
ChatMessageSchema,
ChatSendResponseSchema,
type ChatMessage,
type ChatMessageInput,
type ChatSendResponse,
type ChatSendResponseInput,
} from "@/data/schemas/chat";
import {
buildChatMediaCacheKey,
@@ -11,10 +18,8 @@ import {
uniqueMediaTargets,
} from "../chat_media_cache_helpers";
function makeMessage(
overrides: Partial<Parameters<typeof ChatMessage.from>[0]> = {},
): ChatMessage {
return ChatMessage.from({
function makeMessage(overrides: Partial<ChatMessageInput> = {}): ChatMessage {
return ChatMessageSchema.parse({
id: "msg-1",
role: "assistant",
type: "image",
@@ -31,9 +36,9 @@ function makeMessage(
}
function makeResponse(
overrides: Partial<Parameters<typeof ChatSendResponse.from>[0]> = {},
overrides: Partial<ChatSendResponseInput> = {},
): ChatSendResponse {
return ChatSendResponse.from({
return ChatSendResponseSchema.parse({
reply: "",
messageId: "msg-1",
isGuest: false,
@@ -1,14 +1,14 @@
import { describe, expect, it, vi } from "vitest";
import { TipPaymentPlansResponse } from "@/data/schemas/payment";
import { PaymentRepository } from "@/data/repositories/payment_repository";
import { TipPaymentPlansResponseSchema } from "@/data/schemas/payment";
import type { PaymentApi } from "@/data/services/api";
import { Result } from "@/utils/result";
describe("PaymentRepository", () => {
it("adapts tip plans without caching them as subscription plans", async () => {
const getTipPlans = vi.fn().mockResolvedValue(
TipPaymentPlansResponse.from({
TipPaymentPlansResponseSchema.parse({
plans: [
{
planId: "tip_coffee_usd_9_99",
+31 -30
View File
@@ -1,30 +1,31 @@
import { ExceptionHandler } from "@/core/errors";
import { ApiError, AuthApi, authApi, ErrorCode } from "@/data/services/api";
import type { IAuthRepository } from "@/data/repositories/interfaces";
import {
FacebookIdentityRequest,
FacebookPsidLoginRequest,
FacebookLoginRequest,
FbIdLoginRequest,
GoogleLoginRequest,
GuestLoginRequest,
FacebookIdentityRequestSchema,
FacebookLoginRequestSchema,
FacebookPsidLoginRequestSchema,
FbIdLoginRequestSchema,
GoogleLoginRequestSchema,
GuestLoginRequestSchema,
GuestLoginResponse,
LoginRequest,
LoginRequestSchema,
LoginResponse,
LoginResponseSchema,
LoginStatus,
type LoginStatus as LoginStatusT,
RefreshTokenRequest,
RefreshTokenRequestSchema,
RefreshTokenResponse,
RegisterRequest,
RegisterRequestSchema,
type LoginStatus as LoginStatusT,
} from "@/data/schemas/auth";
import { User } from "@/data/schemas/user";
import { AppEnvUtil } from "@/utils/app-env";
import { PlatformDetector } from "@/utils/platform-detect";
import { Result } from "@/utils/result";
import { Logger } from "@/utils/logger";
import { deviceIdentifier } from "@/utils/device_identifier";
import type { IAuthRepository } from "@/data/repositories/interfaces";
import { ApiError, AuthApi, authApi, ErrorCode } from "@/data/services/api";
import { AuthStorage, type IAuthStorage } from "@/data/storage/auth";
import { UserStorage, type IUserStorage } from "@/data/storage/user";
import { AppEnvUtil } from "@/utils/app-env";
import { deviceIdentifier } from "@/utils/device_identifier";
import { Logger } from "@/utils/logger";
import { PlatformDetector } from "@/utils/platform-detect";
import { Result } from "@/utils/result";
import { createLazySingleton } from "./lazy_singleton";
const log = new Logger("DataRepositoriesAuthRepository");
@@ -52,7 +53,7 @@ export class AuthRepository implements IAuthRepository {
}): Promise<Result<void>> {
return Result.wrap(async () => {
await this.api.register(
RegisterRequest.from(
RegisterRequestSchema.parse(
withTestAccountFlag({
username: input.username,
email: input.email,
@@ -76,7 +77,7 @@ export class AuthRepository implements IAuthRepository {
return Result.wrap(async () => {
const email = input.email?.trim() ?? "";
const response = await this.api.emailLogin(
LoginRequest.from(
LoginRequestSchema.parse(
withTestAccountFlag({
email,
username: input.username,
@@ -152,7 +153,7 @@ export class AuthRepository implements IAuthRepository {
});
return Result.wrap(async () => {
const response = await this.api.guestLogin(
GuestLoginRequest.from(withTestAccountFlag({ deviceId })),
GuestLoginRequestSchema.parse(withTestAccountFlag({ deviceId })),
);
log.debug("[AuthRepository.guestLogin] API call SUCCESS (Zod parsed)", {
hasToken: !!response.token,
@@ -190,7 +191,7 @@ export class AuthRepository implements IAuthRepository {
}): Promise<Result<LoginResponse>> {
return this._socialLogin(LoginStatus.Google, () =>
this.api.googleLogin(
GoogleLoginRequest.from(
GoogleLoginRequestSchema.parse(
withTestAccountFlag({
idToken: input.idToken,
platform: getAuthPlatform(),
@@ -210,7 +211,7 @@ export class AuthRepository implements IAuthRepository {
}): Promise<Result<LoginResponse>> {
return this._socialLogin(LoginStatus.Facebook, () =>
this.api.facebookLogin(
FacebookLoginRequest.from(
FacebookLoginRequestSchema.parse(
withTestAccountFlag({
accessToken: input.accessToken,
platform: getAuthPlatform(),
@@ -230,7 +231,7 @@ export class AuthRepository implements IAuthRepository {
}): Promise<Result<LoginResponse>> {
return this._socialLogin(LoginStatus.Facebook, () =>
this.api.facebookIdLogin(
FbIdLoginRequest.from(
FbIdLoginRequestSchema.parse(
withTestAccountFlag({
fbId: input.asid,
avatarUrl: input.avatarUrl ?? "",
@@ -250,12 +251,12 @@ export class AuthRepository implements IAuthRepository {
return Result.wrap(async () => {
const response = await this.api.bindFacebookIdentity(
FacebookIdentityRequest.from({
FacebookIdentityRequestSchema.parse({
asid: input.asid ?? "",
psid: input.psid ?? "",
}),
);
const data = response.toJson();
const data = response;
await this._saveFacebookIdentity({
asid: data.fbAsid,
psid: data.fbPsid,
@@ -271,13 +272,13 @@ export class AuthRepository implements IAuthRepository {
}): Promise<Result<LoginStatusT>> {
return Result.wrap(async () => {
const response = await this.api.facebookPsidLogin(
FacebookPsidLoginRequest.from({
FacebookPsidLoginRequestSchema.parse({
psid: input.psid,
deviceId: input.deviceId ?? "",
bindToGuest: input.bindToGuest ?? true,
}),
);
const data = response.toJson();
const data = response;
await this._saveFacebookIdentity({
asid: data.fbAsid,
psid: data.fbPsid,
@@ -303,7 +304,7 @@ export class AuthRepository implements IAuthRepository {
}
await this._saveLoginData(
LoginResponse.from({
LoginResponseSchema.parse({
token: data.token,
refreshToken: data.refreshToken,
user: data.user ?? { id: data.userId },
@@ -330,7 +331,7 @@ export class AuthRepository implements IAuthRepository {
);
}
const response = await this.api.refreshToken(
RefreshTokenRequest.from({ refreshToken: existing.data }),
RefreshTokenRequestSchema.parse({ refreshToken: existing.data }),
);
const tokenResult = await this.storage.setLoginToken(response.token);
if (Result.isErr(tokenResult)) throw tokenResult.error;
@@ -404,7 +405,7 @@ export class AuthRepository implements IAuthRepository {
private async _cacheUser(user: User): Promise<void> {
try {
const userResult = await this.userStorage.setUser(user.toJson());
const userResult = await this.userStorage.setUser(user);
if (Result.isErr(userResult)) {
log.warn("[AuthRepository] setUser failed", userResult.error);
}
@@ -1,5 +1,5 @@
import { ChatMessage } from "@/data/schemas/chat";
import type { UnlockedPrivateMessageLocalPatch } from "@/data/repositories/interfaces";
import { ChatMessage, ChatMessageSchema } from "@/data/schemas/chat";
import { LocalChatStorage, LocalMessage } from "@/data/storage/chat";
import { Result } from "@/utils/result";
@@ -20,8 +20,7 @@ type LocalMessageStorage = Pick<
export class ChatLocalMessageStore {
constructor(
private readonly localStorage: LocalMessageStorage,
private readonly resolveIdentity: ChatCacheIdentityResolver =
resolveChatCacheOwnerKey,
private readonly resolveIdentity: ChatCacheIdentityResolver = resolveChatCacheOwnerKey,
) {}
async markMessageUnlocked(
@@ -40,8 +39,8 @@ export class ChatLocalMessageStore {
const updatedMessages = localResult.data.map((message) => {
if (!shouldMarkMessageUnlocked(message, messageId)) return message;
changed = true;
return ChatMessage.from({
...message.toJson(),
return ChatMessageSchema.parse({
...message,
content: normalizeUnlockedContent(patch.content, message.content),
audioUrl: normalizeUnlockedAudioUrl(patch.audioUrl, message.audioUrl),
image: patch.image ?? message.image,
@@ -85,7 +84,9 @@ export class ChatLocalMessageStore {
});
}
async getMessages(identity?: string): Promise<Result<ChatMessage[]>> {
async getMessages(
identity?: string,
): Promise<Result<readonly ChatMessage[]>> {
return Result.wrap(async () => {
const ownerKey = await this._requireIdentity(identity);
const result = await this._getMessages(ownerKey);
@@ -128,12 +129,10 @@ export class ChatLocalMessageStore {
private async _getMessages(
identity: string,
): Promise<Result<ChatMessage[]>> {
): Promise<Result<readonly ChatMessage[]>> {
const result = await this.localStorage.getAllMessagesBySession(identity);
if (Result.isErr(result)) return result;
return Result.ok(
result.data.map((message) => this._localToChat(message)),
);
return Result.ok(result.data.map((message) => this._localToChat(message)));
}
private _saveMessages(
@@ -161,7 +160,7 @@ export class ChatLocalMessageStore {
}
private _localToChat(local: LocalMessage): ChatMessage {
return ChatMessage.from({
return ChatMessageSchema.parse({
id: local.id,
role: local.role,
type: local.type,
@@ -1,14 +1,14 @@
import type { UnlockPrivateMessageInput } from "@/data/repositories/interfaces";
import {
ChatHistoryResponse,
ChatSendResponse,
SendMessageRequest,
UnlockHistoryRequest,
SendMessageRequestSchema,
UnlockHistoryRequestSchema,
UnlockHistoryResponse,
UnlockPrivateRequest,
UnlockPrivateRequestSchema,
UnlockPrivateResponse,
} from "@/data/schemas/chat";
import type { ChatApi } from "@/data/services/api";
import type { UnlockPrivateMessageInput } from "@/data/repositories/interfaces";
import { Result } from "@/utils/result";
export class ChatRemoteDataSource {
@@ -20,7 +20,7 @@ export class ChatRemoteDataSource {
options?: { image?: string; useWebSocket?: boolean },
): Promise<Result<ChatSendResponse>> {
return Result.wrap(async () => {
const request = SendMessageRequest.from({
const request = SendMessageRequestSchema.parse({
characterId,
message,
image: options?.image ?? "",
@@ -42,7 +42,7 @@ export class ChatRemoteDataSource {
input: UnlockPrivateMessageInput,
): Promise<Result<UnlockPrivateResponse>> {
return Result.wrap(() =>
this.api.unlockPrivateMessage(UnlockPrivateRequest.from(input)),
this.api.unlockPrivateMessage(UnlockPrivateRequestSchema.parse(input)),
);
}
@@ -50,7 +50,7 @@ export class ChatRemoteDataSource {
characterId: string,
): Promise<Result<UnlockHistoryResponse>> {
return Result.wrap(() =>
this.api.unlockHistory(UnlockHistoryRequest.from({ characterId })),
this.api.unlockHistory(UnlockHistoryRequestSchema.parse({ characterId })),
);
}
}
+11 -9
View File
@@ -1,3 +1,10 @@
import type {
CacheRemoteChatMediaInput,
ChatMediaLookupInput,
IChatRepository,
UnlockPrivateMessageInput,
UnlockedPrivateMessageLocalPatch,
} from "@/data/repositories/interfaces";
import type {
ChatHistoryResponse,
ChatMessage,
@@ -11,13 +18,6 @@ import {
LocalChatStorage,
type LocalChatMediaRow,
} from "@/data/storage/chat";
import type {
CacheRemoteChatMediaInput,
ChatMediaLookupInput,
IChatRepository,
UnlockPrivateMessageInput,
UnlockedPrivateMessageLocalPatch,
} from "@/data/repositories/interfaces";
import type { Result } from "@/utils/result";
import { ChatLocalMessageStore } from "./chat_local_message_store";
@@ -60,7 +60,9 @@ export class ChatRepository implements IChatRepository {
}
/** 一键解锁历史锁定消息。 */
async unlockHistory(characterId: string): Promise<Result<UnlockHistoryResponse>> {
async unlockHistory(
characterId: string,
): Promise<Result<UnlockHistoryResponse>> {
return this.remote.unlockHistory(characterId);
}
@@ -101,7 +103,7 @@ export class ChatRepository implements IChatRepository {
/** 读取所有本地消息,按 dbId 升序。 */
async getLocalMessages(
cacheIdentity?: string,
): Promise<Result<ChatMessage[]>> {
): Promise<Result<readonly ChatMessage[]>> {
return this.localMessages.getMessages(cacheIdentity);
}
@@ -4,18 +4,19 @@
* 聚合聊天远程操作、身份隔离的本地历史,以及媒体缓存能力。
*/
import type { Result } from "@/utils/result";
import type {
ChatHistoryResponse,
ChatImageData,
ChatLockDetailData,
ChatLockType,
ChatMediaKind,
ChatMessage,
ChatSendResponse,
UnlockHistoryResponse,
UnlockPrivateResponse,
} from "@/data/schemas/chat";
import type { ChatLockDetailData } from "@/data/schemas/chat";
import type { ChatImageData, ChatLockType } from "@/data/schemas/chat";
import type { LocalChatMediaRow } from "@/data/storage/chat";
import type { Result } from "@/utils/result";
export interface ChatMediaLookupInput {
characterId: string;
@@ -84,7 +85,9 @@ export interface IChatRepository {
): Promise<Result<void>>;
/** 读取所有本地消息,按 dbId 升序。 */
getLocalMessages(cacheIdentity?: string): Promise<Result<ChatMessage[]>>;
getLocalMessages(
cacheIdentity?: string,
): Promise<Result<readonly ChatMessage[]>>;
/** 清空本地消息。 */
clearLocalMessages(cacheIdentity?: string): Promise<Result<void>>;
+5 -5
View File
@@ -3,10 +3,10 @@
*
* 指标/埋点仓库:纯远程 fire-and-forget 上报,无本地状态。
*/
import { MetricsApi, metricsApi } from "@/data/services/api";
import { AppEvent, PwaEvent } from "@/data/schemas/metrics";
import { Result } from "@/utils/result";
import type { IMetricsRepository } from "@/data/repositories/interfaces";
import { AppEventSchema, PwaEventSchema } from "@/data/schemas/metrics";
import { MetricsApi, metricsApi } from "@/data/services/api";
import { Result } from "@/utils/result";
import { createLazySingleton } from "./lazy_singleton";
export class MetricsRepository implements IMetricsRepository {
@@ -24,7 +24,7 @@ export class MetricsRepository implements IMetricsRepository {
}): Promise<Result<void>> {
return Result.wrap(async () => {
await this.api.reportPwaEvent(
PwaEvent.from({
PwaEventSchema.parse({
deviceId: input.deviceId,
deviceType: input.deviceType,
timestamp: Math.floor(Date.now() / 1000),
@@ -45,7 +45,7 @@ export class MetricsRepository implements IMetricsRepository {
}): Promise<Result<void>> {
return Result.wrap(async () => {
await this.api.reportUserInfo(
AppEvent.from({
AppEventSchema.parse({
userId: input.userId,
browser: input.browser,
userAgent: input.userAgent,
+8 -8
View File
@@ -3,16 +3,17 @@
*
* 支付 / 付费墙相关远程调用。
*/
import type { IPaymentRepository } from "@/data/repositories/interfaces";
import {
CreatePaymentOrderRequest,
CreatePaymentOrderRequestSchema,
CreatePaymentOrderResponse,
PayChannel,
PaymentOrderStatusResponse,
PaymentPlansResponse,
PaymentPlansResponseSchema,
} from "@/data/schemas/payment";
import { PaymentApi, paymentApi } from "@/data/services/api";
import { PaymentPlansStorage } from "@/data/storage/payment";
import type { IPaymentRepository } from "@/data/repositories/interfaces";
import { Result } from "@/utils/result";
import { createLazySingleton } from "./lazy_singleton";
@@ -23,7 +24,7 @@ export class PaymentRepository implements IPaymentRepository {
async getPlans(): Promise<Result<PaymentPlansResponse>> {
return Result.wrap(async () => {
const response = await this.api.getPlans();
await PaymentPlansStorage.setPlans(response.toJson());
await PaymentPlansStorage.setPlans(response);
return response;
});
}
@@ -33,7 +34,7 @@ export class PaymentRepository implements IPaymentRepository {
return Result.wrap(async () => {
const result = await PaymentPlansStorage.getPlans();
if (Result.isErr(result)) throw result.error;
return result.data ? PaymentPlansResponse.from(result.data) : null;
return result.data ? PaymentPlansResponseSchema.parse(result.data) : null;
});
}
@@ -41,9 +42,9 @@ export class PaymentRepository implements IPaymentRepository {
async getTipPlans(): Promise<Result<PaymentPlansResponse>> {
return Result.wrap(async () => {
const response = await this.api.getTipPlans();
return PaymentPlansResponse.from({
return PaymentPlansResponseSchema.parse({
plans: response.plans.map((plan) => ({
...plan.toJson(),
...plan,
orderType: "tip",
vipDays: null,
dolAmount: null,
@@ -72,7 +73,7 @@ export class PaymentRepository implements IPaymentRepository {
payChannel: PayChannel,
autoRenew: boolean,
): Promise<Result<CreatePaymentOrderResponse>> {
const request = CreatePaymentOrderRequest.from({
const request = CreatePaymentOrderRequestSchema.parse({
planId,
payChannel,
autoRenew,
@@ -86,7 +87,6 @@ export class PaymentRepository implements IPaymentRepository {
): Promise<Result<PaymentOrderStatusResponse>> {
return Result.wrap(() => this.api.getOrderStatus(orderId));
}
}
/** 全局懒单例。 */
@@ -1,16 +1,16 @@
import type {
GetPrivateAlbumsInput,
IPrivateRoomRepository,
} from "@/data/repositories/interfaces";
import {
UnlockPrivateAlbumRequest,
type PrivateAlbumsResponse,
type PrivateAlbumUnlockResponse,
UnlockPrivateAlbumRequestSchema,
} from "@/data/schemas/private-room";
import {
PrivateRoomApi,
privateRoomApi,
} from "@/data/services/api/private_room_api";
import type {
GetPrivateAlbumsInput,
IPrivateRoomRepository,
} from "@/data/repositories/interfaces";
import { Result } from "@/utils/result";
import { createLazySingleton } from "./lazy_singleton";
@@ -31,7 +31,7 @@ export class PrivateRoomRepository implements IPrivateRoomRepository {
return Result.wrap(() =>
this.api.unlockAlbum(
albumId,
UnlockPrivateAlbumRequest.from({ expectedCost }),
UnlockPrivateAlbumRequestSchema.parse({ expectedCost }),
),
);
}
@@ -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", () => {
+5 -26
View File
@@ -8,37 +8,16 @@ import { z } from "zod";
import { stringOrNull } from "../nullable-defaults";
export const FacebookUserDataSchema = z.object({
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({
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;
@@ -6,38 +6,21 @@ import { z } from "zod";
import { booleanOrFalse, stringOrEmpty } from "../../nullable-defaults";
export const FacebookLoginRequestSchema = z.object({
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({
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;
@@ -6,36 +6,16 @@ import { z } from "zod";
import { booleanOrFalse, stringOrEmpty } from "../../nullable-defaults";
export const FbIdLoginRequestSchema = z.object({
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;
@@ -6,38 +6,17 @@ import { z } from "zod";
import { booleanOrFalse, stringOrEmpty } from "../../nullable-defaults";
export const GoogleLoginRequestSchema = z.object({
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;
@@ -6,33 +6,14 @@ import { z } from "zod";
import { booleanOrFalse } from "../../nullable-defaults";
export const GuestLoginRequestSchema = z.object({
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;
+5 -28
View File
@@ -6,7 +6,8 @@ import { z } from "zod";
import { booleanOrFalse, stringOrEmpty } from "../../nullable-defaults";
export const LoginRequestSchema = z.object({
export const LoginRequestSchema = z
.object({
email: stringOrEmpty,
username: stringOrEmpty,
password: z.string(),
@@ -14,34 +15,10 @@ export const LoginRequestSchema = z.object({
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;
@@ -4,23 +4,16 @@
*/
import { z } from "zod";
import {
createSchemaModel,
type SchemaModel,
} from "../../schema_model";
export const RefreshTokenRequestSchema = z.object({
export const RefreshTokenRequestSchema = z
.object({
refreshToken: z.string(),
});
})
.readonly();
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;
@@ -6,41 +6,18 @@ import { z } from "zod";
import { booleanOrFalse, stringOrEmpty } from "../../nullable-defaults";
export const RegisterRequestSchema = z.object({
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({
export const FacebookIdentityBindingSchema = z
.object({
conflicts: arrayOrEmpty(z.unknown()),
bound: arrayOrEmpty(z.unknown()),
});
})
.readonly();
export const FacebookIdentityResponseSchema = z.object({
export const FacebookIdentityResponseSchema = z
.object({
fbAsid: stringOrEmpty,
fbPsid: stringOrEmpty,
facebookBinding: FacebookIdentityBindingSchema.default({
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,7 +10,8 @@ import {
} from "../../nullable-defaults";
import { UserSchema } from "../../user/user";
export const FacebookPsidLoginResponseSchema = z.object({
export const FacebookPsidLoginResponseSchema = z
.object({
token: z.string(),
refreshToken: stringOrEmpty,
matchedBy: stringOrEmpty,
@@ -20,7 +21,8 @@ export const FacebookPsidLoginResponseSchema = z.object({
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;
@@ -4,45 +4,21 @@
*/
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({
export const GuestLoginResponseSchema = z
.object({
token: z.string(),
deviceId: z.string(),
userId: z.string(),
isDeviceUser: booleanOrTrue,
expiresIn: numberOr(2592000),
user: schemaOrNull(UserSchema),
});
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;
@@ -5,40 +5,17 @@
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({
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;
@@ -4,25 +4,14 @@
*/
import { z } from "zod";
import {
createSchemaModel,
type SchemaModel,
} from "../../schema_model";
import { booleanOrTrue } from "../../nullable-defaults";
export const LogoutResponseSchema = z.object({
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;
@@ -4,34 +4,19 @@
*/
import { z } from "zod";
export const RefreshTokenResponseSchema = z.object({
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",
+5 -25
View File
@@ -1,37 +1,17 @@
import { z } from "zod";
export const CharacterSchema = z.object({
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");
});
});
+4 -35
View File
@@ -5,12 +5,7 @@
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>;
@@ -10,7 +10,8 @@ import {
stringOrEmpty,
} from "../../nullable-defaults";
export const SendMessageRequestSchema = z.object({
export const SendMessageRequestSchema = z
.object({
characterId: z.string().min(1),
message: stringOrEmpty,
image: stringOrEmpty,
@@ -21,37 +22,10 @@ export const SendMessageRequestSchema = z.object({
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({
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;
@@ -9,9 +9,10 @@ import {
booleanOrFalse,
numberOrZero,
} from "../../nullable-defaults";
import { ChatMessage, ChatMessageSchema } from "../chat_message";
import { ChatMessageSchema } from "../chat_message";
export const ChatHistoryResponseSchema = z.object({
export const ChatHistoryResponseSchema = z
.object({
messages: arrayOrEmpty(ChatMessageSchema),
total: numberOrZero,
limit: numberOrZero,
@@ -20,34 +21,14 @@ export const ChatHistoryResponseSchema = z.object({
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;
@@ -10,14 +10,10 @@ 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({
export const ChatSendResponseSchema = z
.object({
reply: stringOrEmpty,
audioUrl: stringOrEmpty,
messageId: stringOrEmpty,
@@ -31,39 +27,10 @@ export const ChatSendResponseSchema = z.object({
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,7 +18,8 @@ export const UnlockHistoryReasonSchema = z.enum([
"no_locked_messages",
]);
export const UnlockHistoryResponseSchema = z.object({
export const UnlockHistoryResponseSchema = z
.object({
unlocked: booleanOrFalse,
reason: UnlockHistoryReasonSchema,
totalLocked: numberOrZero,
@@ -32,7 +33,8 @@ export const UnlockHistoryResponseSchema = z.object({
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,15 +6,16 @@ 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({
export const UnlockPrivateResponseSchema = z
.object({
unlocked: booleanOrFalse,
content: stringOrEmpty,
messageId: stringOrEmpty,
@@ -27,7 +28,8 @@ export const UnlockPrivateResponseSchema = z.object({
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({
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;
+5 -14
View File
@@ -4,25 +4,16 @@
*/
import { z } from "zod";
import {
createSchemaModel,
type SchemaModel,
} from "../../schema_model";
import { stringOrEmpty } from "../../nullable-defaults";
export const AppEventSchema = z.object({
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;
+5 -19
View File
@@ -4,36 +4,22 @@
*/
import { z } from "zod";
import {
createSchemaModel,
type SchemaModel,
} from "../../schema_model";
import {
booleanOrFalse,
numberOrZero,
stringOrEmpty,
} from "../../nullable-defaults";
export const PwaEventSchema = z.object({
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",
+5 -35
View File
@@ -9,7 +9,8 @@ import {
stringOrNull,
} from "../nullable-defaults";
export const PaymentPlanSchema = z.object({
export const PaymentPlanSchema = z
.object({
planId: z.string(),
planName: z.string(),
orderType: z.string(),
@@ -24,41 +25,10 @@ export const PaymentPlanSchema = z.object({
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({
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,9 +19,10 @@ const paymentUrlKeys = [
"url",
] as const;
export const CreatePaymentOrderResponseSchema = z.object({
export const CreatePaymentOrderResponseSchema = z
.object({
orderId: z.string(),
payParams: z.record(z.string(), z.unknown()),
payParams: z.record(z.string(), z.unknown()).readonly(),
cashierUrl: stringOrNull,
cashier_url: stringOrNull,
checkoutUrl: stringOrNull,
@@ -33,12 +34,17 @@ export const CreatePaymentOrderResponseSchema = z.object({
redirectUrl: stringOrNull,
redirect_url: stringOrNull,
url: stringOrNull,
}).transform((data) => {
})
.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)) {
if (
typeof value === "string" &&
value.length > 0 &&
!(key in payParams)
) {
payParams[key] = value;
}
}
@@ -47,7 +53,8 @@ export const CreatePaymentOrderResponseSchema = z.object({
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({
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({
export const FirstRechargeOfferSchema = z
.object({
enabled: booleanOrFalse,
type: stringOrEmpty,
discountPercent: numberOrZero,
});
})
.readonly();
export const PaymentPlansResponseSchema = z.object({
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({
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;
+5 -26
View File
@@ -1,36 +1,15 @@
import { z } from "zod";
export const TipPaymentPlanSchema = z.object({
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 });
});
+13 -37
View File
@@ -9,17 +9,22 @@ import {
stringOrNull,
} from "../nullable-defaults";
export const PrivateAlbumImageSchema = z.object({
export const PrivateAlbumImageSchema = z
.object({
url: stringOrEmpty,
locked: booleanOrFalse,
index: numberOrZero,
});
})
.readonly();
export const PrivateAlbumLockDetailSchema = z.object({
export const PrivateAlbumLockDetailSchema = z
.object({
locked: booleanOrFalse,
});
})
.readonly();
export const PrivateAlbumSchema = z.object({
export const PrivateAlbumSchema = z
.object({
albumId: stringOrEmpty,
title: stringOrEmpty,
content: stringOrNull,
@@ -31,40 +36,11 @@ export const PrivateAlbumSchema = z.object({
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({
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,7 +19,8 @@ export const PrivateAlbumUnlockReasonSchema = z.enum([
"not_found",
]);
export const PrivateAlbumUnlockResponseSchema = z.object({
export const PrivateAlbumUnlockResponseSchema = z
.object({
albumId: stringOrEmpty,
locked: booleanOrFalse,
unlocked: booleanOrFalse,
@@ -29,7 +30,8 @@ export const PrivateAlbumUnlockResponseSchema = z.object({
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({
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,
+5 -24
View File
@@ -6,33 +6,14 @@ import { z } from "zod";
import { stringOrEmpty } from "../nullable-defaults";
export const AvatarDataSchema = z.object({
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;
+4 -2
View File
@@ -8,7 +8,8 @@ import { z } from "zod";
import { numberOrZero, stringOrEmpty } from "../nullable-defaults";
export const PersistedUserSchema = z.object({
export const PersistedUserSchema = z
.object({
id: stringOrEmpty,
username: stringOrEmpty,
countryCode: stringOrEmpty,
@@ -16,7 +17,8 @@ export const PersistedUserSchema = z.object({
dailyFreeChatRemaining: numberOrZero,
dailyFreePrivateLimit: numberOrZero,
dailyFreePrivateRemaining: numberOrZero,
});
})
.readonly();
export type PersistedUserInput = z.input<typeof PersistedUserSchema>;
export type PersistedUserData = z.output<typeof PersistedUserSchema>;
+5 -27
View File
@@ -14,39 +14,17 @@ export const PERSONALITY_TRAITS_DEFAULTS = {
romantic: 0.5,
} as const;
export const PersonalityTraitsSchema = z.object({
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;
+5 -26
View File
@@ -6,37 +6,16 @@ import { z } from "zod";
import { numberOrZero, stringOrEmpty } from "../nullable-defaults";
export const RecentMemorySchema = z.object({
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;
+14 -62
View File
@@ -3,18 +3,14 @@
*
*/
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({
export const UserSchema = z
.object({
id: stringOrEmpty, // 兜底:理论 id 不会 null,但保持防御
username: stringOrEmpty,
email: stringOrEmpty,
@@ -32,67 +28,23 @@ export const UserSchema = z.object({
dailyFreePrivateLimit: numberOrZero,
dailyFreePrivateUsed: numberOrZero,
dailyFreePrivateRemaining: numberOrZero,
personalityTraits: schemaOr(PersonalityTraitsSchema, PERSONALITY_TRAITS_DEFAULTS),
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),
export const EMPTY_USER: User = UserSchema.parse({
id: "",
username: "",
});
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(),
});
}
}
@@ -8,10 +8,12 @@ import { z } from "zod";
import { booleanOrFalse, numberOrZero } from "../nullable-defaults";
export const UserEntitlementSnapshotSchema = z.object({
export const UserEntitlementSnapshotSchema = z
.object({
isVip: booleanOrFalse,
creditBalance: numberOrZero,
});
})
.readonly();
export type UserEntitlementSnapshotInput = z.input<
typeof UserEntitlementSnapshotSchema
+15 -37
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,9 +102,11 @@ export const UserEntitlementsHistoryUnlockSchema = z
HISTORY_UNLOCK_COSTS_DEFAULTS,
),
})
.passthrough();
.passthrough()
.readonly();
export const UserEntitlementsSchema = z.object({
export const UserEntitlementsSchema = z
.object({
userId: stringOrEmpty,
isGuest: booleanOrFalse,
isVip: booleanOrFalse,
@@ -114,38 +120,10 @@ export const UserEntitlementsSchema = z.object({
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;
@@ -34,7 +34,7 @@ describe("FeedbackApi", () => {
images: [image],
});
expect(response.toJson()).toEqual({ feedbackId: "feedback-123" });
expect(response).toEqual({ feedbackId: "feedback-123" });
expect(httpClientMock).toHaveBeenCalledWith(
"/api/feedback",
expect.objectContaining({ method: "POST", body: expect.any(FormData) }),
@@ -1,9 +1,9 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
SendMessageRequest,
UnlockHistoryRequest,
UnlockPrivateRequest,
SendMessageRequestSchema,
UnlockHistoryRequestSchema,
UnlockPrivateRequestSchema,
} from "@/data/schemas/chat";
const httpClientMock = vi.hoisted(() => vi.fn());
@@ -34,7 +34,7 @@ describe("multi-character API contract", () => {
});
await new ChatApi().sendMessage(
SendMessageRequest.from({
SendMessageRequestSchema.parse({
characterId: CHARACTER_ID,
message: "Hello",
}),
@@ -76,13 +76,13 @@ describe("multi-character API contract", () => {
});
await api.unlockPrivateMessage(
UnlockPrivateRequest.from({
UnlockPrivateRequestSchema.parse({
characterId: CHARACTER_ID,
messageId: "message-1",
}),
);
await api.unlockHistory(
UnlockHistoryRequest.from({ characterId: CHARACTER_ID }),
UnlockHistoryRequestSchema.parse({ characterId: CHARACTER_ID }),
);
expect(httpClientMock).toHaveBeenNthCalledWith(
@@ -37,7 +37,7 @@ describe("PaymentApi", () => {
const response = await new PaymentApi().getTipPlans();
expect(httpClientMock).toHaveBeenCalledWith("/api/payment/tip-plans");
expect(response.toJson()).toEqual({
expect(response).toEqual({
plans: [
{
planId: "tip_coffee_usd_19_99",
+39 -37
View File
@@ -6,28 +6,33 @@
*/
import { z } from "zod";
import { httpClient } from "./http_client";
import { ApiPath } from "./api_path";
import { ApiEnvelope, unwrap } from "./response_helper";
import {
FacebookIdentityRequest,
FacebookIdentityResponse,
FacebookIdentityResponseSchema,
FacebookLoginRequest,
FacebookPsidLoginRequest,
FacebookPsidLoginResponse,
FacebookPsidLoginResponseSchema,
FbIdLoginRequest,
GoogleLoginRequest,
GuestLoginRequest,
GuestLoginResponse,
GuestLoginResponseSchema,
LoginRequest,
LoginResponse,
LoginResponseSchema,
RefreshTokenRequest,
RefreshTokenResponse,
RefreshTokenResponseSchema,
RegisterRequest,
} from "@/data/schemas/auth";
import { User } from "@/data/schemas/user";
import { User, UserSchema } from "@/data/schemas/user";
import type { UserData } from "@/data/schemas/user/user";
import { Logger } from "@/utils/logger";
import { ApiPath } from "./api_path";
import { httpClient } from "./http_client";
import { ApiEnvelope, unwrap } from "./response_helper";
const log = new Logger("DataServicesApiAuthApi");
@@ -36,11 +41,11 @@ export class AuthApi {
* 邮箱密码登录
*/
async emailLogin(body: LoginRequest): Promise<LoginResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.emailLogin,
{ method: "POST", body: body.toJson() }
);
return LoginResponse.fromJson(unwrap(env) as Record<string, unknown>);
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.emailLogin, {
method: "POST",
body,
});
return LoginResponseSchema.parse(unwrap(env) as Record<string, unknown>);
}
/**
@@ -49,7 +54,7 @@ export class AuthApi {
async register(body: RegisterRequest): Promise<void> {
await httpClient<ApiEnvelope<unknown>>(ApiPath.register, {
method: "POST",
body: body.toJson(),
body,
});
}
@@ -59,20 +64,20 @@ export class AuthApi {
async googleLogin(body: GoogleLoginRequest): Promise<LoginResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.googleLogin, {
method: "POST",
body: body.toJson(),
body,
});
return LoginResponse.fromJson(unwrap(env) as Record<string, unknown>);
return LoginResponseSchema.parse(unwrap(env) as Record<string, unknown>);
}
/**
* Facebook 登录
*/
async facebookLogin(body: FacebookLoginRequest): Promise<LoginResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.facebookLogin,
{ method: "POST", body: body.toJson() }
);
return LoginResponse.fromJson(unwrap(env) as Record<string, unknown>);
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.facebookLogin, {
method: "POST",
body,
});
return LoginResponseSchema.parse(unwrap(env) as Record<string, unknown>);
}
/**
@@ -81,9 +86,9 @@ export class AuthApi {
async facebookIdLogin(body: FbIdLoginRequest): Promise<LoginResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.facebookIdLogin,
{ method: "POST", body: body.toJson() }
{ method: "POST", body },
);
return LoginResponse.fromJson(unwrap(env) as Record<string, unknown>);
return LoginResponseSchema.parse(unwrap(env) as Record<string, unknown>);
}
/**
@@ -94,9 +99,9 @@ export class AuthApi {
): Promise<FacebookPsidLoginResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.facebookPsidLogin,
{ method: "POST", body: body.toJson() },
{ method: "POST", body },
);
return FacebookPsidLoginResponse.fromJson(
return FacebookPsidLoginResponseSchema.parse(
unwrap(env) as Record<string, unknown>,
);
}
@@ -109,9 +114,9 @@ export class AuthApi {
): Promise<FacebookIdentityResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.userFacebookIdentity,
{ method: "POST", body: body.toJson() },
{ method: "POST", body },
);
return FacebookIdentityResponse.fromJson(
return FacebookIdentityResponseSchema.parse(
unwrap(env) as Record<string, unknown>,
);
}
@@ -120,7 +125,7 @@ export class AuthApi {
* 退出登录(fire-and-forget
*
* 后端 logout 端点的 envelope `data` 字段常为 `null`(登出端点没有业务数据),
* 不要再用 `unwrap` + `LogoutResponse.fromJson` 解析 —— Zod schema 是 z.object
* 不要再用 `unwrap` + `LogoutResponseSchema.parse` 解析 —— Zod schema 是 z.object
* 对 null 会抛 ZodError "Invalid input: expected object, received null"。
*
* 对齐 `register` 的写法:调通就完事,不解析响应体。
@@ -137,19 +142,20 @@ export class AuthApi {
async guestLogin(body: GuestLoginRequest): Promise<GuestLoginResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.guestLogin, {
method: "POST",
body: body.toJson(),
body,
});
log.debug("[AuthApi.guestLogin] raw envelope from backend", env);
const unwrapped = unwrap(env) as Record<string, unknown>;
const userObj = unwrapped.user as { lastMessageAt?: unknown } | undefined;
log.debug("[AuthApi.guestLogin] unwrapped data (pre-parse)", {
hasUser: "user" in unwrapped,
userKeys: userObj && typeof userObj === "object" ? Object.keys(userObj) : null,
userKeys:
userObj && typeof userObj === "object" ? Object.keys(userObj) : null,
lastMessageAt: userObj?.lastMessageAt,
lastMessageAtType: typeof userObj?.lastMessageAt,
});
try {
return GuestLoginResponse.fromJson(unwrapped);
return GuestLoginResponseSchema.parse(unwrapped);
} catch (e) {
if (e instanceof z.ZodError) {
log.error("[AuthApi.guestLogin] ZodError parsing response", {
@@ -167,15 +173,13 @@ export class AuthApi {
/**
* 刷新 Token
*/
async refreshToken(
body: RefreshTokenRequest
): Promise<RefreshTokenResponse> {
async refreshToken(body: RefreshTokenRequest): Promise<RefreshTokenResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.refresh, {
method: "POST",
body: body.toJson(),
body,
});
return RefreshTokenResponse.fromJson(
unwrap(env) as Record<string, unknown>
return RefreshTokenResponseSchema.parse(
unwrap(env) as Record<string, unknown>,
);
}
@@ -183,10 +187,8 @@ export class AuthApi {
* 获取当前用户信息
*/
async getCurrentUser(): Promise<User> {
const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.getCurrentUser
);
return User.fromJson(unwrap(env) as UserData);
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.getCurrentUser);
return UserSchema.parse(unwrap(env) as UserData);
}
}
+5 -2
View File
@@ -1,4 +1,7 @@
import { CharactersResponse } from "@/data/schemas/character";
import {
CharactersResponse,
CharactersResponseSchema,
} from "@/data/schemas/character";
import { ApiPath } from "./api_path";
import { httpClient } from "./http_client";
@@ -7,7 +10,7 @@ import { type ApiEnvelope, unwrap } from "./response_helper";
export class CharacterApi {
async getCharacters(): Promise<CharactersResponse> {
const envelope = await httpClient<ApiEnvelope<unknown>>(ApiPath.characters);
return CharactersResponse.fromJson(unwrap(envelope));
return CharactersResponseSchema.parse(unwrap(envelope));
}
}
+15 -11
View File
@@ -4,18 +4,22 @@
* 聊天相关接口
*
*/
import { httpClient } from "./http_client";
import { ApiPath } from "./api_path";
import { ApiEnvelope, unwrap } from "./response_helper";
import {
ChatHistoryResponse,
ChatHistoryResponseSchema,
ChatSendResponse,
ChatSendResponseSchema,
SendMessageRequest,
UnlockHistoryRequest,
UnlockHistoryResponse,
UnlockHistoryResponseSchema,
UnlockPrivateRequest,
UnlockPrivateResponse,
UnlockPrivateResponseSchema,
} from "@/data/schemas/chat";
import { ApiPath } from "./api_path";
import { httpClient } from "./http_client";
import { ApiEnvelope, unwrap } from "./response_helper";
export class ChatApi {
/**
@@ -24,9 +28,9 @@ export class ChatApi {
async sendMessage(body: SendMessageRequest): Promise<ChatSendResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.chatSend, {
method: "POST",
body: body.toJson(),
body,
});
return ChatSendResponse.fromJson(unwrap(env) as Record<string, unknown>);
return ChatSendResponseSchema.parse(unwrap(env) as Record<string, unknown>);
}
/**
@@ -40,8 +44,8 @@ export class ChatApi {
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.chatHistory, {
query: { characterId, limit, offset },
});
return ChatHistoryResponse.fromJson(
unwrap(env) as Record<string, unknown>
return ChatHistoryResponseSchema.parse(
unwrap(env) as Record<string, unknown>,
);
}
@@ -55,10 +59,10 @@ export class ChatApi {
ApiPath.chatUnlockPrivate,
{
method: "POST",
body: body.toJson(),
body,
},
);
return UnlockPrivateResponse.fromJson(
return UnlockPrivateResponseSchema.parse(
unwrap(env) as Record<string, unknown>,
);
}
@@ -73,10 +77,10 @@ export class ChatApi {
ApiPath.chatUnlockHistory,
{
method: "POST",
body: body.toJson(),
body,
},
);
return UnlockHistoryResponse.fromJson(
return UnlockHistoryResponseSchema.parse(
unwrap(env) as Record<string, unknown>,
);
}
+2 -1
View File
@@ -1,5 +1,6 @@
import {
FeedbackSubmitResponse,
FeedbackSubmitResponseSchema,
type SubmitFeedbackInput,
} from "@/data/schemas/feedback";
@@ -19,7 +20,7 @@ export class FeedbackApi {
method: "POST",
body,
});
return FeedbackSubmitResponse.fromJson(unwrap(envelope));
return FeedbackSubmitResponseSchema.parse(unwrap(envelope));
}
}
+8 -8
View File
@@ -4,10 +4,10 @@
* 数据看板/上报相关接口
*
*/
import { httpClient } from "./http_client";
import { ApiPath } from "./api_path";
import { ApiEnvelope, unwrapOptional } from "./response_helper";
import { AppEvent, PwaEvent } from "@/data/schemas/metrics";
import { ApiPath } from "./api_path";
import { httpClient } from "./http_client";
import { ApiEnvelope, unwrapOptional } from "./response_helper";
export class MetricsApi {
/**
@@ -16,7 +16,7 @@ export class MetricsApi {
async reportPwaEvent(body: PwaEvent): Promise<void> {
const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.metricsPwaEvent,
{ method: "POST", body: body.toJson() }
{ method: "POST", body },
);
unwrapOptional(env);
}
@@ -25,10 +25,10 @@ export class MetricsApi {
* 上报用户信息
*/
async reportUserInfo(body: AppEvent): Promise<void> {
const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.reportUserInfo,
{ method: "POST", body: body.toJson() }
);
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.reportUserInfo, {
method: "POST",
body,
});
unwrapOptional(env);
}
}
+11 -12
View File
@@ -6,9 +6,13 @@
import {
CreatePaymentOrderRequest,
CreatePaymentOrderResponse,
CreatePaymentOrderResponseSchema,
PaymentOrderStatusResponse,
PaymentOrderStatusResponseSchema,
PaymentPlansResponse,
PaymentPlansResponseSchema,
TipPaymentPlansResponse,
TipPaymentPlansResponseSchema,
} from "@/data/schemas/payment";
import { ApiPath } from "./api_path";
@@ -21,17 +25,15 @@ export class PaymentApi {
*/
async getPlans(): Promise<PaymentPlansResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.paymentPlans);
return PaymentPlansResponse.fromJson(
return PaymentPlansResponseSchema.parse(
unwrap(env) as Record<string, unknown>,
);
}
/** 获取咖啡打赏套餐列表。 */
async getTipPlans(): Promise<TipPaymentPlansResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.paymentTipPlans,
);
return TipPaymentPlansResponse.fromJson(
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.paymentTipPlans);
return TipPaymentPlansResponseSchema.parse(
unwrap(env) as Record<string, unknown>,
);
}
@@ -46,10 +48,10 @@ export class PaymentApi {
ApiPath.paymentCreateOrder,
{
method: "POST",
body: body.toJson(),
body,
},
);
return CreatePaymentOrderResponse.fromJson(
return CreatePaymentOrderResponseSchema.parse(
unwrap(env) as Record<string, unknown>,
);
}
@@ -57,20 +59,17 @@ export class PaymentApi {
/**
* 查询支付订单状态
*/
async getOrderStatus(
orderId: string,
): Promise<PaymentOrderStatusResponse> {
async getOrderStatus(orderId: string): Promise<PaymentOrderStatusResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.paymentOrderStatus,
{
query: { order_id: orderId },
},
);
return PaymentOrderStatusResponse.fromJson(
return PaymentOrderStatusResponseSchema.parse(
unwrap(env) as Record<string, unknown>,
);
}
}
/**
+6 -4
View File
@@ -1,9 +1,11 @@
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import {
PrivateAlbumsResponse,
PrivateAlbumsResponseSchema,
PrivateAlbumUnlockResponse,
PrivateAlbumUnlockResponseSchema,
UnlockPrivateAlbumRequest,
} from "@/data/schemas/private-room";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import { ApiPath } from "./api_path";
import { httpClient } from "./http_client";
@@ -27,7 +29,7 @@ export class PrivateRoomApi {
},
},
);
return PrivateAlbumsResponse.fromJson(unwrap(env));
return PrivateAlbumsResponseSchema.parse(unwrap(env));
}
async unlockAlbum(
@@ -38,10 +40,10 @@ export class PrivateRoomApi {
ApiPath.privateRoomAlbumUnlock(albumId),
{
method: "POST",
body: body.toJson(),
body,
},
);
return PrivateAlbumUnlockResponse.fromJson(unwrap(env));
return PrivateAlbumUnlockResponseSchema.parse(unwrap(env));
}
}
+10 -6
View File
@@ -4,14 +4,16 @@
* 用户相关接口
*
*/
import { httpClient } from "./http_client";
import { ApiPath } from "./api_path";
import { ApiEnvelope, unwrap } from "./response_helper";
import {
User,
UserEntitlements,
UserEntitlementsSchema,
UserSchema,
} from "@/data/schemas/user";
import type { UserData } from "@/data/schemas/user/user";
import { ApiPath } from "./api_path";
import { httpClient } from "./http_client";
import { ApiEnvelope, unwrap } from "./response_helper";
export class UserApi {
/**
@@ -19,15 +21,17 @@ export class UserApi {
*/
async getCurrentUser(): Promise<User> {
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.userProfile);
return User.fromJson(unwrap(env) as UserData);
return UserSchema.parse(unwrap(env) as UserData);
}
/**
* 获取当前用户权益快照
*/
async getEntitlements(): Promise<UserEntitlements> {
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.userEntitlements);
return UserEntitlements.fromJson(unwrap(env) as Record<string, unknown>);
const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.userEntitlements,
);
return UserEntitlementsSchema.parse(unwrap(env) as Record<string, unknown>);
}
}
+12 -5
View File
@@ -11,7 +11,7 @@
* - 不指定 version 走最新稳定版
* - 不需要 appId / appSecretaccessToken 自身就是凭证)
*/
import { FacebookUserData } from "@/data/schemas/auth";
import { FacebookUserData, FacebookUserDataSchema } from "@/data/schemas/auth";
import { Logger } from "@/utils/logger";
import { Result, toError } from "@/utils/result";
@@ -43,26 +43,33 @@ export async function fetchFacebookUserData(
const url = `${GRAPH_ME_URL}?fields=${encodeURIComponent(FIELDS)}&access_token=${encodeURIComponent(accessToken)}`;
log.debug({ url: url.replace(accessToken, "<redacted>") }, "fetchFacebookUserData REQUEST");
log.debug(
{ url: url.replace(accessToken, "<redacted>") },
"fetchFacebookUserData REQUEST",
);
try {
const res = await fetch(url, { method: "GET" });
const json = (await res.json()) as Record<string, unknown>;
if (!res.ok) {
const errBody = (json?.error as Record<string, unknown> | undefined) ?? {};
const errBody =
(json?.error as Record<string, unknown> | undefined) ?? {};
const message =
typeof errBody.message === "string"
? errBody.message
: `Graph /me failed: HTTP ${res.status}`;
log.warn({ status: res.status, body: errBody }, "fetchFacebookUserData FAILED");
log.warn(
{ status: res.status, body: errBody },
"fetchFacebookUserData FAILED",
);
return Result.err(new Error(message));
}
// 提取 picture.data.url —— Graph API 返回嵌套结构
const pictureUrl = extractPictureUrl(json.picture);
const data = FacebookUserData.from({
const data = FacebookUserDataSchema.parse({
id: typeof json.id === "string" ? json.id : null,
name: typeof json.name === "string" ? json.name : null,
email: typeof json.email === "string" ? json.email : null,
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { PaymentPlan } from "@/data/schemas/payment";
import { PaymentPlanSchema } from "@/data/schemas/payment";
import {
behaviorAnalytics,
@@ -31,7 +31,7 @@ function createSdkMock(): CozsweetBehaviorAnalyticsSdk {
};
}
const plan = PaymentPlan.from({
const plan = PaymentPlanSchema.parse({
planId: "credits-100",
planName: "100 Credits",
orderType: "dol",
@@ -126,9 +126,7 @@ describe("behavior analytics adapter", () => {
expect(sanitizeAnalyticsCheckoutUrl("stripe_embedded")).toBe(
"stripe_embedded",
);
expect(sanitizeAnalyticsCheckoutUrl("not a url")).toBe(
"external_checkout",
);
expect(sanitizeAnalyticsCheckoutUrl("not a url")).toBe("external_checkout");
expect(sanitizeAnalyticsCheckoutUrl("javascript:alert('no')")).toBe(
"external_checkout",
);
@@ -1,20 +1,24 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import { ChatSendResponse } from "@/data/schemas/chat";
import {
ChatSendResponseSchema,
type ChatSendResponse,
type ChatSendResponseInput,
} from "@/data/schemas/chat";
import type { ChatState } from "@/stores/chat/chat-state";
import {
applyNetworkHistoryLoadedOutput,
applyHttpSendOutput,
applyNetworkHistoryLoadedOutput,
countLockedHistoryMessages,
localMessagesToUi,
sendResponseToUiMessage,
} from "@/stores/chat/helper";
function makeResponse(
overrides: Partial<Parameters<typeof ChatSendResponse.from>[0]> = {},
overrides: Partial<ChatSendResponseInput> = {},
): ChatSendResponse {
return ChatSendResponse.from({
return ChatSendResponseSchema.parse({
reply: "AI reply",
messageId: "msg-1",
isGuest: false,
@@ -100,9 +104,7 @@ describe("sendResponseToUiMessage", () => {
}),
);
expect(message.audioUrl).toBe(
"https://example.com/unlocked-voice.mp3",
);
expect(message.audioUrl).toBe("https://example.com/unlocked-voice.mp3");
expect(message.locked).toBe(false);
});
@@ -1,12 +1,15 @@
import { fromCallback, fromPromise } from "xstate";
import {
ChatSendResponse,
UnlockPrivateResponse,
} from "@/data/schemas/chat";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import { chatMachine } from "@/stores/chat/chat-machine";
import {
ChatSendResponseSchema,
UnlockPrivateResponseSchema,
type ChatSendResponse,
type UnlockPrivateResponse,
type UnlockPrivateResponseInput,
} from "@/data/schemas/chat";
import type { ChatEvent } from "@/stores/chat/chat-events";
import { chatMachine } from "@/stores/chat/chat-machine";
import type {
UnlockMessageOutput as MachineUnlockMessageOutput,
UnlockMessageRequest,
@@ -36,7 +39,7 @@ export const TEST_CHAT_MACHINE_INPUT = {
};
export function makeChatSendResponse(): ChatSendResponse {
return ChatSendResponse.from({
return ChatSendResponseSchema.parse({
reply: "",
audioUrl: "",
messageId: "msg-1",
@@ -51,9 +54,9 @@ export function makeChatSendResponse(): ChatSendResponse {
}
export function makeUnlockPrivateResponse(
overrides: Partial<Parameters<typeof UnlockPrivateResponse.from>[0]> = {},
overrides: Partial<UnlockPrivateResponseInput> = {},
): UnlockPrivateResponse {
return UnlockPrivateResponse.from({
return UnlockPrivateResponseSchema.parse({
unlocked: true,
content: "unlocked content",
audioUrl: "",
@@ -106,10 +109,8 @@ export function createTestChatMachine(
return () => undefined;
},
),
unlockHistory: fromPromise<
UnlockHistoryOutput,
{ characterId: string }
>(async () => {
unlockHistory: fromPromise<UnlockHistoryOutput, { characterId: string }>(
async () => {
if (options.unlockHistoryError) {
throw options.unlockHistoryError;
}
@@ -120,7 +121,8 @@ export function createTestChatMachine(
messages: [],
...options.unlockHistoryOutput,
};
}),
},
),
unlockMessage: fromPromise<
MachineUnlockMessageOutput,
UnlockMessageRequest & { characterId: string }
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { UnlockPrivateResponse } from "@/data/schemas/chat";
import { UnlockPrivateResponseSchema } from "@/data/schemas/chat";
import type { PendingChatPromotion } from "@/data/storage/navigation";
import {
appendPromotionMessage,
@@ -50,7 +50,7 @@ describe("chat promotion", () => {
lockType: "image_paywall",
clientLockId: "promotion-1",
},
response: UnlockPrivateResponse.from({
response: UnlockPrivateResponseSchema.parse({
unlocked: true,
messageId: "backend-1",
image: {
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { createActor, fromPromise, waitFor } from "xstate";
import { ChatSendResponse } from "@/data/schemas/chat";
import { ChatSendResponseSchema } from "@/data/schemas/chat";
import type {
UnlockMessageOutput,
UnlockMessageRequest,
@@ -666,7 +666,7 @@ describe("chat unlock flow", () => {
actor.send({
type: "ChatQueuedHttpDone",
output: {
response: ChatSendResponse.from({
response: ChatSendResponseSchema.parse({
reply: "",
audioUrl: "",
messageId: "",
@@ -1,7 +1,10 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createActor, waitFor } from "xstate";
import { PaymentPlansResponse } from "@/data/schemas/payment";
import {
PaymentPlansResponse,
PaymentPlansResponseSchema,
} from "@/data/schemas/payment";
import {
createTestPaymentMachine,
@@ -32,7 +35,7 @@ describe("payment catalog flow", () => {
const refreshCatalog = vi.fn();
const actor = createActor(
createTestPaymentMachine({
refreshedPlans: PaymentPlansResponse.from({ plans: [tipPlan] }),
refreshedPlans: PaymentPlansResponseSchema.parse({ plans: [tipPlan] }),
onLoadCatalog: loadCatalog,
onRefreshCatalog: refreshCatalog,
}),
@@ -90,7 +93,7 @@ describe("payment catalog flow", () => {
it("preserves normal VIP original price outside first recharge", async () => {
const actor = createActor(
createTestPaymentMachine({
refreshedPlans: PaymentPlansResponse.from({
refreshedPlans: PaymentPlansResponseSchema.parse({
isFirstRecharge: false,
plans: [
{
@@ -120,7 +123,9 @@ describe("payment catalog flow", () => {
});
const actor = createActor(
createTestPaymentMachine({
cachedPlans: PaymentPlansResponse.from({ plans: [quarterlyPlan] }),
cachedPlans: PaymentPlansResponseSchema.parse({
plans: [quarterlyPlan],
}),
refreshPlans: async () => refreshPromise,
}),
).start();
@@ -131,7 +136,7 @@ describe("payment catalog flow", () => {
);
resolveRefresh(
PaymentPlansResponse.from({ plans: [monthlyPlan, lifetimePlan] }),
PaymentPlansResponseSchema.parse({ plans: [monthlyPlan, lifetimePlan] }),
);
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
expect(actor.getSnapshot().context.selectedPlanId).toBe("vip_monthly");
@@ -140,7 +145,7 @@ describe("payment catalog flow", () => {
});
function firstRechargeResponse(): PaymentPlansResponse {
return PaymentPlansResponse.from({
return PaymentPlansResponseSchema.parse({
isFirstRecharge: true,
firstRechargeOffer: {
enabled: true,
@@ -3,9 +3,11 @@ import { fromPromise } from "xstate";
import {
CreatePaymentOrderResponse,
CreatePaymentOrderResponseSchema,
type PayChannel,
PaymentOrderStatusResponse,
PaymentOrderStatusResponseSchema,
PaymentPlansResponse,
PaymentPlansResponseSchema,
} from "@/data/schemas/payment";
import { paymentMachine } from "@/stores/payment/payment-machine";
import type { PaymentPlanCatalog } from "@/stores/payment/payment-state";
@@ -114,39 +116,37 @@ export function createTestPaymentMachine(
overrides.onLoadCatalog?.(input.catalog);
return overrides.cachedPlans ?? null;
}),
refreshPlans: fromPromise<
PaymentPlansResponse,
PaymentPlansActorInput
>(async ({ input }) => {
refreshPlans: fromPromise<PaymentPlansResponse, PaymentPlansActorInput>(
async ({ input }) => {
overrides.onRefreshCatalog?.(input.catalog);
if (overrides.refreshPlans) return overrides.refreshPlans();
return (
overrides.refreshedPlans ??
PaymentPlansResponse.from({
PaymentPlansResponseSchema.parse({
plans: [monthlyPlan, lifetimePlan, creditPlan],
})
);
}),
createOrder: fromPromise<
CreatePaymentOrderResponse,
CreateOrderInput
>(async ({ input }) => {
},
),
createOrder: fromPromise<CreatePaymentOrderResponse, CreateOrderInput>(
async ({ input }) => {
createOrderSpy(input);
if (overrides.createOrderError) throw overrides.createOrderError;
return CreatePaymentOrderResponse.from({
return CreatePaymentOrderResponseSchema.parse({
orderId: "pay_test_001",
payParams: {
provider: "stripe",
clientSecret: "pi_test_secret_test",
},
});
}),
},
),
pollOrderStatus: fromPromise(async () => {
const status =
orderStatuses[Math.min(pollCount, orderStatuses.length - 1)] ??
orderStatus;
pollCount += 1;
return PaymentOrderStatusResponse.from({
return PaymentOrderStatusResponseSchema.parse({
orderId: "pay_test_001",
status,
orderType: "vip_monthly",
+18 -24
View File
@@ -1,4 +1,8 @@
import { PaymentPlan, type PaymentPlansResponse } from "@/data/schemas/payment";
import {
PaymentPlan,
PaymentPlanSchema,
type PaymentPlansResponse,
} from "@/data/schemas/payment";
import type { PaymentState } from "../payment-state";
import { resetOrderState } from "./order";
@@ -28,7 +32,7 @@ export function getDefaultPlanId(plans: readonly PaymentPlan[]): string {
}
export function hydratePlansState(
plans: PaymentPlan[],
plans: readonly PaymentPlan[],
selectedPlanId: string,
): Pick<
PaymentState,
@@ -48,11 +52,7 @@ export function hydratePlansResponseState(
selectedPlanId: string,
): Pick<
PaymentState,
| "plans"
| "isFirstRecharge"
| "selectedPlanId"
| "autoRenew"
| "errorMessage"
"plans" | "isFirstRecharge" | "selectedPlanId" | "autoRenew" | "errorMessage"
> {
const plans = normalizeFirstRechargePlans(
response.plans,
@@ -65,13 +65,15 @@ export function hydratePlansResponseState(
}
export function refreshPlansState(
plans: PaymentPlan[],
plans: readonly PaymentPlan[],
selectedPlanId: string,
): Pick<
PaymentState,
"plans" | "selectedPlanId" | "autoRenew" | "errorMessage"
> {
const nextSelectedPlanId = plans.some((plan) => plan.planId === selectedPlanId)
const nextSelectedPlanId = plans.some(
(plan) => plan.planId === selectedPlanId,
)
? selectedPlanId
: getDefaultPlanId(plans);
return {
@@ -87,11 +89,7 @@ export function refreshPlansResponseState(
selectedPlanId: string,
): Pick<
PaymentState,
| "plans"
| "isFirstRecharge"
| "selectedPlanId"
| "autoRenew"
| "errorMessage"
"plans" | "isFirstRecharge" | "selectedPlanId" | "autoRenew" | "errorMessage"
> {
const plans = normalizeFirstRechargePlans(
response.plans,
@@ -106,15 +104,15 @@ export function refreshPlansResponseState(
export function normalizeFirstRechargePlans(
plans: readonly PaymentPlan[],
isFirstRecharge: boolean,
): PaymentPlan[] {
): readonly PaymentPlan[] {
if (isFirstRecharge) return [...plans];
return plans.map(consumeFirstRechargePlan);
}
export function consumeFirstRechargePlan(plan: PaymentPlan): PaymentPlan {
if (!plan.isFirstRechargeOffer) {
return PaymentPlan.from({
...plan.toJson(),
return PaymentPlanSchema.parse({
...plan,
isFirstRechargeOffer: false,
firstRechargeDiscountPercent: null,
promotionType: null,
@@ -122,8 +120,8 @@ export function consumeFirstRechargePlan(plan: PaymentPlan): PaymentPlan {
}
const originalAmountCents = plan.originalAmountCents;
return PaymentPlan.from({
...plan.toJson(),
return PaymentPlanSchema.parse({
...plan,
amountCents: originalAmountCents ?? plan.amountCents,
originalAmountCents: originalAmountCents ?? plan.originalAmountCents,
isFirstRechargeOffer: false,
@@ -136,11 +134,7 @@ export function consumeFirstRechargeState(
context: PaymentState,
): Pick<
PaymentState,
| "plans"
| "isFirstRecharge"
| "selectedPlanId"
| "autoRenew"
| "errorMessage"
"plans" | "isFirstRecharge" | "selectedPlanId" | "autoRenew" | "errorMessage"
> {
const plans = context.plans.map(consumeFirstRechargePlan);
return {
+1 -1
View File
@@ -11,7 +11,7 @@ export type PaymentPlanCatalog = "default" | "tip";
export interface PaymentState {
planCatalog: PaymentPlanCatalog;
plans: PaymentPlan[];
plans: readonly PaymentPlan[];
isFirstRecharge: boolean;
selectedPlanId: string;
payChannel: PayChannel;
@@ -1,10 +1,14 @@
import { createActor, fromPromise, waitFor } from "xstate";
import {
PrivateAlbumsResponse,
PrivateAlbumUnlockResponse,
} from "@/data/schemas/private-room";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import {
PrivateAlbumUnlockResponseSchema,
PrivateAlbumsResponseSchema,
type PrivateAlbumUnlockResponse,
type PrivateAlbumUnlockResponseInput,
type PrivateAlbumsResponse,
type PrivateAlbumsResponseInput,
} from "@/data/schemas/private-room";
import { privateRoomMachine } from "@/stores/private-room/private-room-machine";
export const ALBUM_ID = "a1b2c3d4-0000-0000-0000-000000000000";
@@ -31,9 +35,9 @@ export function makeAlbum(index = 0) {
}
export function makeAlbumsResponse(
overrides: Partial<Parameters<typeof PrivateAlbumsResponse.from>[0]> = {},
overrides: Partial<PrivateAlbumsResponseInput> = {},
): PrivateAlbumsResponse {
return PrivateAlbumsResponse.from({
return PrivateAlbumsResponseSchema.parse({
items: [makeAlbum()],
creditBalance: 500,
...overrides,
@@ -41,9 +45,9 @@ export function makeAlbumsResponse(
}
export function makeUnlockResponse(
overrides: Partial<Parameters<typeof PrivateAlbumUnlockResponse.from>[0]> = {},
overrides: Partial<PrivateAlbumUnlockResponseInput> = {},
): PrivateAlbumUnlockResponse {
return PrivateAlbumUnlockResponse.from({
return PrivateAlbumUnlockResponseSchema.parse({
albumId: ALBUM_ID,
locked: false,
unlocked: true,
@@ -61,23 +65,24 @@ export function makeUnlockResponse(
});
}
export function createTestPrivateRoomMachine(options: {
export function createTestPrivateRoomMachine(
options: {
loadResponse?: PrivateAlbumsResponse;
unlockResponse?: PrivateAlbumUnlockResponse;
loadError?: Error;
unlockError?: Error;
onLoad?: (input: { characterId: string }) => void;
} = {}) {
} = {},
) {
return privateRoomMachine.provide({
actors: {
loadAlbums: fromPromise<
PrivateAlbumsResponse,
{ characterId: string }
>(async ({ input }) => {
loadAlbums: fromPromise<PrivateAlbumsResponse, { characterId: string }>(
async ({ input }) => {
options.onLoad?.(input);
if (options.loadError) throw options.loadError;
return options.loadResponse ?? makeAlbumsResponse();
}),
},
),
unlockAlbum: fromPromise(async () => {
if (options.unlockError) throw options.unlockError;
return options.unlockResponse ?? makeUnlockResponse();

Some files were not shown because too many files have changed in this diff Show More