From ae97366a4a82c6f56b70a097e6ffb9a3cb9d8bad Mon Sep 17 00:00:00 2001 From: chenhang Date: Fri, 17 Jul 2026 13:21:40 +0800 Subject: [PATCH] refactor(data): replace schema classes with readonly models --- .agents/skills/sync-backend-api/SKILL.md | 93 ++++++------- .../use-payment-plan-analytics.test.tsx | 11 +- .../__tests__/private-album-card.test.tsx | 16 +-- .../subscription-screen.helpers.test.ts | 16 ++- .../tip/__tests__/tip-screen.helpers.test.ts | 4 +- .../auth_repository.facebook_identity.test.ts | 14 +- .../__tests__/auth_repository.session.test.ts | 30 +++-- .../chat_local_message_store.test.ts | 9 +- .../chat_media_cache_helpers.test.ts | 19 ++- .../__tests__/payment_repository.test.ts | 4 +- src/data/repositories/auth_repository.ts | 61 ++++----- .../repositories/chat_local_message_store.ts | 21 ++- .../repositories/chat_remote_data_source.ts | 14 +- src/data/repositories/chat_repository.ts | 20 +-- .../interfaces/ichat_repository.ts | 11 +- src/data/repositories/metrics_repository.ts | 10 +- src/data/repositories/payment_repository.ts | 16 +-- .../repositories/private_room_repository.ts | 12 +- .../__tests__/immutable_schema.test.ts | 84 ++++++++++++ .../schemas/__tests__/schema_model.test.ts | 42 ------ .../auth/__tests__/facebook_identity.test.ts | 22 ++-- src/data/schemas/auth/facebook_user_data.ts | 41 ++---- .../auth/request/facebook_identity_request.ts | 23 +--- .../auth/request/facebook_login_request.ts | 51 +++----- .../request/facebook_psid_login_request.ts | 25 ++-- .../auth/request/fb_id_login_request.ts | 40 ++---- .../auth/request/google_login_request.ts | 43 ++---- .../auth/request/guest_login_request.ts | 35 ++--- .../schemas/auth/request/login_request.ts | 49 ++----- .../auth/request/refresh_token_request.ts | 31 ++--- .../schemas/auth/request/register_request.ts | 47 ++----- .../response/facebook_identity_response.ts | 41 +++--- .../response/facebook_psid_login_response.ts | 48 ++----- .../auth/response/guest_login_response.ts | 52 ++------ .../schemas/auth/response/login_response.ts | 43 ++---- .../schemas/auth/response/logout_response.ts | 25 +--- .../auth/response/refresh_token_response.ts | 45 +++---- .../character/__tests__/character.test.ts | 10 +- src/data/schemas/character/character.ts | 42 ++---- .../schemas/character/characters_response.ts | 39 ++---- .../__tests__/chat_history_response.test.ts | 4 +- .../__tests__/unlock_private_request.test.ts | 10 +- .../__tests__/unlock_private_response.test.ts | 26 ++-- src/data/schemas/chat/chat_message.ts | 41 +----- src/data/schemas/chat/chat_payloads.ts | 10 +- .../chat/request/send_message_request.ts | 58 +++------ .../chat/request/unlock_history_request.ts | 25 +--- .../chat/request/unlock_private_request.ts | 28 +--- .../chat/response/chat_history_response.ts | 61 +++------ .../chat/response/chat_send_response.ts | 73 +++-------- .../chat/response/unlock_history_response.ts | 56 +++----- .../chat/response/unlock_private_response.ts | 66 +++------- .../response/feedback_submit_response.ts | 23 +--- src/data/schemas/metrics/request/app_event.ts | 27 ++-- src/data/schemas/metrics/request/pwa_event.ts | 36 ++---- src/data/schemas/nullable-defaults.ts | 10 +- .../payment/__tests__/payment_plan.test.ts | 24 ++-- src/data/schemas/payment/payment_plan.ts | 68 +++------- .../request/create_payment_order_request.ts | 40 ++---- .../response/create_payment_order_response.ts | 86 +++++------- .../response/payment_order_status_response.ts | 40 ++---- .../response/payment_plans_response.ts | 60 +++------ .../response/tip_payment_plans_response.ts | 38 +----- src/data/schemas/payment/tip_payment_plan.ts | 39 ++---- .../__tests__/private_album.test.ts | 20 +-- .../schemas/private-room/private_album.ts | 80 ++++-------- .../request/unlock_private_album_request.ts | 24 +--- .../response/private_album_unlock_response.ts | 56 ++------ .../response/private_albums_response.ts | 41 ++---- src/data/schemas/schema_model.ts | 53 -------- src/data/schemas/user/__tests__/user.test.ts | 18 +-- src/data/schemas/user/avatar_data.ts | 35 ++--- src/data/schemas/user/persisted_user.ts | 20 +-- src/data/schemas/user/personality_traits.ts | 44 ++----- src/data/schemas/user/recent_memory.ts | 41 ++---- src/data/schemas/user/user.ts | 122 ++++++------------ .../schemas/user/user_entitlement_snapshot.ts | 10 +- src/data/schemas/user/user_entitlements.ts | 78 ++++------- .../api/__tests__/feedback_api.test.ts | 2 +- .../api/__tests__/multi_character_api.test.ts | 12 +- .../api/__tests__/payment_api.test.ts | 2 +- src/data/services/api/auth_api.ts | 78 +++++------ src/data/services/api/character_api.ts | 7 +- src/data/services/api/chat_api.ts | 28 ++-- src/data/services/api/feedback_api.ts | 3 +- src/data/services/api/metrics_api.ts | 18 +-- src/data/services/api/payment_api.ts | 23 ++-- src/data/services/api/private_room_api.ts | 10 +- src/data/services/api/user_api.ts | 18 ++- src/data/services/facebook-graph.ts | 17 ++- .../__tests__/behavior_analytics.test.ts | 8 +- .../chat/__tests__/chat-helpers.test.ts | 16 ++- .../chat/__tests__/chat-machine.test-utils.ts | 48 +++---- .../chat/__tests__/chat-promotion.test.ts | 4 +- .../chat/__tests__/chat-unlock-flow.test.ts | 4 +- .../__tests__/payment-catalog-flow.test.ts | 17 ++- .../__tests__/payment-machine.test-utils.ts | 58 ++++----- src/stores/payment/helper/catalog.ts | 42 +++--- src/stores/payment/payment-state.ts | 2 +- .../private-room-machine.test-utils.ts | 51 ++++---- src/stores/private-room/helper/albums.ts | 9 +- src/stores/private-room/private-room-state.ts | 4 +- src/stores/user/machine/actors/profile.ts | 6 +- 103 files changed, 1220 insertions(+), 2117 deletions(-) create mode 100644 src/data/schemas/__tests__/immutable_schema.test.ts delete mode 100644 src/data/schemas/__tests__/schema_model.test.ts delete mode 100644 src/data/schemas/schema_model.ts diff --git a/.agents/skills/sync-backend-api/SKILL.md b/.agents/skills/sync-backend-api/SKILL.md index d841065e..7909ddbb 100644 --- a/.agents/skills/sync-backend-api/SKILL.md +++ b/.agents/skills/sync-backend-api/SKILL.md @@ -12,7 +12,7 @@ description: Sync this Next.js frontend with backend API documentation 当后端 API 新增、删除、字段结构调整、响应语义变化时,使用本 skill 更新: - 网络层 -- schema / 不可变数据类 +- schema / 不可变纯数据 - repository - storage - XState store @@ -22,18 +22,18 @@ 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//*.ts` | Zod schema 与不可变数据类,负责解析和序列化 | -| Repository Interface | `src/data/repositories/interfaces/i*_repository.ts` | 仓库接口 | -| Repository | `src/data/repositories/*_repository.ts` | 业务仓库实现 | -| Storage | `src/data/storage/*` | 本地持久化 | -| Store | `src/stores//*` | XState 状态机、actors、helpers、sync | -| UI | `src/app/**` | Next.js app router UI | -| Mock | `src/data/mock//**` | 模拟请求 / 响应数据 | -| Tests | `**/__tests__/*.test.ts` | schema model、helper、machine transition 测试 | +| 层级 | 路径 | 说明 | +| -------------------- | --------------------------------------------------- | ----------------------------------------------- | +| API Path | `src/data/services/api/api_path.ts` | 统一维护接口路径 | +| API Service | `src/data/services/api/*_api.ts` | 调用 `httpClient` 并解析响应 | +| Schema Model | `src/data/schemas//*.ts` | Zod schema 与不可变纯数据类型,负责解析和归一化 | +| Repository Interface | `src/data/repositories/interfaces/i*_repository.ts` | 仓库接口 | +| Repository | `src/data/repositories/*_repository.ts` | 业务仓库实现 | +| Storage | `src/data/storage/*` | 本地持久化 | +| Store | `src/stores//*` | XState 状态机、actors、helpers、sync | +| UI | `src/app/**` | Next.js app router UI | +| Mock | `src/data/mock//**` | 模拟请求 / 响应数据 | +| Tests | `**/__tests__/*.test.ts` | schema model、helper、machine transition 测试 | ## 后端文档来源 @@ -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//*.ts - 输入类型用 `z.input`。 - 对后端可能返回 `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({ - id: z.string(), - count: z.number().default(0), -}); +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; export type XxxResponseData = z.output; - -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/_api.ts ```ts const env = await httpClient>(ApiPath.xxx, { method: "POST", - body: request.toJson(), + body: request, }); -return XxxResponse.fromJson(unwrap(env) as Record); +return XxxResponseSchema.parse(unwrap(env)); ``` 注意: @@ -207,7 +196,7 @@ async getXxx(): Promise> { Repository 可以做轻量编排,例如: -- request 数据类构造 +- request schema 解析 - 多接口组合 - 本地 storage 同步 - 本地模型与接口模型转换 @@ -333,7 +322,7 @@ pnpm exec vitest run 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 ### 修改响应字段 1. 更新 schema。 -2. 更新同文件数据类的公开字段和 `toJson`。 +2. 更新同文件输入/输出类型及相关 parse 调用。 3. 更新 mapper/helper。 4. 更新 UI 或状态机依赖字段。 5. 更新 mock 和测试。 diff --git a/src/app/_hooks/__tests__/use-payment-plan-analytics.test.tsx b/src/app/_hooks/__tests__/use-payment-plan-analytics.test.tsx index a31939b7..51ccee4f 100644 --- a/src/app/_hooks/__tests__/use-payment-plan-analytics.test.tsx +++ b/src/app/_hooks/__tests__/use-payment-plan-analytics.test.tsx @@ -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); diff --git a/src/app/private-room/components/__tests__/private-album-card.test.tsx b/src/app/private-room/components/__tests__/private-album-card.test.tsx index 348995f4..e6d105d2 100644 --- a/src/app/private-room/components/__tests__/private-album-card.test.tsx +++ b/src/app/private-room/components/__tests__/private-album-card.test.tsx @@ -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[0]> = {}, -): PrivateAlbum { - return PrivateAlbum.from({ +function makeAlbum(overrides: Partial = {}): 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", () => { diff --git a/src/app/subscription/__tests__/subscription-screen.helpers.test.ts b/src/app/subscription/__tests__/subscription-screen.helpers.test.ts index 0cecc65f..9aa03d88 100644 --- a/src/app/subscription/__tests__/subscription-screen.helpers.test.ts +++ b/src/app/subscription/__tests__/subscription-screen.helpers.test.ts @@ -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[0]>, -): PaymentPlan { - return PaymentPlan.from({ +function makePlan(overrides: Partial): 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"); diff --git a/src/app/tip/__tests__/tip-screen.helpers.test.ts b/src/app/tip/__tests__/tip-screen.helpers.test.ts index 5ff67e9f..59ebaa86 100644 --- a/src/app/tip/__tests__/tip-screen.helpers.test.ts +++ b/src/app/tip/__tests__/tip-screen.helpers.test.ts @@ -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): PaymentPlan { - return PaymentPlan.from({ + return PaymentPlanSchema.parse({ planId: "coins_100", planName: "Coins", orderType: "dol", diff --git a/src/data/repositories/__tests__/auth_repository.facebook_identity.test.ts b/src/data/repositories/__tests__/auth_repository.facebook_identity.test.ts index b570654c..c340c4b1 100644 --- a/src/data/repositories/__tests__/auth_repository.facebook_identity.test.ts +++ b/src/data/repositories/__tests__/auth_repository.facebook_identity.test.ts @@ -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) { 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", diff --git a/src/data/repositories/__tests__/auth_repository.session.test.ts b/src/data/repositories/__tests__/auth_repository.session.test.ts index c71b2976..dc20d7ed 100644 --- a/src/data/repositories/__tests__/auth_repository.session.test.ts +++ b/src/data/repositories/__tests__/auth_repository.session.test.ts @@ -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: { - api?: Partial; - storage?: Partial; - userStorage?: Partial; -} = {}) { +function createRepository( + input: { + api?: Partial; + storage?: Partial; + userStorage?: Partial; + } = {}, +) { 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", diff --git a/src/data/repositories/__tests__/chat_local_message_store.test.ts b/src/data/repositories/__tests__/chat_local_message_store.test.ts index 685a969b..a9135dd5 100644 --- a/src/data/repositories/__tests__/chat_local_message_store.test.ts +++ b/src/data/repositories/__tests__/chat_local_message_store.test.ts @@ -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")]); diff --git a/src/data/repositories/__tests__/chat_media_cache_helpers.test.ts b/src/data/repositories/__tests__/chat_media_cache_helpers.test.ts index 9ce5fad1..3bb3a129 100644 --- a/src/data/repositories/__tests__/chat_media_cache_helpers.test.ts +++ b/src/data/repositories/__tests__/chat_media_cache_helpers.test.ts @@ -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[0]> = {}, -): ChatMessage { - return ChatMessage.from({ +function makeMessage(overrides: Partial = {}): ChatMessage { + return ChatMessageSchema.parse({ id: "msg-1", role: "assistant", type: "image", @@ -31,9 +36,9 @@ function makeMessage( } function makeResponse( - overrides: Partial[0]> = {}, + overrides: Partial = {}, ): ChatSendResponse { - return ChatSendResponse.from({ + return ChatSendResponseSchema.parse({ reply: "", messageId: "msg-1", isGuest: false, diff --git a/src/data/repositories/__tests__/payment_repository.test.ts b/src/data/repositories/__tests__/payment_repository.test.ts index e079ed90..29c9f4f2 100644 --- a/src/data/repositories/__tests__/payment_repository.test.ts +++ b/src/data/repositories/__tests__/payment_repository.test.ts @@ -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", diff --git a/src/data/repositories/auth_repository.ts b/src/data/repositories/auth_repository.ts index 79abe756..d7242815 100644 --- a/src/data/repositories/auth_repository.ts +++ b/src/data/repositories/auth_repository.ts @@ -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> { 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> { 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> { 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> { 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> { 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 { 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); } diff --git a/src/data/repositories/chat_local_message_store.ts b/src/data/repositories/chat_local_message_store.ts index 89408fbd..8c99ddc6 100644 --- a/src/data/repositories/chat_local_message_store.ts +++ b/src/data/repositories/chat_local_message_store.ts @@ -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> { + async getMessages( + identity?: string, + ): Promise> { 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> { + ): Promise> { 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, diff --git a/src/data/repositories/chat_remote_data_source.ts b/src/data/repositories/chat_remote_data_source.ts index 706d998c..c771f632 100644 --- a/src/data/repositories/chat_remote_data_source.ts +++ b/src/data/repositories/chat_remote_data_source.ts @@ -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> { 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> { 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> { return Result.wrap(() => - this.api.unlockHistory(UnlockHistoryRequest.from({ characterId })), + this.api.unlockHistory(UnlockHistoryRequestSchema.parse({ characterId })), ); } } diff --git a/src/data/repositories/chat_repository.ts b/src/data/repositories/chat_repository.ts index 998d6e9f..4f0645ca 100644 --- a/src/data/repositories/chat_repository.ts +++ b/src/data/repositories/chat_repository.ts @@ -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> { + async unlockHistory( + characterId: string, + ): Promise> { return this.remote.unlockHistory(characterId); } @@ -101,7 +103,7 @@ export class ChatRepository implements IChatRepository { /** 读取所有本地消息,按 dbId 升序。 */ async getLocalMessages( cacheIdentity?: string, - ): Promise> { + ): Promise> { return this.localMessages.getMessages(cacheIdentity); } diff --git a/src/data/repositories/interfaces/ichat_repository.ts b/src/data/repositories/interfaces/ichat_repository.ts index a80ee0bc..07d52ac8 100644 --- a/src/data/repositories/interfaces/ichat_repository.ts +++ b/src/data/repositories/interfaces/ichat_repository.ts @@ -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>; /** 读取所有本地消息,按 dbId 升序。 */ - getLocalMessages(cacheIdentity?: string): Promise>; + getLocalMessages( + cacheIdentity?: string, + ): Promise>; /** 清空本地消息。 */ clearLocalMessages(cacheIdentity?: string): Promise>; diff --git a/src/data/repositories/metrics_repository.ts b/src/data/repositories/metrics_repository.ts index 2e4941dd..b5318491 100644 --- a/src/data/repositories/metrics_repository.ts +++ b/src/data/repositories/metrics_repository.ts @@ -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> { 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> { return Result.wrap(async () => { await this.api.reportUserInfo( - AppEvent.from({ + AppEventSchema.parse({ userId: input.userId, browser: input.browser, userAgent: input.userAgent, diff --git a/src/data/repositories/payment_repository.ts b/src/data/repositories/payment_repository.ts index be5e5dbd..c1adbbb1 100644 --- a/src/data/repositories/payment_repository.ts +++ b/src/data/repositories/payment_repository.ts @@ -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> { 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> { 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> { - const request = CreatePaymentOrderRequest.from({ + const request = CreatePaymentOrderRequestSchema.parse({ planId, payChannel, autoRenew, @@ -86,7 +87,6 @@ export class PaymentRepository implements IPaymentRepository { ): Promise> { return Result.wrap(() => this.api.getOrderStatus(orderId)); } - } /** 全局懒单例。 */ diff --git a/src/data/repositories/private_room_repository.ts b/src/data/repositories/private_room_repository.ts index ec577862..228e4d93 100644 --- a/src/data/repositories/private_room_repository.ts +++ b/src/data/repositories/private_room_repository.ts @@ -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 }), ), ); } diff --git a/src/data/schemas/__tests__/immutable_schema.test.ts b/src/data/schemas/__tests__/immutable_schema.test.ts new file mode 100644 index 00000000..94340bd2 --- /dev/null +++ b/src/data/schemas/__tests__/immutable_schema.test.ts @@ -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); + } +} diff --git a/src/data/schemas/__tests__/schema_model.test.ts b/src/data/schemas/__tests__/schema_model.test.ts deleted file mode 100644 index 04a9195b..00000000 --- a/src/data/schemas/__tests__/schema_model.test.ts +++ /dev/null @@ -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; -const ExampleModel = createSchemaModel( - 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); - }); -}); diff --git a/src/data/schemas/auth/__tests__/facebook_identity.test.ts b/src/data/schemas/auth/__tests__/facebook_identity.test.ts index d0f2b117..e7b9d5d5 100644 --- a/src/data/schemas/auth/__tests__/facebook_identity.test.ts +++ b/src/data/schemas/auth/__tests__/facebook_identity.test.ts @@ -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", () => { diff --git a/src/data/schemas/auth/facebook_user_data.ts b/src/data/schemas/auth/facebook_user_data.ts index 52f8a52e..a5d2806f 100644 --- a/src/data/schemas/auth/facebook_user_data.ts +++ b/src/data/schemas/auth/facebook_user_data.ts @@ -1,6 +1,6 @@ /** * Facebook 用户数据 schema - * + * * * 字段对齐 Dart 端 `String?` 可空语义:缺字段时存 `null`。 */ @@ -8,37 +8,16 @@ import { z } from "zod"; import { stringOrNull } from "../nullable-defaults"; -export const FacebookUserDataSchema = z.object({ - id: stringOrNull, - name: stringOrNull, - email: stringOrNull, - pictureUrl: stringOrNull, -}); +export const FacebookUserDataSchema = z + .object({ + id: stringOrNull, + name: stringOrNull, + email: stringOrNull, + pictureUrl: stringOrNull, + }) + .readonly(); export type FacebookUserDataInput = z.input; export type FacebookUserDataData = z.output; -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; diff --git a/src/data/schemas/auth/request/facebook_identity_request.ts b/src/data/schemas/auth/request/facebook_identity_request.ts index b56658f0..35c39c45 100644 --- a/src/data/schemas/auth/request/facebook_identity_request.ts +++ b/src/data/schemas/auth/request/facebook_identity_request.ts @@ -3,17 +3,14 @@ */ import { z } from "zod"; -import { - createSchemaModel, - type SchemaModel, -} from "../../schema_model"; - import { stringOrEmpty } from "../../nullable-defaults"; -export const FacebookIdentityRequestSchema = z.object({ - asid: stringOrEmpty, - psid: stringOrEmpty, -}); +export const FacebookIdentityRequestSchema = z + .object({ + asid: stringOrEmpty, + psid: stringOrEmpty, + }) + .readonly(); export type FacebookIdentityRequestInput = z.input< typeof FacebookIdentityRequestSchema @@ -21,10 +18,4 @@ export type FacebookIdentityRequestInput = z.input< export type FacebookIdentityRequestData = z.output< typeof FacebookIdentityRequestSchema >; - -export type FacebookIdentityRequest = SchemaModel< - typeof FacebookIdentityRequestSchema ->; -export const FacebookIdentityRequest = createSchemaModel( - FacebookIdentityRequestSchema, -); +export type FacebookIdentityRequest = FacebookIdentityRequestData; diff --git a/src/data/schemas/auth/request/facebook_login_request.ts b/src/data/schemas/auth/request/facebook_login_request.ts index 603ed73b..ac8d99ea 100644 --- a/src/data/schemas/auth/request/facebook_login_request.ts +++ b/src/data/schemas/auth/request/facebook_login_request.ts @@ -1,43 +1,26 @@ /** * Facebook 登录请求 - * + * */ import { z } from "zod"; import { booleanOrFalse, stringOrEmpty } from "../../nullable-defaults"; -export const FacebookLoginRequestSchema = z.object({ - accessToken: z.string(), - platform: z.string(), - guestId: stringOrEmpty, - psid: stringOrEmpty, - isTestAccount: booleanOrFalse, -}); +export const FacebookLoginRequestSchema = z + .object({ + accessToken: z.string(), + platform: z.string(), + guestId: stringOrEmpty, + psid: stringOrEmpty, + isTestAccount: booleanOrFalse, + }) + .readonly(); -export type FacebookLoginRequestInput = z.input; -export type FacebookLoginRequestData = z.output; +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; diff --git a/src/data/schemas/auth/request/facebook_psid_login_request.ts b/src/data/schemas/auth/request/facebook_psid_login_request.ts index 0435b6d1..dc95d96d 100644 --- a/src/data/schemas/auth/request/facebook_psid_login_request.ts +++ b/src/data/schemas/auth/request/facebook_psid_login_request.ts @@ -3,18 +3,15 @@ */ import { z } from "zod"; -import { - createSchemaModel, - type SchemaModel, -} from "../../schema_model"; - import { booleanOrTrue, stringOrEmpty } from "../../nullable-defaults"; -export const FacebookPsidLoginRequestSchema = z.object({ - psid: z.string(), - deviceId: stringOrEmpty, - bindToGuest: booleanOrTrue, -}); +export const FacebookPsidLoginRequestSchema = z + .object({ + psid: z.string(), + deviceId: stringOrEmpty, + bindToGuest: booleanOrTrue, + }) + .readonly(); export type FacebookPsidLoginRequestInput = z.input< typeof FacebookPsidLoginRequestSchema @@ -22,10 +19,4 @@ export type FacebookPsidLoginRequestInput = z.input< export type FacebookPsidLoginRequestData = z.output< typeof FacebookPsidLoginRequestSchema >; - -export type FacebookPsidLoginRequest = SchemaModel< - typeof FacebookPsidLoginRequestSchema ->; -export const FacebookPsidLoginRequest = createSchemaModel( - FacebookPsidLoginRequestSchema, -); +export type FacebookPsidLoginRequest = FacebookPsidLoginRequestData; diff --git a/src/data/schemas/auth/request/fb_id_login_request.ts b/src/data/schemas/auth/request/fb_id_login_request.ts index d20121ce..c4822086 100644 --- a/src/data/schemas/auth/request/fb_id_login_request.ts +++ b/src/data/schemas/auth/request/fb_id_login_request.ts @@ -1,41 +1,21 @@ /** * Facebook ID 登录请求 - * + * */ import { z } from "zod"; import { booleanOrFalse, stringOrEmpty } from "../../nullable-defaults"; -export const FbIdLoginRequestSchema = z.object({ - fbId: z.string(), - avatarUrl: stringOrEmpty, - psid: stringOrEmpty, - isTestAccount: booleanOrFalse, -}); +export const FbIdLoginRequestSchema = z + .object({ + fbId: z.string(), + avatarUrl: stringOrEmpty, + psid: stringOrEmpty, + isTestAccount: booleanOrFalse, + }) + .readonly(); export type FbIdLoginRequestInput = z.input; export type FbIdLoginRequestData = z.output; -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; diff --git a/src/data/schemas/auth/request/google_login_request.ts b/src/data/schemas/auth/request/google_login_request.ts index 6ed9975b..350ae3dc 100644 --- a/src/data/schemas/auth/request/google_login_request.ts +++ b/src/data/schemas/auth/request/google_login_request.ts @@ -1,43 +1,22 @@ /** * Google 登录请求 - * + * */ import { z } from "zod"; import { booleanOrFalse, stringOrEmpty } from "../../nullable-defaults"; -export const GoogleLoginRequestSchema = z.object({ - idToken: z.string(), - platform: z.string(), - guestId: stringOrEmpty, - psid: stringOrEmpty, - isTestAccount: booleanOrFalse, -}); +export const GoogleLoginRequestSchema = z + .object({ + idToken: z.string(), + platform: z.string(), + guestId: stringOrEmpty, + psid: stringOrEmpty, + isTestAccount: booleanOrFalse, + }) + .readonly(); export type GoogleLoginRequestInput = z.input; export type GoogleLoginRequestData = z.output; -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; diff --git a/src/data/schemas/auth/request/guest_login_request.ts b/src/data/schemas/auth/request/guest_login_request.ts index c836a272..be1e6060 100644 --- a/src/data/schemas/auth/request/guest_login_request.ts +++ b/src/data/schemas/auth/request/guest_login_request.ts @@ -1,38 +1,19 @@ /** * 游客登录请求 - * + * */ import { z } from "zod"; import { booleanOrFalse } from "../../nullable-defaults"; -export const GuestLoginRequestSchema = z.object({ - deviceId: z.string(), - isTestAccount: booleanOrFalse, -}); +export const GuestLoginRequestSchema = z + .object({ + deviceId: z.string(), + isTestAccount: booleanOrFalse, + }) + .readonly(); export type GuestLoginRequestInput = z.input; export type GuestLoginRequestData = z.output; -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; diff --git a/src/data/schemas/auth/request/login_request.ts b/src/data/schemas/auth/request/login_request.ts index 0b7ed9ec..f157248c 100644 --- a/src/data/schemas/auth/request/login_request.ts +++ b/src/data/schemas/auth/request/login_request.ts @@ -1,47 +1,24 @@ /** * 登录请求 - * + * */ import { z } from "zod"; import { booleanOrFalse, stringOrEmpty } from "../../nullable-defaults"; -export const LoginRequestSchema = z.object({ - email: stringOrEmpty, - username: stringOrEmpty, - password: z.string(), - platform: z.string(), - guestId: stringOrEmpty, - psid: stringOrEmpty, - isTestAccount: booleanOrFalse, -}); +export const LoginRequestSchema = z + .object({ + email: stringOrEmpty, + username: stringOrEmpty, + password: z.string(), + platform: z.string(), + guestId: stringOrEmpty, + psid: stringOrEmpty, + isTestAccount: booleanOrFalse, + }) + .readonly(); export type LoginRequestInput = z.input; export type LoginRequestData = z.output; -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; diff --git a/src/data/schemas/auth/request/refresh_token_request.ts b/src/data/schemas/auth/request/refresh_token_request.ts index 64a81e00..05e27b0e 100644 --- a/src/data/schemas/auth/request/refresh_token_request.ts +++ b/src/data/schemas/auth/request/refresh_token_request.ts @@ -1,26 +1,19 @@ /** * 刷新 Token 请求 - * + * */ import { z } from "zod"; -import { - createSchemaModel, - type SchemaModel, -} from "../../schema_model"; +export const RefreshTokenRequestSchema = z + .object({ + refreshToken: z.string(), + }) + .readonly(); -export const RefreshTokenRequestSchema = z.object({ - refreshToken: z.string(), -}); - -export type RefreshTokenRequestInput = z.input; -export type RefreshTokenRequestData = z.output; - -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; diff --git a/src/data/schemas/auth/request/register_request.ts b/src/data/schemas/auth/request/register_request.ts index a2ad1a98..311d9bb4 100644 --- a/src/data/schemas/auth/request/register_request.ts +++ b/src/data/schemas/auth/request/register_request.ts @@ -1,46 +1,23 @@ /** * 注册请求 - * + * */ import { z } from "zod"; import { booleanOrFalse, stringOrEmpty } from "../../nullable-defaults"; -export const RegisterRequestSchema = z.object({ - username: z.string(), - email: z.string(), - password: z.string(), - platform: z.string(), - guestId: stringOrEmpty, - isTestAccount: booleanOrFalse, -}); +export const RegisterRequestSchema = z + .object({ + username: z.string(), + email: z.string(), + password: z.string(), + platform: z.string(), + guestId: stringOrEmpty, + isTestAccount: booleanOrFalse, + }) + .readonly(); export type RegisterRequestInput = z.input; export type RegisterRequestData = z.output; -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; diff --git a/src/data/schemas/auth/response/facebook_identity_response.ts b/src/data/schemas/auth/response/facebook_identity_response.ts index bfb6b1a9..96e5ec95 100644 --- a/src/data/schemas/auth/response/facebook_identity_response.ts +++ b/src/data/schemas/auth/response/facebook_identity_response.ts @@ -3,26 +3,25 @@ */ import { z } from "zod"; -import { - createSchemaModel, - type SchemaModel, -} from "../../schema_model"; - import { arrayOrEmpty, stringOrEmpty } from "../../nullable-defaults"; -export const FacebookIdentityBindingSchema = z.object({ - conflicts: arrayOrEmpty(z.unknown()), - bound: arrayOrEmpty(z.unknown()), -}); +export const FacebookIdentityBindingSchema = z + .object({ + conflicts: arrayOrEmpty(z.unknown()), + bound: arrayOrEmpty(z.unknown()), + }) + .readonly(); -export const FacebookIdentityResponseSchema = z.object({ - fbAsid: stringOrEmpty, - fbPsid: stringOrEmpty, - facebookBinding: FacebookIdentityBindingSchema.default({ - conflicts: [], - bound: [], - }), -}); +export const FacebookIdentityResponseSchema = z + .object({ + fbAsid: stringOrEmpty, + fbPsid: stringOrEmpty, + facebookBinding: FacebookIdentityBindingSchema.prefault({ + conflicts: [], + bound: [], + }), + }) + .readonly(); export type FacebookIdentityResponseInput = z.input< typeof FacebookIdentityResponseSchema @@ -30,10 +29,4 @@ export type FacebookIdentityResponseInput = z.input< export type FacebookIdentityResponseData = z.output< typeof FacebookIdentityResponseSchema >; - -export type FacebookIdentityResponse = SchemaModel< - typeof FacebookIdentityResponseSchema ->; -export const FacebookIdentityResponse = createSchemaModel( - FacebookIdentityResponseSchema, -); +export type FacebookIdentityResponse = FacebookIdentityResponseData; diff --git a/src/data/schemas/auth/response/facebook_psid_login_response.ts b/src/data/schemas/auth/response/facebook_psid_login_response.ts index 24ce71b5..10217c26 100644 --- a/src/data/schemas/auth/response/facebook_psid_login_response.ts +++ b/src/data/schemas/auth/response/facebook_psid_login_response.ts @@ -10,17 +10,19 @@ import { } from "../../nullable-defaults"; import { UserSchema } from "../../user/user"; -export const FacebookPsidLoginResponseSchema = z.object({ - token: z.string(), - refreshToken: stringOrEmpty, - matchedBy: stringOrEmpty, - fbAsid: stringOrEmpty, - fbPsid: stringOrEmpty, - hasCompleteFacebookIdentity: booleanOrFalse, - isGuest: booleanOrFalse, - user: schemaOrNull(UserSchema), - userId: stringOrEmpty, -}); +export const FacebookPsidLoginResponseSchema = z + .object({ + token: z.string(), + refreshToken: stringOrEmpty, + matchedBy: stringOrEmpty, + fbAsid: stringOrEmpty, + fbPsid: stringOrEmpty, + hasCompleteFacebookIdentity: booleanOrFalse, + isGuest: booleanOrFalse, + user: schemaOrNull(UserSchema), + userId: stringOrEmpty, + }) + .readonly(); export type FacebookPsidLoginResponseInput = z.input< typeof FacebookPsidLoginResponseSchema @@ -29,26 +31,4 @@ export type FacebookPsidLoginResponseData = z.output< typeof FacebookPsidLoginResponseSchema >; -export class FacebookPsidLoginResponse { - private constructor(input: FacebookPsidLoginResponseInput) { - const data = FacebookPsidLoginResponseSchema.parse(input); - Object.assign(this, data); - Object.freeze(this); - } - - static from( - input: FacebookPsidLoginResponseInput, - ): FacebookPsidLoginResponse { - return new FacebookPsidLoginResponse(input); - } - - static fromJson(json: unknown): FacebookPsidLoginResponse { - return FacebookPsidLoginResponse.from( - json as FacebookPsidLoginResponseInput, - ); - } - - toJson(): FacebookPsidLoginResponseData { - return FacebookPsidLoginResponseSchema.parse(this); - } -} +export type FacebookPsidLoginResponse = FacebookPsidLoginResponseData; diff --git a/src/data/schemas/auth/response/guest_login_response.ts b/src/data/schemas/auth/response/guest_login_response.ts index fa25bd8b..2e139c86 100644 --- a/src/data/schemas/auth/response/guest_login_response.ts +++ b/src/data/schemas/auth/response/guest_login_response.ts @@ -1,48 +1,24 @@ /** * 游客登录响应 - * + * */ import { z } from "zod"; -import { booleanOrTrue, numberOr, schemaOrNull } from "../../nullable-defaults"; -import { User, UserSchema } from "../../user/user"; +import { booleanOrTrue, numberOr, schemaOr } from "../../nullable-defaults"; +import { EMPTY_USER, UserSchema } from "../../user/user"; -export const GuestLoginResponseSchema = z.object({ - token: z.string(), - deviceId: z.string(), - userId: z.string(), - isDeviceUser: booleanOrTrue, - expiresIn: numberOr(2592000), - user: schemaOrNull(UserSchema), -}); +export const GuestLoginResponseSchema = z + .object({ + token: z.string(), + deviceId: z.string(), + userId: z.string(), + isDeviceUser: booleanOrTrue, + expiresIn: numberOr(2592000), + user: schemaOr(UserSchema, EMPTY_USER), + }) + .readonly(); export type GuestLoginResponseInput = z.input; export type GuestLoginResponseData = z.output; -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; diff --git a/src/data/schemas/auth/response/login_response.ts b/src/data/schemas/auth/response/login_response.ts index 23423b36..53969e5a 100644 --- a/src/data/schemas/auth/response/login_response.ts +++ b/src/data/schemas/auth/response/login_response.ts @@ -1,44 +1,21 @@ /** * 登录响应 - * + * */ import { z } from "zod"; import { stringOrEmpty } from "../../nullable-defaults"; -import { User, UserSchema } from "../../user/user"; +import { UserSchema } from "../../user/user"; -export const LoginResponseSchema = z.object({ - user: UserSchema, - token: z.string(), - refreshToken: stringOrEmpty, -}); +export const LoginResponseSchema = z + .object({ + user: UserSchema, + token: z.string(), + refreshToken: stringOrEmpty, + }) + .readonly(); export type LoginResponseInput = z.input; export type LoginResponseData = z.output; -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; diff --git a/src/data/schemas/auth/response/logout_response.ts b/src/data/schemas/auth/response/logout_response.ts index 9b1d738d..edbf09f8 100644 --- a/src/data/schemas/auth/response/logout_response.ts +++ b/src/data/schemas/auth/response/logout_response.ts @@ -1,28 +1,17 @@ /** * 退出登录响应 - * + * */ import { z } from "zod"; -import { - createSchemaModel, - type SchemaModel, -} from "../../schema_model"; - import { booleanOrTrue } from "../../nullable-defaults"; -export const LogoutResponseSchema = z.object({ - success: booleanOrTrue, -}); +export const LogoutResponseSchema = z + .object({ + success: booleanOrTrue, + }) + .readonly(); export type LogoutResponseInput = z.input; export type LogoutResponseData = z.output; - -export type LogoutResponse = SchemaModel< - typeof LogoutResponseSchema, - "success" ->; -export const LogoutResponse = createSchemaModel< - typeof LogoutResponseSchema, - "success" ->(LogoutResponseSchema); +export type LogoutResponse = LogoutResponseData; diff --git a/src/data/schemas/auth/response/refresh_token_response.ts b/src/data/schemas/auth/response/refresh_token_response.ts index 163a360b..8658b528 100644 --- a/src/data/schemas/auth/response/refresh_token_response.ts +++ b/src/data/schemas/auth/response/refresh_token_response.ts @@ -1,37 +1,22 @@ /** * 刷新 Token 响应 - * + * */ import { z } from "zod"; -export const RefreshTokenResponseSchema = z.object({ - token: z.string(), - refreshToken: z.string(), - userId: z.string(), -}); +export const RefreshTokenResponseSchema = z + .object({ + token: z.string(), + refreshToken: z.string(), + userId: z.string(), + }) + .readonly(); -export type RefreshTokenResponseInput = z.input; -export type RefreshTokenResponseData = z.output; +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; diff --git a/src/data/schemas/character/__tests__/character.test.ts b/src/data/schemas/character/__tests__/character.test.ts index 1c51005c..d770e2dc 100644 --- a/src/data/schemas/character/__tests__/character.test.ts +++ b/src/data/schemas/character/__tests__/character.test.ts @@ -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", diff --git a/src/data/schemas/character/character.ts b/src/data/schemas/character/character.ts index 81555900..00726b63 100644 --- a/src/data/schemas/character/character.ts +++ b/src/data/schemas/character/character.ts @@ -1,37 +1,17 @@ import { z } from "zod"; -export const CharacterSchema = z.object({ - id: z.string().min(1).max(64), - slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/), - displayName: z.string().trim().min(1).max(80), - avatarUrl: z.url().refine((url) => url.startsWith("https://"), { - message: "avatarUrl must use HTTPS", - }), -}); +export const CharacterSchema = z + .object({ + id: z.string().min(1).max(64), + slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/), + displayName: z.string().trim().min(1).max(80), + avatarUrl: z.url().refine((url) => url.startsWith("https://"), { + message: "avatarUrl must use HTTPS", + }), + }) + .readonly(); export type CharacterInput = z.input; export type CharacterData = z.output; -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; diff --git a/src/data/schemas/character/characters_response.ts b/src/data/schemas/character/characters_response.ts index c8975b33..794413cc 100644 --- a/src/data/schemas/character/characters_response.ts +++ b/src/data/schemas/character/characters_response.ts @@ -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; export type CharactersResponseData = z.output; -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; diff --git a/src/data/schemas/chat/__tests__/chat_history_response.test.ts b/src/data/schemas/chat/__tests__/chat_history_response.test.ts index c81529f4..fd00b237 100644 --- a/src/data/schemas/chat/__tests__/chat_history_response.test.ts +++ b/src/data/schemas/chat/__tests__/chat_history_response.test.ts @@ -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, diff --git a/src/data/schemas/chat/__tests__/unlock_private_request.test.ts b/src/data/schemas/chat/__tests__/unlock_private_request.test.ts index b0e9361a..b492f6e9 100644 --- a/src/data/schemas/chat/__tests__/unlock_private_request.test.ts +++ b/src/data/schemas/chat/__tests__/unlock_private_request.test.ts @@ -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", diff --git a/src/data/schemas/chat/__tests__/unlock_private_response.test.ts b/src/data/schemas/chat/__tests__/unlock_private_response.test.ts index b8295495..5bce25d0 100644 --- a/src/data/schemas/chat/__tests__/unlock_private_response.test.ts +++ b/src/data/schemas/chat/__tests__/unlock_private_response.test.ts @@ -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"); }); }); diff --git a/src/data/schemas/chat/chat_message.ts b/src/data/schemas/chat/chat_message.ts index c409d1b0..00944c38 100644 --- a/src/data/schemas/chat/chat_message.ts +++ b/src/data/schemas/chat/chat_message.ts @@ -1,16 +1,11 @@ /** * 聊天历史中的单条消息 - * + * */ import { z } from "zod"; import { stringOr, stringOrEmpty, stringOrNull } from "../nullable-defaults"; -import { - ChatImageSchema, - ChatLockDetailSchema, - type ChatImageData, - type ChatLockDetailData, -} from "./chat_payloads"; +import { ChatImageSchema, ChatLockDetailSchema } from "./chat_payloads"; export const ChatMessageSchema = z .object({ @@ -27,36 +22,10 @@ export const ChatMessageSchema = z .transform(({ created_at, ...data }) => ({ ...data, createdAt: data.createdAt || created_at || "", - })); + })) + .readonly(); export type ChatMessageInput = z.input; export type ChatMessageData = z.output; -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; diff --git a/src/data/schemas/chat/chat_payloads.ts b/src/data/schemas/chat/chat_payloads.ts index 7fef6773..284fd539 100644 --- a/src/data/schemas/chat/chat_payloads.ts +++ b/src/data/schemas/chat/chat_payloads.ts @@ -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; export type ChatImageData = z.output; diff --git a/src/data/schemas/chat/request/send_message_request.ts b/src/data/schemas/chat/request/send_message_request.ts index dd359fed..c0510aac 100644 --- a/src/data/schemas/chat/request/send_message_request.ts +++ b/src/data/schemas/chat/request/send_message_request.ts @@ -1,6 +1,6 @@ /** * 发送消息请求 - * + * */ import { z } from "zod"; @@ -10,48 +10,22 @@ import { stringOrEmpty, } from "../../nullable-defaults"; -export const SendMessageRequestSchema = z.object({ - characterId: z.string().min(1), - message: stringOrEmpty, - image: stringOrEmpty, - imageId: stringOrEmpty, - imageThumbUrl: stringOrEmpty, - imageMediumUrl: stringOrEmpty, - imageOriginalUrl: stringOrEmpty, - imageWidth: numberOrZero, - imageHeight: numberOrZero, - useWebSocket: booleanOrFalse, -}); +export const SendMessageRequestSchema = z + .object({ + characterId: z.string().min(1), + message: stringOrEmpty, + image: stringOrEmpty, + imageId: stringOrEmpty, + imageThumbUrl: stringOrEmpty, + imageMediumUrl: stringOrEmpty, + imageOriginalUrl: stringOrEmpty, + imageWidth: numberOrZero, + imageHeight: numberOrZero, + useWebSocket: booleanOrFalse, + }) + .readonly(); export type SendMessageRequestInput = z.input; export type SendMessageRequestData = z.output; -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; diff --git a/src/data/schemas/chat/request/unlock_history_request.ts b/src/data/schemas/chat/request/unlock_history_request.ts index a28eed8b..b130812d 100644 --- a/src/data/schemas/chat/request/unlock_history_request.ts +++ b/src/data/schemas/chat/request/unlock_history_request.ts @@ -1,8 +1,10 @@ import { z } from "zod"; -export const UnlockHistoryRequestSchema = z.object({ - characterId: z.string().min(1), -}); +export const UnlockHistoryRequestSchema = z + .object({ + characterId: z.string().min(1), + }) + .readonly(); export type UnlockHistoryRequestInput = z.input< typeof UnlockHistoryRequestSchema @@ -11,19 +13,4 @@ export type UnlockHistoryRequestData = z.output< typeof UnlockHistoryRequestSchema >; -export class UnlockHistoryRequest { - declare readonly characterId: string; - - private constructor(input: UnlockHistoryRequestInput) { - Object.assign(this, UnlockHistoryRequestSchema.parse(input)); - Object.freeze(this); - } - - static from(input: UnlockHistoryRequestInput): UnlockHistoryRequest { - return new UnlockHistoryRequest(input); - } - - toJson(): UnlockHistoryRequestData { - return UnlockHistoryRequestSchema.parse(this); - } -} +export type UnlockHistoryRequest = UnlockHistoryRequestData; diff --git a/src/data/schemas/chat/request/unlock_private_request.ts b/src/data/schemas/chat/request/unlock_private_request.ts index 90ce5659..5f540a90 100644 --- a/src/data/schemas/chat/request/unlock_private_request.ts +++ b/src/data/schemas/chat/request/unlock_private_request.ts @@ -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; diff --git a/src/data/schemas/chat/response/chat_history_response.ts b/src/data/schemas/chat/response/chat_history_response.ts index c24f31ab..f4d2a7fd 100644 --- a/src/data/schemas/chat/response/chat_history_response.ts +++ b/src/data/schemas/chat/response/chat_history_response.ts @@ -1,6 +1,6 @@ /** * 聊天历史响应 - * + * */ import { z } from "zod"; @@ -9,45 +9,26 @@ import { booleanOrFalse, numberOrZero, } from "../../nullable-defaults"; -import { ChatMessage, ChatMessageSchema } from "../chat_message"; +import { ChatMessageSchema } from "../chat_message"; -export const ChatHistoryResponseSchema = z.object({ - messages: arrayOrEmpty(ChatMessageSchema), - total: numberOrZero, - limit: numberOrZero, - offset: numberOrZero, - isVip: booleanOrFalse, - privateFreeLimit: numberOrZero, - privateUsedToday: numberOrZero, - privateCanViewFree: booleanOrFalse, -}); +export const ChatHistoryResponseSchema = z + .object({ + messages: arrayOrEmpty(ChatMessageSchema), + total: numberOrZero, + limit: numberOrZero, + offset: numberOrZero, + isVip: booleanOrFalse, + privateFreeLimit: numberOrZero, + privateUsedToday: numberOrZero, + privateCanViewFree: booleanOrFalse, + }) + .readonly(); -export type ChatHistoryResponseInput = z.input; -export type ChatHistoryResponseData = z.output; +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; diff --git a/src/data/schemas/chat/response/chat_send_response.ts b/src/data/schemas/chat/response/chat_send_response.ts index 0107d272..140893a0 100644 --- a/src/data/schemas/chat/response/chat_send_response.ts +++ b/src/data/schemas/chat/response/chat_send_response.ts @@ -1,6 +1,6 @@ /** * 发送消息响应 - * + * */ import { z } from "zod"; import { @@ -10,60 +10,27 @@ import { numberOrZero, stringOrEmpty, } from "../../nullable-defaults"; -import { - ChatImageSchema, - ChatLockDetailSchema, - type ChatImageData, - type ChatLockDetailData, -} from "../chat_payloads"; +import { ChatImageSchema, ChatLockDetailSchema } from "../chat_payloads"; -export const ChatSendResponseSchema = z.object({ - reply: stringOrEmpty, - audioUrl: stringOrEmpty, - messageId: stringOrEmpty, - isGuest: booleanOrFalse, - // 函数式 default —— 每次 parse 时重新调 Date.now()(后端不返回 timestamp 时用当下时间) - timestamp: numberOrLazy(() => Date.now()), - image: ChatImageSchema, - lockDetail: ChatLockDetailSchema, - canSendMessage: booleanOrTrue, - creditBalance: numberOrZero, - creditsCharged: numberOrZero, - requiredCredits: numberOrZero, - shortfallCredits: numberOrZero, -}); +export const ChatSendResponseSchema = z + .object({ + reply: stringOrEmpty, + audioUrl: stringOrEmpty, + messageId: stringOrEmpty, + isGuest: booleanOrFalse, + // 函数式 default —— 每次 parse 时重新调 Date.now()(后端不返回 timestamp 时用当下时间) + timestamp: numberOrLazy(() => Date.now()), + image: ChatImageSchema, + lockDetail: ChatLockDetailSchema, + canSendMessage: booleanOrTrue, + creditBalance: numberOrZero, + creditsCharged: numberOrZero, + requiredCredits: numberOrZero, + shortfallCredits: numberOrZero, + }) + .readonly(); export type ChatSendResponseInput = z.input; export type ChatSendResponseData = z.output; -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; diff --git a/src/data/schemas/chat/response/unlock_history_response.ts b/src/data/schemas/chat/response/unlock_history_response.ts index 69f57885..2c6cbcd1 100644 --- a/src/data/schemas/chat/response/unlock_history_response.ts +++ b/src/data/schemas/chat/response/unlock_history_response.ts @@ -18,21 +18,23 @@ export const UnlockHistoryReasonSchema = z.enum([ "no_locked_messages", ]); -export const UnlockHistoryResponseSchema = z.object({ - unlocked: booleanOrFalse, - reason: UnlockHistoryReasonSchema, - totalLocked: numberOrZero, - unlockedCount: numberOrZero, - privateCount: numberOrZero, - imageCount: numberOrZero, - voiceCount: numberOrZero, - requiredCredits: numberOrZero, - currentCredits: numberOrZero, - remainingCredits: numberOrZero, - shortfallCredits: numberOrZero, - costsByMessage: UnlockHistoryCostsByMessageSchema, - messageIds: arrayOrEmpty(z.string()), -}); +export const UnlockHistoryResponseSchema = z + .object({ + unlocked: booleanOrFalse, + reason: UnlockHistoryReasonSchema, + totalLocked: numberOrZero, + unlockedCount: numberOrZero, + privateCount: numberOrZero, + imageCount: numberOrZero, + voiceCount: numberOrZero, + requiredCredits: numberOrZero, + currentCredits: numberOrZero, + remainingCredits: numberOrZero, + shortfallCredits: numberOrZero, + costsByMessage: UnlockHistoryCostsByMessageSchema, + messageIds: arrayOrEmpty(z.string()), + }) + .readonly(); export type UnlockHistoryReason = z.output; 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; diff --git a/src/data/schemas/chat/response/unlock_private_response.ts b/src/data/schemas/chat/response/unlock_private_response.ts index 38ee1b31..7fff4d26 100644 --- a/src/data/schemas/chat/response/unlock_private_response.ts +++ b/src/data/schemas/chat/response/unlock_private_response.ts @@ -6,28 +6,30 @@ import { stringOrEmpty, stringOrNull, } from "../../nullable-defaults"; -import { ChatImageSchema, type ChatImageData } from "../chat_payloads"; import { ChatLockTypeSchema } from "../chat_lock_type"; +import { ChatImageSchema } from "../chat_payloads"; /** * 单条历史付费 / 私密消息解锁响应。 */ export const UnlockPrivateReasonSchema = stringOrEmpty; -export const UnlockPrivateResponseSchema = z.object({ - unlocked: booleanOrFalse, - content: stringOrEmpty, - messageId: stringOrEmpty, - clientLockId: stringOrNull, - lockType: ChatLockTypeSchema.nullable().default(null), - audioUrl: stringOrEmpty, - image: ChatImageSchema, - reason: UnlockPrivateReasonSchema, - creditBalance: numberOrZero, - creditsCharged: numberOrZero, - requiredCredits: numberOrZero, - shortfallCredits: numberOrZero, -}); +export const UnlockPrivateResponseSchema = z + .object({ + unlocked: booleanOrFalse, + content: stringOrEmpty, + messageId: stringOrEmpty, + clientLockId: stringOrNull, + lockType: ChatLockTypeSchema.nullable().default(null), + audioUrl: stringOrEmpty, + image: ChatImageSchema, + reason: UnlockPrivateReasonSchema, + creditBalance: numberOrZero, + creditsCharged: numberOrZero, + requiredCredits: numberOrZero, + shortfallCredits: numberOrZero, + }) + .readonly(); export type UnlockPrivateReason = z.output; 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; diff --git a/src/data/schemas/feedback/response/feedback_submit_response.ts b/src/data/schemas/feedback/response/feedback_submit_response.ts index 76a6d87c..4732c874 100644 --- a/src/data/schemas/feedback/response/feedback_submit_response.ts +++ b/src/data/schemas/feedback/response/feedback_submit_response.ts @@ -1,13 +1,10 @@ import { z } from "zod"; -import { - createSchemaModel, - type SchemaModel, -} from "../../schema_model"; - -export const FeedbackSubmitResponseSchema = z.object({ - feedbackId: z.string().min(1), -}); +export const FeedbackSubmitResponseSchema = z + .object({ + feedbackId: z.string().min(1), + }) + .readonly(); export type FeedbackSubmitResponseInput = z.input< typeof FeedbackSubmitResponseSchema @@ -15,12 +12,4 @@ export type FeedbackSubmitResponseInput = z.input< export type FeedbackSubmitResponseData = z.output< typeof FeedbackSubmitResponseSchema >; - -export type FeedbackSubmitResponse = SchemaModel< - typeof FeedbackSubmitResponseSchema, - "feedbackId" ->; -export const FeedbackSubmitResponse = createSchemaModel< - typeof FeedbackSubmitResponseSchema, - "feedbackId" ->(FeedbackSubmitResponseSchema); +export type FeedbackSubmitResponse = FeedbackSubmitResponseData; diff --git a/src/data/schemas/metrics/request/app_event.ts b/src/data/schemas/metrics/request/app_event.ts index eed49773..ff269e09 100644 --- a/src/data/schemas/metrics/request/app_event.ts +++ b/src/data/schemas/metrics/request/app_event.ts @@ -1,28 +1,19 @@ /** * 应用事件上报请求 - * + * */ import { z } from "zod"; -import { - createSchemaModel, - type SchemaModel, -} from "../../schema_model"; - import { stringOrEmpty } from "../../nullable-defaults"; -export const AppEventSchema = z.object({ - userId: stringOrEmpty, - browser: stringOrEmpty, - userAgent: stringOrEmpty, -}); +export const AppEventSchema = z + .object({ + userId: stringOrEmpty, + browser: stringOrEmpty, + userAgent: stringOrEmpty, + }) + .readonly(); export type AppEventInput = z.input; export type AppEventData = z.output; - -type AppEventPublicKey = "userId" | "browser" | "userAgent"; -export type AppEvent = SchemaModel; -export const AppEvent = createSchemaModel< - typeof AppEventSchema, - AppEventPublicKey ->(AppEventSchema); +export type AppEvent = AppEventData; diff --git a/src/data/schemas/metrics/request/pwa_event.ts b/src/data/schemas/metrics/request/pwa_event.ts index b92994c1..09a03916 100644 --- a/src/data/schemas/metrics/request/pwa_event.ts +++ b/src/data/schemas/metrics/request/pwa_event.ts @@ -1,39 +1,25 @@ /** * PWA 事件上报请求 - * + * */ import { z } from "zod"; -import { - createSchemaModel, - type SchemaModel, -} from "../../schema_model"; - import { booleanOrFalse, numberOrZero, stringOrEmpty, } from "../../nullable-defaults"; -export const PwaEventSchema = z.object({ - deviceId: stringOrEmpty, - deviceType: stringOrEmpty, - timestamp: numberOrZero, - pwaInstalled: booleanOrFalse, - pwaSupported: booleanOrFalse, -}); +export const PwaEventSchema = z + .object({ + deviceId: stringOrEmpty, + deviceType: stringOrEmpty, + timestamp: numberOrZero, + pwaInstalled: booleanOrFalse, + pwaSupported: booleanOrFalse, + }) + .readonly(); export type PwaEventInput = z.input; export type PwaEventData = z.output; - -type PwaEventPublicKey = - | "deviceId" - | "deviceType" - | "timestamp" - | "pwaInstalled" - | "pwaSupported"; -export type PwaEvent = SchemaModel; -export const PwaEvent = createSchemaModel< - typeof PwaEventSchema, - PwaEventPublicKey ->(PwaEventSchema); +export type PwaEvent = PwaEventData; diff --git a/src/data/schemas/nullable-defaults.ts b/src/data/schemas/nullable-defaults.ts index 03fed6a8..0f72e1b8 100644 --- a/src/data/schemas/nullable-defaults.ts +++ b/src/data/schemas/nullable-defaults.ts @@ -91,7 +91,8 @@ export function arrayOrEmpty(schema: T) { .array(schema) .nullable() .transform((v) => v ?? []) - .default(() => []); + .default(() => []) + .readonly(); } /** `Record | null | undefined` → `Record`(默认空对象) */ @@ -100,7 +101,8 @@ export function recordOrEmpty(schema: T) { .record(z.string(), schema) .nullable() .transform((v) => v ?? {}) - .default(() => ({})); + .default(() => ({})) + .readonly(); } /** `Record | null | undefined` → `Record` */ @@ -129,10 +131,10 @@ export function schemaOr( ); if (typeof defaultValue === "function") { - return preprocessed.default(defaultValue as () => SchemaDefault); + return preprocessed.prefault(defaultValue as () => SchemaDefault); } - return preprocessed.default(defaultValue); + return preprocessed.prefault(defaultValue); } /** `T | null | undefined` → `T | null`(默认 null) */ diff --git a/src/data/schemas/payment/__tests__/payment_plan.test.ts b/src/data/schemas/payment/__tests__/payment_plan.test.ts index 90cc735e..e2582212 100644 --- a/src/data/schemas/payment/__tests__/payment_plan.test.ts +++ b/src/data/schemas/payment/__tests__/payment_plan.test.ts @@ -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", diff --git a/src/data/schemas/payment/payment_plan.ts b/src/data/schemas/payment/payment_plan.ts index 6c5e63ea..07364ad6 100644 --- a/src/data/schemas/payment/payment_plan.ts +++ b/src/data/schemas/payment/payment_plan.ts @@ -9,56 +9,26 @@ import { stringOrNull, } from "../nullable-defaults"; -export const PaymentPlanSchema = z.object({ - planId: z.string(), - planName: z.string(), - orderType: z.string(), - vipDays: numberOrNull, - dolAmount: numberOrNull, - creditBalance: z.number(), - amountCents: z.number(), - originalAmountCents: numberOrNull, - dailyPriceCents: numberOrNull, - currency: z.string(), - isFirstRechargeOffer: booleanOrFalse, - mostPopular: booleanOrFalse, - firstRechargeDiscountPercent: numberOrNull, - promotionType: stringOrNull, -}); +export const PaymentPlanSchema = z + .object({ + planId: z.string(), + planName: z.string(), + orderType: z.string(), + vipDays: numberOrNull, + dolAmount: numberOrNull, + creditBalance: z.number(), + amountCents: z.number(), + originalAmountCents: numberOrNull, + dailyPriceCents: numberOrNull, + currency: z.string(), + isFirstRechargeOffer: booleanOrFalse, + mostPopular: booleanOrFalse, + firstRechargeDiscountPercent: numberOrNull, + promotionType: stringOrNull, + }) + .readonly(); export type PaymentPlanInput = z.input; export type PaymentPlanData = z.output; -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; diff --git a/src/data/schemas/payment/request/create_payment_order_request.ts b/src/data/schemas/payment/request/create_payment_order_request.ts index 38793281..db4e19dc 100644 --- a/src/data/schemas/payment/request/create_payment_order_request.ts +++ b/src/data/schemas/payment/request/create_payment_order_request.ts @@ -5,11 +5,13 @@ import { z } from "zod"; export const PayChannelSchema = z.enum(["stripe", "ezpay"]); -export const CreatePaymentOrderRequestSchema = z.object({ - planId: z.string(), - payChannel: PayChannelSchema, - autoRenew: z.boolean(), -}); +export const CreatePaymentOrderRequestSchema = z + .object({ + planId: z.string(), + payChannel: PayChannelSchema, + autoRenew: z.boolean(), + }) + .readonly(); export type PayChannel = z.output; 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; diff --git a/src/data/schemas/payment/response/create_payment_order_response.ts b/src/data/schemas/payment/response/create_payment_order_response.ts index dccd397a..680835fe 100644 --- a/src/data/schemas/payment/response/create_payment_order_response.ts +++ b/src/data/schemas/payment/response/create_payment_order_response.ts @@ -19,35 +19,42 @@ const paymentUrlKeys = [ "url", ] as const; -export const CreatePaymentOrderResponseSchema = z.object({ - orderId: z.string(), - payParams: z.record(z.string(), z.unknown()), - cashierUrl: stringOrNull, - cashier_url: stringOrNull, - checkoutUrl: stringOrNull, - checkout_url: stringOrNull, - paymentUrl: stringOrNull, - payment_url: stringOrNull, - approvalUrl: stringOrNull, - approval_url: stringOrNull, - redirectUrl: stringOrNull, - redirect_url: stringOrNull, - url: stringOrNull, -}).transform((data) => { - const payParams = { ...data.payParams }; +export const CreatePaymentOrderResponseSchema = z + .object({ + orderId: z.string(), + payParams: z.record(z.string(), z.unknown()).readonly(), + cashierUrl: stringOrNull, + cashier_url: stringOrNull, + checkoutUrl: stringOrNull, + checkout_url: stringOrNull, + paymentUrl: stringOrNull, + payment_url: stringOrNull, + approvalUrl: stringOrNull, + approval_url: stringOrNull, + redirectUrl: stringOrNull, + redirect_url: stringOrNull, + url: stringOrNull, + }) + .transform((data) => { + const payParams = { ...data.payParams }; - for (const key of paymentUrlKeys) { - const value = data[key]; - if (typeof value === "string" && value.length > 0 && !(key in payParams)) { - payParams[key] = value; + for (const key of paymentUrlKeys) { + const value = data[key]; + if ( + typeof value === "string" && + value.length > 0 && + !(key in payParams) + ) { + payParams[key] = value; + } } - } - return { - orderId: data.orderId, - payParams, - }; -}); + return { + orderId: data.orderId, + payParams, + }; + }) + .readonly(); export type CreatePaymentOrderResponseInput = z.input< typeof CreatePaymentOrderResponseSchema @@ -56,29 +63,4 @@ export type CreatePaymentOrderResponseData = z.output< typeof CreatePaymentOrderResponseSchema >; -export class CreatePaymentOrderResponse { - declare readonly orderId: string; - declare readonly payParams: Record; - - 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; diff --git a/src/data/schemas/payment/response/payment_order_status_response.ts b/src/data/schemas/payment/response/payment_order_status_response.ts index d35f1686..8f6117fe 100644 --- a/src/data/schemas/payment/response/payment_order_status_response.ts +++ b/src/data/schemas/payment/response/payment_order_status_response.ts @@ -5,12 +5,14 @@ import { z } from "zod"; export const PaymentOrderStatusSchema = z.enum(["pending", "paid", "failed"]); -export const PaymentOrderStatusResponseSchema = z.object({ - orderId: z.string(), - status: PaymentOrderStatusSchema, - orderType: z.string(), - planId: z.string(), -}); +export const PaymentOrderStatusResponseSchema = z + .object({ + orderId: z.string(), + status: PaymentOrderStatusSchema, + orderType: z.string(), + planId: z.string(), + }) + .readonly(); export type PaymentOrderStatus = z.output; 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; diff --git a/src/data/schemas/payment/response/payment_plans_response.ts b/src/data/schemas/payment/response/payment_plans_response.ts index 2aee10a4..a041253d 100644 --- a/src/data/schemas/payment/response/payment_plans_response.ts +++ b/src/data/schemas/payment/response/payment_plans_response.ts @@ -10,19 +10,23 @@ import { schemaOrNull, stringOrEmpty, } from "../../nullable-defaults"; -import { PaymentPlan, PaymentPlanSchema } from "../payment_plan"; +import { PaymentPlanSchema } from "../payment_plan"; -export const FirstRechargeOfferSchema = z.object({ - enabled: booleanOrFalse, - type: stringOrEmpty, - discountPercent: numberOrZero, -}); +export const FirstRechargeOfferSchema = z + .object({ + enabled: booleanOrFalse, + type: stringOrEmpty, + discountPercent: numberOrZero, + }) + .readonly(); -export const PaymentPlansResponseSchema = z.object({ - isFirstRecharge: booleanOrFalse, - firstRechargeOffer: schemaOrNull(FirstRechargeOfferSchema), - plans: arrayOrEmpty(PaymentPlanSchema), -}); +export const PaymentPlansResponseSchema = z + .object({ + isFirstRecharge: booleanOrFalse, + firstRechargeOffer: schemaOrNull(FirstRechargeOfferSchema), + plans: arrayOrEmpty(PaymentPlanSchema), + }) + .readonly(); export type PaymentPlansResponseInput = z.input< typeof PaymentPlansResponseSchema @@ -32,36 +36,6 @@ export type PaymentPlansResponseData = z.output< >; export type FirstRechargeOfferData = z.output; -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; diff --git a/src/data/schemas/payment/response/tip_payment_plans_response.ts b/src/data/schemas/payment/response/tip_payment_plans_response.ts index b1b2b47d..a29653c4 100644 --- a/src/data/schemas/payment/response/tip_payment_plans_response.ts +++ b/src/data/schemas/payment/response/tip_payment_plans_response.ts @@ -1,11 +1,13 @@ import { z } from "zod"; import { arrayOrEmpty } from "../../nullable-defaults"; -import { TipPaymentPlan, TipPaymentPlanSchema } from "../tip_payment_plan"; +import { TipPaymentPlanSchema } from "../tip_payment_plan"; -export const TipPaymentPlansResponseSchema = z.object({ - plans: arrayOrEmpty(TipPaymentPlanSchema), -}); +export const TipPaymentPlansResponseSchema = z + .object({ + plans: arrayOrEmpty(TipPaymentPlanSchema), + }) + .readonly(); export type TipPaymentPlansResponseInput = z.input< typeof TipPaymentPlansResponseSchema @@ -14,30 +16,4 @@ export type TipPaymentPlansResponseData = z.output< typeof TipPaymentPlansResponseSchema >; -export class TipPaymentPlansResponse { - declare readonly plans: TipPaymentPlan[]; - - private constructor(input: TipPaymentPlansResponseInput) { - const data = TipPaymentPlansResponseSchema.parse(input); - Object.assign(this, { - plans: data.plans.map((plan) => TipPaymentPlan.from(plan)), - }); - Object.freeze(this); - } - - static from(input: TipPaymentPlansResponseInput): TipPaymentPlansResponse { - return new TipPaymentPlansResponse(input); - } - - static fromJson(json: unknown): TipPaymentPlansResponse { - return TipPaymentPlansResponse.from( - json as TipPaymentPlansResponseInput, - ); - } - - toJson(): TipPaymentPlansResponseData { - return { - plans: this.plans.map((plan) => plan.toJson()), - }; - } -} +export type TipPaymentPlansResponse = TipPaymentPlansResponseData; diff --git a/src/data/schemas/payment/tip_payment_plan.ts b/src/data/schemas/payment/tip_payment_plan.ts index a3a8955e..01599dbd 100644 --- a/src/data/schemas/payment/tip_payment_plan.ts +++ b/src/data/schemas/payment/tip_payment_plan.ts @@ -1,36 +1,15 @@ import { z } from "zod"; -export const TipPaymentPlanSchema = z.object({ - planId: z.string(), - planName: z.string(), - amountCents: z.number(), - currency: z.string(), -}); +export const TipPaymentPlanSchema = z + .object({ + planId: z.string(), + planName: z.string(), + amountCents: z.number(), + currency: z.string(), + }) + .readonly(); export type TipPaymentPlanInput = z.input; export type TipPaymentPlanData = z.output; -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; diff --git a/src/data/schemas/private-room/__tests__/private_album.test.ts b/src/data/schemas/private-room/__tests__/private_album.test.ts index 629ae5f8..ecf38982 100644 --- a/src/data/schemas/private-room/__tests__/private_album.test.ts +++ b/src/data/schemas/private-room/__tests__/private_album.test.ts @@ -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 }); }); diff --git a/src/data/schemas/private-room/private_album.ts b/src/data/schemas/private-room/private_album.ts index e57887fa..90efb1e2 100644 --- a/src/data/schemas/private-room/private_album.ts +++ b/src/data/schemas/private-room/private_album.ts @@ -9,62 +9,38 @@ import { stringOrNull, } from "../nullable-defaults"; -export const PrivateAlbumImageSchema = z.object({ - url: stringOrEmpty, - locked: booleanOrFalse, - index: numberOrZero, -}); +export const PrivateAlbumImageSchema = z + .object({ + url: stringOrEmpty, + locked: booleanOrFalse, + index: numberOrZero, + }) + .readonly(); -export const PrivateAlbumLockDetailSchema = z.object({ - locked: booleanOrFalse, -}); +export const PrivateAlbumLockDetailSchema = z + .object({ + locked: booleanOrFalse, + }) + .readonly(); -export const PrivateAlbumSchema = z.object({ - albumId: stringOrEmpty, - title: stringOrEmpty, - content: stringOrNull, - previewText: stringOrEmpty, - imageCount: numberOrZero, - images: arrayOrEmpty(PrivateAlbumImageSchema), - locked: booleanOrFalse, - unlocked: booleanOrFalse, - unlockCost: numberOrZero, - publishedAt: stringOrNull, - lockDetail: schemaOr(PrivateAlbumLockDetailSchema, { locked: false }), -}); +export const PrivateAlbumSchema = z + .object({ + albumId: stringOrEmpty, + title: stringOrEmpty, + content: stringOrNull, + previewText: stringOrEmpty, + imageCount: numberOrZero, + images: arrayOrEmpty(PrivateAlbumImageSchema), + locked: booleanOrFalse, + unlocked: booleanOrFalse, + unlockCost: numberOrZero, + publishedAt: stringOrNull, + lockDetail: schemaOr(PrivateAlbumLockDetailSchema, { locked: false }), + }) + .readonly(); export type PrivateAlbumImageData = z.output; export type PrivateAlbumInput = z.input; export type PrivateAlbumData = z.output; -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; diff --git a/src/data/schemas/private-room/request/unlock_private_album_request.ts b/src/data/schemas/private-room/request/unlock_private_album_request.ts index 9ae44cbf..71aba24d 100644 --- a/src/data/schemas/private-room/request/unlock_private_album_request.ts +++ b/src/data/schemas/private-room/request/unlock_private_album_request.ts @@ -2,9 +2,11 @@ import { z } from "zod"; import { numberOrZero } from "../../nullable-defaults"; -export const UnlockPrivateAlbumRequestSchema = z.object({ - expectedCost: numberOrZero, -}); +export const UnlockPrivateAlbumRequestSchema = z + .object({ + expectedCost: numberOrZero, + }) + .readonly(); export type UnlockPrivateAlbumRequestInput = z.input< typeof UnlockPrivateAlbumRequestSchema @@ -13,18 +15,4 @@ export type UnlockPrivateAlbumRequestData = z.output< typeof UnlockPrivateAlbumRequestSchema >; -export class UnlockPrivateAlbumRequest { - private constructor(input: UnlockPrivateAlbumRequestInput) { - const data = UnlockPrivateAlbumRequestSchema.parse(input); - Object.assign(this, data); - Object.freeze(this); - } - - static from(input: UnlockPrivateAlbumRequestInput): UnlockPrivateAlbumRequest { - return new UnlockPrivateAlbumRequest(input); - } - - toJson(): UnlockPrivateAlbumRequestData { - return UnlockPrivateAlbumRequestSchema.parse(this); - } -} +export type UnlockPrivateAlbumRequest = UnlockPrivateAlbumRequestData; diff --git a/src/data/schemas/private-room/response/private_album_unlock_response.ts b/src/data/schemas/private-room/response/private_album_unlock_response.ts index ee76e2aa..c71e7ef6 100644 --- a/src/data/schemas/private-room/response/private_album_unlock_response.ts +++ b/src/data/schemas/private-room/response/private_album_unlock_response.ts @@ -19,17 +19,19 @@ export const PrivateAlbumUnlockReasonSchema = z.enum([ "not_found", ]); -export const PrivateAlbumUnlockResponseSchema = z.object({ - albumId: stringOrEmpty, - locked: booleanOrFalse, - unlocked: booleanOrFalse, - reason: PrivateAlbumUnlockReasonSchema.or(stringOrEmpty), - unlockCost: numberOrZero, - requiredCredits: numberOrZero, - creditBalance: numberOrZero, - shortfallCredits: numberOrZero, - images: arrayOrEmpty(PrivateAlbumImageSchema), -}); +export const PrivateAlbumUnlockResponseSchema = z + .object({ + albumId: stringOrEmpty, + locked: booleanOrFalse, + unlocked: booleanOrFalse, + reason: PrivateAlbumUnlockReasonSchema.or(stringOrEmpty), + unlockCost: numberOrZero, + requiredCredits: numberOrZero, + creditBalance: numberOrZero, + shortfallCredits: numberOrZero, + images: arrayOrEmpty(PrivateAlbumImageSchema), + }) + .readonly(); export type PrivateAlbumUnlockReason = z.output< typeof PrivateAlbumUnlockReasonSchema @@ -41,34 +43,4 @@ export type PrivateAlbumUnlockResponseData = z.output< typeof PrivateAlbumUnlockResponseSchema >; -export class PrivateAlbumUnlockResponse { - declare readonly albumId: string; - declare readonly locked: boolean; - declare readonly unlocked: boolean; - declare readonly reason: PrivateAlbumUnlockReason | string; - declare readonly unlockCost: number; - declare readonly requiredCredits: number; - declare readonly creditBalance: number; - declare readonly shortfallCredits: number; - declare readonly images: PrivateAlbumUnlockResponseData["images"]; - - private constructor(input: PrivateAlbumUnlockResponseInput) { - const data = PrivateAlbumUnlockResponseSchema.parse(input); - Object.assign(this, data); - Object.freeze(this); - } - - static from(input: PrivateAlbumUnlockResponseInput): PrivateAlbumUnlockResponse { - return new PrivateAlbumUnlockResponse(input); - } - - static fromJson(json: unknown): PrivateAlbumUnlockResponse { - return PrivateAlbumUnlockResponse.from( - json as PrivateAlbumUnlockResponseInput, - ); - } - - toJson(): PrivateAlbumUnlockResponseData { - return PrivateAlbumUnlockResponseSchema.parse(this); - } -} +export type PrivateAlbumUnlockResponse = PrivateAlbumUnlockResponseData; diff --git a/src/data/schemas/private-room/response/private_albums_response.ts b/src/data/schemas/private-room/response/private_albums_response.ts index afe590d1..9be1c9c8 100644 --- a/src/data/schemas/private-room/response/private_albums_response.ts +++ b/src/data/schemas/private-room/response/private_albums_response.ts @@ -1,12 +1,14 @@ import { z } from "zod"; import { arrayOrEmpty, numberOrZero } from "../../nullable-defaults"; -import { PrivateAlbum, PrivateAlbumSchema } from "../private_album"; +import { PrivateAlbumSchema } from "../private_album"; -export const PrivateAlbumsResponseSchema = z.object({ - items: arrayOrEmpty(PrivateAlbumSchema), - creditBalance: numberOrZero, -}); +export const PrivateAlbumsResponseSchema = z + .object({ + items: arrayOrEmpty(PrivateAlbumSchema), + creditBalance: numberOrZero, + }) + .readonly(); export type PrivateAlbumsResponseInput = z.input< typeof PrivateAlbumsResponseSchema @@ -15,31 +17,4 @@ export type PrivateAlbumsResponseData = z.output< typeof PrivateAlbumsResponseSchema >; -export class PrivateAlbumsResponse { - declare readonly items: PrivateAlbum[]; - declare readonly creditBalance: number; - - private constructor(input: PrivateAlbumsResponseInput) { - const data = PrivateAlbumsResponseSchema.parse(input); - Object.assign(this, { - items: data.items.map((item) => PrivateAlbum.from(item)), - creditBalance: data.creditBalance, - }); - Object.freeze(this); - } - - static from(input: PrivateAlbumsResponseInput): PrivateAlbumsResponse { - return new PrivateAlbumsResponse(input); - } - - static fromJson(json: unknown): PrivateAlbumsResponse { - return PrivateAlbumsResponse.from(json as PrivateAlbumsResponseInput); - } - - toJson(): PrivateAlbumsResponseData { - return { - items: this.items.map((item) => item.toJson()), - creditBalance: this.creditBalance, - }; - } -} +export type PrivateAlbumsResponse = PrivateAlbumsResponseData; diff --git a/src/data/schemas/schema_model.ts b/src/data/schemas/schema_model.ts deleted file mode 100644 index 1cb42b9b..00000000 --- a/src/data/schemas/schema_model.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { z } from "zod"; - -export type SchemaModel< - TSchema extends z.ZodType, - TPublicKey extends keyof z.output = never, -> = Readonly, TPublicKey>> & { - toJson(): z.output; -}; - -export interface SchemaModelFactory< - TSchema extends z.ZodType, - TPublicKey extends keyof z.output = never, -> { - readonly prototype: SchemaModel; - [Symbol.hasInstance](value: unknown): boolean; - from(input: z.input): SchemaModel; - fromJson(json: unknown): SchemaModel; -} - -/** Creates a shallow-frozen data class backed by a Zod schema. */ -export function createSchemaModel< - TSchema extends z.ZodType, - TPublicKey extends keyof z.output = never, ->(schema: TSchema): SchemaModelFactory { - class GeneratedSchemaModel { - private constructor(input: z.input) { - Object.assign(this, schema.parse(input) as object); - Object.freeze(this); - } - - static from( - input: z.input, - ): SchemaModel { - return new GeneratedSchemaModel(input) as SchemaModel< - TSchema, - TPublicKey - >; - } - - static fromJson(json: unknown): SchemaModel { - return GeneratedSchemaModel.from(json as z.input); - } - - toJson(): z.output { - return schema.parse(this); - } - } - - return GeneratedSchemaModel as unknown as SchemaModelFactory< - TSchema, - TPublicKey - >; -} diff --git a/src/data/schemas/user/__tests__/user.test.ts b/src/data/schemas/user/__tests__/user.test.ts index a6d6ae92..76872be3 100644 --- a/src/data/schemas/user/__tests__/user.test.ts +++ b/src/data/schemas/user/__tests__/user.test.ts @@ -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, diff --git a/src/data/schemas/user/avatar_data.ts b/src/data/schemas/user/avatar_data.ts index 20b4da54..948b21c0 100644 --- a/src/data/schemas/user/avatar_data.ts +++ b/src/data/schemas/user/avatar_data.ts @@ -1,38 +1,19 @@ /** * 头像数据模型 - * + * */ import { z } from "zod"; import { stringOrEmpty } from "../nullable-defaults"; -export const AvatarDataSchema = z.object({ - avatarUrl: stringOrEmpty, - userId: stringOrEmpty, -}); +export const AvatarDataSchema = z + .object({ + avatarUrl: stringOrEmpty, + userId: stringOrEmpty, + }) + .readonly(); export type AvatarDataInput = z.input; export type AvatarDataData = z.output; -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; diff --git a/src/data/schemas/user/persisted_user.ts b/src/data/schemas/user/persisted_user.ts index 08922956..74f7e11d 100644 --- a/src/data/schemas/user/persisted_user.ts +++ b/src/data/schemas/user/persisted_user.ts @@ -8,15 +8,17 @@ import { z } from "zod"; import { numberOrZero, stringOrEmpty } from "../nullable-defaults"; -export const PersistedUserSchema = z.object({ - id: stringOrEmpty, - username: stringOrEmpty, - countryCode: stringOrEmpty, - dailyFreeChatLimit: numberOrZero, - dailyFreeChatRemaining: numberOrZero, - dailyFreePrivateLimit: numberOrZero, - dailyFreePrivateRemaining: numberOrZero, -}); +export const PersistedUserSchema = z + .object({ + id: stringOrEmpty, + username: stringOrEmpty, + countryCode: stringOrEmpty, + dailyFreeChatLimit: numberOrZero, + dailyFreeChatRemaining: numberOrZero, + dailyFreePrivateLimit: numberOrZero, + dailyFreePrivateRemaining: numberOrZero, + }) + .readonly(); export type PersistedUserInput = z.input; export type PersistedUserData = z.output; diff --git a/src/data/schemas/user/personality_traits.ts b/src/data/schemas/user/personality_traits.ts index cccd01fa..41d8ac5d 100644 --- a/src/data/schemas/user/personality_traits.ts +++ b/src/data/schemas/user/personality_traits.ts @@ -1,6 +1,6 @@ /** * 个性特征模型 - * + * */ import { z } from "zod"; @@ -14,39 +14,17 @@ export const PERSONALITY_TRAITS_DEFAULTS = { romantic: 0.5, } as const; -export const PersonalityTraitsSchema = z.object({ - cheerful: numberOr(0.5), - caring: numberOr(0.5), - playful: numberOr(0.5), - serious: numberOr(0.5), - romantic: numberOr(0.5), -}); +export const PersonalityTraitsSchema = z + .object({ + cheerful: numberOr(0.5), + caring: numberOr(0.5), + playful: numberOr(0.5), + serious: numberOr(0.5), + romantic: numberOr(0.5), + }) + .readonly(); export type PersonalityTraitsInput = z.input; export type PersonalityTraitsData = z.output; -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; diff --git a/src/data/schemas/user/recent_memory.ts b/src/data/schemas/user/recent_memory.ts index 22e52a8d..e55a4b1c 100644 --- a/src/data/schemas/user/recent_memory.ts +++ b/src/data/schemas/user/recent_memory.ts @@ -1,42 +1,21 @@ /** * 最近记忆模型 - * + * */ import { z } from "zod"; import { numberOrZero, stringOrEmpty } from "../nullable-defaults"; -export const RecentMemorySchema = z.object({ - type: stringOrEmpty, - content: stringOrEmpty, - importance: numberOrZero, - createdAt: stringOrEmpty, -}); +export const RecentMemorySchema = z + .object({ + type: stringOrEmpty, + content: stringOrEmpty, + importance: numberOrZero, + createdAt: stringOrEmpty, + }) + .readonly(); export type RecentMemoryInput = z.input; export type RecentMemoryData = z.output; -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; diff --git a/src/data/schemas/user/user.ts b/src/data/schemas/user/user.ts index 98b4885d..ddd4969c 100644 --- a/src/data/schemas/user/user.ts +++ b/src/data/schemas/user/user.ts @@ -1,98 +1,50 @@ /** * 用户模型 - * + * */ import { z } from "zod"; +import { numberOrZero, schemaOr, stringOrEmpty } from "../nullable-defaults"; import { - PersonalityTraits, - PersonalityTraitsSchema, PERSONALITY_TRAITS_DEFAULTS, + PersonalityTraitsSchema, } from "./personality_traits"; -import { - numberOrZero, - schemaOr, - stringOrEmpty, -} from "../nullable-defaults"; -export const UserSchema = z.object({ - id: stringOrEmpty, // 兜底:理论 id 不会 null,但保持防御 - username: stringOrEmpty, - email: stringOrEmpty, - platform: stringOrEmpty, - country: stringOrEmpty, - countryCode: stringOrEmpty, - fbAsid: stringOrEmpty, - fbPsid: stringOrEmpty, - intimacy: numberOrZero, - dolBalance: numberOrZero, - creditBalance: numberOrZero, - dailyFreeChatLimit: numberOrZero, - dailyFreeChatUsed: numberOrZero, - dailyFreeChatRemaining: numberOrZero, - dailyFreePrivateLimit: numberOrZero, - dailyFreePrivateUsed: numberOrZero, - dailyFreePrivateRemaining: numberOrZero, - personalityTraits: schemaOr(PersonalityTraitsSchema, PERSONALITY_TRAITS_DEFAULTS), - preferredLanguage: stringOrEmpty, - createdAt: stringOrEmpty, - // 后端新游客返回 null("还没发过消息"),前端统一收敛为空字符串。 - lastMessageAt: stringOrEmpty, -}); +export const UserSchema = z + .object({ + id: stringOrEmpty, // 兜底:理论 id 不会 null,但保持防御 + username: stringOrEmpty, + email: stringOrEmpty, + platform: stringOrEmpty, + country: stringOrEmpty, + countryCode: stringOrEmpty, + fbAsid: stringOrEmpty, + fbPsid: stringOrEmpty, + intimacy: numberOrZero, + dolBalance: numberOrZero, + creditBalance: numberOrZero, + dailyFreeChatLimit: numberOrZero, + dailyFreeChatUsed: numberOrZero, + dailyFreeChatRemaining: numberOrZero, + dailyFreePrivateLimit: numberOrZero, + dailyFreePrivateUsed: numberOrZero, + dailyFreePrivateRemaining: numberOrZero, + personalityTraits: schemaOr( + PersonalityTraitsSchema, + PERSONALITY_TRAITS_DEFAULTS, + ), + preferredLanguage: stringOrEmpty, + createdAt: stringOrEmpty, + // 后端新游客返回 null("还没发过消息"),前端统一收敛为空字符串。 + lastMessageAt: stringOrEmpty, + }) + .readonly(); export type UserInput = z.input; export type UserData = z.output; -export class User { - declare readonly id: string; - declare readonly username: string; - declare readonly email: string; - declare readonly platform: string; - declare readonly country: string; - declare readonly countryCode: string; - declare readonly fbAsid: string; - declare readonly fbPsid: string; - declare readonly intimacy: number; - declare readonly dolBalance: number; - declare readonly creditBalance: number; - declare readonly dailyFreeChatLimit: number; - declare readonly dailyFreeChatUsed: number; - declare readonly dailyFreeChatRemaining: number; - declare readonly dailyFreePrivateLimit: number; - declare readonly dailyFreePrivateUsed: number; - declare readonly dailyFreePrivateRemaining: number; - declare readonly personalityTraits: PersonalityTraits; - declare readonly preferredLanguage: string; - declare readonly createdAt: string; - declare readonly lastMessageAt: string; +export type User = UserData; - private constructor(input: UserInput) { - const data = UserSchema.parse(input); - Object.assign(this, { - ...data, - personalityTraits: PersonalityTraits.from(data.personalityTraits), - }); - Object.freeze(this); - } - - static from(input: UserInput): User { - return new User(input); - } - - static fromJson(json: unknown): User { - return User.from(json as UserInput); - } - - /** - * 创建一个具有空 id/username 的 User 实例(用于可选 user 字段的默认占位) - */ - static empty(): User { - return new User({ id: "", username: "" }); - } - - toJson(): UserData { - return UserSchema.parse({ - ...this, - personalityTraits: this.personalityTraits.toJson(), - }); - } -} +export const EMPTY_USER: User = UserSchema.parse({ + id: "", + username: "", +}); diff --git a/src/data/schemas/user/user_entitlement_snapshot.ts b/src/data/schemas/user/user_entitlement_snapshot.ts index 4512b4f5..2c97558b 100644 --- a/src/data/schemas/user/user_entitlement_snapshot.ts +++ b/src/data/schemas/user/user_entitlement_snapshot.ts @@ -8,10 +8,12 @@ import { z } from "zod"; import { booleanOrFalse, numberOrZero } from "../nullable-defaults"; -export const UserEntitlementSnapshotSchema = z.object({ - isVip: booleanOrFalse, - creditBalance: numberOrZero, -}); +export const UserEntitlementSnapshotSchema = z + .object({ + isVip: booleanOrFalse, + creditBalance: numberOrZero, + }) + .readonly(); export type UserEntitlementSnapshotInput = z.input< typeof UserEntitlementSnapshotSchema diff --git a/src/data/schemas/user/user_entitlements.ts b/src/data/schemas/user/user_entitlements.ts index 09f8fe94..86588d32 100644 --- a/src/data/schemas/user/user_entitlements.ts +++ b/src/data/schemas/user/user_entitlements.ts @@ -56,7 +56,8 @@ export const UserEntitlementsPolicySchema = z guestSameAsLoggedInNonVip: booleanOrFalse, refreshAfterPayment: stringOrEmpty, }) - .passthrough(); + .passthrough() + .readonly(); export const UserEntitlementsCostsSchema = z .object({ @@ -68,7 +69,8 @@ export const UserEntitlementsCostsSchema = z private_album_10: numberOrZero, private_album_20: numberOrZero, }) - .passthrough(); + .passthrough() + .readonly(); export const UserEntitlementsQuotasSchema = z .object({ @@ -77,7 +79,8 @@ export const UserEntitlementsQuotasSchema = z voiceMessageFreeDaily: numberOrZero, photoFreeDaily: numberOrZero, }) - .passthrough(); + .passthrough() + .readonly(); export const UserEntitlementsHistoryUnlockCostsSchema = z .object({ @@ -85,7 +88,8 @@ export const UserEntitlementsHistoryUnlockCostsSchema = z voice_message: numberOrZero, photo: numberOrZero, }) - .passthrough(); + .passthrough() + .readonly(); export const UserEntitlementsHistoryUnlockSchema = z .object({ @@ -98,54 +102,28 @@ export const UserEntitlementsHistoryUnlockSchema = z HISTORY_UNLOCK_COSTS_DEFAULTS, ), }) - .passthrough(); + .passthrough() + .readonly(); -export const UserEntitlementsSchema = z.object({ - userId: stringOrEmpty, - isGuest: booleanOrFalse, - isVip: booleanOrFalse, - vipExpiresAt: stringOrNull, - creditBalance: numberOrZero, - dolBalance: numberOrZero, - policy: schemaOr(UserEntitlementsPolicySchema, POLICY_DEFAULTS), - costs: schemaOr(UserEntitlementsCostsSchema, COSTS_DEFAULTS), - quotas: schemaOr(UserEntitlementsQuotasSchema, QUOTAS_DEFAULTS), - historyUnlock: schemaOr( - UserEntitlementsHistoryUnlockSchema, - HISTORY_UNLOCK_DEFAULTS, - ), -}); +export const UserEntitlementsSchema = z + .object({ + userId: stringOrEmpty, + isGuest: booleanOrFalse, + isVip: booleanOrFalse, + vipExpiresAt: stringOrNull, + creditBalance: numberOrZero, + dolBalance: numberOrZero, + policy: schemaOr(UserEntitlementsPolicySchema, POLICY_DEFAULTS), + costs: schemaOr(UserEntitlementsCostsSchema, COSTS_DEFAULTS), + quotas: schemaOr(UserEntitlementsQuotasSchema, QUOTAS_DEFAULTS), + historyUnlock: schemaOr( + UserEntitlementsHistoryUnlockSchema, + HISTORY_UNLOCK_DEFAULTS, + ), + }) + .readonly(); export type UserEntitlementsInput = z.input; export type UserEntitlementsData = z.output; -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; diff --git a/src/data/services/api/__tests__/feedback_api.test.ts b/src/data/services/api/__tests__/feedback_api.test.ts index 1f979b66..f86847a8 100644 --- a/src/data/services/api/__tests__/feedback_api.test.ts +++ b/src/data/services/api/__tests__/feedback_api.test.ts @@ -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) }), diff --git a/src/data/services/api/__tests__/multi_character_api.test.ts b/src/data/services/api/__tests__/multi_character_api.test.ts index 6b3b7c62..90a42dd7 100644 --- a/src/data/services/api/__tests__/multi_character_api.test.ts +++ b/src/data/services/api/__tests__/multi_character_api.test.ts @@ -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( diff --git a/src/data/services/api/__tests__/payment_api.test.ts b/src/data/services/api/__tests__/payment_api.test.ts index 6b4d4939..7a907fa2 100644 --- a/src/data/services/api/__tests__/payment_api.test.ts +++ b/src/data/services/api/__tests__/payment_api.test.ts @@ -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", diff --git a/src/data/services/api/auth_api.ts b/src/data/services/api/auth_api.ts index c44af896..3a973610 100644 --- a/src/data/services/api/auth_api.ts +++ b/src/data/services/api/auth_api.ts @@ -2,32 +2,37 @@ * Auth API * * 认证相关接口 - * + * */ 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 { - const env = await httpClient>( - ApiPath.emailLogin, - { method: "POST", body: body.toJson() } - ); - return LoginResponse.fromJson(unwrap(env) as Record); + const env = await httpClient>(ApiPath.emailLogin, { + method: "POST", + body, + }); + return LoginResponseSchema.parse(unwrap(env) as Record); } /** @@ -49,7 +54,7 @@ export class AuthApi { async register(body: RegisterRequest): Promise { await httpClient>(ApiPath.register, { method: "POST", - body: body.toJson(), + body, }); } @@ -59,20 +64,20 @@ export class AuthApi { async googleLogin(body: GoogleLoginRequest): Promise { const env = await httpClient>(ApiPath.googleLogin, { method: "POST", - body: body.toJson(), + body, }); - return LoginResponse.fromJson(unwrap(env) as Record); + return LoginResponseSchema.parse(unwrap(env) as Record); } /** * Facebook 登录 */ async facebookLogin(body: FacebookLoginRequest): Promise { - const env = await httpClient>( - ApiPath.facebookLogin, - { method: "POST", body: body.toJson() } - ); - return LoginResponse.fromJson(unwrap(env) as Record); + const env = await httpClient>(ApiPath.facebookLogin, { + method: "POST", + body, + }); + return LoginResponseSchema.parse(unwrap(env) as Record); } /** @@ -81,9 +86,9 @@ export class AuthApi { async facebookIdLogin(body: FbIdLoginRequest): Promise { const env = await httpClient>( ApiPath.facebookIdLogin, - { method: "POST", body: body.toJson() } + { method: "POST", body }, ); - return LoginResponse.fromJson(unwrap(env) as Record); + return LoginResponseSchema.parse(unwrap(env) as Record); } /** @@ -94,9 +99,9 @@ export class AuthApi { ): Promise { const env = await httpClient>( ApiPath.facebookPsidLogin, - { method: "POST", body: body.toJson() }, + { method: "POST", body }, ); - return FacebookPsidLoginResponse.fromJson( + return FacebookPsidLoginResponseSchema.parse( unwrap(env) as Record, ); } @@ -109,9 +114,9 @@ export class AuthApi { ): Promise { const env = await httpClient>( ApiPath.userFacebookIdentity, - { method: "POST", body: body.toJson() }, + { method: "POST", body }, ); - return FacebookIdentityResponse.fromJson( + return FacebookIdentityResponseSchema.parse( unwrap(env) as Record, ); } @@ -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 { const env = await httpClient>(ApiPath.guestLogin, { method: "POST", - body: body.toJson(), + body, }); log.debug("[AuthApi.guestLogin] raw envelope from backend", env); const unwrapped = unwrap(env) as Record; 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 { + async refreshToken(body: RefreshTokenRequest): Promise { const env = await httpClient>(ApiPath.refresh, { method: "POST", - body: body.toJson(), + body, }); - return RefreshTokenResponse.fromJson( - unwrap(env) as Record + return RefreshTokenResponseSchema.parse( + unwrap(env) as Record, ); } @@ -183,10 +187,8 @@ export class AuthApi { * 获取当前用户信息 */ async getCurrentUser(): Promise { - const env = await httpClient>( - ApiPath.getCurrentUser - ); - return User.fromJson(unwrap(env) as UserData); + const env = await httpClient>(ApiPath.getCurrentUser); + return UserSchema.parse(unwrap(env) as UserData); } } diff --git a/src/data/services/api/character_api.ts b/src/data/services/api/character_api.ts index a714e5eb..9ae9285a 100644 --- a/src/data/services/api/character_api.ts +++ b/src/data/services/api/character_api.ts @@ -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 { const envelope = await httpClient>(ApiPath.characters); - return CharactersResponse.fromJson(unwrap(envelope)); + return CharactersResponseSchema.parse(unwrap(envelope)); } } diff --git a/src/data/services/api/chat_api.ts b/src/data/services/api/chat_api.ts index dbffc88f..8f4d5dd9 100644 --- a/src/data/services/api/chat_api.ts +++ b/src/data/services/api/chat_api.ts @@ -2,20 +2,24 @@ * Chat API * * 聊天相关接口 - * + * */ -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 { const env = await httpClient>(ApiPath.chatSend, { method: "POST", - body: body.toJson(), + body, }); - return ChatSendResponse.fromJson(unwrap(env) as Record); + return ChatSendResponseSchema.parse(unwrap(env) as Record); } /** @@ -40,8 +44,8 @@ export class ChatApi { const env = await httpClient>(ApiPath.chatHistory, { query: { characterId, limit, offset }, }); - return ChatHistoryResponse.fromJson( - unwrap(env) as Record + return ChatHistoryResponseSchema.parse( + unwrap(env) as Record, ); } @@ -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, ); } @@ -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, ); } diff --git a/src/data/services/api/feedback_api.ts b/src/data/services/api/feedback_api.ts index d0555707..89d1d88f 100644 --- a/src/data/services/api/feedback_api.ts +++ b/src/data/services/api/feedback_api.ts @@ -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)); } } diff --git a/src/data/services/api/metrics_api.ts b/src/data/services/api/metrics_api.ts index b72f0cb3..bc02190a 100644 --- a/src/data/services/api/metrics_api.ts +++ b/src/data/services/api/metrics_api.ts @@ -2,12 +2,12 @@ * Metrics API * * 数据看板/上报相关接口 - * + * */ -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 { const env = await httpClient>( ApiPath.metricsPwaEvent, - { method: "POST", body: body.toJson() } + { method: "POST", body }, ); unwrapOptional(env); } @@ -25,10 +25,10 @@ export class MetricsApi { * 上报用户信息 */ async reportUserInfo(body: AppEvent): Promise { - const env = await httpClient>( - ApiPath.reportUserInfo, - { method: "POST", body: body.toJson() } - ); + const env = await httpClient>(ApiPath.reportUserInfo, { + method: "POST", + body, + }); unwrapOptional(env); } } diff --git a/src/data/services/api/payment_api.ts b/src/data/services/api/payment_api.ts index e0c5c63f..d3196709 100644 --- a/src/data/services/api/payment_api.ts +++ b/src/data/services/api/payment_api.ts @@ -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 { const env = await httpClient>(ApiPath.paymentPlans); - return PaymentPlansResponse.fromJson( + return PaymentPlansResponseSchema.parse( unwrap(env) as Record, ); } /** 获取咖啡打赏套餐列表。 */ async getTipPlans(): Promise { - const env = await httpClient>( - ApiPath.paymentTipPlans, - ); - return TipPaymentPlansResponse.fromJson( + const env = await httpClient>(ApiPath.paymentTipPlans); + return TipPaymentPlansResponseSchema.parse( unwrap(env) as Record, ); } @@ -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, ); } @@ -57,20 +59,17 @@ export class PaymentApi { /** * 查询支付订单状态 */ - async getOrderStatus( - orderId: string, - ): Promise { + async getOrderStatus(orderId: string): Promise { const env = await httpClient>( ApiPath.paymentOrderStatus, { query: { order_id: orderId }, }, ); - return PaymentOrderStatusResponse.fromJson( + return PaymentOrderStatusResponseSchema.parse( unwrap(env) as Record, ); } - } /** diff --git a/src/data/services/api/private_room_api.ts b/src/data/services/api/private_room_api.ts index ce2949c9..ee2b4823 100644 --- a/src/data/services/api/private_room_api.ts +++ b/src/data/services/api/private_room_api.ts @@ -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)); } } diff --git a/src/data/services/api/user_api.ts b/src/data/services/api/user_api.ts index 80711518..9f0c1a58 100644 --- a/src/data/services/api/user_api.ts +++ b/src/data/services/api/user_api.ts @@ -2,16 +2,18 @@ * User API * * 用户相关接口 - * + * */ -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 { const env = await httpClient>(ApiPath.userProfile); - return User.fromJson(unwrap(env) as UserData); + return UserSchema.parse(unwrap(env) as UserData); } /** * 获取当前用户权益快照 */ async getEntitlements(): Promise { - const env = await httpClient>(ApiPath.userEntitlements); - return UserEntitlements.fromJson(unwrap(env) as Record); + const env = await httpClient>( + ApiPath.userEntitlements, + ); + return UserEntitlementsSchema.parse(unwrap(env) as Record); } } diff --git a/src/data/services/facebook-graph.ts b/src/data/services/facebook-graph.ts index d8072e47..bde2c2d5 100644 --- a/src/data/services/facebook-graph.ts +++ b/src/data/services/facebook-graph.ts @@ -11,7 +11,7 @@ * - 不指定 version 走最新稳定版 * - 不需要 appId / appSecret(accessToken 自身就是凭证) */ -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, "") }, "fetchFacebookUserData REQUEST"); + log.debug( + { url: url.replace(accessToken, "") }, + "fetchFacebookUserData REQUEST", + ); try { const res = await fetch(url, { method: "GET" }); const json = (await res.json()) as Record; if (!res.ok) { - const errBody = (json?.error as Record | undefined) ?? {}; + const errBody = + (json?.error as Record | 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, diff --git a/src/lib/analytics/__tests__/behavior_analytics.test.ts b/src/lib/analytics/__tests__/behavior_analytics.test.ts index ea097274..d42c0337 100644 --- a/src/lib/analytics/__tests__/behavior_analytics.test.ts +++ b/src/lib/analytics/__tests__/behavior_analytics.test.ts @@ -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", ); diff --git a/src/stores/chat/__tests__/chat-helpers.test.ts b/src/stores/chat/__tests__/chat-helpers.test.ts index fd5557b7..ff987929 100644 --- a/src/stores/chat/__tests__/chat-helpers.test.ts +++ b/src/stores/chat/__tests__/chat-helpers.test.ts @@ -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[0]> = {}, + overrides: Partial = {}, ): 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); }); diff --git a/src/stores/chat/__tests__/chat-machine.test-utils.ts b/src/stores/chat/__tests__/chat-machine.test-utils.ts index c5c65c47..3e88b630 100644 --- a/src/stores/chat/__tests__/chat-machine.test-utils.ts +++ b/src/stores/chat/__tests__/chat-machine.test-utils.ts @@ -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[0]> = {}, + overrides: Partial = {}, ): UnlockPrivateResponse { - return UnlockPrivateResponse.from({ + return UnlockPrivateResponseSchema.parse({ unlocked: true, content: "unlocked content", audioUrl: "", @@ -106,21 +109,20 @@ export function createTestChatMachine( return () => undefined; }, ), - unlockHistory: fromPromise< - UnlockHistoryOutput, - { characterId: string } - >(async () => { - if (options.unlockHistoryError) { - throw options.unlockHistoryError; - } - return { - unlocked: true, - reason: "ok", - shortfallCredits: 0, - messages: [], - ...options.unlockHistoryOutput, - }; - }), + unlockHistory: fromPromise( + async () => { + if (options.unlockHistoryError) { + throw options.unlockHistoryError; + } + return { + unlocked: true, + reason: "ok", + shortfallCredits: 0, + messages: [], + ...options.unlockHistoryOutput, + }; + }, + ), unlockMessage: fromPromise< MachineUnlockMessageOutput, UnlockMessageRequest & { characterId: string } diff --git a/src/stores/chat/__tests__/chat-promotion.test.ts b/src/stores/chat/__tests__/chat-promotion.test.ts index 904ab6f1..dfb850a7 100644 --- a/src/stores/chat/__tests__/chat-promotion.test.ts +++ b/src/stores/chat/__tests__/chat-promotion.test.ts @@ -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: { diff --git a/src/stores/chat/__tests__/chat-unlock-flow.test.ts b/src/stores/chat/__tests__/chat-unlock-flow.test.ts index 039b95d2..abe30431 100644 --- a/src/stores/chat/__tests__/chat-unlock-flow.test.ts +++ b/src/stores/chat/__tests__/chat-unlock-flow.test.ts @@ -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: "", diff --git a/src/stores/payment/__tests__/payment-catalog-flow.test.ts b/src/stores/payment/__tests__/payment-catalog-flow.test.ts index 6b1f9c78..82c3df87 100644 --- a/src/stores/payment/__tests__/payment-catalog-flow.test.ts +++ b/src/stores/payment/__tests__/payment-catalog-flow.test.ts @@ -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, diff --git a/src/stores/payment/__tests__/payment-machine.test-utils.ts b/src/stores/payment/__tests__/payment-machine.test-utils.ts index 92650889..4a3af7f0 100644 --- a/src/stores/payment/__tests__/payment-machine.test-utils.ts +++ b/src/stores/payment/__tests__/payment-machine.test-utils.ts @@ -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 }) => { - overrides.onRefreshCatalog?.(input.catalog); - if (overrides.refreshPlans) return overrides.refreshPlans(); - return ( - overrides.refreshedPlans ?? - PaymentPlansResponse.from({ - plans: [monthlyPlan, lifetimePlan, creditPlan], - }) - ); - }), - createOrder: fromPromise< - CreatePaymentOrderResponse, - CreateOrderInput - >(async ({ input }) => { - createOrderSpy(input); - if (overrides.createOrderError) throw overrides.createOrderError; - return CreatePaymentOrderResponse.from({ - orderId: "pay_test_001", - payParams: { - provider: "stripe", - clientSecret: "pi_test_secret_test", - }, - }); - }), + refreshPlans: fromPromise( + async ({ input }) => { + overrides.onRefreshCatalog?.(input.catalog); + if (overrides.refreshPlans) return overrides.refreshPlans(); + return ( + overrides.refreshedPlans ?? + PaymentPlansResponseSchema.parse({ + plans: [monthlyPlan, lifetimePlan, creditPlan], + }) + ); + }, + ), + createOrder: fromPromise( + async ({ input }) => { + createOrderSpy(input); + if (overrides.createOrderError) throw overrides.createOrderError; + 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", diff --git a/src/stores/payment/helper/catalog.ts b/src/stores/payment/helper/catalog.ts index aec07d6f..55dbeed4 100644 --- a/src/stores/payment/helper/catalog.ts +++ b/src/stores/payment/helper/catalog.ts @@ -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 { diff --git a/src/stores/payment/payment-state.ts b/src/stores/payment/payment-state.ts index e6620a7a..3f2b6b3f 100644 --- a/src/stores/payment/payment-state.ts +++ b/src/stores/payment/payment-state.ts @@ -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; diff --git a/src/stores/private-room/__tests__/private-room-machine.test-utils.ts b/src/stores/private-room/__tests__/private-room-machine.test-utils.ts index b2144fd8..9f0b9156 100644 --- a/src/stores/private-room/__tests__/private-room-machine.test-utils.ts +++ b/src/stores/private-room/__tests__/private-room-machine.test-utils.ts @@ -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[0]> = {}, + overrides: Partial = {}, ): PrivateAlbumsResponse { - return PrivateAlbumsResponse.from({ + return PrivateAlbumsResponseSchema.parse({ items: [makeAlbum()], creditBalance: 500, ...overrides, @@ -41,9 +45,9 @@ export function makeAlbumsResponse( } export function makeUnlockResponse( - overrides: Partial[0]> = {}, + overrides: Partial = {}, ): 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: { - loadResponse?: PrivateAlbumsResponse; - unlockResponse?: PrivateAlbumUnlockResponse; - loadError?: Error; - unlockError?: Error; - onLoad?: (input: { characterId: string }) => void; -} = {}) { +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 }) => { - options.onLoad?.(input); - if (options.loadError) throw options.loadError; - return options.loadResponse ?? makeAlbumsResponse(); - }), + loadAlbums: fromPromise( + 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(); diff --git a/src/stores/private-room/helper/albums.ts b/src/stores/private-room/helper/albums.ts index 0582355c..3ed80454 100644 --- a/src/stores/private-room/helper/albums.ts +++ b/src/stores/private-room/helper/albums.ts @@ -1,5 +1,6 @@ import { PrivateAlbum, + PrivateAlbumSchema, type PrivateAlbumsResponse, type PrivateAlbumUnlockResponse, } from "@/data/schemas/private-room"; @@ -33,8 +34,8 @@ export function patchAlbumFromUnlock( album: PrivateAlbum, response: PrivateAlbumUnlockResponse, ): PrivateAlbum { - return PrivateAlbum.from({ - ...album.toJson(), + return PrivateAlbumSchema.parse({ + ...album, locked: response.locked, unlocked: response.unlocked, unlockCost: response.unlockCost || album.unlockCost, @@ -46,7 +47,7 @@ export function patchAlbumFromUnlock( export function patchAlbumInList( items: readonly PrivateAlbum[], response: PrivateAlbumUnlockResponse, -): PrivateAlbum[] { +): readonly PrivateAlbum[] { return items.map((album) => album.albumId === response.albumId ? patchAlbumFromUnlock(album, response) @@ -57,6 +58,6 @@ export function patchAlbumInList( export function removeAlbum( items: readonly PrivateAlbum[], albumId: string, -): PrivateAlbum[] { +): readonly PrivateAlbum[] { return items.filter((album) => album.albumId !== albumId); } diff --git a/src/stores/private-room/private-room-state.ts b/src/stores/private-room/private-room-state.ts index ad7167e3..93308105 100644 --- a/src/stores/private-room/private-room-state.ts +++ b/src/stores/private-room/private-room-state.ts @@ -1,5 +1,5 @@ -import type { PrivateAlbum } from "@/data/schemas/private-room"; import { DEFAULT_CHARACTER_ID } from "@/data/constants/character"; +import type { PrivateAlbum } from "@/data/schemas/private-room"; export interface PrivateRoomUnlockPaywallRequest { albumId: string; @@ -11,7 +11,7 @@ export interface PrivateRoomUnlockPaywallRequest { export interface PrivateRoomState { characterId: string; - items: PrivateAlbum[]; + items: readonly PrivateAlbum[]; creditBalance: number; errorMessage: string | null; unlockingAlbumId: string | null; diff --git a/src/stores/user/machine/actors/profile.ts b/src/stores/user/machine/actors/profile.ts index b3da0165..2691234c 100644 --- a/src/stores/user/machine/actors/profile.ts +++ b/src/stores/user/machine/actors/profile.ts @@ -1,9 +1,9 @@ import { fromPromise } from "xstate"; -import type { UserView } from "@/stores/user/user-view"; import { getUserRepository } from "@/data/repositories/user_repository"; import type { UserEntitlementSnapshotData } from "@/data/schemas/user"; import { UserStorage } from "@/data/storage/user/user_storage"; +import type { UserView } from "@/stores/user/user-view"; import { Result } from "@/utils/result"; import { @@ -36,9 +36,9 @@ export const userFetchActor = fromPromise(async () => { if (Result.isErr(userResult)) return { user: null, snapshot }; const view = applyEntitlementSnapshotToView( - toView(userResult.data.toJson()), + toView(userResult.data), snapshot, ); - await userStorage.setUser(userResult.data.toJson()); + await userStorage.setUser(userResult.data); return { user: view, snapshot }; });