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
+2 -1
View File
@@ -4,6 +4,7 @@
"./src/data/models/auth",
"./src/data/models/chat",
"./src/data/models/metrics",
"./src/data/models/user"
"./src/data/models/user",
"./src/data/api"
]
}
+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
+2
View File
@@ -11,7 +11,9 @@
"generate-barrels": "barrelsby --delete"
},
"dependencies": {
"@fingerprintjs/fingerprintjs": "^5.2.0",
"next": "16.2.7",
"ofetch": "^1.5.1",
"react": "19.2.4",
"react-dom": "19.2.4",
"zod": "^4.4.3"
+35
View File
@@ -8,9 +8,15 @@ importers:
.:
dependencies:
'@fingerprintjs/fingerprintjs':
specifier: ^5.2.0
version: 5.2.0
next:
specifier: 16.2.7
version: 16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
ofetch:
specifier: ^1.5.1
version: 1.5.1
react:
specifier: 19.2.4
version: 19.2.4
@@ -169,6 +175,9 @@ packages:
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@fingerprintjs/fingerprintjs@5.2.0':
resolution: {integrity: sha512-j+2nInkwCQNTJcNhOjvkGM/nLRTuGJTC6xai4quqvUpjob2ssrGwBZjS7k55nOmKvge7qvJT2nS3i/IRvQSTQA==}
'@humanfs/core@0.19.2':
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
engines: {node: '>=18.18.0'}
@@ -963,6 +972,9 @@ packages:
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
engines: {node: '>= 0.4'}
destr@2.0.5:
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
@@ -1669,6 +1681,9 @@ packages:
resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==}
engines: {node: '>= 0.4'}
node-fetch-native@1.6.7:
resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
node-releases@2.0.47:
resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==}
engines: {node: '>=18'}
@@ -1705,6 +1720,9 @@ packages:
resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
engines: {node: '>= 0.4'}
ofetch@1.5.1:
resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==}
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@@ -2046,6 +2064,9 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
ufo@1.6.4:
resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==}
unbox-primitive@1.1.0:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'}
@@ -2288,6 +2309,8 @@ snapshots:
'@eslint/core': 0.17.0
levn: 0.4.1
'@fingerprintjs/fingerprintjs@5.2.0': {}
'@humanfs/core@0.19.2':
dependencies:
'@humanfs/types': 0.15.0
@@ -2981,6 +3004,8 @@ snapshots:
has-property-descriptors: 1.0.2
object-keys: 1.1.1
destr@2.0.5: {}
detect-libc@2.1.2: {}
doctrine@2.1.0:
@@ -3801,6 +3826,8 @@ snapshots:
object.entries: 1.1.9
semver: 6.3.1
node-fetch-native@1.6.7: {}
node-releases@2.0.47: {}
object-assign@4.1.1: {}
@@ -3845,6 +3872,12 @@ snapshots:
define-properties: 1.2.1
es-object-atoms: 1.1.2
ofetch@1.5.1:
dependencies:
destr: 2.0.5
node-fetch-native: 1.6.7
ufo: 1.6.4
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
@@ -4269,6 +4302,8 @@ snapshots:
typescript@5.9.3: {}
ufo@1.6.4: {}
unbox-primitive@1.1.0:
dependencies:
call-bound: 1.0.4
+117
View File
@@ -0,0 +1,117 @@
/**
* API 路径管理
* 统一管理所有接口路径
* 原始 Dart: lib/data/services/api/api_path.dart
*/
export class ApiPath {
private constructor() {}
// API 基础路径
private static readonly _baseUrl = "/api";
// ============ 功能模块分组 ============
private static readonly _auth = `${ApiPath._baseUrl}/auth`;
private static readonly _verify = `${ApiPath._baseUrl}/verify`;
private static readonly _user = `${ApiPath._baseUrl}/user`;
private static readonly _payment = `${ApiPath._baseUrl}/payment`;
private static readonly _chat = `${ApiPath._baseUrl}/chat`;
// ============ 认证相关 ============
/** 发送验证码 */
static readonly sendCode = `${ApiPath._verify}/send`;
/** 邮箱密码登录 */
static readonly emailLogin = `${ApiPath._auth}/login`;
/** 注册 */
static readonly register = `${ApiPath._auth}/register`;
/** 设备自动登录 */
static readonly guestLogin = `${ApiPath._auth}/guest`;
/** Apple 登录 */
static readonly appleLogin = `${ApiPath._auth}/login/apple`;
/** Google 登录 */
static readonly googleLogin = `${ApiPath._auth}/login/google`;
/** Facebook 登录 */
static readonly facebookLogin = `${ApiPath._auth}/login/facebook`;
/** Facebook ID 登录(v7.0 新增) */
static readonly facebookIdLogin = `${ApiPath._auth}/login/facebook/by-id`;
/** 刷新 Token */
static readonly refresh = `${ApiPath._auth}/refresh`;
/** 退出登录 */
static readonly logout = `${ApiPath._auth}/logout`;
/** 获取当前用户信息 */
static readonly getCurrentUser = `${ApiPath._auth}/me`;
// ============ 用户相关 ============
/** 获取用户统计信息 */
static readonly userStats = `${ApiPath._user}/stats`;
/** 获取个人信息(与 /auth/me 等价) */
static readonly userProfile = `${ApiPath._user}/profile`;
/** 更新个人信息 */
static readonly updateProfile = `${ApiPath._user}/profile`;
/** 上传头像 */
static readonly userAvatar = `${ApiPath._user}/avatar`;
/** 查询积分余额 */
static readonly userCredits = `${ApiPath._user}/credits`;
/** 查询积分操作历史 */
static readonly userCreditsHistory = `${ApiPath._user}/credits/history`;
// ============ 支付相关 ============
/** 创建充值订单 */
static readonly paymentCreateOrder = `${ApiPath._payment}/order/create`;
/** 查询订单状态 */
static readonly paymentOrderStatus = `${ApiPath._payment}/order/status`;
/** 获取商品套餐列表 */
static readonly paymentProducts = `${ApiPath._payment}/products`;
/** 申请退款 */
static readonly paymentRefund = `${ApiPath._payment}/refund`;
/** 获取商品详情路径 */
static getPaymentProductDetail(productId: string): string {
return `${ApiPath.paymentProducts}/${productId}`;
}
// ============ 聊天相关 ============
/** 发送消息 */
static readonly chatSend = `${ApiPath._chat}/send`;
/** 获取聊天历史 */
static readonly chatHistory = `${ApiPath._chat}/history`;
/** 语音转文字(STT */
static readonly chatStt = `${ApiPath._chat}/stt`;
/** 同步游客消息 */
static readonly chatSync = `${ApiPath._chat}/sync`;
/** 上传图片 */
static readonly chatUploadImage = `${ApiPath._chat}/upload-image`;
// ============ 数据看板相关 ============
private static readonly _metrics = `${ApiPath._baseUrl}/metrics`;
/** 上报 PWA 事件 */
static readonly metricsPwaEvent = `${ApiPath._metrics}/pwa/event`;
// ============ 数据上报相关(v7.0 新增) ============
private static readonly _data = `${ApiPath._baseUrl}/data`;
/** 上报用户信息 */
static readonly reportUserInfo = `${ApiPath._data}/report-user-info`;
}
+59
View File
@@ -0,0 +1,59 @@
/**
* 统一 API 响应包装
*
* 与 Dart 的 `NetResult<T>` 对应:
* - 成功:`{ success: true, data: T }`
* - 失败:`{ success: false, error: ApiError }`
*/
export type ApiResult<T> =
| { success: true; data: T }
| { success: false; error: ApiError };
/**
* API 错误类
*/
export class ApiError extends Error {
readonly code: string;
readonly status?: number;
readonly details?: unknown;
constructor(
code: string,
message: string,
status?: number,
details?: unknown
) {
super(message);
this.name = "ApiError";
this.code = code;
this.status = status;
this.details = details;
Object.freeze(this);
}
/**
* 转换为 JSON 字符串
*/
override toString(): string {
const statusStr = this.status !== undefined ? ` [${this.status}]` : "";
return `${this.name}${statusStr} ${this.code}: ${this.message}`;
}
}
/**
* 错误码常量
*/
export class ErrorCode {
private constructor() {}
static readonly httpUnauthorized = 401;
static readonly httpForbidden = 403;
static readonly httpNotFound = 404;
static readonly httpServerError = 500;
static readonly httpBadRequest = 400;
static readonly networkError = "NETWORK_ERROR";
static readonly timeoutError = "TIMEOUT_ERROR";
static readonly parseError = "PARSE_ERROR";
static readonly unknownError = "UNKNOWN_ERROR";
}
+170
View File
@@ -0,0 +1,170 @@
/**
* Auth API
*
* 认证相关接口
* 原始 Dart: lib/data/services/api/auth_api_client.dart
*/
import { httpClient } from "./http_client";
import { ApiPath } from "./api_path";
import {
AppleLoginRequest,
FacebookLoginRequest,
FbIdLoginRequest,
GoogleLoginRequest,
GuestLoginRequest,
GuestLoginResponse,
LoginRequest,
LoginResponse,
LogoutResponse,
RefreshTokenRequest,
RefreshTokenResponse,
RegisterRequest,
SendCodeRequest,
User,
type UserData,
} from "@/data/models";
/**
* 后端统一响应包装
*/
interface ApiEnvelope<T> {
success: boolean;
data?: T;
message?: string;
error?: string;
}
function unwrap<T>(envelope: ApiEnvelope<T>): T {
if (!envelope.success || envelope.data === undefined) {
throw new Error(envelope.message ?? envelope.error ?? "API call failed");
}
return envelope.data;
}
export class AuthApi {
/**
* 发送验证码
*/
async sendCode(body: SendCodeRequest): Promise<void> {
await httpClient<ApiEnvelope<unknown>>(ApiPath.sendCode, {
method: "POST",
body: body.toJson(),
});
}
/**
* 邮箱密码登录
*/
async emailLogin(body: LoginRequest): Promise<LoginResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.emailLogin,
{ method: "POST", body: body.toJson() }
);
return LoginResponse.fromJson(unwrap(env) as Record<string, unknown>);
}
/**
* 注册
*/
async register(body: RegisterRequest): Promise<void> {
await httpClient<ApiEnvelope<unknown>>(ApiPath.register, {
method: "POST",
body: body.toJson(),
});
}
/**
* Apple 登录
*/
async appleLogin(body: AppleLoginRequest): Promise<LoginResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.appleLogin, {
method: "POST",
body: body.toJson(),
});
return LoginResponse.fromJson(unwrap(env) as Record<string, unknown>);
}
/**
* Google 登录
*/
async googleLogin(body: GoogleLoginRequest): Promise<LoginResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.googleLogin, {
method: "POST",
body: body.toJson(),
});
return LoginResponse.fromJson(unwrap(env) as Record<string, unknown>);
}
/**
* Facebook 登录
*/
async facebookLogin(body: FacebookLoginRequest): Promise<LoginResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.facebookLogin,
{ method: "POST", body: body.toJson() }
);
return LoginResponse.fromJson(unwrap(env) as Record<string, unknown>);
}
/**
* Facebook ID 登录(v7.0 新增)
*/
async facebookIdLogin(body: FbIdLoginRequest): Promise<LoginResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.facebookIdLogin,
{ method: "POST", body: body.toJson() }
);
return LoginResponse.fromJson(unwrap(env) as Record<string, unknown>);
}
/**
* 退出登录
*/
async logout(): Promise<LogoutResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.logout, {
method: "POST",
});
return LogoutResponse.fromJson(unwrap(env) as Record<string, unknown>);
}
/**
* 游客登录
*/
async guestLogin(body: GuestLoginRequest): Promise<GuestLoginResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.guestLogin, {
method: "POST",
body: body.toJson(),
});
return GuestLoginResponse.fromJson(unwrap(env) as Record<string, unknown>);
}
/**
* 刷新 Token
*/
async refreshToken(
body: RefreshTokenRequest
): Promise<RefreshTokenResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.refresh, {
method: "POST",
body: body.toJson(),
});
return RefreshTokenResponse.fromJson(
unwrap(env) as Record<string, unknown>
);
}
/**
* 获取当前用户信息
*/
async getCurrentUser(): Promise<User> {
const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.getCurrentUser
);
return User.fromJson(unwrap(env) as UserData);
}
}
/**
* 全局单例
*/
export const authApi = new AuthApi();
+107
View File
@@ -0,0 +1,107 @@
/**
* Chat API
*
* 聊天相关接口
* 原始 Dart: lib/data/services/api/chat_api_client.dart
*/
import { httpClient } from "./http_client";
import { ApiPath } from "./api_path";
import {
ChatHistoryResponse,
ChatSendResponse,
ChatSyncData,
ChatSyncRequest,
ImageUploadResponse,
SendMessageRequest,
SttData,
SyncMessage,
} from "@/data/models";
/**
* 后端统一响应包装
*/
interface ApiEnvelope<T> {
success: boolean;
data?: T;
message?: string;
error?: string;
}
function unwrap<T>(envelope: ApiEnvelope<T>): T {
if (!envelope.success || envelope.data === undefined) {
throw new Error(envelope.message ?? envelope.error ?? "API call failed");
}
return envelope.data;
}
export class ChatApi {
/**
* 发送消息
*/
async sendMessage(body: SendMessageRequest): Promise<ChatSendResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.chatSend, {
method: "POST",
body: body.toJson(),
});
return ChatSendResponse.fromJson(unwrap(env) as Record<string, unknown>);
}
/**
* 获取聊天历史
*/
async getHistory(limit = 50, offset = 0): Promise<ChatHistoryResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.chatHistory, {
query: { limit, offset },
});
return ChatHistoryResponse.fromJson(
unwrap(env) as Record<string, unknown>
);
}
/**
* 语音转文字(multipart 上传)
*/
async speechToText(audio: Blob | File): Promise<SttData> {
const formData = new FormData();
formData.append("audio", audio);
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.chatStt, {
method: "POST",
body: formData,
});
return SttData.fromJson(unwrap(env) as Record<string, unknown>);
}
/**
* 同步游客消息
*/
async syncMessages(
messages: SyncMessage[]
): Promise<ChatSyncData> {
const body = ChatSyncRequest.from({ messages });
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.chatSync, {
method: "POST",
body: body.toJson(),
});
return ChatSyncData.fromJson(unwrap(env) as Record<string, unknown>);
}
/**
* 上传图片
*/
async uploadImage(image: Blob | File): Promise<ImageUploadResponse> {
const formData = new FormData();
formData.append("image", image);
const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.chatUploadImage,
{ method: "POST", body: formData }
);
return ImageUploadResponse.fromJson(
unwrap(env) as Record<string, unknown>
);
}
}
/**
* 全局单例
*/
export const chatApi = new ChatApi();
+69
View File
@@ -0,0 +1,69 @@
/**
* API 配置管理
*
* 通过环境变量配置后端服务地址与超时时间。
* 原始 Dart: lib/core/net/service_manager.dart
*/
export type AppEnv = "development" | "test" | "production";
export interface ApiConfig {
/** 后端 API 基础 URL(含协议) */
baseUrl: string;
/** WebSocket 基础 URL */
wsUrl: string;
/** 连接超时(ms */
connectTimeout: number;
/** 接收超时(ms */
receiveTimeout: number;
/** 发送超时(ms */
sendTimeout: number;
}
/**
* 超时常量
*/
export class TimeoutConstants {
private constructor() {}
static readonly shortTimeout = 30000;
static readonly longTimeout = 60000;
}
/**
* 解析当前环境
*/
export function getAppEnv(): AppEnv {
const env = process.env.NEXT_PUBLIC_APP_ENV;
if (env === "production" || env === "test") return env;
return "development";
}
/**
* 获取当前 API 配置
*/
export function getApiConfig(): ApiConfig {
const baseUrl =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://172.16.48.49:3002";
const wsUrl =
process.env.NEXT_PUBLIC_WS_BASE_URL ?? "ws://172.16.48.49:3002";
const connectTimeout = Number.parseInt(
process.env.NEXT_PUBLIC_API_CONNECT_TIMEOUT ?? "30000",
10
);
const receiveTimeout = Number.parseInt(
process.env.NEXT_PUBLIC_API_RECEIVE_TIMEOUT ?? "60000",
10
);
const sendTimeout = Number.parseInt(
process.env.NEXT_PUBLIC_API_SEND_TIMEOUT ?? "30000",
10
);
return {
baseUrl,
wsUrl,
connectTimeout,
receiveTimeout,
sendTimeout,
};
}
+76
View File
@@ -0,0 +1,76 @@
/**
* HTTP 客户端工厂
*
* 创建配置好拦截器的 ofetch 实例。
* 原始 Dart: lib/core/net/backend_service.dart
*/
import { ofetch, type $Fetch, type FetchHook } from "ofetch";
import { ApiError, ErrorCode } from "./api_result";
import { getApiConfig } from "./config/api_config";
import { authRefreshInterceptor } from "./interceptor/auth_refresh_interceptor";
import { loggingInterceptor } from "./interceptor/logging_interceptor";
import { tokenInterceptor } from "./interceptor/token_interceptor";
/**
* 合并多个同类型 hook 为单个 hook(按顺序执行)
*/
function chain(...fns: (FetchHook | undefined)[]): FetchHook {
return async (ctx: unknown) => {
for (const fn of fns) {
if (fn) await (fn as (c: unknown) => Promise<void> | void)(ctx);
}
};
}
/**
* 创建 ofetch 实例
*/
export function createHttpClient(): $Fetch {
const config = getApiConfig();
return ofetch.create({
baseURL: config.baseUrl,
timeout: config.receiveTimeout,
retry: 0, // 401 刷新由 auth_refresh_interceptor 自定义处理
headers: {
"Content-Type": "application/json",
},
onRequest: chain(
loggingInterceptor.onRequest,
tokenInterceptor.onRequest
),
onResponse: chain(loggingInterceptor.onResponse),
onResponseError: chain(
loggingInterceptor.onResponseError,
authRefreshInterceptor.onResponseError,
((ctx: unknown) => {
const c = ctx as {
response?: { status?: number; _data?: unknown };
error?: Error;
};
const status = c.response?.status;
const code =
status === ErrorCode.httpUnauthorized
? "HTTP_UNAUTHORIZED"
: status === ErrorCode.httpServerError
? "HTTP_SERVER_ERROR"
: "HTTP_ERROR";
const data = c.response?._data;
const message =
(data as { message?: string; error?: string } | undefined)
?.message ??
(data as { message?: string; error?: string } | undefined)
?.error ??
c.error?.message ??
"Unknown error";
throw new ApiError(code, message, status, data);
}) as FetchHook
),
onRequestError: chain(loggingInterceptor.onResponseError),
});
}
/**
* 默认 HTTP 客户端实例
*/
export const httpClient = createHttpClient();
+5
View File
@@ -0,0 +1,5 @@
/**
* @file Automatically generated by barrelsby.
*/
export * from "./index";
@@ -0,0 +1,168 @@
/**
* 认证刷新拦截器
*
* 处理 401 错误,自动刷新 token 并重试原请求。
* 原始 Dart: lib/core/net/interceptor/auth_interceptor.dart
*/
import type { FetchHook } from "ofetch";
import { ApiPath } from "../api_path";
import { ApiError, ErrorCode } from "../api_result";
import { authStorage } from "../../storage/auth_storage";
import { deviceIdentifier } from "@/utils/device_identifier";
/**
* 不需要 auth token 刷新的路径
* (这些路径返回 401 时不触发刷新)
*/
const NO_AUTH_REFRESH_PATHS: readonly string[] = [
ApiPath.sendCode,
ApiPath.emailLogin,
ApiPath.register,
ApiPath.guestLogin,
ApiPath.refresh,
];
/**
* 是否正在刷新 token(防止并发刷新)
*/
let isRefreshing = false;
/**
* 等待中的刷新 Promise
*/
let refreshPromise: Promise<void> | null = null;
/**
* 实际刷新 token(被拦截器调用)
*/
async function refreshToken(
isGuestMode: boolean,
baseUrl: string
): Promise<void> {
if (isGuestMode) {
await refreshGuestToken(baseUrl);
} else {
await refreshLoginToken(baseUrl);
}
}
/**
* 刷新登录 token
*/
async function refreshLoginToken(baseUrl: string): Promise<void> {
const refreshToken = authStorage.getRefreshToken();
if (!refreshToken) {
throw new Error("No refresh token available");
}
const response = await fetch(`${baseUrl}${ApiPath.refresh}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
});
if (!response.ok) {
throw new Error(`Refresh failed: ${response.status}`);
}
const json = (await response.json()) as {
success?: boolean;
data?: { token?: string; refreshToken?: string };
};
if (json.success && json.data) {
if (json.data.token) authStorage.setLoginToken(json.data.token);
if (json.data.refreshToken)
authStorage.setRefreshToken(json.data.refreshToken);
} else {
throw new Error("Refresh response invalid");
}
}
/**
* 刷新游客 token
*/
async function refreshGuestToken(baseUrl: string): Promise<void> {
const deviceId = await deviceIdentifier.getDeviceId();
const response = await fetch(`${baseUrl}${ApiPath.guestLogin}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ deviceId }),
});
if (!response.ok) {
throw new Error(`Guest refresh failed: ${response.status}`);
}
const json = (await response.json()) as {
success?: boolean;
data?: { token?: string; deviceId?: string };
};
if (json.success && json.data) {
if (json.data.token) authStorage.setGuestToken(json.data.token);
if (json.data.deviceId) authStorage.setDeviceId(json.data.deviceId);
} else {
throw new Error("Guest refresh response invalid");
}
}
/**
* 401 → 刷新 token 并重试
*/
export const onResponseErrorAuthRefresh: FetchHook = async (ctx) => {
const status = ctx.response?.status;
// 只处理 401
if (status !== ErrorCode.httpUnauthorized) return;
// 跳过不需要刷新的路径
const requestUrl =
ctx.request instanceof Request ? ctx.request.url : ctx.request;
const path = new URL(requestUrl).pathname;
if (NO_AUTH_REFRESH_PATHS.some((p) => path.includes(p))) return;
// 已登录则刷新登录 token,否则刷新游客 token
const isGuestMode = !authStorage.hasLoginToken();
// 并发刷新:等待或启动
if (isRefreshing && refreshPromise) {
await refreshPromise;
} else {
isRefreshing = true;
const baseUrl = new URL(requestUrl).origin;
refreshPromise = (async () => {
try {
await refreshToken(isGuestMode, baseUrl);
} finally {
isRefreshing = false;
refreshPromise = null;
}
})();
await refreshPromise;
}
// 刷新失败 → 清除数据并抛出
const newToken = authStorage.getAuthToken();
if (!newToken) {
authStorage.clearAuthData();
throw new ApiError(
ErrorCode.unknownError,
"Token refresh failed",
status
);
}
// 刷新成功:更新请求头的 token
if (ctx.request instanceof Request) {
ctx.request.headers.set("Authorization", `Bearer ${newToken}`);
}
};
/**
* 兼容旧接口的 FetchHooks 形式
*/
export const authRefreshInterceptor = {
onResponseError: onResponseErrorAuthRefresh,
};
@@ -0,0 +1,85 @@
/**
* 日志拦截器
*
* 在开发模式下记录请求/响应/错误日志。
* 原始 Dart: lib/core/net/interceptor/app_interceptor.dart 中的日志部分。
*/
import type { FetchHook } from "ofetch";
const TAG = "[API]";
function isDev(): boolean {
return process.env.NODE_ENV !== "production";
}
function getRequestUrl(request: Request | string): string {
return typeof request === "string" ? request : request.url;
}
function getRequestMethod(
request: Request | string,
options: { method?: string }
): string {
return request instanceof Request ? request.method : options.method ?? "GET";
}
function formatBody(body: unknown): string {
if (body === undefined || body === null) return "";
try {
if (body instanceof FormData) {
const entries: string[] = [];
body.forEach((value, key) => {
if (value instanceof File) {
entries.push(`${key}=File(${value.name})`);
} else {
entries.push(`${key}=${String(value)}`);
}
});
return `FormData{${entries.join(", ")}}`;
}
return JSON.stringify(body);
} catch {
return String(body);
}
}
export const onRequestLogging: FetchHook = async (ctx) => {
if (!isDev()) return;
const method = getRequestMethod(ctx.request, ctx.options);
const url = getRequestUrl(ctx.request);
console.log(
`${TAG}${method} ${url}`,
ctx.options.body !== undefined ? formatBody(ctx.options.body) : ""
);
};
export const onResponseLogging: FetchHook = async (ctx) => {
if (!isDev() || !ctx.response) return;
const method = getRequestMethod(ctx.request, ctx.options);
const url = getRequestUrl(ctx.request);
console.log(
`${TAG}${ctx.response.status} ${method} ${url}`,
formatBody(ctx.response._data)
);
};
export const onErrorLogging: FetchHook = async (ctx) => {
if (!isDev()) return;
const status = ctx.response?.status ?? "N/A";
const method = getRequestMethod(ctx.request, ctx.options);
const url = getRequestUrl(ctx.request);
console.error(
`${TAG}${status} ${method} ${url}`,
ctx.error?.message ?? "Unknown error"
);
};
/**
* 兼容旧接口的 FetchHooks 形式(保留以备兼容)
*/
export const loggingInterceptor = {
onRequest: onRequestLogging,
onResponse: onResponseLogging,
onResponseError: onErrorLogging,
onRequestError: onErrorLogging,
};
@@ -0,0 +1,58 @@
/**
* Token 拦截器
*
* 在请求头中注入 Authorization: Bearer <token>。
* 跳过不需要 token 的路径(登录、注册、发送验证码、刷新等)。
* 原始 Dart: lib/core/net/interceptor/token_interceptor.dart + token_paths.dart
*/
import type { FetchHook } from "ofetch";
import { ApiPath } from "../api_path";
import { authStorage } from "../../storage/auth_storage";
/**
* 不需要 token 的路径
*/
const NO_TOKEN_PATHS: readonly string[] = [
ApiPath.sendCode,
ApiPath.emailLogin,
ApiPath.register,
ApiPath.guestLogin,
];
/**
* 获取请求路径
*/
function getRequestPath(request: Request | string): string {
if (typeof request === "string") {
try {
return new URL(request).pathname;
} catch {
return request;
}
}
return new URL(request.url).pathname;
}
/**
* 路径是否需要 token
*/
function needsToken(path: string): boolean {
return !NO_TOKEN_PATHS.some((noTokenPath) => path.includes(noTokenPath));
}
export const onRequestToken: FetchHook = async (ctx) => {
const path = getRequestPath(ctx.request);
if (!needsToken(path)) return;
const token = authStorage.getAuthToken();
if (token && ctx.request instanceof Request) {
ctx.request.headers.set("Authorization", `Bearer ${token}`);
}
};
/**
* 兼容旧接口的 FetchHooks 形式
*/
export const tokenInterceptor = {
onRequest: onRequestToken,
};
+55
View File
@@ -0,0 +1,55 @@
/**
* Metrics API
*
* 数据看板/上报相关接口
* 原始 Dart: lib/data/services/api/metrics_api_client.dart
*/
import { httpClient } from "./http_client";
import { ApiPath } from "./api_path";
import { AppEvent, PwaEvent } from "@/data/models";
/**
* 后端统一响应包装
*/
interface ApiEnvelope<T> {
success: boolean;
data?: T;
message?: string;
error?: string;
}
function unwrap<T>(envelope: ApiEnvelope<T>): T | undefined {
if (!envelope.success) {
throw new Error(envelope.message ?? envelope.error ?? "API call failed");
}
return envelope.data;
}
export class MetricsApi {
/**
* 上报 PWA 事件
*/
async reportPwaEvent(body: PwaEvent): Promise<void> {
const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.metricsPwaEvent,
{ method: "POST", body: body.toJson() }
);
unwrap(env);
}
/**
* 上报用户信息
*/
async reportUserInfo(body: AppEvent): Promise<void> {
const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.reportUserInfo,
{ method: "POST", body: body.toJson() }
);
unwrap(env);
}
}
/**
* 全局单例
*/
export const metricsApi = new MetricsApi();
+93
View File
@@ -0,0 +1,93 @@
/**
* User API
*
* 用户相关接口
* 原始 Dart: lib/data/services/api/user_api_client.dart
*/
import { httpClient } from "./http_client";
import { ApiPath } from "./api_path";
import {
CreditsData,
CreditsHistoryData,
UpdateProfileRequest,
User,
UserStatsResponse,
type UserData,
} from "@/data/models";
/**
* 后端统一响应包装
*/
interface ApiEnvelope<T> {
success: boolean;
data?: T;
message?: string;
error?: string;
}
function unwrap<T>(envelope: ApiEnvelope<T>): T {
if (!envelope.success || envelope.data === undefined) {
throw new Error(envelope.message ?? envelope.error ?? "API call failed");
}
return envelope.data;
}
export class UserApi {
/**
* 获取用户统计信息
*/
async getUserStats(): Promise<UserStatsResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.userStats);
return UserStatsResponse.fromJson(
unwrap(env) as Record<string, unknown>
);
}
/**
* 获取个人信息
*/
async getCurrentUser(): Promise<User> {
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.userProfile);
return User.fromJson(unwrap(env) as UserData);
}
/**
* 更新个人资料
*/
async updateProfile(body: UpdateProfileRequest): Promise<User> {
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.updateProfile, {
method: "PUT",
body: body.toJson(),
});
return User.fromJson(unwrap(env) as UserData);
}
/**
* 查询积分余额
*/
async getCredits(): Promise<CreditsData> {
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.userCredits);
return CreditsData.fromJson(unwrap(env) as Record<string, unknown>);
}
/**
* 查询积分操作历史
*/
async getCreditsHistory(
limit = 50,
offset = 0
): Promise<CreditsHistoryData> {
const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.userCreditsHistory,
{ query: { limit, offset } }
);
return CreditsHistoryData.fromJson(
unwrap(env) as Record<string, unknown>
);
}
}
/**
* 全局单例
*/
export const userApi = new UserApi();
+118
View File
@@ -0,0 +1,118 @@
/**
* Auth 存储 - localStorage 包装
*
* 存储登录 token、刷新 token、游客 token、设备 ID。
* 原始 Dart: lib/data/services/storage/auth_storage.dart
*
* 注:localStorage 仅在浏览器环境可用;
* 在 Server Components 中调用会抛错,应仅在 Client Components 或拦截器中使用。
*/
const STORAGE_KEYS = {
loginToken: "cozsweet.loginToken",
refreshToken: "cozsweet.refreshToken",
guestToken: "cozsweet.guestToken",
deviceId: "cozsweet.deviceId",
} as const;
function getStorage(): Storage | null {
if (typeof window === "undefined") return null;
try {
return window.localStorage;
} catch {
return null;
}
}
export class AuthStorage {
// ============ 登录 Token ============
getLoginToken(): string | null {
return getStorage()?.getItem(STORAGE_KEYS.loginToken) ?? null;
}
setLoginToken(token: string): void {
getStorage()?.setItem(STORAGE_KEYS.loginToken, token);
}
removeLoginToken(): void {
getStorage()?.removeItem(STORAGE_KEYS.loginToken);
}
// ============ 刷新 Token ============
getRefreshToken(): string | null {
return getStorage()?.getItem(STORAGE_KEYS.refreshToken) ?? null;
}
setRefreshToken(token: string): void {
getStorage()?.setItem(STORAGE_KEYS.refreshToken, token);
}
removeRefreshToken(): void {
getStorage()?.removeItem(STORAGE_KEYS.refreshToken);
}
// ============ 游客 Token ============
getGuestToken(): string | null {
return getStorage()?.getItem(STORAGE_KEYS.guestToken) ?? null;
}
setGuestToken(token: string): void {
getStorage()?.setItem(STORAGE_KEYS.guestToken, token);
}
removeGuestToken(): void {
getStorage()?.removeItem(STORAGE_KEYS.guestToken);
}
// ============ 设备 ID ============
getDeviceId(): string | null {
return getStorage()?.getItem(STORAGE_KEYS.deviceId) ?? null;
}
setDeviceId(deviceId: string): void {
getStorage()?.setItem(STORAGE_KEYS.deviceId, deviceId);
}
removeDeviceId(): void {
getStorage()?.removeItem(STORAGE_KEYS.deviceId);
}
// ============ 复合操作 ============
/**
* 是否有登录 token
*/
hasLoginToken(): boolean {
const token = this.getLoginToken();
return token !== null && token.length > 0;
}
/**
* 获取认证 token(优先登录 token,其次游客 token
*/
getAuthToken(): string | null {
const loginToken = this.getLoginToken();
if (loginToken !== null && loginToken.length > 0) return loginToken;
const guestToken = this.getGuestToken();
if (guestToken !== null && guestToken.length > 0) return guestToken;
return null;
}
/**
* 清除所有 auth 数据(登录/刷新/游客 token)
*/
clearAuthData(): void {
this.removeLoginToken();
this.removeRefreshToken();
this.removeGuestToken();
}
}
/**
* 全局单例
*/
export const authStorage = new AuthStorage();
+66
View File
@@ -0,0 +1,66 @@
/**
* 设备 ID 工具类
*
* 使用 fingerprintjs 生成客户端设备指纹。
* 原始 Dart: lib/data/services/device_id_service_impl.dart
*
* 注:仅在浏览器环境可用;SSR/Server Components 中调用会抛错。
*/
import { authStorage } from "../../storage/auth_storage";
export class DeviceIdentifier {
private cachedId: string | null = null;
private initPromise: Promise<string> | null = null;
/**
* 获取或生成设备 ID
*
* 优先级:内存缓存 → localStorage 缓存 → fingerprintjs 生成
* 并发安全:多个并发调用会共享同一个 fingerprintjs 加载 Promise
*/
async getDeviceId(): Promise<string> {
if (this.cachedId) return this.cachedId;
if (this.initPromise) return this.initPromise;
this.initPromise = this.initialize();
this.cachedId = await this.initPromise;
return this.cachedId;
}
/**
* 重置缓存与持久化(用于测试或多账号切换)
*/
reset(): void {
this.cachedId = null;
this.initPromise = null;
authStorage.removeDeviceId();
}
private async initialize(): Promise<string> {
// 1. 优先从 localStorage 读取
const stored = authStorage.getDeviceId();
if (stored) return stored;
// 2. SSR/Node 环境守卫:调用方必须保证在浏览器中调用
if (typeof window === "undefined") {
throw new Error(
"DeviceIdentifier only works in browser environment. " +
"Call getDeviceId() from a Client Component or browser code."
);
}
// 3. 动态加载 fingerprintjs(避免 Next.js SSR 打包分析时执行)
const { load } = await import("@fingerprintjs/fingerprintjs");
const fp = await load();
const result = await fp.get();
// 4. 持久化
authStorage.setDeviceId(result.visitorId);
return result.visitorId;
}
}
/**
* 全局单例
*/
export const deviceIdentifier = new DeviceIdentifier();