229 lines
7.7 KiB
Markdown
229 lines
7.7 KiB
Markdown
# 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` + 端到端测试
|