feat(api): migrate HTTP layer to ofetch with token interceptor

This commit is contained in:
2026-06-08 15:49:48 +08:00
parent d7943f5f06
commit f4e1c30051
18 changed files with 1446 additions and 159 deletions
+161 -158
View File
@@ -1,46 +1,51 @@
# Implementation Plan: Zod 重构数据模型
# Implementation Plan: HTTP API 层迁移
## [Overview]
`src/data/models/` 下 28 个数据类从「手写 `readonly` + 手动 `??` 兜底 + `fromJson` 守卫」模式重构为「Zod schema 驱动 + 类包装」模式,样板代码量减少约 70%,同时获得运行时验证能力
Flutter 项目 `/lib/core/net/``/lib/data/services/api/` 下的 HTTP 网络层迁移至 Next.js 16,使用 **ofetch** 作为 HTTP 客户端(轻量、同构、拦截器钩子丰富),通过 `.env.local` 配置环境变量,复用已迁移的 Zod 数据模型进行响应验证
## [Types]
### Schema 类型(每个数据类三个导出)
### ApiResult 统一响应包装
每个数据类使用同一份 Zod schema 同时驱动**验证、类型推导、默认值兜底**:
```typescript
// src/data/api/api_result.ts
export type ApiResult<T> =
| { success: true; data: T }
| { success: false; error: ApiError };
| 导出 | 来源 | 用途 |
|---|---|---|
| `XxxSchema` | `z.object({...})` | 验证规则与默认值定义 |
| `XxxInput` | `z.input<typeof XxxSchema>` | 构造输入类型(字段可缺失,触发 `.default()` |
| `XxxData` | `z.output<typeof XxxSchema>` | 解析后类型(所有字段已应用默认值) |
| `class Xxx` | 手写 + `Object.assign` | 不可变类,保留自定义方法 |
export class ApiError extends Error {
readonly code: string;
readonly status?: number;
readonly details?: unknown;
constructor(code: string, message: string, status?: number, details?: unknown);
}
```
### 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())` |
```typescript
// src/data/api/config/api_config.ts
export type AppEnv = "development" | "test" | "production";
### 28 个类清单
export interface ApiConfig {
baseUrl: string;
wsUrl: string;
connectTimeout: number; // ms
receiveTimeout: number; // ms
sendTimeout: number; // ms
}
```
**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
```typescript
// ofetch 原生 hooks
onRequest?: (ctx: { request: Request; options: FetchOptions }) => void | Promise<void>;
onResponse?: (ctx: { request: Request; response: Response; options: FetchOptions }) => void | Promise<void>;
onResponseError?: (ctx: { request: Request; response: Response; options: FetchOptions; error: FetchError }) => void | Promise<void>;
onError?: (ctx: { request: Request; error: Error; options: FetchOptions }) => void | Promise<void>;
```
## [Files]
@@ -48,124 +53,132 @@
| 路径 | 用途 |
|---|---|
| `implementation_plan.md` | 本文档 |
| `src/data/api/api_path.ts` | 路径常量(40+ endpoints |
| `src/data/api/api_result.ts` | 统一响应包装 `ApiResult<T>` + `ApiError` |
| `src/data/api/config/api_config.ts` | 环境 → baseUrl/超时 配置 |
| `src/data/api/storage/auth_storage.ts` | localStorage 包装(token 持久化) |
| `src/data/api/storage/device_storage.ts` | 设备 ID 持久化 |
| `src/data/api/http_client.ts` | ofetch 实例 + 拦截器装配 |
| `src/data/api/interceptor/token_interceptor.ts` | 注入 `Authorization: Bearer` |
| `src/data/api/interceptor/auth_refresh_interceptor.ts` | 401 → 自动刷新 token |
| `src/data/api/interceptor/logging_interceptor.ts` | 请求/响应日志(开发模式) |
| `src/data/api/auth_api.ts` | 11 个 auth 接口 |
| `src/data/api/chat_api.ts` | 5 个 chat 接口(含 multipart |
| `src/data/api/user_api.ts` | 5 个 user 接口 |
| `src/data/api/metrics_api.ts` | 2 个 metrics 接口 |
| `src/data/api/index.ts` | barrel 导出 |
| `.env.local` | 本地环境变量(不提交) |
| `.env.example` | 环境变量模板(提交) |
### 修改
| 路径 | 修改 |
|---|---|
| `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` | 不变 |
| `package.json` | 添加 `ofetch` 依赖 |
| `pnpm-lock.yaml` | `pnpm install` 自动更新 |
| `.gitignore` | 确保 `.env.local` 已被忽略(Next.js 默认) |
### 删除
### 不迁移
无(保留原文件结构,重写内容)
-`websocket_handler.dart` / `websocket_manager.dart` — WebSocket 独立项
-`message_queue.dart` — 离线消息队列
-`service_manager.dart` 的 dio 特定部分(已被 ofetch 实例替代)
-`payment_api_client.dart` — 原始 Dart 也不存在客户端类
## [Functions]
### 移除的样板函数(每个类)
### 新增
| 函数 | 位置 | 移除原因 |
| 函数 | 签名 | 文件 | 用途 |
|---|---|---|---|
| `getAuthToken()` | `() => Promise<string \| null>` | `storage/auth_storage.ts` | 优先登录 token,回退游客 token |
| `setLoginToken(t)` | `(token: string) => Promise<void>` | 同上 | 持久化登录 token |
| `setGuestToken(t)` | `(token: string) => Promise<void>` | 同上 | 持久化游客 token |
| `clearAuthData()` | `() => Promise<void>` | 同上 | 清除所有 auth 数据 |
| `getDeviceId()` | `() => string` | `storage/device_storage.ts` | 读取/生成设备 ID |
| `getApiConfig()` | `() => ApiConfig` | `config/api_config.ts` | 当前环境配置 |
| `createHttpClient()` | `() => typeof ofetch` | `http_client.ts` | ofetch 实例工厂 |
### 删除/重写
| 函数 | 旧实现 | 新实现 |
|---|---|---|
| `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` | 不变 |
| `BackendService` 单例 | Dio + 4 ApiClient 字段 | ofetch + 4 api 类模块级单例 |
| `getBaseUrl()` | ServiceType.getBaseUrl() | api_config.ts 静态方法 |
| `getServiceConfig()` | ServiceType 扩展 | api_config.ts 静态方法 |
## [Classes]
### 重构模式(统一范式)
### 新增
| 类 | 文件 | 关键方法 |
|---|---|---|
| `ApiError` | `api_result.ts` | 构造、`toString()` |
| `AuthStorage` | `storage/auth_storage.ts` | `getLoginToken`/`setLoginToken`/`getRefreshToken`/`getGuestToken`/`setGuestToken`/`hasLoginToken`/`clearAuthData` |
| `DeviceStorage` | `storage/device_storage.ts` | `getDeviceId()` |
| `AuthApi` | `auth_api.ts` | `sendCode`/`emailLogin`/`register`/`appleLogin`/`googleLogin`/`facebookLogin`/`facebookIdLogin`/`logout`/`guestLogin`/`refreshToken`/`getCurrentUser` |
| `ChatApi` | `chat_api.ts` | `sendMessage`/`getHistory`/`speechToText(multipart)`/`syncMessages`/`uploadImage(multipart)` |
| `UserApi` | `user_api.ts` | `getUserStats`/`getCurrentUser`/`updateProfile`/`getCredits`/`getCreditsHistory` |
| `MetricsApi` | `metrics_api.ts` | `reportPwaEvent`/`reportUserInfo` |
### 修改
无(无现有 class 需要修改)
### 拦截器实现(函数式,非 class)
由于 ofetch 钩子是函数式 API,拦截器以函数形式实现:
```typescript
import { z } from "zod";
// interceptor/token_interceptor.ts
export const tokenInterceptor: FetchHook = async (ctx) => {
const path = new URL(ctx.request.url).pathname;
if (!needsToken(path)) return;
const token = await getAuthToken();
if (token) {
ctx.request.headers.set("Authorization", `Bearer ${token}`);
}
};
// 1. Schema(单一数据源)
export const XxxSchema = z.object({
field1: z.string(),
field2: z.number().default(0),
// ...
// interceptor/auth_refresh_interceptor.ts
export const authRefreshInterceptor: FetchHook = async (ctx) => {
if (ctx.response?.status !== 401) return;
// 401 处理 + token 刷新 + 请求重试
};
// http_client.ts
const client = ofetch.create({
baseURL: getApiConfig().baseUrl,
timeout: getApiConfig().receiveTimeout,
hooks: {
onRequest: [loggingInterceptor, tokenInterceptor],
onResponse: [loggingInterceptor],
onResponseError: [authRefreshInterceptor, loggingInterceptor],
},
});
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` | 模式验证与类型推导 |
| `ofetch` | `^1.5.0` | 同构 HTTP 客户端(浏览器 + Node.js |
### 安装命令
### 安装
```bash
pnpm add zod
pnpm add ofetch
```
### 版本选择
选择 zod v3(最新稳定版),原因:
- 文档完善,社区生态成熟
- 与 TypeScript 5 完全兼容
- 体积适中(按需 tree-shake 后约 10-15KB
- v4 仍在 beta,暂不采用
选择 ofetch v1.5+,原因:
- 完整同构支持(unjs/h3 生态)
- 内置拦截器钩子
- 体积小(~5KB gzipped
- TypeScript 优先
## [Testing]
@@ -175,54 +188,44 @@ pnpm add zod
- `npx tsc --noEmit` — 0 错误
- `pnpm lint` — 通过
全部完成后进行端到端测试
- 构造最小输入(仅 required 字段)→ `Model.fromJson({...})` → 验证默认值已应用
- 构造完整输入 → `Model.fromJson({...})``.toJson()` → 验证往返一致性
- 构造无效输入(缺失 required 字段)→ `Model.fromJson({...})` → 验证抛出 `ZodError`
最终验证
- 模块导入测试(`import { authApi, chatApi } from '@/data/api'`
- 拦截器单元测试(mock localStorage 与 fetch
- 端到端测试(启动 dev server,触发实际请求)
### 端到端测试脚本(临时)
### 测试要点
实施完成后在 `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);
}
```
1. **环境变量加载**`process.env.NEXT_PUBLIC_API_BASE_URL` 正确解析
2. **Token 注入**`getCurrentUser` 自动携带 Authorization 头
3. **401 刷新**:模拟 401 响应,验证 token 刷新队列与重试
4. **Multipart 上传**`uploadImage` 正确发送 FormData
5. **超时控制**30s/60s 配置生效
## [Implementation Order]
按依赖关系倒序执行**5批次**
按依赖关系,**9步骤**
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` + 端到端测试
1. **安装 ofetch**`pnpm add ofetch`(修改 `package.json` + `pnpm-lock.yaml`
2. **创建 `.env.example` + `.env.local`**:环境变量模板
3. **批次 A:基础设施**5 个文件
- `api_path.ts`(路径常量
- `api_result.ts`(响应包装
- `config/api_config.ts`(环境配置
- `storage/auth_storage.ts`localStorage 包装)
- `storage/device_storage.ts`(设备 ID
4. **批次 B:拦截器 + http_client**4 个文件)
- `interceptor/token_interceptor.ts`
- `interceptor/auth_refresh_interceptor.ts`
- `interceptor/logging_interceptor.ts`
- `http_client.ts`ofetch 实例装配)
5. **批次 CAPI clients**4 个文件,独立)
- `auth_api.ts`11 个方法)
- `chat_api.ts`5 个方法)
- `user_api.ts`5 个方法)
- `metrics_api.ts`2 个方法)
6. **批次 Dbarrel 导出**1 个文件)
- `index.ts`
7. **最终验证**
- `npx tsc --noEmit`
- `pnpm lint`
- 端到端 smoke test