From f4e1c30051b3d10a79d9be1bafcceaaed61c69ca Mon Sep 17 00:00:00 2001 From: chenhang Date: Mon, 8 Jun 2026 15:49:48 +0800 Subject: [PATCH] feat(api): migrate HTTP layer to ofetch with token interceptor --- barrelsby.json | 3 +- implementation_plan.md | 319 +++++++++--------- package.json | 2 + pnpm-lock.yaml | 35 ++ src/data/api/api_path.ts | 117 +++++++ src/data/api/api_result.ts | 59 ++++ src/data/api/auth_api.ts | 170 ++++++++++ src/data/api/chat_api.ts | 107 ++++++ src/data/api/config/api_config.ts | 69 ++++ src/data/api/http_client.ts | 76 +++++ src/data/api/index.ts | 5 + .../interceptor/auth_refresh_interceptor.ts | 168 +++++++++ .../api/interceptor/logging_interceptor.ts | 85 +++++ src/data/api/interceptor/token_interceptor.ts | 58 ++++ src/data/api/metrics_api.ts | 55 +++ src/data/api/user_api.ts | 93 +++++ src/data/storage/auth_storage.ts | 118 +++++++ src/utils/device_identifier.ts | 66 ++++ 18 files changed, 1446 insertions(+), 159 deletions(-) create mode 100644 src/data/api/api_path.ts create mode 100644 src/data/api/api_result.ts create mode 100644 src/data/api/auth_api.ts create mode 100644 src/data/api/chat_api.ts create mode 100644 src/data/api/config/api_config.ts create mode 100644 src/data/api/http_client.ts create mode 100644 src/data/api/index.ts create mode 100644 src/data/api/interceptor/auth_refresh_interceptor.ts create mode 100644 src/data/api/interceptor/logging_interceptor.ts create mode 100644 src/data/api/interceptor/token_interceptor.ts create mode 100644 src/data/api/metrics_api.ts create mode 100644 src/data/api/user_api.ts create mode 100644 src/data/storage/auth_storage.ts create mode 100644 src/utils/device_identifier.ts diff --git a/barrelsby.json b/barrelsby.json index 33cfe370..bc876d75 100644 --- a/barrelsby.json +++ b/barrelsby.json @@ -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" ] } diff --git a/implementation_plan.md b/implementation_plan.md index 37f4b4a8..fb8df9e9 100644 --- a/implementation_plan.md +++ b/implementation_plan.md @@ -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 = + | { success: true; data: T } + | { success: false; error: ApiError }; -| 导出 | 来源 | 用途 | -|---|---|---| -| `XxxSchema` | `z.object({...})` | 验证规则与默认值定义 | -| `XxxInput` | `z.input` | 构造输入类型(字段可缺失,触发 `.default()`) | -| `XxxData` | `z.output` | 解析后类型(所有字段已应用默认值) | -| `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 items` | `z.array(TItemSchema)` | -| `PersonalityTraits? personalityTraits` | `PersonalityTraitsSchema.default({})` | -| `Map` | `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; +onResponse?: (ctx: { request: Request; response: Response; options: FetchOptions }) => void | Promise; +onResponseError?: (ctx: { request: Request; response: Response; options: FetchOptions; error: FetchError }) => void | Promise; +onError?: (ctx: { request: Request; error: Error; options: FetchOptions }) => void | Promise; +``` ## [Files] @@ -48,124 +53,132 @@ | 路径 | 用途 | |---|---| -| `implementation_plan.md` | 本文档 | +| `src/data/api/api_path.ts` | 路径常量(40+ endpoints) | +| `src/data/api/api_result.ts` | 统一响应包装 `ApiResult` + `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` | `storage/auth_storage.ts` | 优先登录 token,回退游客 token | +| `setLoginToken(t)` | `(token: string) => Promise` | 同上 | 持久化登录 token | +| `setGuestToken(t)` | `(token: string) => Promise` | 同上 | 持久化游客 token | +| `clearAuthData()` | `() => Promise` | 同上 | 清除所有 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; -export type XxxData = z.output; - -// 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. **批次 C:API clients**(4 个文件,独立) + - `auth_api.ts`(11 个方法) + - `chat_api.ts`(5 个方法) + - `user_api.ts`(5 个方法) + - `metrics_api.ts`(2 个方法) +6. **批次 D:barrel 导出**(1 个文件) + - `index.ts` +7. **最终验证**: + - `npx tsc --noEmit` + - `pnpm lint` + - 端到端 smoke test diff --git a/package.json b/package.json index ac16a9b8..d3c4cf3a 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e1b69443..c35f351d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/data/api/api_path.ts b/src/data/api/api_path.ts new file mode 100644 index 00000000..6a779246 --- /dev/null +++ b/src/data/api/api_path.ts @@ -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`; +} diff --git a/src/data/api/api_result.ts b/src/data/api/api_result.ts new file mode 100644 index 00000000..af4c685f --- /dev/null +++ b/src/data/api/api_result.ts @@ -0,0 +1,59 @@ +/** + * 统一 API 响应包装 + * + * 与 Dart 的 `NetResult` 对应: + * - 成功:`{ success: true, data: T }` + * - 失败:`{ success: false, error: ApiError }` + */ +export type ApiResult = + | { 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"; +} diff --git a/src/data/api/auth_api.ts b/src/data/api/auth_api.ts new file mode 100644 index 00000000..abc809ed --- /dev/null +++ b/src/data/api/auth_api.ts @@ -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 { + success: boolean; + data?: T; + message?: string; + error?: string; +} + +function unwrap(envelope: ApiEnvelope): 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 { + await httpClient>(ApiPath.sendCode, { + method: "POST", + body: body.toJson(), + }); + } + + /** + * 邮箱密码登录 + */ + async emailLogin(body: LoginRequest): Promise { + const env = await httpClient>( + ApiPath.emailLogin, + { method: "POST", body: body.toJson() } + ); + return LoginResponse.fromJson(unwrap(env) as Record); + } + + /** + * 注册 + */ + async register(body: RegisterRequest): Promise { + await httpClient>(ApiPath.register, { + method: "POST", + body: body.toJson(), + }); + } + + /** + * Apple 登录 + */ + async appleLogin(body: AppleLoginRequest): Promise { + const env = await httpClient>(ApiPath.appleLogin, { + method: "POST", + body: body.toJson(), + }); + return LoginResponse.fromJson(unwrap(env) as Record); + } + + /** + * Google 登录 + */ + async googleLogin(body: GoogleLoginRequest): Promise { + const env = await httpClient>(ApiPath.googleLogin, { + method: "POST", + body: body.toJson(), + }); + return LoginResponse.fromJson(unwrap(env) as Record); + } + + /** + * Facebook 登录 + */ + async facebookLogin(body: FacebookLoginRequest): Promise { + const env = await httpClient>( + ApiPath.facebookLogin, + { method: "POST", body: body.toJson() } + ); + return LoginResponse.fromJson(unwrap(env) as Record); + } + + /** + * Facebook ID 登录(v7.0 新增) + */ + async facebookIdLogin(body: FbIdLoginRequest): Promise { + const env = await httpClient>( + ApiPath.facebookIdLogin, + { method: "POST", body: body.toJson() } + ); + return LoginResponse.fromJson(unwrap(env) as Record); + } + + /** + * 退出登录 + */ + async logout(): Promise { + const env = await httpClient>(ApiPath.logout, { + method: "POST", + }); + return LogoutResponse.fromJson(unwrap(env) as Record); + } + + /** + * 游客登录 + */ + async guestLogin(body: GuestLoginRequest): Promise { + const env = await httpClient>(ApiPath.guestLogin, { + method: "POST", + body: body.toJson(), + }); + return GuestLoginResponse.fromJson(unwrap(env) as Record); + } + + /** + * 刷新 Token + */ + async refreshToken( + body: RefreshTokenRequest + ): Promise { + const env = await httpClient>(ApiPath.refresh, { + method: "POST", + body: body.toJson(), + }); + return RefreshTokenResponse.fromJson( + unwrap(env) as Record + ); + } + + /** + * 获取当前用户信息 + */ + async getCurrentUser(): Promise { + const env = await httpClient>( + ApiPath.getCurrentUser + ); + return User.fromJson(unwrap(env) as UserData); + } +} + +/** + * 全局单例 + */ +export const authApi = new AuthApi(); diff --git a/src/data/api/chat_api.ts b/src/data/api/chat_api.ts new file mode 100644 index 00000000..3a70e985 --- /dev/null +++ b/src/data/api/chat_api.ts @@ -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 { + success: boolean; + data?: T; + message?: string; + error?: string; +} + +function unwrap(envelope: ApiEnvelope): 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 { + const env = await httpClient>(ApiPath.chatSend, { + method: "POST", + body: body.toJson(), + }); + return ChatSendResponse.fromJson(unwrap(env) as Record); + } + + /** + * 获取聊天历史 + */ + async getHistory(limit = 50, offset = 0): Promise { + const env = await httpClient>(ApiPath.chatHistory, { + query: { limit, offset }, + }); + return ChatHistoryResponse.fromJson( + unwrap(env) as Record + ); + } + + /** + * 语音转文字(multipart 上传) + */ + async speechToText(audio: Blob | File): Promise { + const formData = new FormData(); + formData.append("audio", audio); + const env = await httpClient>(ApiPath.chatStt, { + method: "POST", + body: formData, + }); + return SttData.fromJson(unwrap(env) as Record); + } + + /** + * 同步游客消息 + */ + async syncMessages( + messages: SyncMessage[] + ): Promise { + const body = ChatSyncRequest.from({ messages }); + const env = await httpClient>(ApiPath.chatSync, { + method: "POST", + body: body.toJson(), + }); + return ChatSyncData.fromJson(unwrap(env) as Record); + } + + /** + * 上传图片 + */ + async uploadImage(image: Blob | File): Promise { + const formData = new FormData(); + formData.append("image", image); + const env = await httpClient>( + ApiPath.chatUploadImage, + { method: "POST", body: formData } + ); + return ImageUploadResponse.fromJson( + unwrap(env) as Record + ); + } +} + +/** + * 全局单例 + */ +export const chatApi = new ChatApi(); diff --git a/src/data/api/config/api_config.ts b/src/data/api/config/api_config.ts new file mode 100644 index 00000000..a07ee6ed --- /dev/null +++ b/src/data/api/config/api_config.ts @@ -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, + }; +} diff --git a/src/data/api/http_client.ts b/src/data/api/http_client.ts new file mode 100644 index 00000000..922d0926 --- /dev/null +++ b/src/data/api/http_client.ts @@ -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)(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(); diff --git a/src/data/api/index.ts b/src/data/api/index.ts new file mode 100644 index 00000000..9e0bc11d --- /dev/null +++ b/src/data/api/index.ts @@ -0,0 +1,5 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from "./index"; diff --git a/src/data/api/interceptor/auth_refresh_interceptor.ts b/src/data/api/interceptor/auth_refresh_interceptor.ts new file mode 100644 index 00000000..835f3425 --- /dev/null +++ b/src/data/api/interceptor/auth_refresh_interceptor.ts @@ -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 | null = null; + +/** + * 实际刷新 token(被拦截器调用) + */ +async function refreshToken( + isGuestMode: boolean, + baseUrl: string +): Promise { + if (isGuestMode) { + await refreshGuestToken(baseUrl); + } else { + await refreshLoginToken(baseUrl); + } +} + +/** + * 刷新登录 token + */ +async function refreshLoginToken(baseUrl: string): Promise { + 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 { + 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, +}; diff --git a/src/data/api/interceptor/logging_interceptor.ts b/src/data/api/interceptor/logging_interceptor.ts new file mode 100644 index 00000000..bbbf0c52 --- /dev/null +++ b/src/data/api/interceptor/logging_interceptor.ts @@ -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, +}; diff --git a/src/data/api/interceptor/token_interceptor.ts b/src/data/api/interceptor/token_interceptor.ts new file mode 100644 index 00000000..3f405999 --- /dev/null +++ b/src/data/api/interceptor/token_interceptor.ts @@ -0,0 +1,58 @@ +/** + * Token 拦截器 + * + * 在请求头中注入 Authorization: Bearer 。 + * 跳过不需要 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, +}; diff --git a/src/data/api/metrics_api.ts b/src/data/api/metrics_api.ts new file mode 100644 index 00000000..1af9f9cd --- /dev/null +++ b/src/data/api/metrics_api.ts @@ -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 { + success: boolean; + data?: T; + message?: string; + error?: string; +} + +function unwrap(envelope: ApiEnvelope): 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 { + const env = await httpClient>( + ApiPath.metricsPwaEvent, + { method: "POST", body: body.toJson() } + ); + unwrap(env); + } + + /** + * 上报用户信息 + */ + async reportUserInfo(body: AppEvent): Promise { + const env = await httpClient>( + ApiPath.reportUserInfo, + { method: "POST", body: body.toJson() } + ); + unwrap(env); + } +} + +/** + * 全局单例 + */ +export const metricsApi = new MetricsApi(); diff --git a/src/data/api/user_api.ts b/src/data/api/user_api.ts new file mode 100644 index 00000000..4fc3a4dd --- /dev/null +++ b/src/data/api/user_api.ts @@ -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 { + success: boolean; + data?: T; + message?: string; + error?: string; +} + +function unwrap(envelope: ApiEnvelope): 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 { + const env = await httpClient>(ApiPath.userStats); + return UserStatsResponse.fromJson( + unwrap(env) as Record + ); + } + + /** + * 获取个人信息 + */ + async getCurrentUser(): Promise { + const env = await httpClient>(ApiPath.userProfile); + return User.fromJson(unwrap(env) as UserData); + } + + /** + * 更新个人资料 + */ + async updateProfile(body: UpdateProfileRequest): Promise { + const env = await httpClient>(ApiPath.updateProfile, { + method: "PUT", + body: body.toJson(), + }); + return User.fromJson(unwrap(env) as UserData); + } + + /** + * 查询积分余额 + */ + async getCredits(): Promise { + const env = await httpClient>(ApiPath.userCredits); + return CreditsData.fromJson(unwrap(env) as Record); + } + + /** + * 查询积分操作历史 + */ + async getCreditsHistory( + limit = 50, + offset = 0 + ): Promise { + const env = await httpClient>( + ApiPath.userCreditsHistory, + { query: { limit, offset } } + ); + return CreditsHistoryData.fromJson( + unwrap(env) as Record + ); + } +} + +/** + * 全局单例 + */ +export const userApi = new UserApi(); diff --git a/src/data/storage/auth_storage.ts b/src/data/storage/auth_storage.ts new file mode 100644 index 00000000..4af47a72 --- /dev/null +++ b/src/data/storage/auth_storage.ts @@ -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(); diff --git a/src/utils/device_identifier.ts b/src/utils/device_identifier.ts new file mode 100644 index 00000000..96c0ddc9 --- /dev/null +++ b/src/utils/device_identifier.ts @@ -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 | null = null; + + /** + * 获取或生成设备 ID + * + * 优先级:内存缓存 → localStorage 缓存 → fingerprintjs 生成 + * 并发安全:多个并发调用会共享同一个 fingerprintjs 加载 Promise + */ + async getDeviceId(): Promise { + 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 { + // 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();