refactor(models): migrate 28 data models to Zod schema-driven pattern

This commit is contained in:
2026-06-08 14:50:20 +08:00
parent 700ad0bc1a
commit d7943f5f06
36 changed files with 1204 additions and 1358 deletions
+228
View File
@@ -0,0 +1,228 @@
# Implementation Plan: Zod 重构数据模型
## [Overview]
`src/data/models/` 下 28 个数据类从「手写 `readonly` + 手动 `??` 兜底 + `fromJson` 守卫」模式重构为「Zod schema 驱动 + 类包装」模式,样板代码量减少约 70%,同时获得运行时验证能力。
## [Types]
### Schema 类型(每个数据类三个导出)
每个数据类使用同一份 Zod schema 同时驱动**验证、类型推导、默认值兜底**:
| 导出 | 来源 | 用途 |
|---|---|---|
| `XxxSchema` | `z.object({...})` | 验证规则与默认值定义 |
| `XxxInput` | `z.input<typeof XxxSchema>` | 构造输入类型(字段可缺失,触发 `.default()` |
| `XxxData` | `z.output<typeof XxxSchema>` | 解析后类型(所有字段已应用默认值) |
| `class Xxx` | 手写 + `Object.assign` | 不可变类,保留自定义方法 |
### Zod 字段类型映射
| Dart 字段 | Zod schema |
|---|---|
| `required String id` | `z.string()` |
| `@Default('web') String platform` | `z.string().default('web')` |
| `String? email` | `z.string().default('')` |
| `@Default(0) int intimacy` | `z.number().default(0)` |
| `bool? isGuest` | `z.boolean().default(false)` |
| `required List<T> items` | `z.array(TItemSchema)` |
| `PersonalityTraits? personalityTraits` | `PersonalityTraitsSchema.default({})` |
| `Map<String, dynamic>` | `z.record(z.string(), z.unknown())` |
### 28 个类清单
**user/ (8)**: PersonalityTraits, User, CreditsData, CreditsHistoryData, AvatarData, UpdateProfileRequest, RecentMemory, UserStatsResponse
**auth/ 请求 (9)**: SendCodeRequest, RegisterRequest, GoogleLoginRequest, FacebookLoginRequest, GuestLoginRequest, LoginRequest, AppleLoginRequest, RefreshTokenRequest, FbIdLoginRequest
**auth/ 响应 (4)**: LoginResponse, GuestLoginResponse, LogoutResponse, RefreshTokenResponse
**chat/ (10)**: ChatMessage, ChatSendResponse, ChatHistoryResponse, ChatSyncRequest, ChatSyncData, SyncMessage, GuestChatQuota, ImageUploadResponse, SendMessageRequest, SttData
**metrics/ (2)**: AppEvent, PwaEvent
## [Files]
### 新增
| 路径 | 用途 |
|---|---|
| `implementation_plan.md` | 本文档 |
### 修改
| 路径 | 修改 |
|---|---|
| `package.json` | 添加 `zod: ^3.23.0` 依赖 |
| `src/data/models/**/*.ts` | 28 个模型文件全部重构为 Zod 驱动 + 类包装模式 |
| `src/data/models/index.ts` | 不变(仍然 `export * from "./..."` |
| `src/data/models/auth/index.ts` | 不变 |
| `src/data/models/chat/index.ts` | 不变 |
| `src/data/models/metrics/index.ts` | 不变 |
| `src/data/models/user/index.ts` | 不变 |
| `pnpm-lock.yaml` | `pnpm install` 自动更新 |
### 删除
无(保留原文件结构,重写内容)
## [Functions]
### 移除的样板函数(每个类)
| 函数 | 位置 | 移除原因 |
|---|---|---|
| `requireString` 局部辅助 | 每个 `fromJson` | 由 Zod schema 验证替代 |
| `requireNumber` 局部辅助 | 每个 `fromJson` | 由 Zod schema 验证替代 |
| 构造函数中 `params.x ?? defaultX` ×N | 每个类 | 由 `.default()` schema 替代 |
### 保留/调整的方法
| 方法 | 签名 | 变化 |
|---|---|---|
| `static fromJson(json)` | `(json: unknown) => Xxx` | 接收 `unknown`(更安全),内部 `Schema.parse(json)` |
| `toJson()` | `() => XxxData` | 返回 `Schema.parse(this)`,保证输出一致性 |
| 静态常量(`GuestChatQuota` | `static readonly X` | 不变 |
| 静态方法(`GuestChatQuota.threshold` 等) | `static get X()` | 不变 |
| 实例方法(`GuestChatQuota.needsReset` | `(todayString: string) => boolean` | 不变 |
## [Classes]
### 重构模式(统一范式)
```typescript
import { z } from "zod";
// 1. Schema(单一数据源)
export const XxxSchema = z.object({
field1: z.string(),
field2: z.number().default(0),
// ...
});
export type XxxInput = z.input<typeof XxxSchema>;
export type XxxData = z.output<typeof XxxSchema>;
// 2. 类(包装 + 不可变 + 自定义方法)
export class Xxx {
// 仅类型声明,无运行时初始化
declare readonly field1: string;
declare readonly field2: number;
// ...
private constructor(data: XxxData) {
Object.assign(this, data); // 运行时填值
Object.freeze(this); // 不可变
}
static from(input: XxxInput): Xxx {
return new Xxx(XxxSchema.parse(input));
}
static fromJson(json: unknown): Xxx {
return Xxx.from(json as XxxInput);
}
toJson(): XxxData {
return XxxSchema.parse(this);
}
}
```
### 28 个类的具体调整
| 类 | 特殊处理 |
|---|---|
| `PersonalityTraits` | 嵌套引用基类,5 个 double 字段各有 `.default(0.5)` |
| `User` | 引用 `PersonalityTraitsSchema.default({})` |
| `UserStatsResponse` | 引用 `PersonalityTraitsSchema` + `RecentMemory` 数组 |
| `LoginResponse` | 引用 `User` |
| `GuestLoginResponse` | 引用 `User`(默认值 `new User({id:"",username:""})` 仍需保留逻辑) |
| `ChatHistoryResponse` | 数组字段 `z.array(ChatMessageSchema)` |
| `ChatSyncRequest` | 数组字段 `z.array(SyncMessageSchema)` |
| `CreditsHistoryData` | `records: z.array(z.record(z.string(), z.unknown()))` |
| `GuestChatQuota` | 保留所有静态常量/方法/实例方法 |
| 其他 19 个 | 简单结构,直接映射 |
## [Dependencies]
### 新增
| 包 | 版本 | 用途 |
|---|---|---|
| `zod` | `^3.23.0` | 模式验证与类型推导 |
### 安装命令
```bash
pnpm add zod
```
### 版本选择
选择 zod v3(最新稳定版),原因:
- 文档完善,社区生态成熟
- 与 TypeScript 5 完全兼容
- 体积适中(按需 tree-shake 后约 10-15KB
- v4 仍在 beta,暂不采用
## [Testing]
### 验证策略
每个批次完成后:
- `npx tsc --noEmit` — 0 错误
- `pnpm lint` — 通过
全部完成后进行端到端测试:
- 构造最小输入(仅 required 字段)→ `Model.fromJson({...})` → 验证默认值已应用
- 构造完整输入 → `Model.fromJson({...})``.toJson()` → 验证往返一致性
- 构造无效输入(缺失 required 字段)→ `Model.fromJson({...})` → 验证抛出 `ZodError`
### 端到端测试脚本(临时)
实施完成后在 `src/_test_zod.ts` 写入以下验证代码(完成后删除):
```typescript
import { User, ChatMessage, AppEvent } from "@/data/models";
import { z } from "zod";
// 1. 最小输入 + 默认值
const u1 = User.fromJson({ id: "u1", username: "alice" });
console.assert(u1.email === "");
console.assert(u1.platform === "web");
console.assert(u1.intimacy === 0);
console.assert(u1.personalityTraits.cheerful === 0.5);
// 2. 完整输入 + 往返
const original = { id: "u1", username: "alice", email: "a@b.com", intimacy: 10 };
const u2 = User.fromJson(original);
const json = u2.toJson();
console.assert(JSON.stringify(json) === JSON.stringify({...original, platform: "web", dolBalance: 0, ...}));
// 3. 无效输入抛错
try {
User.fromJson({ id: "u1" }); // 缺 username
console.assert(false, "应该抛错");
} catch (e) {
console.assert(e instanceof z.ZodError);
}
```
## [Implementation Order]
按依赖关系倒序执行,**5 个批次**:
1. **安装依赖**`pnpm add zod`(修改 `package.json` + `pnpm-lock.yaml`
2. **批次 A`user/` (8 文件)**
- `PersonalityTraits`(最底层,被 `User` 引用)
- `User`(引用 `PersonalityTraits`
- `RecentMemory`(被 `UserStatsResponse` 引用)
- `UserStatsResponse`(引用 `PersonalityTraits` + `RecentMemory`
- `CreditsData``CreditsHistoryData``AvatarData``UpdateProfileRequest`
3. **批次 B`auth/` 请求 (9 文件)** — 独立
4. **批次 C`auth/` 响应 (4 文件)** — 依赖 `User`
5. **批次 D`chat/` (10 文件)** — 内部 `ChatMessage` 引用
6. **批次 E`metrics/` (2 文件)** — 独立
7. **最终验证**`tsc --noEmit` + `lint` + 端到端测试