refactor(data): replace schema classes with readonly models

This commit is contained in:
2026-07-17 13:21:40 +08:00
parent 3437312167
commit ae97366a4a
103 changed files with 1220 additions and 2117 deletions
+41 -52
View File
@@ -12,7 +12,7 @@ description: Sync this Next.js frontend with backend API documentation
当后端 API 新增、删除、字段结构调整、响应语义变化时,使用本 skill 更新: 当后端 API 新增、删除、字段结构调整、响应语义变化时,使用本 skill 更新:
- 网络层 - 网络层
- schema / 不可变数据 - schema / 不可变数据
- repository - repository
- storage - storage
- XState store - 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 Path | `src/data/services/api/api_path.ts` | 统一维护接口路径 |
| API Service | `src/data/services/api/*_api.ts` | 调用 `httpClient` 并解析响应 | | API Service | `src/data/services/api/*_api.ts` | 调用 `httpClient` 并解析响应 |
| Schema Model | `src/data/schemas/<module>/*.ts` | Zod schema 与不可变数据类,负责解析和序列化 | | Schema Model | `src/data/schemas/<module>/*.ts` | Zod schema 与不可变数据类,负责解析和归一化 |
| Repository Interface | `src/data/repositories/interfaces/i*_repository.ts` | 仓库接口 | | Repository Interface | `src/data/repositories/interfaces/i*_repository.ts` | 仓库接口 |
| Repository | `src/data/repositories/*_repository.ts` | 业务仓库实现 | | Repository | `src/data/repositories/*_repository.ts` | 业务仓库实现 |
| Storage | `src/data/storage/*` | 本地持久化 | | Storage | `src/data/storage/*` | 本地持久化 |
| Store | `src/stores/<module>/*` | XState 状态机、actors、helpers、sync | | Store | `src/stores/<module>/*` | XState 状态机、actors、helpers、sync |
| UI | `src/app/**` | Next.js app router UI | | UI | `src/app/**` | Next.js app router UI |
| Mock | `src/data/mock/<module>/**` | 模拟请求 / 响应数据 | | Mock | `src/data/mock/<module>/**` | 模拟请求 / 响应数据 |
| Tests | `**/__tests__/*.test.ts` | schema model、helper、machine transition 测试 | | Tests | `**/__tests__/*.test.ts` | schema model、helper、machine transition 测试 |
## 后端文档来源 ## 后端文档来源
@@ -84,13 +84,16 @@ git status --short
否则 schema 应尽量防御性解析,例如: 否则 schema 应尽量防御性解析,例如:
```ts ```ts
z.string().nullable().transform((v) => v ?? "").default("") z.string()
.nullable()
.transform((v) => v ?? "")
.default("");
``` ```
项目已有通用 helper 项目已有通用 helper
```ts ```ts
src/data/schemas/nullable-defaults.ts src / data / schemas / nullable - defaults.ts;
``` ```
### 3. 更新 API Path ### 3. 更新 API Path
@@ -98,7 +101,7 @@ src/data/schemas/nullable-defaults.ts
新增或修改: 新增或修改:
```ts ```ts
src/data/services/api/api_path.ts src / data / services / api / api_path.ts;
``` ```
示例: 示例:
@@ -122,46 +125,32 @@ src/data/schemas/<module>/*.ts
- 输入类型用 `z.input<typeof Schema>` - 输入类型用 `z.input<typeof Schema>`
- 对后端可能返回 `null` 的字段做防御性处理。 - 对后端可能返回 `null` 的字段做防御性处理。
- 不要在 schema 中写 UI 逻辑。 - 不要在 schema 中写 UI 逻辑。
- Schema 与对应不可变数据类必须定义在同一个文件中 - Schema 输出必须是纯数据,不要定义 class 或工厂对象
- 数据类的 `declare readonly` 只声明业务代码直接访问的字段 - 对象 Schema 使用 `.readonly()`;已知嵌套对象、数组和 Record 也必须逐层 readonly
- transform 应先完成归一化,再在最终输出应用 `.readonly()`
- 容器默认值必须经过 Schema 解析;使用 `prefault` 或在最外层应用 readonly,不能让 `.default()` 返回未冻结对象。
- 后端返回但前端既不读取、也不透传的字段,不要加入 Schema。 - 后端返回但前端既不读取、也不透传的字段,不要加入 Schema。
- 仅用于请求序列化、响应兼容或内部透传的字段保留在 Schema 中,不声明 class 属性 - 仅用于请求序列化、响应兼容或内部透传的字段直接保留在 Schema 中。
- 数据类统一使用 `from()``fromJson()``toJson()` `Object.freeze(this)` - 统一使用 `XxxSchema.parse()` 构造请求与解析响应,不增加 `from()``fromJson()` `toJson()` 包装
- 没有嵌套转换或自定义方法时,使用 `src/data/schemas/schema_model.ts``createSchemaModel()`
- 不要再创建独立的数据传输对象层。 - 不要再创建独立的数据传输对象层。
示例: 示例:
```ts ```ts
export const XxxResponseSchema = z.object({ export const XxxResponseSchema = z
id: z.string(), .object({
count: z.number().default(0), id: z.string(),
}); tags: z
.array(z.string())
.default(() => [])
.readonly(),
count: z.number().default(0),
})
.readonly();
export type XxxResponseInput = z.input<typeof XxxResponseSchema>; export type XxxResponseInput = z.input<typeof XxxResponseSchema>;
export type XxxResponseData = z.output<typeof XxxResponseSchema>; export type XxxResponseData = z.output<typeof XxxResponseSchema>;
export type XxxResponse = XxxResponseData;
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);
}
}
``` ```
### 5. 更新 API Service ### 5. 更新 API Service
@@ -177,9 +166,9 @@ src/data/services/api/<module>_api.ts
```ts ```ts
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.xxx, { const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.xxx, {
method: "POST", method: "POST",
body: request.toJson(), body: request,
}); });
return XxxResponse.fromJson(unwrap(env) as Record<string, unknown>); return XxxResponseSchema.parse(unwrap(env));
``` ```
注意: 注意:
@@ -207,7 +196,7 @@ async getXxx(): Promise<Result<XxxResponse>> {
Repository 可以做轻量编排,例如: Repository 可以做轻量编排,例如:
- request 数据类构造 - request schema 解析
- 多接口组合 - 多接口组合
- 本地 storage 同步 - 本地 storage 同步
- 本地模型与接口模型转换 - 本地模型与接口模型转换
@@ -333,7 +322,7 @@ pnpm exec vitest run <related tests>
1. 文档确认 path / method / payload。 1. 文档确认 path / method / payload。
2. `api_path.ts` 新增路径。 2. `api_path.ts` 新增路径。
3. 在同一文件新增 schema 与不可变数据类 3. 在同一文件新增 readonly schema 与输入/输出类型
4. API service 新增方法。 4. API service 新增方法。
5. repository interface + implementation 新增方法。 5. repository interface + implementation 新增方法。
6. 如需要,接入 store actor / event。 6. 如需要,接入 store actor / event。
@@ -343,7 +332,7 @@ pnpm exec vitest run <related tests>
### 修改响应字段 ### 修改响应字段
1. 更新 schema。 1. 更新 schema。
2. 更新同文件数据类的公开字段和 `toJson` 2. 更新同文件输入/输出类型及相关 parse 调用
3. 更新 mapper/helper。 3. 更新 mapper/helper。
4. 更新 UI 或状态机依赖字段。 4. 更新 UI 或状态机依赖字段。
5. 更新 mock 和测试。 5. 更新 mock 和测试。
@@ -2,12 +2,12 @@ import { act } from "react";
import { createRoot, type Root } from "react-dom/client"; import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 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 { behaviorAnalytics } from "@/lib/analytics";
import { usePaymentPlanAnalytics } from "../use-payment-plan-analytics"; import { usePaymentPlanAnalytics } from "../use-payment-plan-analytics";
const firstPlan = PaymentPlan.from({ const firstPlan = PaymentPlanSchema.parse({
planId: "plan-1", planId: "plan-1",
planName: "Plan One", planName: "Plan One",
orderType: "dol", orderType: "dol",
@@ -18,7 +18,7 @@ const firstPlan = PaymentPlan.from({
originalAmountCents: null, originalAmountCents: null,
currency: "USD", currency: "USD",
}); });
const secondPlan = PaymentPlan.from({ const secondPlan = PaymentPlanSchema.parse({
planId: "plan-2", planId: "plan-2",
planName: "Plan Two", planName: "Plan Two",
orderType: "dol", orderType: "dol",
@@ -43,8 +43,9 @@ describe("usePaymentPlanAnalytics", () => {
let root: Root; let root: Root;
beforeEach(() => { 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"); container = document.createElement("div");
document.body.appendChild(container); document.body.appendChild(container);
root = createRoot(container); root = createRoot(container);
@@ -1,16 +1,18 @@
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest"; 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"; import { PrivateAlbumCard } from "../private-album-card";
const COVER_URL = "/images/private-room/banner.png"; const COVER_URL = "/images/private-room/banner.png";
function makeAlbum( function makeAlbum(overrides: Partial<PrivateAlbumInput> = {}): PrivateAlbum {
overrides: Partial<Parameters<typeof PrivateAlbum.from>[0]> = {}, return PrivateAlbumSchema.parse({
): PrivateAlbum {
return PrivateAlbum.from({
albumId: "album-1", albumId: "album-1",
title: "Private morning", title: "Private morning",
content: "A quiet morning by the water.", content: "A quiet morning by the water.",
@@ -61,9 +63,7 @@ describe("PrivateAlbumCard", () => {
expect(html).toContain("8 photos"); expect(html).toContain("8 photos");
expect(html).toContain("A quiet morning by the water."); expect(html).toContain("A quiet morning by the water.");
expect(html).toContain('aria-label="Open 8 private album photos"'); expect(html).toContain('aria-label="Open 8 private album photos"');
expect(html).toContain( expect(html).toContain('data-analytics-key="private_album.open_gallery"');
'data-analytics-key="private_album.open_gallery"',
);
}); });
it("uses the backend first image as the locked cover", () => { it("uses the backend first image as the locked cover", () => {
@@ -1,6 +1,10 @@
import { afterEach, describe, expect, it, vi } from "vitest"; 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 { import {
canChooseSubscriptionPayChannel, canChooseSubscriptionPayChannel,
@@ -13,10 +17,8 @@ import {
toVipOfferPlanViews, toVipOfferPlanViews,
} from "../subscription-screen.helpers"; } from "../subscription-screen.helpers";
function makePlan( function makePlan(overrides: Partial<PaymentPlanInput>): PaymentPlan {
overrides: Partial<Parameters<typeof PaymentPlan.from>[0]>, return PaymentPlanSchema.parse({
): PaymentPlan {
return PaymentPlan.from({
planId: "plan", planId: "plan",
planName: "Plan", planName: "Plan",
orderType: "vip_monthly", orderType: "vip_monthly",
@@ -138,7 +140,9 @@ describe("subscription screen helpers", () => {
originalPrice: "", 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"); expect(selected?.id).toBe("coin_1000");
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; 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 type { PaymentPlanInput } from "@/data/schemas/payment/payment_plan";
import { import {
@@ -10,7 +10,7 @@ import {
} from "../tip-screen.helpers"; } from "../tip-screen.helpers";
function makePlan(input: Partial<PaymentPlanInput>): PaymentPlan { function makePlan(input: Partial<PaymentPlanInput>): PaymentPlan {
return PaymentPlan.from({ return PaymentPlanSchema.parse({
planId: "coins_100", planId: "coins_100",
planName: "Coins", planName: "Coins",
orderType: "dol", orderType: "dol",
@@ -1,11 +1,11 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { AuthRepository } from "@/data/repositories/auth_repository";
import { import {
FacebookIdentityResponse, FacebookIdentityResponseSchema,
FacebookPsidLoginResponse, FacebookPsidLoginResponseSchema,
LoginStatus, LoginStatus,
} from "@/data/schemas/auth"; } from "@/data/schemas/auth";
import { AuthRepository } from "@/data/repositories/auth_repository";
import type { AuthApi } from "@/data/services/api"; import type { AuthApi } from "@/data/services/api";
import type { IAuthStorage } from "@/data/storage/auth"; import type { IAuthStorage } from "@/data/storage/auth";
import type { IUserStorage } from "@/data/storage/user"; import type { IUserStorage } from "@/data/storage/user";
@@ -36,7 +36,7 @@ function createRepository(overrides: Partial<AuthApi>) {
describe("AuthRepository facebook identity", () => { describe("AuthRepository facebook identity", () => {
it("binds facebook identity and stores returned ids", async () => { it("binds facebook identity and stores returned ids", async () => {
const bindFacebookIdentity = vi.fn(async () => const bindFacebookIdentity = vi.fn(async () =>
FacebookIdentityResponse.fromJson({ FacebookIdentityResponseSchema.parse({
fbAsid: "asid-1", fbAsid: "asid-1",
fbPsid: "psid-1", fbPsid: "psid-1",
facebookBinding: { conflicts: [], bound: [] }, facebookBinding: { conflicts: [], bound: [] },
@@ -57,7 +57,7 @@ describe("AuthRepository facebook identity", () => {
it("saves real login data from complete psid login responses", async () => { it("saves real login data from complete psid login responses", async () => {
const facebookPsidLogin = vi.fn(async () => const facebookPsidLogin = vi.fn(async () =>
FacebookPsidLoginResponse.fromJson({ FacebookPsidLoginResponseSchema.parse({
token: "login-token", token: "login-token",
refreshToken: "refresh-token", refreshToken: "refresh-token",
matchedBy: "psid", matchedBy: "psid",
@@ -88,7 +88,7 @@ describe("AuthRepository facebook identity", () => {
it("saves guest data from incomplete psid login responses", async () => { it("saves guest data from incomplete psid login responses", async () => {
const facebookPsidLogin = vi.fn(async () => const facebookPsidLogin = vi.fn(async () =>
FacebookPsidLoginResponse.fromJson({ FacebookPsidLoginResponseSchema.parse({
token: "guest-token", token: "guest-token",
matchedBy: "psid_incomplete", matchedBy: "psid_incomplete",
fbPsid: "psid-1", fbPsid: "psid-1",
@@ -116,7 +116,7 @@ describe("AuthRepository facebook identity", () => {
it("does not report guest psid login success when session persistence fails", async () => { it("does not report guest psid login success when session persistence fails", async () => {
const storageError = new Error("guest token write failed"); const storageError = new Error("guest token write failed");
const facebookPsidLogin = vi.fn(async () => const facebookPsidLogin = vi.fn(async () =>
FacebookPsidLoginResponse.fromJson({ FacebookPsidLoginResponseSchema.parse({
token: "guest-token", token: "guest-token",
matchedBy: "psid_incomplete", matchedBy: "psid_incomplete",
fbPsid: "psid-1", fbPsid: "psid-1",
@@ -1,22 +1,24 @@
import { afterEach, describe, expect, it, vi } from "vitest"; 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 { AuthRepository } from "@/data/repositories/auth_repository";
import {
GuestLoginResponseSchema,
LoginResponseSchema,
LoginStatus,
RefreshTokenResponseSchema,
} from "@/data/schemas/auth";
import type { AuthApi } from "@/data/services/api"; import type { AuthApi } from "@/data/services/api";
import type { IAuthStorage } from "@/data/storage/auth"; import type { IAuthStorage } from "@/data/storage/auth";
import type { IUserStorage } from "@/data/storage/user"; import type { IUserStorage } from "@/data/storage/user";
import { Result } from "@/utils/result"; import { Result } from "@/utils/result";
function createRepository(input: { function createRepository(
api?: Partial<AuthApi>; input: {
storage?: Partial<IAuthStorage>; api?: Partial<AuthApi>;
userStorage?: Partial<IUserStorage>; storage?: Partial<IAuthStorage>;
} = {}) { userStorage?: Partial<IUserStorage>;
} = {},
) {
const storage = { const storage = {
getRefreshToken: vi.fn(async () => Result.ok("refresh-token")), getRefreshToken: vi.fn(async () => Result.ok("refresh-token")),
setLoginToken: vi.fn(async () => Result.ok(undefined)), setLoginToken: vi.fn(async () => Result.ok(undefined)),
@@ -52,7 +54,7 @@ afterEach(() => {
}); });
describe("AuthRepository session Result handling", () => { describe("AuthRepository session Result handling", () => {
const loginResponse = LoginResponse.from({ const loginResponse = LoginResponseSchema.parse({
token: "login-token", token: "login-token",
refreshToken: "refresh-token", refreshToken: "refresh-token",
user: { id: "user-1", username: "Elio" }, 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 () => { it("does not report refresh success when a new token cannot be stored", async () => {
const storageError = new Error("login token write failed"); const storageError = new Error("login token write failed");
const response = RefreshTokenResponse.from({ const response = RefreshTokenResponseSchema.parse({
token: "new-login-token", token: "new-login-token",
refreshToken: "new-refresh-token", refreshToken: "new-refresh-token",
userId: "user-1", userId: "user-1",
@@ -135,7 +137,7 @@ describe("AuthRepository session Result handling", () => {
it("does not report guest login success when session persistence fails", async () => { it("does not report guest login success when session persistence fails", async () => {
const storageError = new Error("guest token write failed"); const storageError = new Error("guest token write failed");
const response = GuestLoginResponse.from({ const response = GuestLoginResponseSchema.parse({
token: "guest-token", token: "guest-token",
deviceId: "device-1", deviceId: "device-1",
userId: "guest-1", userId: "guest-1",
@@ -1,13 +1,13 @@
import { describe, expect, it, vi } from "vitest"; 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 { LocalMessage } from "@/data/storage/chat";
import { Result } from "@/utils/result"; import { Result } from "@/utils/result";
import { ChatLocalMessageStore } from "../chat_local_message_store"; import { ChatLocalMessageStore } from "../chat_local_message_store";
function makeMessage(id: string, content: string): ChatMessage { function makeMessage(id: string, content: string): ChatMessage {
return ChatMessage.from({ return ChatMessageSchema.parse({
id, id,
role: "assistant", role: "assistant",
type: "text", type: "text",
@@ -53,9 +53,8 @@ describe("ChatLocalMessageStore identity isolation", () => {
it("reads, replaces, and clears only the current identity", async () => { it("reads, replaces, and clears only the current identity", async () => {
const memory = createMemoryStorage(); const memory = createMemoryStorage();
let identity = "user:account-a"; let identity = "user:account-a";
const store = new ChatLocalMessageStore( const store = new ChatLocalMessageStore(memory.storage, async () =>
memory.storage, Result.ok(identity),
async () => Result.ok(identity),
); );
await store.saveMessages([makeMessage("a-1", "account A")]); await store.saveMessages([makeMessage("a-1", "account A")]);
@@ -1,7 +1,14 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character"; 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 { import {
buildChatMediaCacheKey, buildChatMediaCacheKey,
@@ -11,10 +18,8 @@ import {
uniqueMediaTargets, uniqueMediaTargets,
} from "../chat_media_cache_helpers"; } from "../chat_media_cache_helpers";
function makeMessage( function makeMessage(overrides: Partial<ChatMessageInput> = {}): ChatMessage {
overrides: Partial<Parameters<typeof ChatMessage.from>[0]> = {}, return ChatMessageSchema.parse({
): ChatMessage {
return ChatMessage.from({
id: "msg-1", id: "msg-1",
role: "assistant", role: "assistant",
type: "image", type: "image",
@@ -31,9 +36,9 @@ function makeMessage(
} }
function makeResponse( function makeResponse(
overrides: Partial<Parameters<typeof ChatSendResponse.from>[0]> = {}, overrides: Partial<ChatSendResponseInput> = {},
): ChatSendResponse { ): ChatSendResponse {
return ChatSendResponse.from({ return ChatSendResponseSchema.parse({
reply: "", reply: "",
messageId: "msg-1", messageId: "msg-1",
isGuest: false, isGuest: false,
@@ -1,14 +1,14 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { TipPaymentPlansResponse } from "@/data/schemas/payment";
import { PaymentRepository } from "@/data/repositories/payment_repository"; import { PaymentRepository } from "@/data/repositories/payment_repository";
import { TipPaymentPlansResponseSchema } from "@/data/schemas/payment";
import type { PaymentApi } from "@/data/services/api"; import type { PaymentApi } from "@/data/services/api";
import { Result } from "@/utils/result"; import { Result } from "@/utils/result";
describe("PaymentRepository", () => { describe("PaymentRepository", () => {
it("adapts tip plans without caching them as subscription plans", async () => { it("adapts tip plans without caching them as subscription plans", async () => {
const getTipPlans = vi.fn().mockResolvedValue( const getTipPlans = vi.fn().mockResolvedValue(
TipPaymentPlansResponse.from({ TipPaymentPlansResponseSchema.parse({
plans: [ plans: [
{ {
planId: "tip_coffee_usd_9_99", planId: "tip_coffee_usd_9_99",
+31 -30
View File
@@ -1,30 +1,31 @@
import { ExceptionHandler } from "@/core/errors"; import { ExceptionHandler } from "@/core/errors";
import { ApiError, AuthApi, authApi, ErrorCode } from "@/data/services/api"; import type { IAuthRepository } from "@/data/repositories/interfaces";
import { import {
FacebookIdentityRequest, FacebookIdentityRequestSchema,
FacebookPsidLoginRequest, FacebookLoginRequestSchema,
FacebookLoginRequest, FacebookPsidLoginRequestSchema,
FbIdLoginRequest, FbIdLoginRequestSchema,
GoogleLoginRequest, GoogleLoginRequestSchema,
GuestLoginRequest, GuestLoginRequestSchema,
GuestLoginResponse, GuestLoginResponse,
LoginRequest, LoginRequestSchema,
LoginResponse, LoginResponse,
LoginResponseSchema,
LoginStatus, LoginStatus,
type LoginStatus as LoginStatusT, RefreshTokenRequestSchema,
RefreshTokenRequest,
RefreshTokenResponse, RefreshTokenResponse,
RegisterRequest, RegisterRequestSchema,
type LoginStatus as LoginStatusT,
} from "@/data/schemas/auth"; } from "@/data/schemas/auth";
import { User } from "@/data/schemas/user"; import { User } from "@/data/schemas/user";
import { AppEnvUtil } from "@/utils/app-env"; import { ApiError, AuthApi, authApi, ErrorCode } from "@/data/services/api";
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 { AuthStorage, type IAuthStorage } from "@/data/storage/auth"; import { AuthStorage, type IAuthStorage } from "@/data/storage/auth";
import { UserStorage, type IUserStorage } from "@/data/storage/user"; 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"; import { createLazySingleton } from "./lazy_singleton";
const log = new Logger("DataRepositoriesAuthRepository"); const log = new Logger("DataRepositoriesAuthRepository");
@@ -52,7 +53,7 @@ export class AuthRepository implements IAuthRepository {
}): Promise<Result<void>> { }): Promise<Result<void>> {
return Result.wrap(async () => { return Result.wrap(async () => {
await this.api.register( await this.api.register(
RegisterRequest.from( RegisterRequestSchema.parse(
withTestAccountFlag({ withTestAccountFlag({
username: input.username, username: input.username,
email: input.email, email: input.email,
@@ -76,7 +77,7 @@ export class AuthRepository implements IAuthRepository {
return Result.wrap(async () => { return Result.wrap(async () => {
const email = input.email?.trim() ?? ""; const email = input.email?.trim() ?? "";
const response = await this.api.emailLogin( const response = await this.api.emailLogin(
LoginRequest.from( LoginRequestSchema.parse(
withTestAccountFlag({ withTestAccountFlag({
email, email,
username: input.username, username: input.username,
@@ -152,7 +153,7 @@ export class AuthRepository implements IAuthRepository {
}); });
return Result.wrap(async () => { return Result.wrap(async () => {
const response = await this.api.guestLogin( const response = await this.api.guestLogin(
GuestLoginRequest.from(withTestAccountFlag({ deviceId })), GuestLoginRequestSchema.parse(withTestAccountFlag({ deviceId })),
); );
log.debug("[AuthRepository.guestLogin] API call SUCCESS (Zod parsed)", { log.debug("[AuthRepository.guestLogin] API call SUCCESS (Zod parsed)", {
hasToken: !!response.token, hasToken: !!response.token,
@@ -190,7 +191,7 @@ export class AuthRepository implements IAuthRepository {
}): Promise<Result<LoginResponse>> { }): Promise<Result<LoginResponse>> {
return this._socialLogin(LoginStatus.Google, () => return this._socialLogin(LoginStatus.Google, () =>
this.api.googleLogin( this.api.googleLogin(
GoogleLoginRequest.from( GoogleLoginRequestSchema.parse(
withTestAccountFlag({ withTestAccountFlag({
idToken: input.idToken, idToken: input.idToken,
platform: getAuthPlatform(), platform: getAuthPlatform(),
@@ -210,7 +211,7 @@ export class AuthRepository implements IAuthRepository {
}): Promise<Result<LoginResponse>> { }): Promise<Result<LoginResponse>> {
return this._socialLogin(LoginStatus.Facebook, () => return this._socialLogin(LoginStatus.Facebook, () =>
this.api.facebookLogin( this.api.facebookLogin(
FacebookLoginRequest.from( FacebookLoginRequestSchema.parse(
withTestAccountFlag({ withTestAccountFlag({
accessToken: input.accessToken, accessToken: input.accessToken,
platform: getAuthPlatform(), platform: getAuthPlatform(),
@@ -230,7 +231,7 @@ export class AuthRepository implements IAuthRepository {
}): Promise<Result<LoginResponse>> { }): Promise<Result<LoginResponse>> {
return this._socialLogin(LoginStatus.Facebook, () => return this._socialLogin(LoginStatus.Facebook, () =>
this.api.facebookIdLogin( this.api.facebookIdLogin(
FbIdLoginRequest.from( FbIdLoginRequestSchema.parse(
withTestAccountFlag({ withTestAccountFlag({
fbId: input.asid, fbId: input.asid,
avatarUrl: input.avatarUrl ?? "", avatarUrl: input.avatarUrl ?? "",
@@ -250,12 +251,12 @@ export class AuthRepository implements IAuthRepository {
return Result.wrap(async () => { return Result.wrap(async () => {
const response = await this.api.bindFacebookIdentity( const response = await this.api.bindFacebookIdentity(
FacebookIdentityRequest.from({ FacebookIdentityRequestSchema.parse({
asid: input.asid ?? "", asid: input.asid ?? "",
psid: input.psid ?? "", psid: input.psid ?? "",
}), }),
); );
const data = response.toJson(); const data = response;
await this._saveFacebookIdentity({ await this._saveFacebookIdentity({
asid: data.fbAsid, asid: data.fbAsid,
psid: data.fbPsid, psid: data.fbPsid,
@@ -271,13 +272,13 @@ export class AuthRepository implements IAuthRepository {
}): Promise<Result<LoginStatusT>> { }): Promise<Result<LoginStatusT>> {
return Result.wrap(async () => { return Result.wrap(async () => {
const response = await this.api.facebookPsidLogin( const response = await this.api.facebookPsidLogin(
FacebookPsidLoginRequest.from({ FacebookPsidLoginRequestSchema.parse({
psid: input.psid, psid: input.psid,
deviceId: input.deviceId ?? "", deviceId: input.deviceId ?? "",
bindToGuest: input.bindToGuest ?? true, bindToGuest: input.bindToGuest ?? true,
}), }),
); );
const data = response.toJson(); const data = response;
await this._saveFacebookIdentity({ await this._saveFacebookIdentity({
asid: data.fbAsid, asid: data.fbAsid,
psid: data.fbPsid, psid: data.fbPsid,
@@ -303,7 +304,7 @@ export class AuthRepository implements IAuthRepository {
} }
await this._saveLoginData( await this._saveLoginData(
LoginResponse.from({ LoginResponseSchema.parse({
token: data.token, token: data.token,
refreshToken: data.refreshToken, refreshToken: data.refreshToken,
user: data.user ?? { id: data.userId }, user: data.user ?? { id: data.userId },
@@ -330,7 +331,7 @@ export class AuthRepository implements IAuthRepository {
); );
} }
const response = await this.api.refreshToken( const response = await this.api.refreshToken(
RefreshTokenRequest.from({ refreshToken: existing.data }), RefreshTokenRequestSchema.parse({ refreshToken: existing.data }),
); );
const tokenResult = await this.storage.setLoginToken(response.token); const tokenResult = await this.storage.setLoginToken(response.token);
if (Result.isErr(tokenResult)) throw tokenResult.error; if (Result.isErr(tokenResult)) throw tokenResult.error;
@@ -404,7 +405,7 @@ export class AuthRepository implements IAuthRepository {
private async _cacheUser(user: User): Promise<void> { private async _cacheUser(user: User): Promise<void> {
try { try {
const userResult = await this.userStorage.setUser(user.toJson()); const userResult = await this.userStorage.setUser(user);
if (Result.isErr(userResult)) { if (Result.isErr(userResult)) {
log.warn("[AuthRepository] setUser failed", userResult.error); log.warn("[AuthRepository] setUser failed", userResult.error);
} }
@@ -1,5 +1,5 @@
import { ChatMessage } from "@/data/schemas/chat";
import type { UnlockedPrivateMessageLocalPatch } from "@/data/repositories/interfaces"; import type { UnlockedPrivateMessageLocalPatch } from "@/data/repositories/interfaces";
import { ChatMessage, ChatMessageSchema } from "@/data/schemas/chat";
import { LocalChatStorage, LocalMessage } from "@/data/storage/chat"; import { LocalChatStorage, LocalMessage } from "@/data/storage/chat";
import { Result } from "@/utils/result"; import { Result } from "@/utils/result";
@@ -20,8 +20,7 @@ type LocalMessageStorage = Pick<
export class ChatLocalMessageStore { export class ChatLocalMessageStore {
constructor( constructor(
private readonly localStorage: LocalMessageStorage, private readonly localStorage: LocalMessageStorage,
private readonly resolveIdentity: ChatCacheIdentityResolver = private readonly resolveIdentity: ChatCacheIdentityResolver = resolveChatCacheOwnerKey,
resolveChatCacheOwnerKey,
) {} ) {}
async markMessageUnlocked( async markMessageUnlocked(
@@ -40,8 +39,8 @@ export class ChatLocalMessageStore {
const updatedMessages = localResult.data.map((message) => { const updatedMessages = localResult.data.map((message) => {
if (!shouldMarkMessageUnlocked(message, messageId)) return message; if (!shouldMarkMessageUnlocked(message, messageId)) return message;
changed = true; changed = true;
return ChatMessage.from({ return ChatMessageSchema.parse({
...message.toJson(), ...message,
content: normalizeUnlockedContent(patch.content, message.content), content: normalizeUnlockedContent(patch.content, message.content),
audioUrl: normalizeUnlockedAudioUrl(patch.audioUrl, message.audioUrl), audioUrl: normalizeUnlockedAudioUrl(patch.audioUrl, message.audioUrl),
image: patch.image ?? message.image, image: patch.image ?? message.image,
@@ -85,7 +84,9 @@ export class ChatLocalMessageStore {
}); });
} }
async getMessages(identity?: string): Promise<Result<ChatMessage[]>> { async getMessages(
identity?: string,
): Promise<Result<readonly ChatMessage[]>> {
return Result.wrap(async () => { return Result.wrap(async () => {
const ownerKey = await this._requireIdentity(identity); const ownerKey = await this._requireIdentity(identity);
const result = await this._getMessages(ownerKey); const result = await this._getMessages(ownerKey);
@@ -128,12 +129,10 @@ export class ChatLocalMessageStore {
private async _getMessages( private async _getMessages(
identity: string, identity: string,
): Promise<Result<ChatMessage[]>> { ): Promise<Result<readonly ChatMessage[]>> {
const result = await this.localStorage.getAllMessagesBySession(identity); const result = await this.localStorage.getAllMessagesBySession(identity);
if (Result.isErr(result)) return result; if (Result.isErr(result)) return result;
return Result.ok( return Result.ok(result.data.map((message) => this._localToChat(message)));
result.data.map((message) => this._localToChat(message)),
);
} }
private _saveMessages( private _saveMessages(
@@ -161,7 +160,7 @@ export class ChatLocalMessageStore {
} }
private _localToChat(local: LocalMessage): ChatMessage { private _localToChat(local: LocalMessage): ChatMessage {
return ChatMessage.from({ return ChatMessageSchema.parse({
id: local.id, id: local.id,
role: local.role, role: local.role,
type: local.type, type: local.type,
@@ -1,14 +1,14 @@
import type { UnlockPrivateMessageInput } from "@/data/repositories/interfaces";
import { import {
ChatHistoryResponse, ChatHistoryResponse,
ChatSendResponse, ChatSendResponse,
SendMessageRequest, SendMessageRequestSchema,
UnlockHistoryRequest, UnlockHistoryRequestSchema,
UnlockHistoryResponse, UnlockHistoryResponse,
UnlockPrivateRequest, UnlockPrivateRequestSchema,
UnlockPrivateResponse, UnlockPrivateResponse,
} from "@/data/schemas/chat"; } from "@/data/schemas/chat";
import type { ChatApi } from "@/data/services/api"; import type { ChatApi } from "@/data/services/api";
import type { UnlockPrivateMessageInput } from "@/data/repositories/interfaces";
import { Result } from "@/utils/result"; import { Result } from "@/utils/result";
export class ChatRemoteDataSource { export class ChatRemoteDataSource {
@@ -20,7 +20,7 @@ export class ChatRemoteDataSource {
options?: { image?: string; useWebSocket?: boolean }, options?: { image?: string; useWebSocket?: boolean },
): Promise<Result<ChatSendResponse>> { ): Promise<Result<ChatSendResponse>> {
return Result.wrap(async () => { return Result.wrap(async () => {
const request = SendMessageRequest.from({ const request = SendMessageRequestSchema.parse({
characterId, characterId,
message, message,
image: options?.image ?? "", image: options?.image ?? "",
@@ -42,7 +42,7 @@ export class ChatRemoteDataSource {
input: UnlockPrivateMessageInput, input: UnlockPrivateMessageInput,
): Promise<Result<UnlockPrivateResponse>> { ): Promise<Result<UnlockPrivateResponse>> {
return Result.wrap(() => return Result.wrap(() =>
this.api.unlockPrivateMessage(UnlockPrivateRequest.from(input)), this.api.unlockPrivateMessage(UnlockPrivateRequestSchema.parse(input)),
); );
} }
@@ -50,7 +50,7 @@ export class ChatRemoteDataSource {
characterId: string, characterId: string,
): Promise<Result<UnlockHistoryResponse>> { ): Promise<Result<UnlockHistoryResponse>> {
return Result.wrap(() => return Result.wrap(() =>
this.api.unlockHistory(UnlockHistoryRequest.from({ characterId })), this.api.unlockHistory(UnlockHistoryRequestSchema.parse({ characterId })),
); );
} }
} }
+11 -9
View File
@@ -1,3 +1,10 @@
import type {
CacheRemoteChatMediaInput,
ChatMediaLookupInput,
IChatRepository,
UnlockPrivateMessageInput,
UnlockedPrivateMessageLocalPatch,
} from "@/data/repositories/interfaces";
import type { import type {
ChatHistoryResponse, ChatHistoryResponse,
ChatMessage, ChatMessage,
@@ -11,13 +18,6 @@ import {
LocalChatStorage, LocalChatStorage,
type LocalChatMediaRow, type LocalChatMediaRow,
} from "@/data/storage/chat"; } from "@/data/storage/chat";
import type {
CacheRemoteChatMediaInput,
ChatMediaLookupInput,
IChatRepository,
UnlockPrivateMessageInput,
UnlockedPrivateMessageLocalPatch,
} from "@/data/repositories/interfaces";
import type { Result } from "@/utils/result"; import type { Result } from "@/utils/result";
import { ChatLocalMessageStore } from "./chat_local_message_store"; import { ChatLocalMessageStore } from "./chat_local_message_store";
@@ -60,7 +60,9 @@ export class ChatRepository implements IChatRepository {
} }
/** 一键解锁历史锁定消息。 */ /** 一键解锁历史锁定消息。 */
async unlockHistory(characterId: string): Promise<Result<UnlockHistoryResponse>> { async unlockHistory(
characterId: string,
): Promise<Result<UnlockHistoryResponse>> {
return this.remote.unlockHistory(characterId); return this.remote.unlockHistory(characterId);
} }
@@ -101,7 +103,7 @@ export class ChatRepository implements IChatRepository {
/** 读取所有本地消息,按 dbId 升序。 */ /** 读取所有本地消息,按 dbId 升序。 */
async getLocalMessages( async getLocalMessages(
cacheIdentity?: string, cacheIdentity?: string,
): Promise<Result<ChatMessage[]>> { ): Promise<Result<readonly ChatMessage[]>> {
return this.localMessages.getMessages(cacheIdentity); return this.localMessages.getMessages(cacheIdentity);
} }
@@ -4,18 +4,19 @@
* 聚合聊天远程操作、身份隔离的本地历史,以及媒体缓存能力。 * 聚合聊天远程操作、身份隔离的本地历史,以及媒体缓存能力。
*/ */
import type { Result } from "@/utils/result";
import type { import type {
ChatHistoryResponse, ChatHistoryResponse,
ChatImageData,
ChatLockDetailData,
ChatLockType,
ChatMediaKind, ChatMediaKind,
ChatMessage, ChatMessage,
ChatSendResponse, ChatSendResponse,
UnlockHistoryResponse, UnlockHistoryResponse,
UnlockPrivateResponse, UnlockPrivateResponse,
} from "@/data/schemas/chat"; } 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 { LocalChatMediaRow } from "@/data/storage/chat";
import type { Result } from "@/utils/result";
export interface ChatMediaLookupInput { export interface ChatMediaLookupInput {
characterId: string; characterId: string;
@@ -84,7 +85,9 @@ export interface IChatRepository {
): Promise<Result<void>>; ): Promise<Result<void>>;
/** 读取所有本地消息,按 dbId 升序。 */ /** 读取所有本地消息,按 dbId 升序。 */
getLocalMessages(cacheIdentity?: string): Promise<Result<ChatMessage[]>>; getLocalMessages(
cacheIdentity?: string,
): Promise<Result<readonly ChatMessage[]>>;
/** 清空本地消息。 */ /** 清空本地消息。 */
clearLocalMessages(cacheIdentity?: string): Promise<Result<void>>; clearLocalMessages(cacheIdentity?: string): Promise<Result<void>>;
+5 -5
View File
@@ -3,10 +3,10 @@
* *
* 指标/埋点仓库:纯远程 fire-and-forget 上报,无本地状态。 * 指标/埋点仓库:纯远程 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 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"; import { createLazySingleton } from "./lazy_singleton";
export class MetricsRepository implements IMetricsRepository { export class MetricsRepository implements IMetricsRepository {
@@ -24,7 +24,7 @@ export class MetricsRepository implements IMetricsRepository {
}): Promise<Result<void>> { }): Promise<Result<void>> {
return Result.wrap(async () => { return Result.wrap(async () => {
await this.api.reportPwaEvent( await this.api.reportPwaEvent(
PwaEvent.from({ PwaEventSchema.parse({
deviceId: input.deviceId, deviceId: input.deviceId,
deviceType: input.deviceType, deviceType: input.deviceType,
timestamp: Math.floor(Date.now() / 1000), timestamp: Math.floor(Date.now() / 1000),
@@ -45,7 +45,7 @@ export class MetricsRepository implements IMetricsRepository {
}): Promise<Result<void>> { }): Promise<Result<void>> {
return Result.wrap(async () => { return Result.wrap(async () => {
await this.api.reportUserInfo( await this.api.reportUserInfo(
AppEvent.from({ AppEventSchema.parse({
userId: input.userId, userId: input.userId,
browser: input.browser, browser: input.browser,
userAgent: input.userAgent, userAgent: input.userAgent,
+8 -8
View File
@@ -3,16 +3,17 @@
* *
* 支付 / 付费墙相关远程调用。 * 支付 / 付费墙相关远程调用。
*/ */
import type { IPaymentRepository } from "@/data/repositories/interfaces";
import { import {
CreatePaymentOrderRequest, CreatePaymentOrderRequestSchema,
CreatePaymentOrderResponse, CreatePaymentOrderResponse,
PayChannel, PayChannel,
PaymentOrderStatusResponse, PaymentOrderStatusResponse,
PaymentPlansResponse, PaymentPlansResponse,
PaymentPlansResponseSchema,
} from "@/data/schemas/payment"; } from "@/data/schemas/payment";
import { PaymentApi, paymentApi } from "@/data/services/api"; import { PaymentApi, paymentApi } from "@/data/services/api";
import { PaymentPlansStorage } from "@/data/storage/payment"; import { PaymentPlansStorage } from "@/data/storage/payment";
import type { IPaymentRepository } from "@/data/repositories/interfaces";
import { Result } from "@/utils/result"; import { Result } from "@/utils/result";
import { createLazySingleton } from "./lazy_singleton"; import { createLazySingleton } from "./lazy_singleton";
@@ -23,7 +24,7 @@ export class PaymentRepository implements IPaymentRepository {
async getPlans(): Promise<Result<PaymentPlansResponse>> { async getPlans(): Promise<Result<PaymentPlansResponse>> {
return Result.wrap(async () => { return Result.wrap(async () => {
const response = await this.api.getPlans(); const response = await this.api.getPlans();
await PaymentPlansStorage.setPlans(response.toJson()); await PaymentPlansStorage.setPlans(response);
return response; return response;
}); });
} }
@@ -33,7 +34,7 @@ export class PaymentRepository implements IPaymentRepository {
return Result.wrap(async () => { return Result.wrap(async () => {
const result = await PaymentPlansStorage.getPlans(); const result = await PaymentPlansStorage.getPlans();
if (Result.isErr(result)) throw result.error; if (Result.isErr(result)) throw result.error;
return result.data ? PaymentPlansResponse.from(result.data) : null; return result.data ? PaymentPlansResponseSchema.parse(result.data) : null;
}); });
} }
@@ -41,9 +42,9 @@ export class PaymentRepository implements IPaymentRepository {
async getTipPlans(): Promise<Result<PaymentPlansResponse>> { async getTipPlans(): Promise<Result<PaymentPlansResponse>> {
return Result.wrap(async () => { return Result.wrap(async () => {
const response = await this.api.getTipPlans(); const response = await this.api.getTipPlans();
return PaymentPlansResponse.from({ return PaymentPlansResponseSchema.parse({
plans: response.plans.map((plan) => ({ plans: response.plans.map((plan) => ({
...plan.toJson(), ...plan,
orderType: "tip", orderType: "tip",
vipDays: null, vipDays: null,
dolAmount: null, dolAmount: null,
@@ -72,7 +73,7 @@ export class PaymentRepository implements IPaymentRepository {
payChannel: PayChannel, payChannel: PayChannel,
autoRenew: boolean, autoRenew: boolean,
): Promise<Result<CreatePaymentOrderResponse>> { ): Promise<Result<CreatePaymentOrderResponse>> {
const request = CreatePaymentOrderRequest.from({ const request = CreatePaymentOrderRequestSchema.parse({
planId, planId,
payChannel, payChannel,
autoRenew, autoRenew,
@@ -86,7 +87,6 @@ export class PaymentRepository implements IPaymentRepository {
): Promise<Result<PaymentOrderStatusResponse>> { ): Promise<Result<PaymentOrderStatusResponse>> {
return Result.wrap(() => this.api.getOrderStatus(orderId)); return Result.wrap(() => this.api.getOrderStatus(orderId));
} }
} }
/** 全局懒单例。 */ /** 全局懒单例。 */
@@ -1,16 +1,16 @@
import type {
GetPrivateAlbumsInput,
IPrivateRoomRepository,
} from "@/data/repositories/interfaces";
import { import {
UnlockPrivateAlbumRequest,
type PrivateAlbumsResponse, type PrivateAlbumsResponse,
type PrivateAlbumUnlockResponse, type PrivateAlbumUnlockResponse,
UnlockPrivateAlbumRequestSchema,
} from "@/data/schemas/private-room"; } from "@/data/schemas/private-room";
import { import {
PrivateRoomApi, PrivateRoomApi,
privateRoomApi, privateRoomApi,
} from "@/data/services/api/private_room_api"; } from "@/data/services/api/private_room_api";
import type {
GetPrivateAlbumsInput,
IPrivateRoomRepository,
} from "@/data/repositories/interfaces";
import { Result } from "@/utils/result"; import { Result } from "@/utils/result";
import { createLazySingleton } from "./lazy_singleton"; import { createLazySingleton } from "./lazy_singleton";
@@ -31,7 +31,7 @@ export class PrivateRoomRepository implements IPrivateRoomRepository {
return Result.wrap(() => return Result.wrap(() =>
this.api.unlockAlbum( this.api.unlockAlbum(
albumId, albumId,
UnlockPrivateAlbumRequest.from({ expectedCost }), UnlockPrivateAlbumRequestSchema.parse({ expectedCost }),
), ),
); );
} }
@@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest";
import { FacebookIdentityResponseSchema } from "@/data/schemas/auth";
import { ChatHistoryResponseSchema } from "@/data/schemas/chat";
import { PaymentPlansResponseSchema } from "@/data/schemas/payment";
import { PrivateAlbumsResponseSchema } from "@/data/schemas/private-room";
import { UserSchema } from "@/data/schemas/user";
describe("immutable schemas", () => {
it("freezes nested user data", () => {
const user = UserSchema.parse({
id: "user-1",
username: "Ada",
personalityTraits: null,
});
expectDeepFrozen(user);
});
it("freezes chat history messages and payloads", () => {
const response = ChatHistoryResponseSchema.parse({
messages: [
{
id: "message-1",
role: "assistant",
content: "Hello",
image: { type: "jpg", url: "https://example.com/image.jpg" },
lockDetail: { locked: false, reason: null },
},
],
});
expectDeepFrozen(response);
});
it("freezes payment and private-room collections", () => {
const plans = PaymentPlansResponseSchema.parse({
firstRechargeOffer: {
enabled: true,
type: "half_price",
discountPercent: 50,
},
plans: [
{
planId: "vip_monthly",
planName: "Monthly VIP",
orderType: "vip_monthly",
creditBalance: 0,
amountCents: 999,
currency: "USD",
},
],
});
const albums = PrivateAlbumsResponseSchema.parse({
items: [
{
albumId: "album-1",
title: "Private album",
images: [{ url: "https://example.com/image.jpg", index: 0 }],
lockDetail: { locked: true },
},
],
});
expectDeepFrozen(plans);
expectDeepFrozen(albums);
});
it("freezes containers created from missing defaults", () => {
const response = FacebookIdentityResponseSchema.parse({});
expectDeepFrozen(response);
expect(response.facebookBinding).toEqual({ conflicts: [], bound: [] });
});
});
function expectDeepFrozen(value: unknown): void {
if (value === null || typeof value !== "object") return;
expect(Object.isFrozen(value)).toBe(true);
for (const child of Object.values(value)) {
expectDeepFrozen(child);
}
}
@@ -1,42 +0,0 @@
import { describe, expect, it } from "vitest";
import { z } from "zod";
import {
createSchemaModel,
type SchemaModel,
} from "../schema_model";
const ExampleSchema = z.object({
id: z.string(),
traceId: z.string().default("generated-trace"),
});
type ExampleModel = SchemaModel<typeof ExampleSchema, "id">;
const ExampleModel = createSchemaModel<typeof ExampleSchema, "id">(
ExampleSchema,
);
describe("createSchemaModel", () => {
it("parses, freezes and serializes through the source schema", () => {
const model: ExampleModel = ExampleModel.from({ id: "item-1" });
expect(model).toBeInstanceOf(ExampleModel);
expect(Object.isFrozen(model)).toBe(true);
expect(model.id).toBe("item-1");
expect(model.toJson()).toEqual({
id: "item-1",
traceId: "generated-trace",
});
});
it("keeps non-public schema fields out of the declared model interface", () => {
const model = ExampleModel.fromJson({ id: "item-1", traceId: "trace-1" });
// @ts-expect-error traceId is serialized but not part of the public API.
expect(model.traceId).toBe("trace-1");
});
it("preserves schema validation errors", () => {
expect(() => ExampleModel.fromJson({ id: 1 })).toThrow(z.ZodError);
});
});
@@ -1,15 +1,15 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
FacebookIdentityResponse, FacebookIdentityResponseSchema,
FacebookPsidLoginResponse, FacebookPsidLoginResponseSchema,
LoginRequest, LoginRequestSchema,
} from "@/data/schemas/auth"; } from "@/data/schemas/auth";
import { UserSchema } from "@/data/schemas/user/user"; import { UserSchema } from "@/data/schemas/user/user";
describe("facebook identity schema models", () => { describe("facebook identity schema models", () => {
it("parses facebook identity binding responses", () => { it("parses facebook identity binding responses", () => {
const response = FacebookIdentityResponse.fromJson({ const response = FacebookIdentityResponseSchema.parse({
fbAsid: "asid-1", fbAsid: "asid-1",
fbPsid: "psid-1", fbPsid: "psid-1",
facebookBinding: { facebookBinding: {
@@ -18,7 +18,7 @@ describe("facebook identity schema models", () => {
}, },
}); });
expect(response.toJson()).toMatchObject({ expect(response).toMatchObject({
fbAsid: "asid-1", fbAsid: "asid-1",
fbPsid: "psid-1", fbPsid: "psid-1",
facebookBinding: { facebookBinding: {
@@ -30,7 +30,7 @@ describe("facebook identity schema models", () => {
it("parses facebook psid login responses for real users and guests", () => { it("parses facebook psid login responses for real users and guests", () => {
expect( expect(
FacebookPsidLoginResponse.fromJson({ FacebookPsidLoginResponseSchema.parse({
token: "login-token", token: "login-token",
refreshToken: "refresh-token", refreshToken: "refresh-token",
matchedBy: "psid", matchedBy: "psid",
@@ -39,7 +39,7 @@ describe("facebook identity schema models", () => {
hasCompleteFacebookIdentity: true, hasCompleteFacebookIdentity: true,
isGuest: false, isGuest: false,
user: { id: "user-1", username: "Elio" }, user: { id: "user-1", username: "Elio" },
}).toJson(), }),
).toMatchObject({ ).toMatchObject({
isGuest: false, isGuest: false,
hasCompleteFacebookIdentity: true, hasCompleteFacebookIdentity: true,
@@ -47,14 +47,14 @@ describe("facebook identity schema models", () => {
}); });
expect( expect(
FacebookPsidLoginResponse.fromJson({ FacebookPsidLoginResponseSchema.parse({
token: "guest-token", token: "guest-token",
matchedBy: "guest", matchedBy: "guest",
fbPsid: "psid-1", fbPsid: "psid-1",
hasCompleteFacebookIdentity: false, hasCompleteFacebookIdentity: false,
isGuest: true, isGuest: true,
userId: "guest-1", userId: "guest-1",
}).toJson(), }),
).toMatchObject({ ).toMatchObject({
refreshToken: "", refreshToken: "",
isGuest: true, isGuest: true,
@@ -64,7 +64,7 @@ describe("facebook identity schema models", () => {
}); });
it("keeps psid in request serialization without a public field declaration", () => { it("keeps psid in request serialization without a public field declaration", () => {
const request = LoginRequest.from({ const request = LoginRequestSchema.parse({
email: "user@example.com", email: "user@example.com",
password: "password", password: "password",
platform: "web", platform: "web",
@@ -72,7 +72,7 @@ describe("facebook identity schema models", () => {
psid: "psid-1", psid: "psid-1",
}); });
expect(request.toJson()).toMatchObject({ psid: "psid-1" }); expect(request).toMatchObject({ psid: "psid-1" });
}); });
it("accepts facebook identity fields in user schema", () => { it("accepts facebook identity fields in user schema", () => {
+10 -31
View File
@@ -1,6 +1,6 @@
/** /**
* Facebook 用户数据 schema * Facebook 用户数据 schema
* *
* *
* 字段对齐 Dart 端 `String?` 可空语义:缺字段时存 `null`。 * 字段对齐 Dart 端 `String?` 可空语义:缺字段时存 `null`。
*/ */
@@ -8,37 +8,16 @@ import { z } from "zod";
import { stringOrNull } from "../nullable-defaults"; import { stringOrNull } from "../nullable-defaults";
export const FacebookUserDataSchema = z.object({ export const FacebookUserDataSchema = z
id: stringOrNull, .object({
name: stringOrNull, id: stringOrNull,
email: stringOrNull, name: stringOrNull,
pictureUrl: stringOrNull, email: stringOrNull,
}); pictureUrl: stringOrNull,
})
.readonly();
export type FacebookUserDataInput = z.input<typeof FacebookUserDataSchema>; export type FacebookUserDataInput = z.input<typeof FacebookUserDataSchema>;
export type FacebookUserDataData = z.output<typeof FacebookUserDataSchema>; export type FacebookUserDataData = z.output<typeof FacebookUserDataSchema>;
export class FacebookUserData { export type FacebookUserData = FacebookUserDataData;
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);
}
}
@@ -3,17 +3,14 @@
*/ */
import { z } from "zod"; import { z } from "zod";
import {
createSchemaModel,
type SchemaModel,
} from "../../schema_model";
import { stringOrEmpty } from "../../nullable-defaults"; import { stringOrEmpty } from "../../nullable-defaults";
export const FacebookIdentityRequestSchema = z.object({ export const FacebookIdentityRequestSchema = z
asid: stringOrEmpty, .object({
psid: stringOrEmpty, asid: stringOrEmpty,
}); psid: stringOrEmpty,
})
.readonly();
export type FacebookIdentityRequestInput = z.input< export type FacebookIdentityRequestInput = z.input<
typeof FacebookIdentityRequestSchema typeof FacebookIdentityRequestSchema
@@ -21,10 +18,4 @@ export type FacebookIdentityRequestInput = z.input<
export type FacebookIdentityRequestData = z.output< export type FacebookIdentityRequestData = z.output<
typeof FacebookIdentityRequestSchema typeof FacebookIdentityRequestSchema
>; >;
export type FacebookIdentityRequest = FacebookIdentityRequestData;
export type FacebookIdentityRequest = SchemaModel<
typeof FacebookIdentityRequestSchema
>;
export const FacebookIdentityRequest = createSchemaModel(
FacebookIdentityRequestSchema,
);
@@ -1,43 +1,26 @@
/** /**
* Facebook 登录请求 * Facebook 登录请求
* *
*/ */
import { z } from "zod"; import { z } from "zod";
import { booleanOrFalse, stringOrEmpty } from "../../nullable-defaults"; import { booleanOrFalse, stringOrEmpty } from "../../nullable-defaults";
export const FacebookLoginRequestSchema = z.object({ export const FacebookLoginRequestSchema = z
accessToken: z.string(), .object({
platform: z.string(), accessToken: z.string(),
guestId: stringOrEmpty, platform: z.string(),
psid: stringOrEmpty, guestId: stringOrEmpty,
isTestAccount: booleanOrFalse, psid: stringOrEmpty,
}); isTestAccount: booleanOrFalse,
})
.readonly();
export type FacebookLoginRequestInput = z.input<typeof FacebookLoginRequestSchema>; export type FacebookLoginRequestInput = z.input<
export type FacebookLoginRequestData = z.output<typeof FacebookLoginRequestSchema>; typeof FacebookLoginRequestSchema
>;
export type FacebookLoginRequestData = z.output<
typeof FacebookLoginRequestSchema
>;
export class FacebookLoginRequest { export type FacebookLoginRequest = FacebookLoginRequestData;
declare readonly accessToken: string;
declare readonly platform: string;
declare readonly guestId: string;
declare readonly isTestAccount: boolean;
private constructor(input: FacebookLoginRequestInput) {
const data = FacebookLoginRequestSchema.parse(input);
Object.assign(this, data);
Object.freeze(this);
}
static from(input: FacebookLoginRequestInput): FacebookLoginRequest {
return new FacebookLoginRequest(input);
}
static fromJson(json: unknown): FacebookLoginRequest {
return FacebookLoginRequest.from(json as FacebookLoginRequestInput);
}
toJson(): FacebookLoginRequestData {
return FacebookLoginRequestSchema.parse(this);
}
}
@@ -3,18 +3,15 @@
*/ */
import { z } from "zod"; import { z } from "zod";
import {
createSchemaModel,
type SchemaModel,
} from "../../schema_model";
import { booleanOrTrue, stringOrEmpty } from "../../nullable-defaults"; import { booleanOrTrue, stringOrEmpty } from "../../nullable-defaults";
export const FacebookPsidLoginRequestSchema = z.object({ export const FacebookPsidLoginRequestSchema = z
psid: z.string(), .object({
deviceId: stringOrEmpty, psid: z.string(),
bindToGuest: booleanOrTrue, deviceId: stringOrEmpty,
}); bindToGuest: booleanOrTrue,
})
.readonly();
export type FacebookPsidLoginRequestInput = z.input< export type FacebookPsidLoginRequestInput = z.input<
typeof FacebookPsidLoginRequestSchema typeof FacebookPsidLoginRequestSchema
@@ -22,10 +19,4 @@ export type FacebookPsidLoginRequestInput = z.input<
export type FacebookPsidLoginRequestData = z.output< export type FacebookPsidLoginRequestData = z.output<
typeof FacebookPsidLoginRequestSchema typeof FacebookPsidLoginRequestSchema
>; >;
export type FacebookPsidLoginRequest = FacebookPsidLoginRequestData;
export type FacebookPsidLoginRequest = SchemaModel<
typeof FacebookPsidLoginRequestSchema
>;
export const FacebookPsidLoginRequest = createSchemaModel(
FacebookPsidLoginRequestSchema,
);
@@ -1,41 +1,21 @@
/** /**
* Facebook ID 登录请求 * Facebook ID 登录请求
* *
*/ */
import { z } from "zod"; import { z } from "zod";
import { booleanOrFalse, stringOrEmpty } from "../../nullable-defaults"; import { booleanOrFalse, stringOrEmpty } from "../../nullable-defaults";
export const FbIdLoginRequestSchema = z.object({ export const FbIdLoginRequestSchema = z
fbId: z.string(), .object({
avatarUrl: stringOrEmpty, fbId: z.string(),
psid: stringOrEmpty, avatarUrl: stringOrEmpty,
isTestAccount: booleanOrFalse, psid: stringOrEmpty,
}); isTestAccount: booleanOrFalse,
})
.readonly();
export type FbIdLoginRequestInput = z.input<typeof FbIdLoginRequestSchema>; export type FbIdLoginRequestInput = z.input<typeof FbIdLoginRequestSchema>;
export type FbIdLoginRequestData = z.output<typeof FbIdLoginRequestSchema>; export type FbIdLoginRequestData = z.output<typeof FbIdLoginRequestSchema>;
export class FbIdLoginRequest { export type FbIdLoginRequest = FbIdLoginRequestData;
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);
}
}
@@ -1,43 +1,22 @@
/** /**
* Google 登录请求 * Google 登录请求
* *
*/ */
import { z } from "zod"; import { z } from "zod";
import { booleanOrFalse, stringOrEmpty } from "../../nullable-defaults"; import { booleanOrFalse, stringOrEmpty } from "../../nullable-defaults";
export const GoogleLoginRequestSchema = z.object({ export const GoogleLoginRequestSchema = z
idToken: z.string(), .object({
platform: z.string(), idToken: z.string(),
guestId: stringOrEmpty, platform: z.string(),
psid: stringOrEmpty, guestId: stringOrEmpty,
isTestAccount: booleanOrFalse, psid: stringOrEmpty,
}); isTestAccount: booleanOrFalse,
})
.readonly();
export type GoogleLoginRequestInput = z.input<typeof GoogleLoginRequestSchema>; export type GoogleLoginRequestInput = z.input<typeof GoogleLoginRequestSchema>;
export type GoogleLoginRequestData = z.output<typeof GoogleLoginRequestSchema>; export type GoogleLoginRequestData = z.output<typeof GoogleLoginRequestSchema>;
export class GoogleLoginRequest { export type GoogleLoginRequest = GoogleLoginRequestData;
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);
}
}
@@ -1,38 +1,19 @@
/** /**
* 游客登录请求 * 游客登录请求
* *
*/ */
import { z } from "zod"; import { z } from "zod";
import { booleanOrFalse } from "../../nullable-defaults"; import { booleanOrFalse } from "../../nullable-defaults";
export const GuestLoginRequestSchema = z.object({ export const GuestLoginRequestSchema = z
deviceId: z.string(), .object({
isTestAccount: booleanOrFalse, deviceId: z.string(),
}); isTestAccount: booleanOrFalse,
})
.readonly();
export type GuestLoginRequestInput = z.input<typeof GuestLoginRequestSchema>; export type GuestLoginRequestInput = z.input<typeof GuestLoginRequestSchema>;
export type GuestLoginRequestData = z.output<typeof GuestLoginRequestSchema>; export type GuestLoginRequestData = z.output<typeof GuestLoginRequestSchema>;
export class GuestLoginRequest { export type GuestLoginRequest = GuestLoginRequestData;
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);
}
}
+13 -36
View File
@@ -1,47 +1,24 @@
/** /**
* 登录请求 * 登录请求
* *
*/ */
import { z } from "zod"; import { z } from "zod";
import { booleanOrFalse, stringOrEmpty } from "../../nullable-defaults"; import { booleanOrFalse, stringOrEmpty } from "../../nullable-defaults";
export const LoginRequestSchema = z.object({ export const LoginRequestSchema = z
email: stringOrEmpty, .object({
username: stringOrEmpty, email: stringOrEmpty,
password: z.string(), username: stringOrEmpty,
platform: z.string(), password: z.string(),
guestId: stringOrEmpty, platform: z.string(),
psid: stringOrEmpty, guestId: stringOrEmpty,
isTestAccount: booleanOrFalse, psid: stringOrEmpty,
}); isTestAccount: booleanOrFalse,
})
.readonly();
export type LoginRequestInput = z.input<typeof LoginRequestSchema>; export type LoginRequestInput = z.input<typeof LoginRequestSchema>;
export type LoginRequestData = z.output<typeof LoginRequestSchema>; export type LoginRequestData = z.output<typeof LoginRequestSchema>;
export class LoginRequest { export type LoginRequest = LoginRequestData;
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);
}
}
@@ -1,26 +1,19 @@
/** /**
* 刷新 Token 请求 * 刷新 Token 请求
* *
*/ */
import { z } from "zod"; import { z } from "zod";
import { export const RefreshTokenRequestSchema = z
createSchemaModel, .object({
type SchemaModel, refreshToken: z.string(),
} from "../../schema_model"; })
.readonly();
export const RefreshTokenRequestSchema = z.object({ export type RefreshTokenRequestInput = z.input<
refreshToken: z.string(), typeof RefreshTokenRequestSchema
});
export type RefreshTokenRequestInput = z.input<typeof RefreshTokenRequestSchema>;
export type RefreshTokenRequestData = z.output<typeof RefreshTokenRequestSchema>;
export type RefreshTokenRequest = SchemaModel<
typeof RefreshTokenRequestSchema,
"refreshToken"
>; >;
export const RefreshTokenRequest = createSchemaModel< export type RefreshTokenRequestData = z.output<
typeof RefreshTokenRequestSchema, typeof RefreshTokenRequestSchema
"refreshToken" >;
>(RefreshTokenRequestSchema); export type RefreshTokenRequest = RefreshTokenRequestData;
@@ -1,46 +1,23 @@
/** /**
* 注册请求 * 注册请求
* *
*/ */
import { z } from "zod"; import { z } from "zod";
import { booleanOrFalse, stringOrEmpty } from "../../nullable-defaults"; import { booleanOrFalse, stringOrEmpty } from "../../nullable-defaults";
export const RegisterRequestSchema = z.object({ export const RegisterRequestSchema = z
username: z.string(), .object({
email: z.string(), username: z.string(),
password: z.string(), email: z.string(),
platform: z.string(), password: z.string(),
guestId: stringOrEmpty, platform: z.string(),
isTestAccount: booleanOrFalse, guestId: stringOrEmpty,
}); isTestAccount: booleanOrFalse,
})
.readonly();
export type RegisterRequestInput = z.input<typeof RegisterRequestSchema>; export type RegisterRequestInput = z.input<typeof RegisterRequestSchema>;
export type RegisterRequestData = z.output<typeof RegisterRequestSchema>; export type RegisterRequestData = z.output<typeof RegisterRequestSchema>;
export class RegisterRequest { export type RegisterRequest = RegisterRequestData;
declare readonly username: string;
declare readonly email: string;
declare readonly password: string;
declare readonly platform: string;
declare readonly guestId: string;
declare readonly isTestAccount: boolean;
private constructor(input: RegisterRequestInput) {
const data = RegisterRequestSchema.parse(input);
Object.assign(this, data);
Object.freeze(this);
}
static from(input: RegisterRequestInput): RegisterRequest {
return new RegisterRequest(input);
}
static fromJson(json: unknown): RegisterRequest {
return RegisterRequest.from(json as RegisterRequestInput);
}
toJson(): RegisterRequestData {
return RegisterRequestSchema.parse(this);
}
}
@@ -3,26 +3,25 @@
*/ */
import { z } from "zod"; import { z } from "zod";
import {
createSchemaModel,
type SchemaModel,
} from "../../schema_model";
import { arrayOrEmpty, stringOrEmpty } from "../../nullable-defaults"; import { arrayOrEmpty, stringOrEmpty } from "../../nullable-defaults";
export const FacebookIdentityBindingSchema = z.object({ export const FacebookIdentityBindingSchema = z
conflicts: arrayOrEmpty(z.unknown()), .object({
bound: arrayOrEmpty(z.unknown()), conflicts: arrayOrEmpty(z.unknown()),
}); bound: arrayOrEmpty(z.unknown()),
})
.readonly();
export const FacebookIdentityResponseSchema = z.object({ export const FacebookIdentityResponseSchema = z
fbAsid: stringOrEmpty, .object({
fbPsid: stringOrEmpty, fbAsid: stringOrEmpty,
facebookBinding: FacebookIdentityBindingSchema.default({ fbPsid: stringOrEmpty,
conflicts: [], facebookBinding: FacebookIdentityBindingSchema.prefault({
bound: [], conflicts: [],
}), bound: [],
}); }),
})
.readonly();
export type FacebookIdentityResponseInput = z.input< export type FacebookIdentityResponseInput = z.input<
typeof FacebookIdentityResponseSchema typeof FacebookIdentityResponseSchema
@@ -30,10 +29,4 @@ export type FacebookIdentityResponseInput = z.input<
export type FacebookIdentityResponseData = z.output< export type FacebookIdentityResponseData = z.output<
typeof FacebookIdentityResponseSchema typeof FacebookIdentityResponseSchema
>; >;
export type FacebookIdentityResponse = FacebookIdentityResponseData;
export type FacebookIdentityResponse = SchemaModel<
typeof FacebookIdentityResponseSchema
>;
export const FacebookIdentityResponse = createSchemaModel(
FacebookIdentityResponseSchema,
);
@@ -10,17 +10,19 @@ import {
} from "../../nullable-defaults"; } from "../../nullable-defaults";
import { UserSchema } from "../../user/user"; import { UserSchema } from "../../user/user";
export const FacebookPsidLoginResponseSchema = z.object({ export const FacebookPsidLoginResponseSchema = z
token: z.string(), .object({
refreshToken: stringOrEmpty, token: z.string(),
matchedBy: stringOrEmpty, refreshToken: stringOrEmpty,
fbAsid: stringOrEmpty, matchedBy: stringOrEmpty,
fbPsid: stringOrEmpty, fbAsid: stringOrEmpty,
hasCompleteFacebookIdentity: booleanOrFalse, fbPsid: stringOrEmpty,
isGuest: booleanOrFalse, hasCompleteFacebookIdentity: booleanOrFalse,
user: schemaOrNull(UserSchema), isGuest: booleanOrFalse,
userId: stringOrEmpty, user: schemaOrNull(UserSchema),
}); userId: stringOrEmpty,
})
.readonly();
export type FacebookPsidLoginResponseInput = z.input< export type FacebookPsidLoginResponseInput = z.input<
typeof FacebookPsidLoginResponseSchema typeof FacebookPsidLoginResponseSchema
@@ -29,26 +31,4 @@ export type FacebookPsidLoginResponseData = z.output<
typeof FacebookPsidLoginResponseSchema typeof FacebookPsidLoginResponseSchema
>; >;
export class FacebookPsidLoginResponse { export type FacebookPsidLoginResponse = FacebookPsidLoginResponseData;
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);
}
}
@@ -1,48 +1,24 @@
/** /**
* 游客登录响应 * 游客登录响应
* *
*/ */
import { z } from "zod"; import { z } from "zod";
import { booleanOrTrue, numberOr, schemaOrNull } from "../../nullable-defaults"; import { booleanOrTrue, numberOr, schemaOr } from "../../nullable-defaults";
import { User, UserSchema } from "../../user/user"; import { EMPTY_USER, UserSchema } from "../../user/user";
export const GuestLoginResponseSchema = z.object({ export const GuestLoginResponseSchema = z
token: z.string(), .object({
deviceId: z.string(), token: z.string(),
userId: z.string(), deviceId: z.string(),
isDeviceUser: booleanOrTrue, userId: z.string(),
expiresIn: numberOr(2592000), isDeviceUser: booleanOrTrue,
user: schemaOrNull(UserSchema), expiresIn: numberOr(2592000),
}); user: schemaOr(UserSchema, EMPTY_USER),
})
.readonly();
export type GuestLoginResponseInput = z.input<typeof GuestLoginResponseSchema>; export type GuestLoginResponseInput = z.input<typeof GuestLoginResponseSchema>;
export type GuestLoginResponseData = z.output<typeof GuestLoginResponseSchema>; export type GuestLoginResponseData = z.output<typeof GuestLoginResponseSchema>;
export class GuestLoginResponse { export type GuestLoginResponse = GuestLoginResponseData;
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;
}
}
@@ -1,44 +1,21 @@
/** /**
* 登录响应 * 登录响应
* *
*/ */
import { z } from "zod"; import { z } from "zod";
import { stringOrEmpty } from "../../nullable-defaults"; import { stringOrEmpty } from "../../nullable-defaults";
import { User, UserSchema } from "../../user/user"; import { UserSchema } from "../../user/user";
export const LoginResponseSchema = z.object({ export const LoginResponseSchema = z
user: UserSchema, .object({
token: z.string(), user: UserSchema,
refreshToken: stringOrEmpty, token: z.string(),
}); refreshToken: stringOrEmpty,
})
.readonly();
export type LoginResponseInput = z.input<typeof LoginResponseSchema>; export type LoginResponseInput = z.input<typeof LoginResponseSchema>;
export type LoginResponseData = z.output<typeof LoginResponseSchema>; export type LoginResponseData = z.output<typeof LoginResponseSchema>;
export class LoginResponse { export type LoginResponse = LoginResponseData;
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);
}
}
@@ -1,28 +1,17 @@
/** /**
* 退出登录响应 * 退出登录响应
* *
*/ */
import { z } from "zod"; import { z } from "zod";
import {
createSchemaModel,
type SchemaModel,
} from "../../schema_model";
import { booleanOrTrue } from "../../nullable-defaults"; import { booleanOrTrue } from "../../nullable-defaults";
export const LogoutResponseSchema = z.object({ export const LogoutResponseSchema = z
success: booleanOrTrue, .object({
}); success: booleanOrTrue,
})
.readonly();
export type LogoutResponseInput = z.input<typeof LogoutResponseSchema>; export type LogoutResponseInput = z.input<typeof LogoutResponseSchema>;
export type LogoutResponseData = z.output<typeof LogoutResponseSchema>; export type LogoutResponseData = z.output<typeof LogoutResponseSchema>;
export type LogoutResponse = LogoutResponseData;
export type LogoutResponse = SchemaModel<
typeof LogoutResponseSchema,
"success"
>;
export const LogoutResponse = createSchemaModel<
typeof LogoutResponseSchema,
"success"
>(LogoutResponseSchema);
@@ -1,37 +1,22 @@
/** /**
* 刷新 Token 响应 * 刷新 Token 响应
* *
*/ */
import { z } from "zod"; import { z } from "zod";
export const RefreshTokenResponseSchema = z.object({ export const RefreshTokenResponseSchema = z
token: z.string(), .object({
refreshToken: z.string(), token: z.string(),
userId: z.string(), refreshToken: z.string(),
}); userId: z.string(),
})
.readonly();
export type RefreshTokenResponseInput = z.input<typeof RefreshTokenResponseSchema>; export type RefreshTokenResponseInput = z.input<
export type RefreshTokenResponseData = z.output<typeof RefreshTokenResponseSchema>; typeof RefreshTokenResponseSchema
>;
export type RefreshTokenResponseData = z.output<
typeof RefreshTokenResponseSchema
>;
export class RefreshTokenResponse { export type RefreshTokenResponse = RefreshTokenResponseData;
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);
}
}
@@ -1,10 +1,10 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { Character } from "@/data/schemas/character"; import { CharacterSchema } from "@/data/schemas/character";
describe("Character", () => { describe("Character", () => {
it("keeps only the four public character fields", () => { it("keeps only the four public character fields", () => {
const character = Character.fromJson({ const character = CharacterSchema.parse({
id: "character_elio", id: "character_elio",
slug: "elio", slug: "elio",
displayName: " Elio Silvestri ", displayName: " Elio Silvestri ",
@@ -13,7 +13,7 @@ describe("Character", () => {
sortOrder: 1, sortOrder: 1,
}); });
expect(character.toJson()).toEqual({ expect(character).toEqual({
id: "character_elio", id: "character_elio",
slug: "elio", slug: "elio",
displayName: "Elio Silvestri", displayName: "Elio Silvestri",
@@ -23,7 +23,7 @@ describe("Character", () => {
it("rejects unsafe slugs and non-HTTPS avatars", () => { it("rejects unsafe slugs and non-HTTPS avatars", () => {
expect(() => expect(() =>
Character.from({ CharacterSchema.parse({
id: "character_aria", id: "character_aria",
slug: "Aria Profile", slug: "Aria Profile",
displayName: "Aria", displayName: "Aria",
@@ -31,7 +31,7 @@ describe("Character", () => {
}), }),
).toThrow(); ).toThrow();
expect(() => expect(() =>
Character.from({ CharacterSchema.parse({
id: "character_aria", id: "character_aria",
slug: "aria", slug: "aria",
displayName: "Aria", displayName: "Aria",
+11 -31
View File
@@ -1,37 +1,17 @@
import { z } from "zod"; import { z } from "zod";
export const CharacterSchema = z.object({ export const CharacterSchema = z
id: z.string().min(1).max(64), .object({
slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/), id: z.string().min(1).max(64),
displayName: z.string().trim().min(1).max(80), slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
avatarUrl: z.url().refine((url) => url.startsWith("https://"), { displayName: z.string().trim().min(1).max(80),
message: "avatarUrl must use HTTPS", avatarUrl: z.url().refine((url) => url.startsWith("https://"), {
}), message: "avatarUrl must use HTTPS",
}); }),
})
.readonly();
export type CharacterInput = z.input<typeof CharacterSchema>; export type CharacterInput = z.input<typeof CharacterSchema>;
export type CharacterData = z.output<typeof CharacterSchema>; export type CharacterData = z.output<typeof CharacterSchema>;
export class Character { export type Character = CharacterData;
declare readonly id: string;
declare readonly slug: string;
declare readonly displayName: string;
declare readonly avatarUrl: string;
private constructor(input: CharacterInput) {
Object.assign(this, CharacterSchema.parse(input));
Object.freeze(this);
}
static from(input: CharacterInput): Character {
return new Character(input);
}
static fromJson(json: unknown): Character {
return Character.from(json as CharacterInput);
}
toJson(): CharacterData {
return CharacterSchema.parse(this);
}
}
@@ -1,36 +1,17 @@
import { z } from "zod"; import { z } from "zod";
import { Character, CharacterSchema } from "./character"; import { CharacterSchema } from "./character";
export const CharactersResponseSchema = z.object({ export const CharactersResponseSchema = z
items: z.array(CharacterSchema).default([]), .object({
}); items: z
.array(CharacterSchema)
.default(() => [])
.readonly(),
})
.readonly();
export type CharactersResponseInput = z.input<typeof CharactersResponseSchema>; export type CharactersResponseInput = z.input<typeof CharactersResponseSchema>;
export type CharactersResponseData = z.output<typeof CharactersResponseSchema>; export type CharactersResponseData = z.output<typeof CharactersResponseSchema>;
export class CharactersResponse { export type CharactersResponse = CharactersResponseData;
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()),
});
}
}
@@ -1,10 +1,10 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { ChatHistoryResponse } from "@/data/schemas/chat"; import { ChatHistoryResponseSchema } from "@/data/schemas/chat";
describe("ChatHistoryResponse", () => { describe("ChatHistoryResponse", () => {
it("exposes the pagination total and limit used by chat history", () => { it("exposes the pagination total and limit used by chat history", () => {
const response = ChatHistoryResponse.from({ const response = ChatHistoryResponseSchema.parse({
messages: [], messages: [],
total: 120, total: 120,
limit: 50, limit: 50,
@@ -1,15 +1,15 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character"; import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import { UnlockPrivateRequest } from "@/data/schemas/chat"; import { UnlockPrivateRequestSchema } from "@/data/schemas/chat";
describe("UnlockPrivateRequest", () => { describe("UnlockPrivateRequest", () => {
it("serializes an existing backend message unlock", () => { it("serializes an existing backend message unlock", () => {
expect( expect(
UnlockPrivateRequest.from({ UnlockPrivateRequestSchema.parse({
characterId: DEFAULT_CHARACTER_ID, characterId: DEFAULT_CHARACTER_ID,
messageId: "message-1", messageId: "message-1",
}).toJson(), }),
).toEqual({ ).toEqual({
characterId: DEFAULT_CHARACTER_ID, characterId: DEFAULT_CHARACTER_ID,
messageId: "message-1", messageId: "message-1",
@@ -18,11 +18,11 @@ describe("UnlockPrivateRequest", () => {
it("serializes a temporary promotion lock without a fake message id", () => { it("serializes a temporary promotion lock without a fake message id", () => {
expect( expect(
UnlockPrivateRequest.from({ UnlockPrivateRequestSchema.parse({
characterId: DEFAULT_CHARACTER_ID, characterId: DEFAULT_CHARACTER_ID,
lockType: "voice_message", lockType: "voice_message",
clientLockId: "promotion-1", clientLockId: "promotion-1",
}).toJson(), }),
).toEqual({ ).toEqual({
characterId: DEFAULT_CHARACTER_ID, characterId: DEFAULT_CHARACTER_ID,
lockType: "voice_message", lockType: "voice_message",
@@ -1,10 +1,10 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { UnlockPrivateResponse } from "@/data/schemas/chat"; import { UnlockPrivateResponseSchema } from "@/data/schemas/chat";
describe("UnlockPrivateResponse", () => { describe("UnlockPrivateResponse", () => {
it("parses a successful single-message unlock response", () => { it("parses a successful single-message unlock response", () => {
const response = UnlockPrivateResponse.from({ const response = UnlockPrivateResponseSchema.parse({
unlocked: true, unlocked: true,
content: "完整消息内容", content: "完整消息内容",
audioUrl: "https://example.com/unlocked-voice.mp3", audioUrl: "https://example.com/unlocked-voice.mp3",
@@ -26,7 +26,7 @@ describe("UnlockPrivateResponse", () => {
}); });
it("parses canonical ids, audio, and unlocked images", () => { it("parses canonical ids, audio, and unlocked images", () => {
const response = UnlockPrivateResponse.from({ const response = UnlockPrivateResponseSchema.parse({
unlocked: true, unlocked: true,
messageId: "backend-message-1", messageId: "backend-message-1",
clientLockId: "promotion-1", clientLockId: "promotion-1",
@@ -43,14 +43,14 @@ describe("UnlockPrivateResponse", () => {
expect(response.content).toBe("Unlocked reply"); expect(response.content).toBe("Unlocked reply");
expect(response.audioUrl).toBe("https://example.com/audio.mp3"); expect(response.audioUrl).toBe("https://example.com/audio.mp3");
expect(response.image.url).toBe("https://example.com/image.jpg"); expect(response.image.url).toBe("https://example.com/image.jpg");
expect(response.toJson()).toMatchObject({ expect(response).toMatchObject({
clientLockId: "promotion-1", clientLockId: "promotion-1",
lockType: "image_paywall", lockType: "image_paywall",
}); });
}); });
it("parses an insufficient-credits unlock response", () => { it("parses an insufficient-credits unlock response", () => {
const response = UnlockPrivateResponse.from({ const response = UnlockPrivateResponseSchema.parse({
unlocked: false, unlocked: false,
content: "", content: "",
reason: "insufficient_credits", reason: "insufficient_credits",
@@ -69,7 +69,7 @@ describe("UnlockPrivateResponse", () => {
}); });
it("defaults nullable content to an empty string", () => { it("defaults nullable content to an empty string", () => {
const response = UnlockPrivateResponse.from({ const response = UnlockPrivateResponseSchema.parse({
unlocked: false, unlocked: false,
content: null, content: null,
audioUrl: null, audioUrl: null,
@@ -81,7 +81,7 @@ describe("UnlockPrivateResponse", () => {
}); });
it("defaults nullable credit fields to zero", () => { it("defaults nullable credit fields to zero", () => {
const response = UnlockPrivateResponse.from({ const response = UnlockPrivateResponseSchema.parse({
unlocked: false, unlocked: false,
content: "", content: "",
reason: "insufficient_credits", reason: "insufficient_credits",
@@ -98,7 +98,7 @@ describe("UnlockPrivateResponse", () => {
}); });
it("strips legacy lock detail fields", () => { it("strips legacy lock detail fields", () => {
const response = UnlockPrivateResponse.fromJson({ const response = UnlockPrivateResponseSchema.parse({
unlocked: false, unlocked: false,
lockDetail: { lockDetail: {
locked: true, locked: true,
@@ -110,12 +110,12 @@ describe("UnlockPrivateResponse", () => {
}, },
}); });
expect(response.toJson()).not.toHaveProperty("lockDetail"); expect(response).not.toHaveProperty("lockDetail");
expect(response).not.toHaveProperty("lockDetail"); expect(response).not.toHaveProperty("lockDetail");
}); });
it("does not fall back to removed response aliases", () => { it("does not fall back to removed response aliases", () => {
const response = UnlockPrivateResponse.fromJson({ const response = UnlockPrivateResponseSchema.parse({
unlocked: true, unlocked: true,
message_id: "legacy-message", message_id: "legacy-message",
reply: "Legacy reply", reply: "Legacy reply",
@@ -125,8 +125,8 @@ describe("UnlockPrivateResponse", () => {
expect(response.messageId).toBe(""); expect(response.messageId).toBe("");
expect(response.content).toBe(""); expect(response.content).toBe("");
expect(response.audioUrl).toBe(""); expect(response.audioUrl).toBe("");
expect(response.toJson()).not.toHaveProperty("message_id"); expect(response).not.toHaveProperty("message_id");
expect(response.toJson()).not.toHaveProperty("reply"); expect(response).not.toHaveProperty("reply");
expect(response.toJson()).not.toHaveProperty("audio_url"); expect(response).not.toHaveProperty("audio_url");
}); });
}); });
+5 -36
View File
@@ -1,16 +1,11 @@
/** /**
* 聊天历史中的单条消息 * 聊天历史中的单条消息
* *
*/ */
import { z } from "zod"; import { z } from "zod";
import { stringOr, stringOrEmpty, stringOrNull } from "../nullable-defaults"; import { stringOr, stringOrEmpty, stringOrNull } from "../nullable-defaults";
import { import { ChatImageSchema, ChatLockDetailSchema } from "./chat_payloads";
ChatImageSchema,
ChatLockDetailSchema,
type ChatImageData,
type ChatLockDetailData,
} from "./chat_payloads";
export const ChatMessageSchema = z export const ChatMessageSchema = z
.object({ .object({
@@ -27,36 +22,10 @@ export const ChatMessageSchema = z
.transform(({ created_at, ...data }) => ({ .transform(({ created_at, ...data }) => ({
...data, ...data,
createdAt: data.createdAt || created_at || "", createdAt: data.createdAt || created_at || "",
})); }))
.readonly();
export type ChatMessageInput = z.input<typeof ChatMessageSchema>; export type ChatMessageInput = z.input<typeof ChatMessageSchema>;
export type ChatMessageData = z.output<typeof ChatMessageSchema>; export type ChatMessageData = z.output<typeof ChatMessageSchema>;
export class ChatMessage { export type ChatMessage = ChatMessageData;
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);
}
}
+3 -7
View File
@@ -3,11 +3,7 @@
*/ */
import { z } from "zod"; import { z } from "zod";
import { import { booleanOrFalse, schemaOr, stringOrNull } from "../nullable-defaults";
booleanOrFalse,
schemaOr,
stringOrNull,
} from "../nullable-defaults";
const CHAT_IMAGE_DEFAULTS = { type: null, url: null } as const; const CHAT_IMAGE_DEFAULTS = { type: null, url: null } as const;
@@ -22,7 +18,7 @@ export const ChatImageSchema = schemaOr(
url: stringOrNull, url: stringOrNull,
}), }),
CHAT_IMAGE_DEFAULTS, CHAT_IMAGE_DEFAULTS,
); ).readonly();
export const ChatLockDetailSchema = schemaOr( export const ChatLockDetailSchema = schemaOr(
z.object({ z.object({
@@ -30,7 +26,7 @@ export const ChatLockDetailSchema = schemaOr(
reason: stringOrNull, reason: stringOrNull,
}), }),
CHAT_LOCK_DETAIL_DEFAULTS, CHAT_LOCK_DETAIL_DEFAULTS,
); ).readonly();
export type ChatImageInput = z.input<typeof ChatImageSchema>; export type ChatImageInput = z.input<typeof ChatImageSchema>;
export type ChatImageData = z.output<typeof ChatImageSchema>; export type ChatImageData = z.output<typeof ChatImageSchema>;
@@ -1,6 +1,6 @@
/** /**
* 发送消息请求 * 发送消息请求
* *
*/ */
import { z } from "zod"; import { z } from "zod";
@@ -10,48 +10,22 @@ import {
stringOrEmpty, stringOrEmpty,
} from "../../nullable-defaults"; } from "../../nullable-defaults";
export const SendMessageRequestSchema = z.object({ export const SendMessageRequestSchema = z
characterId: z.string().min(1), .object({
message: stringOrEmpty, characterId: z.string().min(1),
image: stringOrEmpty, message: stringOrEmpty,
imageId: stringOrEmpty, image: stringOrEmpty,
imageThumbUrl: stringOrEmpty, imageId: stringOrEmpty,
imageMediumUrl: stringOrEmpty, imageThumbUrl: stringOrEmpty,
imageOriginalUrl: stringOrEmpty, imageMediumUrl: stringOrEmpty,
imageWidth: numberOrZero, imageOriginalUrl: stringOrEmpty,
imageHeight: numberOrZero, imageWidth: numberOrZero,
useWebSocket: booleanOrFalse, imageHeight: numberOrZero,
}); useWebSocket: booleanOrFalse,
})
.readonly();
export type SendMessageRequestInput = z.input<typeof SendMessageRequestSchema>; export type SendMessageRequestInput = z.input<typeof SendMessageRequestSchema>;
export type SendMessageRequestData = z.output<typeof SendMessageRequestSchema>; export type SendMessageRequestData = z.output<typeof SendMessageRequestSchema>;
export class SendMessageRequest { export type SendMessageRequest = SendMessageRequestData;
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);
}
}
@@ -1,8 +1,10 @@
import { z } from "zod"; import { z } from "zod";
export const UnlockHistoryRequestSchema = z.object({ export const UnlockHistoryRequestSchema = z
characterId: z.string().min(1), .object({
}); characterId: z.string().min(1),
})
.readonly();
export type UnlockHistoryRequestInput = z.input< export type UnlockHistoryRequestInput = z.input<
typeof UnlockHistoryRequestSchema typeof UnlockHistoryRequestSchema
@@ -11,19 +13,4 @@ export type UnlockHistoryRequestData = z.output<
typeof UnlockHistoryRequestSchema typeof UnlockHistoryRequestSchema
>; >;
export class UnlockHistoryRequest { export type UnlockHistoryRequest = UnlockHistoryRequestData;
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);
}
}
@@ -14,7 +14,8 @@ export const UnlockPrivateRequestSchema = z
}) })
.refine((value) => value.messageId || value.lockType, { .refine((value) => value.messageId || value.lockType, {
message: "messageId or lockType is required", message: "messageId or lockType is required",
}); })
.readonly();
export type UnlockPrivateRequestInput = z.input< export type UnlockPrivateRequestInput = z.input<
typeof UnlockPrivateRequestSchema typeof UnlockPrivateRequestSchema
@@ -23,27 +24,4 @@ export type UnlockPrivateRequestData = z.output<
typeof UnlockPrivateRequestSchema typeof UnlockPrivateRequestSchema
>; >;
export class UnlockPrivateRequest { export type UnlockPrivateRequest = UnlockPrivateRequestData;
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);
}
}
@@ -1,6 +1,6 @@
/** /**
* 聊天历史响应 * 聊天历史响应
* *
*/ */
import { z } from "zod"; import { z } from "zod";
@@ -9,45 +9,26 @@ import {
booleanOrFalse, booleanOrFalse,
numberOrZero, numberOrZero,
} from "../../nullable-defaults"; } from "../../nullable-defaults";
import { ChatMessage, ChatMessageSchema } from "../chat_message"; import { ChatMessageSchema } from "../chat_message";
export const ChatHistoryResponseSchema = z.object({ export const ChatHistoryResponseSchema = z
messages: arrayOrEmpty(ChatMessageSchema), .object({
total: numberOrZero, messages: arrayOrEmpty(ChatMessageSchema),
limit: numberOrZero, total: numberOrZero,
offset: numberOrZero, limit: numberOrZero,
isVip: booleanOrFalse, offset: numberOrZero,
privateFreeLimit: numberOrZero, isVip: booleanOrFalse,
privateUsedToday: numberOrZero, privateFreeLimit: numberOrZero,
privateCanViewFree: booleanOrFalse, privateUsedToday: numberOrZero,
}); privateCanViewFree: booleanOrFalse,
})
.readonly();
export type ChatHistoryResponseInput = z.input<typeof ChatHistoryResponseSchema>; export type ChatHistoryResponseInput = z.input<
export type ChatHistoryResponseData = z.output<typeof ChatHistoryResponseSchema>; typeof ChatHistoryResponseSchema
>;
export type ChatHistoryResponseData = z.output<
typeof ChatHistoryResponseSchema
>;
export class ChatHistoryResponse { export type ChatHistoryResponse = ChatHistoryResponseData;
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);
}
}
@@ -1,6 +1,6 @@
/** /**
* 发送消息响应 * 发送消息响应
* *
*/ */
import { z } from "zod"; import { z } from "zod";
import { import {
@@ -10,60 +10,27 @@ import {
numberOrZero, numberOrZero,
stringOrEmpty, stringOrEmpty,
} from "../../nullable-defaults"; } from "../../nullable-defaults";
import { import { ChatImageSchema, ChatLockDetailSchema } from "../chat_payloads";
ChatImageSchema,
ChatLockDetailSchema,
type ChatImageData,
type ChatLockDetailData,
} from "../chat_payloads";
export const ChatSendResponseSchema = z.object({ export const ChatSendResponseSchema = z
reply: stringOrEmpty, .object({
audioUrl: stringOrEmpty, reply: stringOrEmpty,
messageId: stringOrEmpty, audioUrl: stringOrEmpty,
isGuest: booleanOrFalse, messageId: stringOrEmpty,
// 函数式 default —— 每次 parse 时重新调 Date.now()(后端不返回 timestamp 时用当下时间) isGuest: booleanOrFalse,
timestamp: numberOrLazy(() => Date.now()), // 函数式 default —— 每次 parse 时重新调 Date.now()(后端不返回 timestamp 时用当下时间)
image: ChatImageSchema, timestamp: numberOrLazy(() => Date.now()),
lockDetail: ChatLockDetailSchema, image: ChatImageSchema,
canSendMessage: booleanOrTrue, lockDetail: ChatLockDetailSchema,
creditBalance: numberOrZero, canSendMessage: booleanOrTrue,
creditsCharged: numberOrZero, creditBalance: numberOrZero,
requiredCredits: numberOrZero, creditsCharged: numberOrZero,
shortfallCredits: numberOrZero, requiredCredits: numberOrZero,
}); shortfallCredits: numberOrZero,
})
.readonly();
export type ChatSendResponseInput = z.input<typeof ChatSendResponseSchema>; export type ChatSendResponseInput = z.input<typeof ChatSendResponseSchema>;
export type ChatSendResponseData = z.output<typeof ChatSendResponseSchema>; export type ChatSendResponseData = z.output<typeof ChatSendResponseSchema>;
export class ChatSendResponse { export type ChatSendResponse = ChatSendResponseData;
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);
}
}
@@ -18,21 +18,23 @@ export const UnlockHistoryReasonSchema = z.enum([
"no_locked_messages", "no_locked_messages",
]); ]);
export const UnlockHistoryResponseSchema = z.object({ export const UnlockHistoryResponseSchema = z
unlocked: booleanOrFalse, .object({
reason: UnlockHistoryReasonSchema, unlocked: booleanOrFalse,
totalLocked: numberOrZero, reason: UnlockHistoryReasonSchema,
unlockedCount: numberOrZero, totalLocked: numberOrZero,
privateCount: numberOrZero, unlockedCount: numberOrZero,
imageCount: numberOrZero, privateCount: numberOrZero,
voiceCount: numberOrZero, imageCount: numberOrZero,
requiredCredits: numberOrZero, voiceCount: numberOrZero,
currentCredits: numberOrZero, requiredCredits: numberOrZero,
remainingCredits: numberOrZero, currentCredits: numberOrZero,
shortfallCredits: numberOrZero, remainingCredits: numberOrZero,
costsByMessage: UnlockHistoryCostsByMessageSchema, shortfallCredits: numberOrZero,
messageIds: arrayOrEmpty(z.string()), costsByMessage: UnlockHistoryCostsByMessageSchema,
}); messageIds: arrayOrEmpty(z.string()),
})
.readonly();
export type UnlockHistoryReason = z.output<typeof UnlockHistoryReasonSchema>; export type UnlockHistoryReason = z.output<typeof UnlockHistoryReasonSchema>;
export type UnlockHistoryResponseInput = z.input< export type UnlockHistoryResponseInput = z.input<
@@ -42,26 +44,4 @@ export type UnlockHistoryResponseData = z.output<
typeof UnlockHistoryResponseSchema typeof UnlockHistoryResponseSchema
>; >;
export class UnlockHistoryResponse { export type UnlockHistoryResponse = UnlockHistoryResponseData;
declare readonly unlocked: boolean;
declare readonly reason: UnlockHistoryReason;
declare readonly shortfallCredits: number;
private constructor(input: UnlockHistoryResponseInput) {
const data = UnlockHistoryResponseSchema.parse(input);
Object.assign(this, data);
Object.freeze(this);
}
static from(input: UnlockHistoryResponseInput): UnlockHistoryResponse {
return new UnlockHistoryResponse(input);
}
static fromJson(json: unknown): UnlockHistoryResponse {
return UnlockHistoryResponse.from(json as UnlockHistoryResponseInput);
}
toJson(): UnlockHistoryResponseData {
return UnlockHistoryResponseSchema.parse(this);
}
}
@@ -6,28 +6,30 @@ import {
stringOrEmpty, stringOrEmpty,
stringOrNull, stringOrNull,
} from "../../nullable-defaults"; } from "../../nullable-defaults";
import { ChatImageSchema, type ChatImageData } from "../chat_payloads";
import { ChatLockTypeSchema } from "../chat_lock_type"; import { ChatLockTypeSchema } from "../chat_lock_type";
import { ChatImageSchema } from "../chat_payloads";
/** /**
* 单条历史付费 / 私密消息解锁响应。 * 单条历史付费 / 私密消息解锁响应。
*/ */
export const UnlockPrivateReasonSchema = stringOrEmpty; export const UnlockPrivateReasonSchema = stringOrEmpty;
export const UnlockPrivateResponseSchema = z.object({ export const UnlockPrivateResponseSchema = z
unlocked: booleanOrFalse, .object({
content: stringOrEmpty, unlocked: booleanOrFalse,
messageId: stringOrEmpty, content: stringOrEmpty,
clientLockId: stringOrNull, messageId: stringOrEmpty,
lockType: ChatLockTypeSchema.nullable().default(null), clientLockId: stringOrNull,
audioUrl: stringOrEmpty, lockType: ChatLockTypeSchema.nullable().default(null),
image: ChatImageSchema, audioUrl: stringOrEmpty,
reason: UnlockPrivateReasonSchema, image: ChatImageSchema,
creditBalance: numberOrZero, reason: UnlockPrivateReasonSchema,
creditsCharged: numberOrZero, creditBalance: numberOrZero,
requiredCredits: numberOrZero, creditsCharged: numberOrZero,
shortfallCredits: numberOrZero, requiredCredits: numberOrZero,
}); shortfallCredits: numberOrZero,
})
.readonly();
export type UnlockPrivateReason = z.output<typeof UnlockPrivateReasonSchema>; export type UnlockPrivateReason = z.output<typeof UnlockPrivateReasonSchema>;
export type UnlockPrivateResponseInput = z.input< export type UnlockPrivateResponseInput = z.input<
@@ -37,36 +39,4 @@ export type UnlockPrivateResponseData = z.output<
typeof UnlockPrivateResponseSchema typeof UnlockPrivateResponseSchema
>; >;
/** export type UnlockPrivateResponse = UnlockPrivateResponseData;
* 单条历史付费 / 私密消息解锁响应数据类。
*/
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);
}
}
@@ -1,13 +1,10 @@
import { z } from "zod"; import { z } from "zod";
import { export const FeedbackSubmitResponseSchema = z
createSchemaModel, .object({
type SchemaModel, feedbackId: z.string().min(1),
} from "../../schema_model"; })
.readonly();
export const FeedbackSubmitResponseSchema = z.object({
feedbackId: z.string().min(1),
});
export type FeedbackSubmitResponseInput = z.input< export type FeedbackSubmitResponseInput = z.input<
typeof FeedbackSubmitResponseSchema typeof FeedbackSubmitResponseSchema
@@ -15,12 +12,4 @@ export type FeedbackSubmitResponseInput = z.input<
export type FeedbackSubmitResponseData = z.output< export type FeedbackSubmitResponseData = z.output<
typeof FeedbackSubmitResponseSchema typeof FeedbackSubmitResponseSchema
>; >;
export type FeedbackSubmitResponse = FeedbackSubmitResponseData;
export type FeedbackSubmitResponse = SchemaModel<
typeof FeedbackSubmitResponseSchema,
"feedbackId"
>;
export const FeedbackSubmitResponse = createSchemaModel<
typeof FeedbackSubmitResponseSchema,
"feedbackId"
>(FeedbackSubmitResponseSchema);
+9 -18
View File
@@ -1,28 +1,19 @@
/** /**
* 应用事件上报请求 * 应用事件上报请求
* *
*/ */
import { z } from "zod"; import { z } from "zod";
import {
createSchemaModel,
type SchemaModel,
} from "../../schema_model";
import { stringOrEmpty } from "../../nullable-defaults"; import { stringOrEmpty } from "../../nullable-defaults";
export const AppEventSchema = z.object({ export const AppEventSchema = z
userId: stringOrEmpty, .object({
browser: stringOrEmpty, userId: stringOrEmpty,
userAgent: stringOrEmpty, browser: stringOrEmpty,
}); userAgent: stringOrEmpty,
})
.readonly();
export type AppEventInput = z.input<typeof AppEventSchema>; export type AppEventInput = z.input<typeof AppEventSchema>;
export type AppEventData = z.output<typeof AppEventSchema>; export type AppEventData = z.output<typeof AppEventSchema>;
export type AppEvent = AppEventData;
type AppEventPublicKey = "userId" | "browser" | "userAgent";
export type AppEvent = SchemaModel<typeof AppEventSchema, AppEventPublicKey>;
export const AppEvent = createSchemaModel<
typeof AppEventSchema,
AppEventPublicKey
>(AppEventSchema);
+11 -25
View File
@@ -1,39 +1,25 @@
/** /**
* PWA 事件上报请求 * PWA 事件上报请求
* *
*/ */
import { z } from "zod"; import { z } from "zod";
import {
createSchemaModel,
type SchemaModel,
} from "../../schema_model";
import { import {
booleanOrFalse, booleanOrFalse,
numberOrZero, numberOrZero,
stringOrEmpty, stringOrEmpty,
} from "../../nullable-defaults"; } from "../../nullable-defaults";
export const PwaEventSchema = z.object({ export const PwaEventSchema = z
deviceId: stringOrEmpty, .object({
deviceType: stringOrEmpty, deviceId: stringOrEmpty,
timestamp: numberOrZero, deviceType: stringOrEmpty,
pwaInstalled: booleanOrFalse, timestamp: numberOrZero,
pwaSupported: booleanOrFalse, pwaInstalled: booleanOrFalse,
}); pwaSupported: booleanOrFalse,
})
.readonly();
export type PwaEventInput = z.input<typeof PwaEventSchema>; export type PwaEventInput = z.input<typeof PwaEventSchema>;
export type PwaEventData = z.output<typeof PwaEventSchema>; export type PwaEventData = z.output<typeof PwaEventSchema>;
export type PwaEvent = PwaEventData;
type PwaEventPublicKey =
| "deviceId"
| "deviceType"
| "timestamp"
| "pwaInstalled"
| "pwaSupported";
export type PwaEvent = SchemaModel<typeof PwaEventSchema, PwaEventPublicKey>;
export const PwaEvent = createSchemaModel<
typeof PwaEventSchema,
PwaEventPublicKey
>(PwaEventSchema);
+6 -4
View File
@@ -91,7 +91,8 @@ export function arrayOrEmpty<T extends z.ZodType>(schema: T) {
.array(schema) .array(schema)
.nullable() .nullable()
.transform((v) => v ?? []) .transform((v) => v ?? [])
.default(() => []); .default(() => [])
.readonly();
} }
/** `Record<string, T> | null | undefined` → `Record<string, T>`(默认空对象) */ /** `Record<string, T> | null | undefined` → `Record<string, T>`(默认空对象) */
@@ -100,7 +101,8 @@ export function recordOrEmpty<T extends z.ZodType>(schema: T) {
.record(z.string(), schema) .record(z.string(), schema)
.nullable() .nullable()
.transform((v) => v ?? {}) .transform((v) => v ?? {})
.default(() => ({})); .default(() => ({}))
.readonly();
} }
/** `Record<string, unknown> | null | undefined` → `Record<string, unknown>` */ /** `Record<string, unknown> | null | undefined` → `Record<string, unknown>` */
@@ -129,10 +131,10 @@ export function schemaOr<T extends z.ZodType>(
); );
if (typeof defaultValue === "function") { if (typeof defaultValue === "function") {
return preprocessed.default(defaultValue as () => SchemaDefault<T>); return preprocessed.prefault(defaultValue as () => SchemaDefault<T>);
} }
return preprocessed.default(defaultValue); return preprocessed.prefault(defaultValue);
} }
/** `T | null | undefined` → `T | null`(默认 null */ /** `T | null | undefined` → `T | null`(默认 null */
@@ -2,14 +2,14 @@ import { describe, expect, it } from "vitest";
import { z } from "zod"; import { z } from "zod";
import { import {
PaymentPlan, PaymentPlanSchema,
PaymentPlansResponse, PaymentPlansResponseSchema,
TipPaymentPlansResponse, TipPaymentPlansResponseSchema,
} from "@/data/schemas/payment"; } from "@/data/schemas/payment";
describe("PaymentPlan", () => { describe("PaymentPlan", () => {
it("parses the camelCase payment plan shape", () => { it("parses the camelCase payment plan shape", () => {
const plan = PaymentPlan.from({ const plan = PaymentPlanSchema.parse({
planId: "vip_monthly", planId: "vip_monthly",
planName: "VIP Monthly", planName: "VIP Monthly",
orderType: "vip_monthly", orderType: "vip_monthly",
@@ -26,7 +26,7 @@ describe("PaymentPlan", () => {
expect(plan.planId).toBe("vip_monthly"); expect(plan.planId).toBe("vip_monthly");
expect(plan.creditBalance).toBe(3000); expect(plan.creditBalance).toBe(3000);
expect(plan.mostPopular).toBe(true); expect(plan.mostPopular).toBe(true);
expect(plan.toJson()).toEqual({ expect(plan).toEqual({
planId: "vip_monthly", planId: "vip_monthly",
planName: "VIP Monthly", planName: "VIP Monthly",
orderType: "vip_monthly", orderType: "vip_monthly",
@@ -46,7 +46,7 @@ describe("PaymentPlan", () => {
it("rejects the legacy snake_case payment plan shape", () => { it("rejects the legacy snake_case payment plan shape", () => {
expect(() => expect(() =>
PaymentPlan.fromJson({ PaymentPlanSchema.parse({
plan_id: "vip_monthly", plan_id: "vip_monthly",
plan_name: "VIP Monthly", plan_name: "VIP Monthly",
order_type: "vip_monthly", order_type: "vip_monthly",
@@ -61,7 +61,7 @@ describe("PaymentPlan", () => {
}); });
it("defaults mostPopular to false when the backend omits it", () => { it("defaults mostPopular to false when the backend omits it", () => {
const plan = PaymentPlan.from({ const plan = PaymentPlanSchema.parse({
planId: "vip_monthly", planId: "vip_monthly",
planName: "VIP Monthly", planName: "VIP Monthly",
orderType: "vip_monthly", orderType: "vip_monthly",
@@ -80,7 +80,7 @@ describe("PaymentPlan", () => {
describe("PaymentPlansResponse", () => { describe("PaymentPlansResponse", () => {
it("parses plans from the new backend response data shape", () => { it("parses plans from the new backend response data shape", () => {
const response = PaymentPlansResponse.from({ const response = PaymentPlansResponseSchema.parse({
plans: [ plans: [
{ {
planId: "dol_1000", planId: "dol_1000",
@@ -102,7 +102,7 @@ describe("PaymentPlansResponse", () => {
}); });
it("parses first recharge offer metadata", () => { it("parses first recharge offer metadata", () => {
const response = PaymentPlansResponse.from({ const response = PaymentPlansResponseSchema.parse({
isFirstRecharge: true, isFirstRecharge: true,
firstRechargeOffer: { firstRechargeOffer: {
enabled: true, enabled: true,
@@ -140,7 +140,7 @@ describe("PaymentPlansResponse", () => {
}); });
it("defaults nullable first recharge offer metadata", () => { it("defaults nullable first recharge offer metadata", () => {
const response = PaymentPlansResponse.from({ const response = PaymentPlansResponseSchema.parse({
isFirstRecharge: true, isFirstRecharge: true,
firstRechargeOffer: { firstRechargeOffer: {
enabled: null, enabled: null,
@@ -160,7 +160,7 @@ describe("PaymentPlansResponse", () => {
describe("TipPaymentPlansResponse", () => { describe("TipPaymentPlansResponse", () => {
it("keeps only fields used by the tip payment flow", () => { it("keeps only fields used by the tip payment flow", () => {
const response = TipPaymentPlansResponse.fromJson({ const response = TipPaymentPlansResponseSchema.parse({
plans: [ plans: [
{ {
planId: "tip_coffee_usd_4_99", planId: "tip_coffee_usd_4_99",
@@ -177,7 +177,7 @@ describe("TipPaymentPlansResponse", () => {
], ],
}); });
expect(response.toJson()).toEqual({ expect(response).toEqual({
plans: [ plans: [
{ {
planId: "tip_coffee_usd_4_99", planId: "tip_coffee_usd_4_99",
+19 -49
View File
@@ -9,56 +9,26 @@ import {
stringOrNull, stringOrNull,
} from "../nullable-defaults"; } from "../nullable-defaults";
export const PaymentPlanSchema = z.object({ export const PaymentPlanSchema = z
planId: z.string(), .object({
planName: z.string(), planId: z.string(),
orderType: z.string(), planName: z.string(),
vipDays: numberOrNull, orderType: z.string(),
dolAmount: numberOrNull, vipDays: numberOrNull,
creditBalance: z.number(), dolAmount: numberOrNull,
amountCents: z.number(), creditBalance: z.number(),
originalAmountCents: numberOrNull, amountCents: z.number(),
dailyPriceCents: numberOrNull, originalAmountCents: numberOrNull,
currency: z.string(), dailyPriceCents: numberOrNull,
isFirstRechargeOffer: booleanOrFalse, currency: z.string(),
mostPopular: booleanOrFalse, isFirstRechargeOffer: booleanOrFalse,
firstRechargeDiscountPercent: numberOrNull, mostPopular: booleanOrFalse,
promotionType: stringOrNull, firstRechargeDiscountPercent: numberOrNull,
}); promotionType: stringOrNull,
})
.readonly();
export type PaymentPlanInput = z.input<typeof PaymentPlanSchema>; export type PaymentPlanInput = z.input<typeof PaymentPlanSchema>;
export type PaymentPlanData = z.output<typeof PaymentPlanSchema>; export type PaymentPlanData = z.output<typeof PaymentPlanSchema>;
export class PaymentPlan { export type PaymentPlan = PaymentPlanData;
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);
}
}
@@ -5,11 +5,13 @@ import { z } from "zod";
export const PayChannelSchema = z.enum(["stripe", "ezpay"]); export const PayChannelSchema = z.enum(["stripe", "ezpay"]);
export const CreatePaymentOrderRequestSchema = z.object({ export const CreatePaymentOrderRequestSchema = z
planId: z.string(), .object({
payChannel: PayChannelSchema, planId: z.string(),
autoRenew: z.boolean(), payChannel: PayChannelSchema,
}); autoRenew: z.boolean(),
})
.readonly();
export type PayChannel = z.output<typeof PayChannelSchema>; export type PayChannel = z.output<typeof PayChannelSchema>;
export type CreatePaymentOrderRequestInput = z.input< export type CreatePaymentOrderRequestInput = z.input<
@@ -19,30 +21,4 @@ export type CreatePaymentOrderRequestData = z.output<
typeof CreatePaymentOrderRequestSchema typeof CreatePaymentOrderRequestSchema
>; >;
export class CreatePaymentOrderRequest { export type CreatePaymentOrderRequest = CreatePaymentOrderRequestData;
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);
}
}
@@ -19,35 +19,42 @@ const paymentUrlKeys = [
"url", "url",
] as const; ] as const;
export const CreatePaymentOrderResponseSchema = z.object({ export const CreatePaymentOrderResponseSchema = z
orderId: z.string(), .object({
payParams: z.record(z.string(), z.unknown()), orderId: z.string(),
cashierUrl: stringOrNull, payParams: z.record(z.string(), z.unknown()).readonly(),
cashier_url: stringOrNull, cashierUrl: stringOrNull,
checkoutUrl: stringOrNull, cashier_url: stringOrNull,
checkout_url: stringOrNull, checkoutUrl: stringOrNull,
paymentUrl: stringOrNull, checkout_url: stringOrNull,
payment_url: stringOrNull, paymentUrl: stringOrNull,
approvalUrl: stringOrNull, payment_url: stringOrNull,
approval_url: stringOrNull, approvalUrl: stringOrNull,
redirectUrl: stringOrNull, approval_url: stringOrNull,
redirect_url: stringOrNull, redirectUrl: stringOrNull,
url: stringOrNull, redirect_url: stringOrNull,
}).transform((data) => { url: stringOrNull,
const payParams = { ...data.payParams }; })
.transform((data) => {
const payParams = { ...data.payParams };
for (const key of paymentUrlKeys) { for (const key of paymentUrlKeys) {
const value = data[key]; const value = data[key];
if (typeof value === "string" && value.length > 0 && !(key in payParams)) { if (
payParams[key] = value; typeof value === "string" &&
value.length > 0 &&
!(key in payParams)
) {
payParams[key] = value;
}
} }
}
return { return {
orderId: data.orderId, orderId: data.orderId,
payParams, payParams,
}; };
}); })
.readonly();
export type CreatePaymentOrderResponseInput = z.input< export type CreatePaymentOrderResponseInput = z.input<
typeof CreatePaymentOrderResponseSchema typeof CreatePaymentOrderResponseSchema
@@ -56,29 +63,4 @@ export type CreatePaymentOrderResponseData = z.output<
typeof CreatePaymentOrderResponseSchema typeof CreatePaymentOrderResponseSchema
>; >;
export class CreatePaymentOrderResponse { export type CreatePaymentOrderResponse = CreatePaymentOrderResponseData;
declare readonly orderId: string;
declare readonly payParams: Record<string, unknown>;
private constructor(input: CreatePaymentOrderResponseInput) {
const data = CreatePaymentOrderResponseSchema.parse(input);
Object.assign(this, data);
Object.freeze(this);
}
static from(
input: CreatePaymentOrderResponseInput,
): CreatePaymentOrderResponse {
return new CreatePaymentOrderResponse(input);
}
static fromJson(json: unknown): CreatePaymentOrderResponse {
return CreatePaymentOrderResponse.from(
json as CreatePaymentOrderResponseInput,
);
}
toJson(): CreatePaymentOrderResponseData {
return CreatePaymentOrderResponseSchema.parse(this);
}
}
@@ -5,12 +5,14 @@ import { z } from "zod";
export const PaymentOrderStatusSchema = z.enum(["pending", "paid", "failed"]); export const PaymentOrderStatusSchema = z.enum(["pending", "paid", "failed"]);
export const PaymentOrderStatusResponseSchema = z.object({ export const PaymentOrderStatusResponseSchema = z
orderId: z.string(), .object({
status: PaymentOrderStatusSchema, orderId: z.string(),
orderType: z.string(), status: PaymentOrderStatusSchema,
planId: z.string(), orderType: z.string(),
}); planId: z.string(),
})
.readonly();
export type PaymentOrderStatus = z.output<typeof PaymentOrderStatusSchema>; export type PaymentOrderStatus = z.output<typeof PaymentOrderStatusSchema>;
export type PaymentOrderStatusResponseInput = z.input< export type PaymentOrderStatusResponseInput = z.input<
@@ -20,28 +22,4 @@ export type PaymentOrderStatusResponseData = z.output<
typeof PaymentOrderStatusResponseSchema typeof PaymentOrderStatusResponseSchema
>; >;
export class PaymentOrderStatusResponse { export type PaymentOrderStatusResponse = PaymentOrderStatusResponseData;
declare readonly status: PaymentOrderStatus;
private constructor(input: PaymentOrderStatusResponseInput) {
const data = PaymentOrderStatusResponseSchema.parse(input);
Object.assign(this, data);
Object.freeze(this);
}
static from(
input: PaymentOrderStatusResponseInput,
): PaymentOrderStatusResponse {
return new PaymentOrderStatusResponse(input);
}
static fromJson(json: unknown): PaymentOrderStatusResponse {
return PaymentOrderStatusResponse.from(
json as PaymentOrderStatusResponseInput,
);
}
toJson(): PaymentOrderStatusResponseData {
return PaymentOrderStatusResponseSchema.parse(this);
}
}
@@ -10,19 +10,23 @@ import {
schemaOrNull, schemaOrNull,
stringOrEmpty, stringOrEmpty,
} from "../../nullable-defaults"; } from "../../nullable-defaults";
import { PaymentPlan, PaymentPlanSchema } from "../payment_plan"; import { PaymentPlanSchema } from "../payment_plan";
export const FirstRechargeOfferSchema = z.object({ export const FirstRechargeOfferSchema = z
enabled: booleanOrFalse, .object({
type: stringOrEmpty, enabled: booleanOrFalse,
discountPercent: numberOrZero, type: stringOrEmpty,
}); discountPercent: numberOrZero,
})
.readonly();
export const PaymentPlansResponseSchema = z.object({ export const PaymentPlansResponseSchema = z
isFirstRecharge: booleanOrFalse, .object({
firstRechargeOffer: schemaOrNull(FirstRechargeOfferSchema), isFirstRecharge: booleanOrFalse,
plans: arrayOrEmpty(PaymentPlanSchema), firstRechargeOffer: schemaOrNull(FirstRechargeOfferSchema),
}); plans: arrayOrEmpty(PaymentPlanSchema),
})
.readonly();
export type PaymentPlansResponseInput = z.input< export type PaymentPlansResponseInput = z.input<
typeof PaymentPlansResponseSchema typeof PaymentPlansResponseSchema
@@ -32,36 +36,6 @@ export type PaymentPlansResponseData = z.output<
>; >;
export type FirstRechargeOfferData = z.output<typeof FirstRechargeOfferSchema>; export type FirstRechargeOfferData = z.output<typeof FirstRechargeOfferSchema>;
export class PaymentPlansResponse {
declare readonly isFirstRecharge: boolean;
declare readonly firstRechargeOffer: FirstRechargeOfferData | null;
declare readonly plans: PaymentPlan[];
private constructor(input: PaymentPlansResponseInput) {
const data = PaymentPlansResponseSchema.parse(input);
Object.assign(this, {
isFirstRecharge: data.isFirstRecharge,
firstRechargeOffer: data.firstRechargeOffer,
plans: data.plans.map((plan) => PaymentPlan.from(plan)),
});
Object.freeze(this);
}
static from(input: PaymentPlansResponseInput): PaymentPlansResponse {
return new PaymentPlansResponse(input);
}
static fromJson(json: unknown): PaymentPlansResponse {
return PaymentPlansResponse.from(json as PaymentPlansResponseInput);
}
toJson(): PaymentPlansResponseData {
return {
isFirstRecharge: this.isFirstRecharge,
firstRechargeOffer: this.firstRechargeOffer,
plans: this.plans.map((plan) => plan.toJson()),
};
}
}
export type FirstRechargeOffer = FirstRechargeOfferData; export type FirstRechargeOffer = FirstRechargeOfferData;
export type PaymentPlansResponse = PaymentPlansResponseData;
@@ -1,11 +1,13 @@
import { z } from "zod"; import { z } from "zod";
import { arrayOrEmpty } from "../../nullable-defaults"; import { arrayOrEmpty } from "../../nullable-defaults";
import { TipPaymentPlan, TipPaymentPlanSchema } from "../tip_payment_plan"; import { TipPaymentPlanSchema } from "../tip_payment_plan";
export const TipPaymentPlansResponseSchema = z.object({ export const TipPaymentPlansResponseSchema = z
plans: arrayOrEmpty(TipPaymentPlanSchema), .object({
}); plans: arrayOrEmpty(TipPaymentPlanSchema),
})
.readonly();
export type TipPaymentPlansResponseInput = z.input< export type TipPaymentPlansResponseInput = z.input<
typeof TipPaymentPlansResponseSchema typeof TipPaymentPlansResponseSchema
@@ -14,30 +16,4 @@ export type TipPaymentPlansResponseData = z.output<
typeof TipPaymentPlansResponseSchema typeof TipPaymentPlansResponseSchema
>; >;
export class TipPaymentPlansResponse { export type TipPaymentPlansResponse = TipPaymentPlansResponseData;
declare readonly plans: TipPaymentPlan[];
private constructor(input: TipPaymentPlansResponseInput) {
const data = TipPaymentPlansResponseSchema.parse(input);
Object.assign(this, {
plans: data.plans.map((plan) => TipPaymentPlan.from(plan)),
});
Object.freeze(this);
}
static from(input: TipPaymentPlansResponseInput): TipPaymentPlansResponse {
return new TipPaymentPlansResponse(input);
}
static fromJson(json: unknown): TipPaymentPlansResponse {
return TipPaymentPlansResponse.from(
json as TipPaymentPlansResponseInput,
);
}
toJson(): TipPaymentPlansResponseData {
return {
plans: this.plans.map((plan) => plan.toJson()),
};
}
}
+9 -30
View File
@@ -1,36 +1,15 @@
import { z } from "zod"; import { z } from "zod";
export const TipPaymentPlanSchema = z.object({ export const TipPaymentPlanSchema = z
planId: z.string(), .object({
planName: z.string(), planId: z.string(),
amountCents: z.number(), planName: z.string(),
currency: z.string(), amountCents: z.number(),
}); currency: z.string(),
})
.readonly();
export type TipPaymentPlanInput = z.input<typeof TipPaymentPlanSchema>; export type TipPaymentPlanInput = z.input<typeof TipPaymentPlanSchema>;
export type TipPaymentPlanData = z.output<typeof TipPaymentPlanSchema>; export type TipPaymentPlanData = z.output<typeof TipPaymentPlanSchema>;
export class TipPaymentPlan { export type TipPaymentPlan = TipPaymentPlanData;
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);
}
}
@@ -1,9 +1,9 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
PrivateAlbumsResponse, PrivateAlbumUnlockResponseSchema,
PrivateAlbumUnlockResponse, PrivateAlbumsResponseSchema,
UnlockPrivateAlbumRequest, UnlockPrivateAlbumRequestSchema,
} from "@/data/schemas/private-room"; } from "@/data/schemas/private-room";
import { ApiPath } from "@/data/services/api"; import { ApiPath } from "@/data/services/api";
@@ -40,7 +40,7 @@ const lockedAlbum = {
describe("Private Album schema models", () => { describe("Private Album schema models", () => {
it("keeps a locked album cover URL while preserving the lock state", () => { it("keeps a locked album cover URL while preserving the lock state", () => {
const response = PrivateAlbumsResponse.fromJson({ const response = PrivateAlbumsResponseSchema.parse({
items: [lockedAlbum], items: [lockedAlbum],
creditBalance: 100, creditBalance: 100,
nextCursor: null, nextCursor: null,
@@ -54,21 +54,21 @@ describe("Private Album schema models", () => {
}); });
it("strips response fields that are not used by the frontend", () => { it("strips response fields that are not used by the frontend", () => {
const response = PrivateAlbumsResponse.fromJson({ const response = PrivateAlbumsResponseSchema.parse({
items: [lockedAlbum], items: [lockedAlbum],
creditBalance: 100, creditBalance: 100,
packageOptions: [{ imageCount: 8, creditCost: 320 }], 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("momentId");
expect(albumJson).not.toHaveProperty("characterId"); expect(albumJson).not.toHaveProperty("characterId");
expect(albumJson).not.toHaveProperty("collectionKey"); 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", () => { it("parses an unlock response with all album images", () => {
const response = PrivateAlbumUnlockResponse.fromJson({ const response = PrivateAlbumUnlockResponseSchema.parse({
albumId: ALBUM_ID, albumId: ALBUM_ID,
locked: false, locked: false,
unlocked: true, unlocked: true,
@@ -86,12 +86,12 @@ describe("Private Album schema models", () => {
expect(response.unlocked).toBe(true); expect(response.unlocked).toBe(true);
expect(response.images).toHaveLength(8); expect(response.images).toHaveLength(8);
expect(response.creditBalance).toBe(180); expect(response.creditBalance).toBe(180);
expect(response.toJson()).not.toHaveProperty("creditsCharged"); expect(response).not.toHaveProperty("creditsCharged");
}); });
it("serializes the expected unlock cost", () => { it("serializes the expected unlock cost", () => {
expect( expect(
UnlockPrivateAlbumRequest.from({ expectedCost: 320 }).toJson(), UnlockPrivateAlbumRequestSchema.parse({ expectedCost: 320 }),
).toEqual({ expectedCost: 320 }); ).toEqual({ expectedCost: 320 });
}); });
+28 -52
View File
@@ -9,62 +9,38 @@ import {
stringOrNull, stringOrNull,
} from "../nullable-defaults"; } from "../nullable-defaults";
export const PrivateAlbumImageSchema = z.object({ export const PrivateAlbumImageSchema = z
url: stringOrEmpty, .object({
locked: booleanOrFalse, url: stringOrEmpty,
index: numberOrZero, locked: booleanOrFalse,
}); index: numberOrZero,
})
.readonly();
export const PrivateAlbumLockDetailSchema = z.object({ export const PrivateAlbumLockDetailSchema = z
locked: booleanOrFalse, .object({
}); locked: booleanOrFalse,
})
.readonly();
export const PrivateAlbumSchema = z.object({ export const PrivateAlbumSchema = z
albumId: stringOrEmpty, .object({
title: stringOrEmpty, albumId: stringOrEmpty,
content: stringOrNull, title: stringOrEmpty,
previewText: stringOrEmpty, content: stringOrNull,
imageCount: numberOrZero, previewText: stringOrEmpty,
images: arrayOrEmpty(PrivateAlbumImageSchema), imageCount: numberOrZero,
locked: booleanOrFalse, images: arrayOrEmpty(PrivateAlbumImageSchema),
unlocked: booleanOrFalse, locked: booleanOrFalse,
unlockCost: numberOrZero, unlocked: booleanOrFalse,
publishedAt: stringOrNull, unlockCost: numberOrZero,
lockDetail: schemaOr(PrivateAlbumLockDetailSchema, { locked: false }), publishedAt: stringOrNull,
}); lockDetail: schemaOr(PrivateAlbumLockDetailSchema, { locked: false }),
})
.readonly();
export type PrivateAlbumImageData = z.output<typeof PrivateAlbumImageSchema>; export type PrivateAlbumImageData = z.output<typeof PrivateAlbumImageSchema>;
export type PrivateAlbumInput = z.input<typeof PrivateAlbumSchema>; export type PrivateAlbumInput = z.input<typeof PrivateAlbumSchema>;
export type PrivateAlbumData = z.output<typeof PrivateAlbumSchema>; export type PrivateAlbumData = z.output<typeof PrivateAlbumSchema>;
export class PrivateAlbum { export type PrivateAlbum = PrivateAlbumData;
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);
}
}
@@ -2,9 +2,11 @@ import { z } from "zod";
import { numberOrZero } from "../../nullable-defaults"; import { numberOrZero } from "../../nullable-defaults";
export const UnlockPrivateAlbumRequestSchema = z.object({ export const UnlockPrivateAlbumRequestSchema = z
expectedCost: numberOrZero, .object({
}); expectedCost: numberOrZero,
})
.readonly();
export type UnlockPrivateAlbumRequestInput = z.input< export type UnlockPrivateAlbumRequestInput = z.input<
typeof UnlockPrivateAlbumRequestSchema typeof UnlockPrivateAlbumRequestSchema
@@ -13,18 +15,4 @@ export type UnlockPrivateAlbumRequestData = z.output<
typeof UnlockPrivateAlbumRequestSchema typeof UnlockPrivateAlbumRequestSchema
>; >;
export class UnlockPrivateAlbumRequest { export type UnlockPrivateAlbumRequest = UnlockPrivateAlbumRequestData;
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);
}
}
@@ -19,17 +19,19 @@ export const PrivateAlbumUnlockReasonSchema = z.enum([
"not_found", "not_found",
]); ]);
export const PrivateAlbumUnlockResponseSchema = z.object({ export const PrivateAlbumUnlockResponseSchema = z
albumId: stringOrEmpty, .object({
locked: booleanOrFalse, albumId: stringOrEmpty,
unlocked: booleanOrFalse, locked: booleanOrFalse,
reason: PrivateAlbumUnlockReasonSchema.or(stringOrEmpty), unlocked: booleanOrFalse,
unlockCost: numberOrZero, reason: PrivateAlbumUnlockReasonSchema.or(stringOrEmpty),
requiredCredits: numberOrZero, unlockCost: numberOrZero,
creditBalance: numberOrZero, requiredCredits: numberOrZero,
shortfallCredits: numberOrZero, creditBalance: numberOrZero,
images: arrayOrEmpty(PrivateAlbumImageSchema), shortfallCredits: numberOrZero,
}); images: arrayOrEmpty(PrivateAlbumImageSchema),
})
.readonly();
export type PrivateAlbumUnlockReason = z.output< export type PrivateAlbumUnlockReason = z.output<
typeof PrivateAlbumUnlockReasonSchema typeof PrivateAlbumUnlockReasonSchema
@@ -41,34 +43,4 @@ export type PrivateAlbumUnlockResponseData = z.output<
typeof PrivateAlbumUnlockResponseSchema typeof PrivateAlbumUnlockResponseSchema
>; >;
export class PrivateAlbumUnlockResponse { export type PrivateAlbumUnlockResponse = PrivateAlbumUnlockResponseData;
declare readonly albumId: string;
declare readonly locked: boolean;
declare readonly unlocked: boolean;
declare readonly reason: PrivateAlbumUnlockReason | string;
declare readonly unlockCost: number;
declare readonly requiredCredits: number;
declare readonly creditBalance: number;
declare readonly shortfallCredits: number;
declare readonly images: PrivateAlbumUnlockResponseData["images"];
private constructor(input: PrivateAlbumUnlockResponseInput) {
const data = PrivateAlbumUnlockResponseSchema.parse(input);
Object.assign(this, data);
Object.freeze(this);
}
static from(input: PrivateAlbumUnlockResponseInput): PrivateAlbumUnlockResponse {
return new PrivateAlbumUnlockResponse(input);
}
static fromJson(json: unknown): PrivateAlbumUnlockResponse {
return PrivateAlbumUnlockResponse.from(
json as PrivateAlbumUnlockResponseInput,
);
}
toJson(): PrivateAlbumUnlockResponseData {
return PrivateAlbumUnlockResponseSchema.parse(this);
}
}
@@ -1,12 +1,14 @@
import { z } from "zod"; import { z } from "zod";
import { arrayOrEmpty, numberOrZero } from "../../nullable-defaults"; import { arrayOrEmpty, numberOrZero } from "../../nullable-defaults";
import { PrivateAlbum, PrivateAlbumSchema } from "../private_album"; import { PrivateAlbumSchema } from "../private_album";
export const PrivateAlbumsResponseSchema = z.object({ export const PrivateAlbumsResponseSchema = z
items: arrayOrEmpty(PrivateAlbumSchema), .object({
creditBalance: numberOrZero, items: arrayOrEmpty(PrivateAlbumSchema),
}); creditBalance: numberOrZero,
})
.readonly();
export type PrivateAlbumsResponseInput = z.input< export type PrivateAlbumsResponseInput = z.input<
typeof PrivateAlbumsResponseSchema typeof PrivateAlbumsResponseSchema
@@ -15,31 +17,4 @@ export type PrivateAlbumsResponseData = z.output<
typeof PrivateAlbumsResponseSchema typeof PrivateAlbumsResponseSchema
>; >;
export class PrivateAlbumsResponse { export type PrivateAlbumsResponse = PrivateAlbumsResponseData;
declare readonly items: PrivateAlbum[];
declare readonly creditBalance: number;
private constructor(input: PrivateAlbumsResponseInput) {
const data = PrivateAlbumsResponseSchema.parse(input);
Object.assign(this, {
items: data.items.map((item) => PrivateAlbum.from(item)),
creditBalance: data.creditBalance,
});
Object.freeze(this);
}
static from(input: PrivateAlbumsResponseInput): PrivateAlbumsResponse {
return new PrivateAlbumsResponse(input);
}
static fromJson(json: unknown): PrivateAlbumsResponse {
return PrivateAlbumsResponse.from(json as PrivateAlbumsResponseInput);
}
toJson(): PrivateAlbumsResponseData {
return {
items: this.items.map((item) => item.toJson()),
creditBalance: this.creditBalance,
};
}
}
-53
View File
@@ -1,53 +0,0 @@
import type { z } from "zod";
export type SchemaModel<
TSchema extends z.ZodType,
TPublicKey extends keyof z.output<TSchema> = never,
> = Readonly<Pick<z.output<TSchema>, TPublicKey>> & {
toJson(): z.output<TSchema>;
};
export interface SchemaModelFactory<
TSchema extends z.ZodType,
TPublicKey extends keyof z.output<TSchema> = never,
> {
readonly prototype: SchemaModel<TSchema, TPublicKey>;
[Symbol.hasInstance](value: unknown): boolean;
from(input: z.input<TSchema>): SchemaModel<TSchema, TPublicKey>;
fromJson(json: unknown): SchemaModel<TSchema, TPublicKey>;
}
/** Creates a shallow-frozen data class backed by a Zod schema. */
export function createSchemaModel<
TSchema extends z.ZodType,
TPublicKey extends keyof z.output<TSchema> = never,
>(schema: TSchema): SchemaModelFactory<TSchema, TPublicKey> {
class GeneratedSchemaModel {
private constructor(input: z.input<TSchema>) {
Object.assign(this, schema.parse(input) as object);
Object.freeze(this);
}
static from(
input: z.input<TSchema>,
): SchemaModel<TSchema, TPublicKey> {
return new GeneratedSchemaModel(input) as SchemaModel<
TSchema,
TPublicKey
>;
}
static fromJson(json: unknown): SchemaModel<TSchema, TPublicKey> {
return GeneratedSchemaModel.from(json as z.input<TSchema>);
}
toJson(): z.output<TSchema> {
return schema.parse(this);
}
}
return GeneratedSchemaModel as unknown as SchemaModelFactory<
TSchema,
TPublicKey
>;
}
+9 -9
View File
@@ -1,11 +1,11 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { UserEntitlements } from "@/data/schemas/user"; import { UserEntitlementsSchema } from "@/data/schemas/user";
import { User } from "@/data/schemas/user/user"; import { UserSchema } from "@/data/schemas/user/user";
describe("User", () => { describe("User", () => {
it("parses the latest backend user payload", () => { it("parses the latest backend user payload", () => {
const user = User.fromJson({ const user = UserSchema.parse({
id: "d7bb2b3d-e9c5-474c-85fe-f503048f30dd", id: "d7bb2b3d-e9c5-474c-85fe-f503048f30dd",
username: "guest_bbc90a38", username: "guest_bbc90a38",
email: "device_bbc90a38b996187348f54cf2cb0b789e@guest.local", email: "device_bbc90a38b996187348f54cf2cb0b789e@guest.local",
@@ -45,7 +45,7 @@ describe("User", () => {
lastMessageAt: "2026-06-24T11:00:23.156561+00:00", lastMessageAt: "2026-06-24T11:00:23.156561+00:00",
}); });
expect(user.toJson()).toEqual({ expect(user).toEqual({
id: "d7bb2b3d-e9c5-474c-85fe-f503048f30dd", id: "d7bb2b3d-e9c5-474c-85fe-f503048f30dd",
username: "guest_bbc90a38", username: "guest_bbc90a38",
email: "device_bbc90a38b996187348f54cf2cb0b789e@guest.local", email: "device_bbc90a38b996187348f54cf2cb0b789e@guest.local",
@@ -75,17 +75,17 @@ describe("User", () => {
lastMessageAt: "2026-06-24T11:00:23.156561+00:00", 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", () => { it("defaults nullable nested user fields through schema helpers", () => {
const user = User.fromJson({ const user = UserSchema.parse({
id: "user-1", id: "user-1",
username: "guest_user", username: "guest_user",
personalityTraits: null, personalityTraits: null,
}); });
expect(user.personalityTraits.toJson()).toEqual({ expect(user.personalityTraits).toEqual({
caring: 0.5, caring: 0.5,
playful: 0.5, playful: 0.5,
serious: 0.5, serious: 0.5,
@@ -95,7 +95,7 @@ describe("User", () => {
}); });
it("parses the latest backend entitlements payload", () => { it("parses the latest backend entitlements payload", () => {
const entitlements = UserEntitlements.fromJson({ const entitlements = UserEntitlementsSchema.parse({
userId: "user-1", userId: "user-1",
isGuest: false, isGuest: false,
isVip: true, isVip: true,
@@ -142,7 +142,7 @@ describe("User", () => {
}); });
it("defaults nullable entitlement sections through schema helpers", () => { it("defaults nullable entitlement sections through schema helpers", () => {
const entitlements = UserEntitlements.fromJson({ const entitlements = UserEntitlementsSchema.parse({
userId: "user-1", userId: "user-1",
policy: null, policy: null,
costs: null, costs: null,
+8 -27
View File
@@ -1,38 +1,19 @@
/** /**
* 头像数据模型 * 头像数据模型
* *
*/ */
import { z } from "zod"; import { z } from "zod";
import { stringOrEmpty } from "../nullable-defaults"; import { stringOrEmpty } from "../nullable-defaults";
export const AvatarDataSchema = z.object({ export const AvatarDataSchema = z
avatarUrl: stringOrEmpty, .object({
userId: stringOrEmpty, avatarUrl: stringOrEmpty,
}); userId: stringOrEmpty,
})
.readonly();
export type AvatarDataInput = z.input<typeof AvatarDataSchema>; export type AvatarDataInput = z.input<typeof AvatarDataSchema>;
export type AvatarDataData = z.output<typeof AvatarDataSchema>; export type AvatarDataData = z.output<typeof AvatarDataSchema>;
export class AvatarData { export type AvatarData = AvatarDataData;
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);
}
}
+11 -9
View File
@@ -8,15 +8,17 @@ import { z } from "zod";
import { numberOrZero, stringOrEmpty } from "../nullable-defaults"; import { numberOrZero, stringOrEmpty } from "../nullable-defaults";
export const PersistedUserSchema = z.object({ export const PersistedUserSchema = z
id: stringOrEmpty, .object({
username: stringOrEmpty, id: stringOrEmpty,
countryCode: stringOrEmpty, username: stringOrEmpty,
dailyFreeChatLimit: numberOrZero, countryCode: stringOrEmpty,
dailyFreeChatRemaining: numberOrZero, dailyFreeChatLimit: numberOrZero,
dailyFreePrivateLimit: numberOrZero, dailyFreeChatRemaining: numberOrZero,
dailyFreePrivateRemaining: numberOrZero, dailyFreePrivateLimit: numberOrZero,
}); dailyFreePrivateRemaining: numberOrZero,
})
.readonly();
export type PersistedUserInput = z.input<typeof PersistedUserSchema>; export type PersistedUserInput = z.input<typeof PersistedUserSchema>;
export type PersistedUserData = z.output<typeof PersistedUserSchema>; export type PersistedUserData = z.output<typeof PersistedUserSchema>;
+11 -33
View File
@@ -1,6 +1,6 @@
/** /**
* 个性特征模型 * 个性特征模型
* *
*/ */
import { z } from "zod"; import { z } from "zod";
@@ -14,39 +14,17 @@ export const PERSONALITY_TRAITS_DEFAULTS = {
romantic: 0.5, romantic: 0.5,
} as const; } as const;
export const PersonalityTraitsSchema = z.object({ export const PersonalityTraitsSchema = z
cheerful: numberOr(0.5), .object({
caring: numberOr(0.5), cheerful: numberOr(0.5),
playful: numberOr(0.5), caring: numberOr(0.5),
serious: numberOr(0.5), playful: numberOr(0.5),
romantic: numberOr(0.5), serious: numberOr(0.5),
}); romantic: numberOr(0.5),
})
.readonly();
export type PersonalityTraitsInput = z.input<typeof PersonalityTraitsSchema>; export type PersonalityTraitsInput = z.input<typeof PersonalityTraitsSchema>;
export type PersonalityTraitsData = z.output<typeof PersonalityTraitsSchema>; export type PersonalityTraitsData = z.output<typeof PersonalityTraitsSchema>;
export class PersonalityTraits { export type PersonalityTraits = PersonalityTraitsData;
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);
}
}
+10 -31
View File
@@ -1,42 +1,21 @@
/** /**
* 最近记忆模型 * 最近记忆模型
* *
*/ */
import { z } from "zod"; import { z } from "zod";
import { numberOrZero, stringOrEmpty } from "../nullable-defaults"; import { numberOrZero, stringOrEmpty } from "../nullable-defaults";
export const RecentMemorySchema = z.object({ export const RecentMemorySchema = z
type: stringOrEmpty, .object({
content: stringOrEmpty, type: stringOrEmpty,
importance: numberOrZero, content: stringOrEmpty,
createdAt: stringOrEmpty, importance: numberOrZero,
}); createdAt: stringOrEmpty,
})
.readonly();
export type RecentMemoryInput = z.input<typeof RecentMemorySchema>; export type RecentMemoryInput = z.input<typeof RecentMemorySchema>;
export type RecentMemoryData = z.output<typeof RecentMemorySchema>; export type RecentMemoryData = z.output<typeof RecentMemorySchema>;
export class RecentMemory { export type RecentMemory = RecentMemoryData;
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);
}
}
+37 -85
View File
@@ -1,98 +1,50 @@
/** /**
* 用户模型 * 用户模型
* *
*/ */
import { z } from "zod"; import { z } from "zod";
import { numberOrZero, schemaOr, stringOrEmpty } from "../nullable-defaults";
import { import {
PersonalityTraits,
PersonalityTraitsSchema,
PERSONALITY_TRAITS_DEFAULTS, PERSONALITY_TRAITS_DEFAULTS,
PersonalityTraitsSchema,
} from "./personality_traits"; } from "./personality_traits";
import {
numberOrZero,
schemaOr,
stringOrEmpty,
} from "../nullable-defaults";
export const UserSchema = z.object({ export const UserSchema = z
id: stringOrEmpty, // 兜底:理论 id 不会 null,但保持防御 .object({
username: stringOrEmpty, id: stringOrEmpty, // 兜底:理论 id 不会 null,但保持防御
email: stringOrEmpty, username: stringOrEmpty,
platform: stringOrEmpty, email: stringOrEmpty,
country: stringOrEmpty, platform: stringOrEmpty,
countryCode: stringOrEmpty, country: stringOrEmpty,
fbAsid: stringOrEmpty, countryCode: stringOrEmpty,
fbPsid: stringOrEmpty, fbAsid: stringOrEmpty,
intimacy: numberOrZero, fbPsid: stringOrEmpty,
dolBalance: numberOrZero, intimacy: numberOrZero,
creditBalance: numberOrZero, dolBalance: numberOrZero,
dailyFreeChatLimit: numberOrZero, creditBalance: numberOrZero,
dailyFreeChatUsed: numberOrZero, dailyFreeChatLimit: numberOrZero,
dailyFreeChatRemaining: numberOrZero, dailyFreeChatUsed: numberOrZero,
dailyFreePrivateLimit: numberOrZero, dailyFreeChatRemaining: numberOrZero,
dailyFreePrivateUsed: numberOrZero, dailyFreePrivateLimit: numberOrZero,
dailyFreePrivateRemaining: numberOrZero, dailyFreePrivateUsed: numberOrZero,
personalityTraits: schemaOr(PersonalityTraitsSchema, PERSONALITY_TRAITS_DEFAULTS), dailyFreePrivateRemaining: numberOrZero,
preferredLanguage: stringOrEmpty, personalityTraits: schemaOr(
createdAt: stringOrEmpty, PersonalityTraitsSchema,
// 后端新游客返回 null("还没发过消息"),前端统一收敛为空字符串。 PERSONALITY_TRAITS_DEFAULTS,
lastMessageAt: stringOrEmpty, ),
}); preferredLanguage: stringOrEmpty,
createdAt: stringOrEmpty,
// 后端新游客返回 null("还没发过消息"),前端统一收敛为空字符串。
lastMessageAt: stringOrEmpty,
})
.readonly();
export type UserInput = z.input<typeof UserSchema>; export type UserInput = z.input<typeof UserSchema>;
export type UserData = z.output<typeof UserSchema>; export type UserData = z.output<typeof UserSchema>;
export class User { export type User = UserData;
declare readonly id: string;
declare readonly username: string;
declare readonly email: string;
declare readonly platform: string;
declare readonly country: string;
declare readonly countryCode: string;
declare readonly fbAsid: string;
declare readonly fbPsid: string;
declare readonly intimacy: number;
declare readonly dolBalance: number;
declare readonly creditBalance: number;
declare readonly dailyFreeChatLimit: number;
declare readonly dailyFreeChatUsed: number;
declare readonly dailyFreeChatRemaining: number;
declare readonly dailyFreePrivateLimit: number;
declare readonly dailyFreePrivateUsed: number;
declare readonly dailyFreePrivateRemaining: number;
declare readonly personalityTraits: PersonalityTraits;
declare readonly preferredLanguage: string;
declare readonly createdAt: string;
declare readonly lastMessageAt: string;
private constructor(input: UserInput) { export const EMPTY_USER: User = UserSchema.parse({
const data = UserSchema.parse(input); id: "",
Object.assign(this, { username: "",
...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(),
});
}
}
@@ -8,10 +8,12 @@ import { z } from "zod";
import { booleanOrFalse, numberOrZero } from "../nullable-defaults"; import { booleanOrFalse, numberOrZero } from "../nullable-defaults";
export const UserEntitlementSnapshotSchema = z.object({ export const UserEntitlementSnapshotSchema = z
isVip: booleanOrFalse, .object({
creditBalance: numberOrZero, isVip: booleanOrFalse,
}); creditBalance: numberOrZero,
})
.readonly();
export type UserEntitlementSnapshotInput = z.input< export type UserEntitlementSnapshotInput = z.input<
typeof UserEntitlementSnapshotSchema typeof UserEntitlementSnapshotSchema
+28 -50
View File
@@ -56,7 +56,8 @@ export const UserEntitlementsPolicySchema = z
guestSameAsLoggedInNonVip: booleanOrFalse, guestSameAsLoggedInNonVip: booleanOrFalse,
refreshAfterPayment: stringOrEmpty, refreshAfterPayment: stringOrEmpty,
}) })
.passthrough(); .passthrough()
.readonly();
export const UserEntitlementsCostsSchema = z export const UserEntitlementsCostsSchema = z
.object({ .object({
@@ -68,7 +69,8 @@ export const UserEntitlementsCostsSchema = z
private_album_10: numberOrZero, private_album_10: numberOrZero,
private_album_20: numberOrZero, private_album_20: numberOrZero,
}) })
.passthrough(); .passthrough()
.readonly();
export const UserEntitlementsQuotasSchema = z export const UserEntitlementsQuotasSchema = z
.object({ .object({
@@ -77,7 +79,8 @@ export const UserEntitlementsQuotasSchema = z
voiceMessageFreeDaily: numberOrZero, voiceMessageFreeDaily: numberOrZero,
photoFreeDaily: numberOrZero, photoFreeDaily: numberOrZero,
}) })
.passthrough(); .passthrough()
.readonly();
export const UserEntitlementsHistoryUnlockCostsSchema = z export const UserEntitlementsHistoryUnlockCostsSchema = z
.object({ .object({
@@ -85,7 +88,8 @@ export const UserEntitlementsHistoryUnlockCostsSchema = z
voice_message: numberOrZero, voice_message: numberOrZero,
photo: numberOrZero, photo: numberOrZero,
}) })
.passthrough(); .passthrough()
.readonly();
export const UserEntitlementsHistoryUnlockSchema = z export const UserEntitlementsHistoryUnlockSchema = z
.object({ .object({
@@ -98,54 +102,28 @@ export const UserEntitlementsHistoryUnlockSchema = z
HISTORY_UNLOCK_COSTS_DEFAULTS, HISTORY_UNLOCK_COSTS_DEFAULTS,
), ),
}) })
.passthrough(); .passthrough()
.readonly();
export const UserEntitlementsSchema = z.object({ export const UserEntitlementsSchema = z
userId: stringOrEmpty, .object({
isGuest: booleanOrFalse, userId: stringOrEmpty,
isVip: booleanOrFalse, isGuest: booleanOrFalse,
vipExpiresAt: stringOrNull, isVip: booleanOrFalse,
creditBalance: numberOrZero, vipExpiresAt: stringOrNull,
dolBalance: numberOrZero, creditBalance: numberOrZero,
policy: schemaOr(UserEntitlementsPolicySchema, POLICY_DEFAULTS), dolBalance: numberOrZero,
costs: schemaOr(UserEntitlementsCostsSchema, COSTS_DEFAULTS), policy: schemaOr(UserEntitlementsPolicySchema, POLICY_DEFAULTS),
quotas: schemaOr(UserEntitlementsQuotasSchema, QUOTAS_DEFAULTS), costs: schemaOr(UserEntitlementsCostsSchema, COSTS_DEFAULTS),
historyUnlock: schemaOr( quotas: schemaOr(UserEntitlementsQuotasSchema, QUOTAS_DEFAULTS),
UserEntitlementsHistoryUnlockSchema, historyUnlock: schemaOr(
HISTORY_UNLOCK_DEFAULTS, UserEntitlementsHistoryUnlockSchema,
), HISTORY_UNLOCK_DEFAULTS,
}); ),
})
.readonly();
export type UserEntitlementsInput = z.input<typeof UserEntitlementsSchema>; export type UserEntitlementsInput = z.input<typeof UserEntitlementsSchema>;
export type UserEntitlementsData = z.output<typeof UserEntitlementsSchema>; export type UserEntitlementsData = z.output<typeof UserEntitlementsSchema>;
export class UserEntitlements { export type UserEntitlements = UserEntitlementsData;
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);
}
}
@@ -34,7 +34,7 @@ describe("FeedbackApi", () => {
images: [image], images: [image],
}); });
expect(response.toJson()).toEqual({ feedbackId: "feedback-123" }); expect(response).toEqual({ feedbackId: "feedback-123" });
expect(httpClientMock).toHaveBeenCalledWith( expect(httpClientMock).toHaveBeenCalledWith(
"/api/feedback", "/api/feedback",
expect.objectContaining({ method: "POST", body: expect.any(FormData) }), expect.objectContaining({ method: "POST", body: expect.any(FormData) }),
@@ -1,9 +1,9 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { import {
SendMessageRequest, SendMessageRequestSchema,
UnlockHistoryRequest, UnlockHistoryRequestSchema,
UnlockPrivateRequest, UnlockPrivateRequestSchema,
} from "@/data/schemas/chat"; } from "@/data/schemas/chat";
const httpClientMock = vi.hoisted(() => vi.fn()); const httpClientMock = vi.hoisted(() => vi.fn());
@@ -34,7 +34,7 @@ describe("multi-character API contract", () => {
}); });
await new ChatApi().sendMessage( await new ChatApi().sendMessage(
SendMessageRequest.from({ SendMessageRequestSchema.parse({
characterId: CHARACTER_ID, characterId: CHARACTER_ID,
message: "Hello", message: "Hello",
}), }),
@@ -76,13 +76,13 @@ describe("multi-character API contract", () => {
}); });
await api.unlockPrivateMessage( await api.unlockPrivateMessage(
UnlockPrivateRequest.from({ UnlockPrivateRequestSchema.parse({
characterId: CHARACTER_ID, characterId: CHARACTER_ID,
messageId: "message-1", messageId: "message-1",
}), }),
); );
await api.unlockHistory( await api.unlockHistory(
UnlockHistoryRequest.from({ characterId: CHARACTER_ID }), UnlockHistoryRequestSchema.parse({ characterId: CHARACTER_ID }),
); );
expect(httpClientMock).toHaveBeenNthCalledWith( expect(httpClientMock).toHaveBeenNthCalledWith(
@@ -37,7 +37,7 @@ describe("PaymentApi", () => {
const response = await new PaymentApi().getTipPlans(); const response = await new PaymentApi().getTipPlans();
expect(httpClientMock).toHaveBeenCalledWith("/api/payment/tip-plans"); expect(httpClientMock).toHaveBeenCalledWith("/api/payment/tip-plans");
expect(response.toJson()).toEqual({ expect(response).toEqual({
plans: [ plans: [
{ {
planId: "tip_coffee_usd_19_99", planId: "tip_coffee_usd_19_99",
+40 -38
View File
@@ -2,32 +2,37 @@
* Auth API * Auth API
* *
* 认证相关接口 * 认证相关接口
* *
*/ */
import { z } from "zod"; import { z } from "zod";
import { httpClient } from "./http_client";
import { ApiPath } from "./api_path";
import { ApiEnvelope, unwrap } from "./response_helper";
import { import {
FacebookIdentityRequest, FacebookIdentityRequest,
FacebookIdentityResponse, FacebookIdentityResponse,
FacebookIdentityResponseSchema,
FacebookLoginRequest, FacebookLoginRequest,
FacebookPsidLoginRequest, FacebookPsidLoginRequest,
FacebookPsidLoginResponse, FacebookPsidLoginResponse,
FacebookPsidLoginResponseSchema,
FbIdLoginRequest, FbIdLoginRequest,
GoogleLoginRequest, GoogleLoginRequest,
GuestLoginRequest, GuestLoginRequest,
GuestLoginResponse, GuestLoginResponse,
GuestLoginResponseSchema,
LoginRequest, LoginRequest,
LoginResponse, LoginResponse,
LoginResponseSchema,
RefreshTokenRequest, RefreshTokenRequest,
RefreshTokenResponse, RefreshTokenResponse,
RefreshTokenResponseSchema,
RegisterRequest, RegisterRequest,
} from "@/data/schemas/auth"; } 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 type { UserData } from "@/data/schemas/user/user";
import { Logger } from "@/utils/logger"; 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"); const log = new Logger("DataServicesApiAuthApi");
@@ -36,11 +41,11 @@ export class AuthApi {
* 邮箱密码登录 * 邮箱密码登录
*/ */
async emailLogin(body: LoginRequest): Promise<LoginResponse> { async emailLogin(body: LoginRequest): Promise<LoginResponse> {
const env = await httpClient<ApiEnvelope<unknown>>( const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.emailLogin, {
ApiPath.emailLogin, method: "POST",
{ method: "POST", body: body.toJson() } body,
); });
return LoginResponse.fromJson(unwrap(env) as Record<string, unknown>); return LoginResponseSchema.parse(unwrap(env) as Record<string, unknown>);
} }
/** /**
@@ -49,7 +54,7 @@ export class AuthApi {
async register(body: RegisterRequest): Promise<void> { async register(body: RegisterRequest): Promise<void> {
await httpClient<ApiEnvelope<unknown>>(ApiPath.register, { await httpClient<ApiEnvelope<unknown>>(ApiPath.register, {
method: "POST", method: "POST",
body: body.toJson(), body,
}); });
} }
@@ -59,20 +64,20 @@ export class AuthApi {
async googleLogin(body: GoogleLoginRequest): Promise<LoginResponse> { async googleLogin(body: GoogleLoginRequest): Promise<LoginResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.googleLogin, { const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.googleLogin, {
method: "POST", method: "POST",
body: body.toJson(), body,
}); });
return LoginResponse.fromJson(unwrap(env) as Record<string, unknown>); return LoginResponseSchema.parse(unwrap(env) as Record<string, unknown>);
} }
/** /**
* Facebook 登录 * Facebook 登录
*/ */
async facebookLogin(body: FacebookLoginRequest): Promise<LoginResponse> { async facebookLogin(body: FacebookLoginRequest): Promise<LoginResponse> {
const env = await httpClient<ApiEnvelope<unknown>>( const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.facebookLogin, {
ApiPath.facebookLogin, method: "POST",
{ method: "POST", body: body.toJson() } body,
); });
return LoginResponse.fromJson(unwrap(env) as Record<string, unknown>); return LoginResponseSchema.parse(unwrap(env) as Record<string, unknown>);
} }
/** /**
@@ -81,9 +86,9 @@ export class AuthApi {
async facebookIdLogin(body: FbIdLoginRequest): Promise<LoginResponse> { async facebookIdLogin(body: FbIdLoginRequest): Promise<LoginResponse> {
const env = await httpClient<ApiEnvelope<unknown>>( const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.facebookIdLogin, ApiPath.facebookIdLogin,
{ method: "POST", body: body.toJson() } { method: "POST", body },
); );
return LoginResponse.fromJson(unwrap(env) as Record<string, unknown>); return LoginResponseSchema.parse(unwrap(env) as Record<string, unknown>);
} }
/** /**
@@ -94,9 +99,9 @@ export class AuthApi {
): Promise<FacebookPsidLoginResponse> { ): Promise<FacebookPsidLoginResponse> {
const env = await httpClient<ApiEnvelope<unknown>>( const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.facebookPsidLogin, ApiPath.facebookPsidLogin,
{ method: "POST", body: body.toJson() }, { method: "POST", body },
); );
return FacebookPsidLoginResponse.fromJson( return FacebookPsidLoginResponseSchema.parse(
unwrap(env) as Record<string, unknown>, unwrap(env) as Record<string, unknown>,
); );
} }
@@ -109,9 +114,9 @@ export class AuthApi {
): Promise<FacebookIdentityResponse> { ): Promise<FacebookIdentityResponse> {
const env = await httpClient<ApiEnvelope<unknown>>( const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.userFacebookIdentity, ApiPath.userFacebookIdentity,
{ method: "POST", body: body.toJson() }, { method: "POST", body },
); );
return FacebookIdentityResponse.fromJson( return FacebookIdentityResponseSchema.parse(
unwrap(env) as Record<string, unknown>, unwrap(env) as Record<string, unknown>,
); );
} }
@@ -120,7 +125,7 @@ export class AuthApi {
* 退出登录(fire-and-forget * 退出登录(fire-and-forget
* *
* 后端 logout 端点的 envelope `data` 字段常为 `null`(登出端点没有业务数据), * 后端 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"。 * 对 null 会抛 ZodError "Invalid input: expected object, received null"。
* *
* 对齐 `register` 的写法:调通就完事,不解析响应体。 * 对齐 `register` 的写法:调通就完事,不解析响应体。
@@ -137,19 +142,20 @@ export class AuthApi {
async guestLogin(body: GuestLoginRequest): Promise<GuestLoginResponse> { async guestLogin(body: GuestLoginRequest): Promise<GuestLoginResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.guestLogin, { const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.guestLogin, {
method: "POST", method: "POST",
body: body.toJson(), body,
}); });
log.debug("[AuthApi.guestLogin] raw envelope from backend", env); log.debug("[AuthApi.guestLogin] raw envelope from backend", env);
const unwrapped = unwrap(env) as Record<string, unknown>; const unwrapped = unwrap(env) as Record<string, unknown>;
const userObj = unwrapped.user as { lastMessageAt?: unknown } | undefined; const userObj = unwrapped.user as { lastMessageAt?: unknown } | undefined;
log.debug("[AuthApi.guestLogin] unwrapped data (pre-parse)", { log.debug("[AuthApi.guestLogin] unwrapped data (pre-parse)", {
hasUser: "user" in unwrapped, 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, lastMessageAt: userObj?.lastMessageAt,
lastMessageAtType: typeof userObj?.lastMessageAt, lastMessageAtType: typeof userObj?.lastMessageAt,
}); });
try { try {
return GuestLoginResponse.fromJson(unwrapped); return GuestLoginResponseSchema.parse(unwrapped);
} catch (e) { } catch (e) {
if (e instanceof z.ZodError) { if (e instanceof z.ZodError) {
log.error("[AuthApi.guestLogin] ZodError parsing response", { log.error("[AuthApi.guestLogin] ZodError parsing response", {
@@ -167,15 +173,13 @@ export class AuthApi {
/** /**
* 刷新 Token * 刷新 Token
*/ */
async refreshToken( async refreshToken(body: RefreshTokenRequest): Promise<RefreshTokenResponse> {
body: RefreshTokenRequest
): Promise<RefreshTokenResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.refresh, { const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.refresh, {
method: "POST", method: "POST",
body: body.toJson(), body,
}); });
return RefreshTokenResponse.fromJson( return RefreshTokenResponseSchema.parse(
unwrap(env) as Record<string, unknown> unwrap(env) as Record<string, unknown>,
); );
} }
@@ -183,10 +187,8 @@ export class AuthApi {
* 获取当前用户信息 * 获取当前用户信息
*/ */
async getCurrentUser(): Promise<User> { async getCurrentUser(): Promise<User> {
const env = await httpClient<ApiEnvelope<unknown>>( const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.getCurrentUser);
ApiPath.getCurrentUser return UserSchema.parse(unwrap(env) as UserData);
);
return User.fromJson(unwrap(env) as UserData);
} }
} }
+5 -2
View File
@@ -1,4 +1,7 @@
import { CharactersResponse } from "@/data/schemas/character"; import {
CharactersResponse,
CharactersResponseSchema,
} from "@/data/schemas/character";
import { ApiPath } from "./api_path"; import { ApiPath } from "./api_path";
import { httpClient } from "./http_client"; import { httpClient } from "./http_client";
@@ -7,7 +10,7 @@ import { type ApiEnvelope, unwrap } from "./response_helper";
export class CharacterApi { export class CharacterApi {
async getCharacters(): Promise<CharactersResponse> { async getCharacters(): Promise<CharactersResponse> {
const envelope = await httpClient<ApiEnvelope<unknown>>(ApiPath.characters); const envelope = await httpClient<ApiEnvelope<unknown>>(ApiPath.characters);
return CharactersResponse.fromJson(unwrap(envelope)); return CharactersResponseSchema.parse(unwrap(envelope));
} }
} }
+16 -12
View File
@@ -2,20 +2,24 @@
* Chat API * Chat API
* *
* 聊天相关接口 * 聊天相关接口
* *
*/ */
import { httpClient } from "./http_client";
import { ApiPath } from "./api_path";
import { ApiEnvelope, unwrap } from "./response_helper";
import { import {
ChatHistoryResponse, ChatHistoryResponse,
ChatHistoryResponseSchema,
ChatSendResponse, ChatSendResponse,
ChatSendResponseSchema,
SendMessageRequest, SendMessageRequest,
UnlockHistoryRequest, UnlockHistoryRequest,
UnlockHistoryResponse, UnlockHistoryResponse,
UnlockHistoryResponseSchema,
UnlockPrivateRequest, UnlockPrivateRequest,
UnlockPrivateResponse, UnlockPrivateResponse,
UnlockPrivateResponseSchema,
} from "@/data/schemas/chat"; } from "@/data/schemas/chat";
import { ApiPath } from "./api_path";
import { httpClient } from "./http_client";
import { ApiEnvelope, unwrap } from "./response_helper";
export class ChatApi { export class ChatApi {
/** /**
@@ -24,9 +28,9 @@ export class ChatApi {
async sendMessage(body: SendMessageRequest): Promise<ChatSendResponse> { async sendMessage(body: SendMessageRequest): Promise<ChatSendResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.chatSend, { const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.chatSend, {
method: "POST", method: "POST",
body: body.toJson(), body,
}); });
return ChatSendResponse.fromJson(unwrap(env) as Record<string, unknown>); return ChatSendResponseSchema.parse(unwrap(env) as Record<string, unknown>);
} }
/** /**
@@ -40,8 +44,8 @@ export class ChatApi {
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.chatHistory, { const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.chatHistory, {
query: { characterId, limit, offset }, query: { characterId, limit, offset },
}); });
return ChatHistoryResponse.fromJson( return ChatHistoryResponseSchema.parse(
unwrap(env) as Record<string, unknown> unwrap(env) as Record<string, unknown>,
); );
} }
@@ -55,10 +59,10 @@ export class ChatApi {
ApiPath.chatUnlockPrivate, ApiPath.chatUnlockPrivate,
{ {
method: "POST", method: "POST",
body: body.toJson(), body,
}, },
); );
return UnlockPrivateResponse.fromJson( return UnlockPrivateResponseSchema.parse(
unwrap(env) as Record<string, unknown>, unwrap(env) as Record<string, unknown>,
); );
} }
@@ -73,10 +77,10 @@ export class ChatApi {
ApiPath.chatUnlockHistory, ApiPath.chatUnlockHistory,
{ {
method: "POST", method: "POST",
body: body.toJson(), body,
}, },
); );
return UnlockHistoryResponse.fromJson( return UnlockHistoryResponseSchema.parse(
unwrap(env) as Record<string, unknown>, unwrap(env) as Record<string, unknown>,
); );
} }
+2 -1
View File
@@ -1,5 +1,6 @@
import { import {
FeedbackSubmitResponse, FeedbackSubmitResponse,
FeedbackSubmitResponseSchema,
type SubmitFeedbackInput, type SubmitFeedbackInput,
} from "@/data/schemas/feedback"; } from "@/data/schemas/feedback";
@@ -19,7 +20,7 @@ export class FeedbackApi {
method: "POST", method: "POST",
body, body,
}); });
return FeedbackSubmitResponse.fromJson(unwrap(envelope)); return FeedbackSubmitResponseSchema.parse(unwrap(envelope));
} }
} }
+9 -9
View File
@@ -2,12 +2,12 @@
* Metrics API * 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 { 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 { export class MetricsApi {
/** /**
@@ -16,7 +16,7 @@ export class MetricsApi {
async reportPwaEvent(body: PwaEvent): Promise<void> { async reportPwaEvent(body: PwaEvent): Promise<void> {
const env = await httpClient<ApiEnvelope<unknown>>( const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.metricsPwaEvent, ApiPath.metricsPwaEvent,
{ method: "POST", body: body.toJson() } { method: "POST", body },
); );
unwrapOptional(env); unwrapOptional(env);
} }
@@ -25,10 +25,10 @@ export class MetricsApi {
* 上报用户信息 * 上报用户信息
*/ */
async reportUserInfo(body: AppEvent): Promise<void> { async reportUserInfo(body: AppEvent): Promise<void> {
const env = await httpClient<ApiEnvelope<unknown>>( const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.reportUserInfo, {
ApiPath.reportUserInfo, method: "POST",
{ method: "POST", body: body.toJson() } body,
); });
unwrapOptional(env); unwrapOptional(env);
} }
} }
+11 -12
View File
@@ -6,9 +6,13 @@
import { import {
CreatePaymentOrderRequest, CreatePaymentOrderRequest,
CreatePaymentOrderResponse, CreatePaymentOrderResponse,
CreatePaymentOrderResponseSchema,
PaymentOrderStatusResponse, PaymentOrderStatusResponse,
PaymentOrderStatusResponseSchema,
PaymentPlansResponse, PaymentPlansResponse,
PaymentPlansResponseSchema,
TipPaymentPlansResponse, TipPaymentPlansResponse,
TipPaymentPlansResponseSchema,
} from "@/data/schemas/payment"; } from "@/data/schemas/payment";
import { ApiPath } from "./api_path"; import { ApiPath } from "./api_path";
@@ -21,17 +25,15 @@ export class PaymentApi {
*/ */
async getPlans(): Promise<PaymentPlansResponse> { async getPlans(): Promise<PaymentPlansResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.paymentPlans); const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.paymentPlans);
return PaymentPlansResponse.fromJson( return PaymentPlansResponseSchema.parse(
unwrap(env) as Record<string, unknown>, unwrap(env) as Record<string, unknown>,
); );
} }
/** 获取咖啡打赏套餐列表。 */ /** 获取咖啡打赏套餐列表。 */
async getTipPlans(): Promise<TipPaymentPlansResponse> { async getTipPlans(): Promise<TipPaymentPlansResponse> {
const env = await httpClient<ApiEnvelope<unknown>>( const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.paymentTipPlans);
ApiPath.paymentTipPlans, return TipPaymentPlansResponseSchema.parse(
);
return TipPaymentPlansResponse.fromJson(
unwrap(env) as Record<string, unknown>, unwrap(env) as Record<string, unknown>,
); );
} }
@@ -46,10 +48,10 @@ export class PaymentApi {
ApiPath.paymentCreateOrder, ApiPath.paymentCreateOrder,
{ {
method: "POST", method: "POST",
body: body.toJson(), body,
}, },
); );
return CreatePaymentOrderResponse.fromJson( return CreatePaymentOrderResponseSchema.parse(
unwrap(env) as Record<string, unknown>, unwrap(env) as Record<string, unknown>,
); );
} }
@@ -57,20 +59,17 @@ export class PaymentApi {
/** /**
* 查询支付订单状态 * 查询支付订单状态
*/ */
async getOrderStatus( async getOrderStatus(orderId: string): Promise<PaymentOrderStatusResponse> {
orderId: string,
): Promise<PaymentOrderStatusResponse> {
const env = await httpClient<ApiEnvelope<unknown>>( const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.paymentOrderStatus, ApiPath.paymentOrderStatus,
{ {
query: { order_id: orderId }, query: { order_id: orderId },
}, },
); );
return PaymentOrderStatusResponse.fromJson( return PaymentOrderStatusResponseSchema.parse(
unwrap(env) as Record<string, unknown>, unwrap(env) as Record<string, unknown>,
); );
} }
} }
/** /**
+6 -4
View File
@@ -1,9 +1,11 @@
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import { import {
PrivateAlbumsResponse, PrivateAlbumsResponse,
PrivateAlbumsResponseSchema,
PrivateAlbumUnlockResponse, PrivateAlbumUnlockResponse,
PrivateAlbumUnlockResponseSchema,
UnlockPrivateAlbumRequest, UnlockPrivateAlbumRequest,
} from "@/data/schemas/private-room"; } from "@/data/schemas/private-room";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import { ApiPath } from "./api_path"; import { ApiPath } from "./api_path";
import { httpClient } from "./http_client"; import { httpClient } from "./http_client";
@@ -27,7 +29,7 @@ export class PrivateRoomApi {
}, },
}, },
); );
return PrivateAlbumsResponse.fromJson(unwrap(env)); return PrivateAlbumsResponseSchema.parse(unwrap(env));
} }
async unlockAlbum( async unlockAlbum(
@@ -38,10 +40,10 @@ export class PrivateRoomApi {
ApiPath.privateRoomAlbumUnlock(albumId), ApiPath.privateRoomAlbumUnlock(albumId),
{ {
method: "POST", method: "POST",
body: body.toJson(), body,
}, },
); );
return PrivateAlbumUnlockResponse.fromJson(unwrap(env)); return PrivateAlbumUnlockResponseSchema.parse(unwrap(env));
} }
} }
+11 -7
View File
@@ -2,16 +2,18 @@
* User API * User API
* *
* 用户相关接口 * 用户相关接口
* *
*/ */
import { httpClient } from "./http_client";
import { ApiPath } from "./api_path";
import { ApiEnvelope, unwrap } from "./response_helper";
import { import {
User, User,
UserEntitlements, UserEntitlements,
UserEntitlementsSchema,
UserSchema,
} from "@/data/schemas/user"; } from "@/data/schemas/user";
import type { UserData } from "@/data/schemas/user/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 { export class UserApi {
/** /**
@@ -19,15 +21,17 @@ export class UserApi {
*/ */
async getCurrentUser(): Promise<User> { async getCurrentUser(): Promise<User> {
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.userProfile); const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.userProfile);
return User.fromJson(unwrap(env) as UserData); return UserSchema.parse(unwrap(env) as UserData);
} }
/** /**
* 获取当前用户权益快照 * 获取当前用户权益快照
*/ */
async getEntitlements(): Promise<UserEntitlements> { async getEntitlements(): Promise<UserEntitlements> {
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.userEntitlements); const env = await httpClient<ApiEnvelope<unknown>>(
return UserEntitlements.fromJson(unwrap(env) as Record<string, unknown>); ApiPath.userEntitlements,
);
return UserEntitlementsSchema.parse(unwrap(env) as Record<string, unknown>);
} }
} }
+12 -5
View File
@@ -11,7 +11,7 @@
* - 不指定 version 走最新稳定版 * - 不指定 version 走最新稳定版
* - 不需要 appId / appSecretaccessToken 自身就是凭证) * - 不需要 appId / appSecretaccessToken 自身就是凭证)
*/ */
import { FacebookUserData } from "@/data/schemas/auth"; import { FacebookUserData, FacebookUserDataSchema } from "@/data/schemas/auth";
import { Logger } from "@/utils/logger"; import { Logger } from "@/utils/logger";
import { Result, toError } from "@/utils/result"; 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)}`; const url = `${GRAPH_ME_URL}?fields=${encodeURIComponent(FIELDS)}&access_token=${encodeURIComponent(accessToken)}`;
log.debug({ url: url.replace(accessToken, "<redacted>") }, "fetchFacebookUserData REQUEST"); log.debug(
{ url: url.replace(accessToken, "<redacted>") },
"fetchFacebookUserData REQUEST",
);
try { try {
const res = await fetch(url, { method: "GET" }); const res = await fetch(url, { method: "GET" });
const json = (await res.json()) as Record<string, unknown>; const json = (await res.json()) as Record<string, unknown>;
if (!res.ok) { if (!res.ok) {
const errBody = (json?.error as Record<string, unknown> | undefined) ?? {}; const errBody =
(json?.error as Record<string, unknown> | undefined) ?? {};
const message = const message =
typeof errBody.message === "string" typeof errBody.message === "string"
? errBody.message ? errBody.message
: `Graph /me failed: HTTP ${res.status}`; : `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)); return Result.err(new Error(message));
} }
// 提取 picture.data.url —— Graph API 返回嵌套结构 // 提取 picture.data.url —— Graph API 返回嵌套结构
const pictureUrl = extractPictureUrl(json.picture); const pictureUrl = extractPictureUrl(json.picture);
const data = FacebookUserData.from({ const data = FacebookUserDataSchema.parse({
id: typeof json.id === "string" ? json.id : null, id: typeof json.id === "string" ? json.id : null,
name: typeof json.name === "string" ? json.name : null, name: typeof json.name === "string" ? json.name : null,
email: typeof json.email === "string" ? json.email : null, email: typeof json.email === "string" ? json.email : null,
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { PaymentPlan } from "@/data/schemas/payment"; import { PaymentPlanSchema } from "@/data/schemas/payment";
import { import {
behaviorAnalytics, behaviorAnalytics,
@@ -31,7 +31,7 @@ function createSdkMock(): CozsweetBehaviorAnalyticsSdk {
}; };
} }
const plan = PaymentPlan.from({ const plan = PaymentPlanSchema.parse({
planId: "credits-100", planId: "credits-100",
planName: "100 Credits", planName: "100 Credits",
orderType: "dol", orderType: "dol",
@@ -126,9 +126,7 @@ describe("behavior analytics adapter", () => {
expect(sanitizeAnalyticsCheckoutUrl("stripe_embedded")).toBe( expect(sanitizeAnalyticsCheckoutUrl("stripe_embedded")).toBe(
"stripe_embedded", "stripe_embedded",
); );
expect(sanitizeAnalyticsCheckoutUrl("not a url")).toBe( expect(sanitizeAnalyticsCheckoutUrl("not a url")).toBe("external_checkout");
"external_checkout",
);
expect(sanitizeAnalyticsCheckoutUrl("javascript:alert('no')")).toBe( expect(sanitizeAnalyticsCheckoutUrl("javascript:alert('no')")).toBe(
"external_checkout", "external_checkout",
); );
@@ -1,20 +1,24 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character"; 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 type { ChatState } from "@/stores/chat/chat-state";
import { import {
applyNetworkHistoryLoadedOutput,
applyHttpSendOutput, applyHttpSendOutput,
applyNetworkHistoryLoadedOutput,
countLockedHistoryMessages, countLockedHistoryMessages,
localMessagesToUi, localMessagesToUi,
sendResponseToUiMessage, sendResponseToUiMessage,
} from "@/stores/chat/helper"; } from "@/stores/chat/helper";
function makeResponse( function makeResponse(
overrides: Partial<Parameters<typeof ChatSendResponse.from>[0]> = {}, overrides: Partial<ChatSendResponseInput> = {},
): ChatSendResponse { ): ChatSendResponse {
return ChatSendResponse.from({ return ChatSendResponseSchema.parse({
reply: "AI reply", reply: "AI reply",
messageId: "msg-1", messageId: "msg-1",
isGuest: false, isGuest: false,
@@ -100,9 +104,7 @@ describe("sendResponseToUiMessage", () => {
}), }),
); );
expect(message.audioUrl).toBe( expect(message.audioUrl).toBe("https://example.com/unlocked-voice.mp3");
"https://example.com/unlocked-voice.mp3",
);
expect(message.locked).toBe(false); expect(message.locked).toBe(false);
}); });
@@ -1,12 +1,15 @@
import { fromCallback, fromPromise } from "xstate"; import { fromCallback, fromPromise } from "xstate";
import {
ChatSendResponse,
UnlockPrivateResponse,
} from "@/data/schemas/chat";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character"; 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 type { ChatEvent } from "@/stores/chat/chat-events";
import { chatMachine } from "@/stores/chat/chat-machine";
import type { import type {
UnlockMessageOutput as MachineUnlockMessageOutput, UnlockMessageOutput as MachineUnlockMessageOutput,
UnlockMessageRequest, UnlockMessageRequest,
@@ -36,7 +39,7 @@ export const TEST_CHAT_MACHINE_INPUT = {
}; };
export function makeChatSendResponse(): ChatSendResponse { export function makeChatSendResponse(): ChatSendResponse {
return ChatSendResponse.from({ return ChatSendResponseSchema.parse({
reply: "", reply: "",
audioUrl: "", audioUrl: "",
messageId: "msg-1", messageId: "msg-1",
@@ -51,9 +54,9 @@ export function makeChatSendResponse(): ChatSendResponse {
} }
export function makeUnlockPrivateResponse( export function makeUnlockPrivateResponse(
overrides: Partial<Parameters<typeof UnlockPrivateResponse.from>[0]> = {}, overrides: Partial<UnlockPrivateResponseInput> = {},
): UnlockPrivateResponse { ): UnlockPrivateResponse {
return UnlockPrivateResponse.from({ return UnlockPrivateResponseSchema.parse({
unlocked: true, unlocked: true,
content: "unlocked content", content: "unlocked content",
audioUrl: "", audioUrl: "",
@@ -106,21 +109,20 @@ export function createTestChatMachine(
return () => undefined; return () => undefined;
}, },
), ),
unlockHistory: fromPromise< unlockHistory: fromPromise<UnlockHistoryOutput, { characterId: string }>(
UnlockHistoryOutput, async () => {
{ characterId: string } if (options.unlockHistoryError) {
>(async () => { throw options.unlockHistoryError;
if (options.unlockHistoryError) { }
throw options.unlockHistoryError; return {
} unlocked: true,
return { reason: "ok",
unlocked: true, shortfallCredits: 0,
reason: "ok", messages: [],
shortfallCredits: 0, ...options.unlockHistoryOutput,
messages: [], };
...options.unlockHistoryOutput, },
}; ),
}),
unlockMessage: fromPromise< unlockMessage: fromPromise<
MachineUnlockMessageOutput, MachineUnlockMessageOutput,
UnlockMessageRequest & { characterId: string } UnlockMessageRequest & { characterId: string }
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; 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 type { PendingChatPromotion } from "@/data/storage/navigation";
import { import {
appendPromotionMessage, appendPromotionMessage,
@@ -50,7 +50,7 @@ describe("chat promotion", () => {
lockType: "image_paywall", lockType: "image_paywall",
clientLockId: "promotion-1", clientLockId: "promotion-1",
}, },
response: UnlockPrivateResponse.from({ response: UnlockPrivateResponseSchema.parse({
unlocked: true, unlocked: true,
messageId: "backend-1", messageId: "backend-1",
image: { image: {
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { createActor, fromPromise, waitFor } from "xstate"; import { createActor, fromPromise, waitFor } from "xstate";
import { ChatSendResponse } from "@/data/schemas/chat"; import { ChatSendResponseSchema } from "@/data/schemas/chat";
import type { import type {
UnlockMessageOutput, UnlockMessageOutput,
UnlockMessageRequest, UnlockMessageRequest,
@@ -666,7 +666,7 @@ describe("chat unlock flow", () => {
actor.send({ actor.send({
type: "ChatQueuedHttpDone", type: "ChatQueuedHttpDone",
output: { output: {
response: ChatSendResponse.from({ response: ChatSendResponseSchema.parse({
reply: "", reply: "",
audioUrl: "", audioUrl: "",
messageId: "", messageId: "",
@@ -1,7 +1,10 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { createActor, waitFor } from "xstate"; import { createActor, waitFor } from "xstate";
import { PaymentPlansResponse } from "@/data/schemas/payment"; import {
PaymentPlansResponse,
PaymentPlansResponseSchema,
} from "@/data/schemas/payment";
import { import {
createTestPaymentMachine, createTestPaymentMachine,
@@ -32,7 +35,7 @@ describe("payment catalog flow", () => {
const refreshCatalog = vi.fn(); const refreshCatalog = vi.fn();
const actor = createActor( const actor = createActor(
createTestPaymentMachine({ createTestPaymentMachine({
refreshedPlans: PaymentPlansResponse.from({ plans: [tipPlan] }), refreshedPlans: PaymentPlansResponseSchema.parse({ plans: [tipPlan] }),
onLoadCatalog: loadCatalog, onLoadCatalog: loadCatalog,
onRefreshCatalog: refreshCatalog, onRefreshCatalog: refreshCatalog,
}), }),
@@ -90,7 +93,7 @@ describe("payment catalog flow", () => {
it("preserves normal VIP original price outside first recharge", async () => { it("preserves normal VIP original price outside first recharge", async () => {
const actor = createActor( const actor = createActor(
createTestPaymentMachine({ createTestPaymentMachine({
refreshedPlans: PaymentPlansResponse.from({ refreshedPlans: PaymentPlansResponseSchema.parse({
isFirstRecharge: false, isFirstRecharge: false,
plans: [ plans: [
{ {
@@ -120,7 +123,9 @@ describe("payment catalog flow", () => {
}); });
const actor = createActor( const actor = createActor(
createTestPaymentMachine({ createTestPaymentMachine({
cachedPlans: PaymentPlansResponse.from({ plans: [quarterlyPlan] }), cachedPlans: PaymentPlansResponseSchema.parse({
plans: [quarterlyPlan],
}),
refreshPlans: async () => refreshPromise, refreshPlans: async () => refreshPromise,
}), }),
).start(); ).start();
@@ -131,7 +136,7 @@ describe("payment catalog flow", () => {
); );
resolveRefresh( resolveRefresh(
PaymentPlansResponse.from({ plans: [monthlyPlan, lifetimePlan] }), PaymentPlansResponseSchema.parse({ plans: [monthlyPlan, lifetimePlan] }),
); );
await waitFor(actor, (snapshot) => snapshot.matches("ready")); await waitFor(actor, (snapshot) => snapshot.matches("ready"));
expect(actor.getSnapshot().context.selectedPlanId).toBe("vip_monthly"); expect(actor.getSnapshot().context.selectedPlanId).toBe("vip_monthly");
@@ -140,7 +145,7 @@ describe("payment catalog flow", () => {
}); });
function firstRechargeResponse(): PaymentPlansResponse { function firstRechargeResponse(): PaymentPlansResponse {
return PaymentPlansResponse.from({ return PaymentPlansResponseSchema.parse({
isFirstRecharge: true, isFirstRecharge: true,
firstRechargeOffer: { firstRechargeOffer: {
enabled: true, enabled: true,
@@ -3,9 +3,11 @@ import { fromPromise } from "xstate";
import { import {
CreatePaymentOrderResponse, CreatePaymentOrderResponse,
CreatePaymentOrderResponseSchema,
type PayChannel, type PayChannel,
PaymentOrderStatusResponse, PaymentOrderStatusResponseSchema,
PaymentPlansResponse, PaymentPlansResponse,
PaymentPlansResponseSchema,
} from "@/data/schemas/payment"; } from "@/data/schemas/payment";
import { paymentMachine } from "@/stores/payment/payment-machine"; import { paymentMachine } from "@/stores/payment/payment-machine";
import type { PaymentPlanCatalog } from "@/stores/payment/payment-state"; import type { PaymentPlanCatalog } from "@/stores/payment/payment-state";
@@ -114,39 +116,37 @@ export function createTestPaymentMachine(
overrides.onLoadCatalog?.(input.catalog); overrides.onLoadCatalog?.(input.catalog);
return overrides.cachedPlans ?? null; return overrides.cachedPlans ?? null;
}), }),
refreshPlans: fromPromise< refreshPlans: fromPromise<PaymentPlansResponse, PaymentPlansActorInput>(
PaymentPlansResponse, async ({ input }) => {
PaymentPlansActorInput overrides.onRefreshCatalog?.(input.catalog);
>(async ({ input }) => { if (overrides.refreshPlans) return overrides.refreshPlans();
overrides.onRefreshCatalog?.(input.catalog); return (
if (overrides.refreshPlans) return overrides.refreshPlans(); overrides.refreshedPlans ??
return ( PaymentPlansResponseSchema.parse({
overrides.refreshedPlans ?? plans: [monthlyPlan, lifetimePlan, creditPlan],
PaymentPlansResponse.from({ })
plans: [monthlyPlan, lifetimePlan, creditPlan], );
}) },
); ),
}), createOrder: fromPromise<CreatePaymentOrderResponse, CreateOrderInput>(
createOrder: fromPromise< async ({ input }) => {
CreatePaymentOrderResponse, createOrderSpy(input);
CreateOrderInput if (overrides.createOrderError) throw overrides.createOrderError;
>(async ({ input }) => { return CreatePaymentOrderResponseSchema.parse({
createOrderSpy(input); orderId: "pay_test_001",
if (overrides.createOrderError) throw overrides.createOrderError; payParams: {
return CreatePaymentOrderResponse.from({ provider: "stripe",
orderId: "pay_test_001", clientSecret: "pi_test_secret_test",
payParams: { },
provider: "stripe", });
clientSecret: "pi_test_secret_test", },
}, ),
});
}),
pollOrderStatus: fromPromise(async () => { pollOrderStatus: fromPromise(async () => {
const status = const status =
orderStatuses[Math.min(pollCount, orderStatuses.length - 1)] ?? orderStatuses[Math.min(pollCount, orderStatuses.length - 1)] ??
orderStatus; orderStatus;
pollCount += 1; pollCount += 1;
return PaymentOrderStatusResponse.from({ return PaymentOrderStatusResponseSchema.parse({
orderId: "pay_test_001", orderId: "pay_test_001",
status, status,
orderType: "vip_monthly", orderType: "vip_monthly",
+18 -24
View File
@@ -1,4 +1,8 @@
import { PaymentPlan, type PaymentPlansResponse } from "@/data/schemas/payment"; import {
PaymentPlan,
PaymentPlanSchema,
type PaymentPlansResponse,
} from "@/data/schemas/payment";
import type { PaymentState } from "../payment-state"; import type { PaymentState } from "../payment-state";
import { resetOrderState } from "./order"; import { resetOrderState } from "./order";
@@ -28,7 +32,7 @@ export function getDefaultPlanId(plans: readonly PaymentPlan[]): string {
} }
export function hydratePlansState( export function hydratePlansState(
plans: PaymentPlan[], plans: readonly PaymentPlan[],
selectedPlanId: string, selectedPlanId: string,
): Pick< ): Pick<
PaymentState, PaymentState,
@@ -48,11 +52,7 @@ export function hydratePlansResponseState(
selectedPlanId: string, selectedPlanId: string,
): Pick< ): Pick<
PaymentState, PaymentState,
| "plans" "plans" | "isFirstRecharge" | "selectedPlanId" | "autoRenew" | "errorMessage"
| "isFirstRecharge"
| "selectedPlanId"
| "autoRenew"
| "errorMessage"
> { > {
const plans = normalizeFirstRechargePlans( const plans = normalizeFirstRechargePlans(
response.plans, response.plans,
@@ -65,13 +65,15 @@ export function hydratePlansResponseState(
} }
export function refreshPlansState( export function refreshPlansState(
plans: PaymentPlan[], plans: readonly PaymentPlan[],
selectedPlanId: string, selectedPlanId: string,
): Pick< ): Pick<
PaymentState, PaymentState,
"plans" | "selectedPlanId" | "autoRenew" | "errorMessage" "plans" | "selectedPlanId" | "autoRenew" | "errorMessage"
> { > {
const nextSelectedPlanId = plans.some((plan) => plan.planId === selectedPlanId) const nextSelectedPlanId = plans.some(
(plan) => plan.planId === selectedPlanId,
)
? selectedPlanId ? selectedPlanId
: getDefaultPlanId(plans); : getDefaultPlanId(plans);
return { return {
@@ -87,11 +89,7 @@ export function refreshPlansResponseState(
selectedPlanId: string, selectedPlanId: string,
): Pick< ): Pick<
PaymentState, PaymentState,
| "plans" "plans" | "isFirstRecharge" | "selectedPlanId" | "autoRenew" | "errorMessage"
| "isFirstRecharge"
| "selectedPlanId"
| "autoRenew"
| "errorMessage"
> { > {
const plans = normalizeFirstRechargePlans( const plans = normalizeFirstRechargePlans(
response.plans, response.plans,
@@ -106,15 +104,15 @@ export function refreshPlansResponseState(
export function normalizeFirstRechargePlans( export function normalizeFirstRechargePlans(
plans: readonly PaymentPlan[], plans: readonly PaymentPlan[],
isFirstRecharge: boolean, isFirstRecharge: boolean,
): PaymentPlan[] { ): readonly PaymentPlan[] {
if (isFirstRecharge) return [...plans]; if (isFirstRecharge) return [...plans];
return plans.map(consumeFirstRechargePlan); return plans.map(consumeFirstRechargePlan);
} }
export function consumeFirstRechargePlan(plan: PaymentPlan): PaymentPlan { export function consumeFirstRechargePlan(plan: PaymentPlan): PaymentPlan {
if (!plan.isFirstRechargeOffer) { if (!plan.isFirstRechargeOffer) {
return PaymentPlan.from({ return PaymentPlanSchema.parse({
...plan.toJson(), ...plan,
isFirstRechargeOffer: false, isFirstRechargeOffer: false,
firstRechargeDiscountPercent: null, firstRechargeDiscountPercent: null,
promotionType: null, promotionType: null,
@@ -122,8 +120,8 @@ export function consumeFirstRechargePlan(plan: PaymentPlan): PaymentPlan {
} }
const originalAmountCents = plan.originalAmountCents; const originalAmountCents = plan.originalAmountCents;
return PaymentPlan.from({ return PaymentPlanSchema.parse({
...plan.toJson(), ...plan,
amountCents: originalAmountCents ?? plan.amountCents, amountCents: originalAmountCents ?? plan.amountCents,
originalAmountCents: originalAmountCents ?? plan.originalAmountCents, originalAmountCents: originalAmountCents ?? plan.originalAmountCents,
isFirstRechargeOffer: false, isFirstRechargeOffer: false,
@@ -136,11 +134,7 @@ export function consumeFirstRechargeState(
context: PaymentState, context: PaymentState,
): Pick< ): Pick<
PaymentState, PaymentState,
| "plans" "plans" | "isFirstRecharge" | "selectedPlanId" | "autoRenew" | "errorMessage"
| "isFirstRecharge"
| "selectedPlanId"
| "autoRenew"
| "errorMessage"
> { > {
const plans = context.plans.map(consumeFirstRechargePlan); const plans = context.plans.map(consumeFirstRechargePlan);
return { return {
+1 -1
View File
@@ -11,7 +11,7 @@ export type PaymentPlanCatalog = "default" | "tip";
export interface PaymentState { export interface PaymentState {
planCatalog: PaymentPlanCatalog; planCatalog: PaymentPlanCatalog;
plans: PaymentPlan[]; plans: readonly PaymentPlan[];
isFirstRecharge: boolean; isFirstRecharge: boolean;
selectedPlanId: string; selectedPlanId: string;
payChannel: PayChannel; payChannel: PayChannel;
@@ -1,10 +1,14 @@
import { createActor, fromPromise, waitFor } from "xstate"; import { createActor, fromPromise, waitFor } from "xstate";
import {
PrivateAlbumsResponse,
PrivateAlbumUnlockResponse,
} from "@/data/schemas/private-room";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character"; 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"; import { privateRoomMachine } from "@/stores/private-room/private-room-machine";
export const ALBUM_ID = "a1b2c3d4-0000-0000-0000-000000000000"; export const ALBUM_ID = "a1b2c3d4-0000-0000-0000-000000000000";
@@ -31,9 +35,9 @@ export function makeAlbum(index = 0) {
} }
export function makeAlbumsResponse( export function makeAlbumsResponse(
overrides: Partial<Parameters<typeof PrivateAlbumsResponse.from>[0]> = {}, overrides: Partial<PrivateAlbumsResponseInput> = {},
): PrivateAlbumsResponse { ): PrivateAlbumsResponse {
return PrivateAlbumsResponse.from({ return PrivateAlbumsResponseSchema.parse({
items: [makeAlbum()], items: [makeAlbum()],
creditBalance: 500, creditBalance: 500,
...overrides, ...overrides,
@@ -41,9 +45,9 @@ export function makeAlbumsResponse(
} }
export function makeUnlockResponse( export function makeUnlockResponse(
overrides: Partial<Parameters<typeof PrivateAlbumUnlockResponse.from>[0]> = {}, overrides: Partial<PrivateAlbumUnlockResponseInput> = {},
): PrivateAlbumUnlockResponse { ): PrivateAlbumUnlockResponse {
return PrivateAlbumUnlockResponse.from({ return PrivateAlbumUnlockResponseSchema.parse({
albumId: ALBUM_ID, albumId: ALBUM_ID,
locked: false, locked: false,
unlocked: true, unlocked: true,
@@ -61,23 +65,24 @@ export function makeUnlockResponse(
}); });
} }
export function createTestPrivateRoomMachine(options: { export function createTestPrivateRoomMachine(
loadResponse?: PrivateAlbumsResponse; options: {
unlockResponse?: PrivateAlbumUnlockResponse; loadResponse?: PrivateAlbumsResponse;
loadError?: Error; unlockResponse?: PrivateAlbumUnlockResponse;
unlockError?: Error; loadError?: Error;
onLoad?: (input: { characterId: string }) => void; unlockError?: Error;
} = {}) { onLoad?: (input: { characterId: string }) => void;
} = {},
) {
return privateRoomMachine.provide({ return privateRoomMachine.provide({
actors: { actors: {
loadAlbums: fromPromise< loadAlbums: fromPromise<PrivateAlbumsResponse, { characterId: string }>(
PrivateAlbumsResponse, async ({ input }) => {
{ characterId: string } options.onLoad?.(input);
>(async ({ input }) => { if (options.loadError) throw options.loadError;
options.onLoad?.(input); return options.loadResponse ?? makeAlbumsResponse();
if (options.loadError) throw options.loadError; },
return options.loadResponse ?? makeAlbumsResponse(); ),
}),
unlockAlbum: fromPromise(async () => { unlockAlbum: fromPromise(async () => {
if (options.unlockError) throw options.unlockError; if (options.unlockError) throw options.unlockError;
return options.unlockResponse ?? makeUnlockResponse(); return options.unlockResponse ?? makeUnlockResponse();

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