# Implementation Plan: HTTP API 层迁移 ## [Overview] 将 Flutter 项目 `/lib/core/net/` 与 `/lib/data/services/api/` 下的 HTTP 网络层迁移至 Next.js 16,使用 **ofetch** 作为 HTTP 客户端(轻量、同构、拦截器钩子丰富),通过 `.env.local` 配置环境变量,复用已迁移的 Zod 数据模型进行响应验证。 ## [Types] ### ApiResult 统一响应包装 ```typescript // src/data/api/api_result.ts export type ApiResult = | { success: true; data: T } | { success: false; error: ApiError }; export class ApiError extends Error { readonly code: string; readonly status?: number; readonly details?: unknown; constructor(code: string, message: string, status?: number, details?: unknown); } ``` ### 环境配置类型 ```typescript // src/data/api/config/api_config.ts export type AppEnv = "development" | "test" | "production"; export interface ApiConfig { baseUrl: string; wsUrl: string; connectTimeout: number; // ms receiveTimeout: number; // ms sendTimeout: number; // ms } ``` ### 拦截器钩子签名 ```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] ### 新增 | 路径 | 用途 | |---|---| | `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` | 添加 `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 实例工厂 | ### 删除/重写 | 函数 | 旧实现 | 新实现 | |---|---|---| | `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 // 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}`); } }; // 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], }, }); ``` ## [Dependencies] ### 新增 | 包 | 版本 | 用途 | |---|---|---| | `ofetch` | `^1.5.0` | 同构 HTTP 客户端(浏览器 + Node.js) | ### 安装 ```bash pnpm add ofetch ``` ### 版本选择 选择 ofetch v1.5+,原因: - 完整同构支持(unjs/h3 生态) - 内置拦截器钩子 - 体积小(~5KB gzipped) - TypeScript 优先 ## [Testing] ### 验证策略 每个批次完成后: - `npx tsc --noEmit` — 0 错误 - `pnpm lint` — 通过 最终验证: - 模块导入测试(`import { authApi, chatApi } from '@/data/api'`) - 拦截器单元测试(mock localStorage 与 fetch) - 端到端测试(启动 dev server,触发实际请求) ### 测试要点 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] 按依赖关系,**9 个步骤**: 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 --- # 后续迁移:第三方登录平台(IAuthPlatform) ## [Overview] 将 Flutter `IAuthPlatform`(`/Users/chase/Documents/cozsweet/lib/data/services/auth/`)迁移为 Next.js 16 项目的纯 TypeScript 服务层。SDK 选用 `@react-oauth/google`(Google Identity Services React 包装器)+ `@greatsumini/react-facebook-login`(`react-facebook` 的活跃 fork,兼容 React 19)。 **Flutter 端的 `AuthRepository` 已经完整迁移**(`src/data/repositories/auth_repository.ts`,含 `googleLogin`/`facebookLogin`/`facebookIdLogin`/`appleLogin`),本步只补齐"生产第三方 identity token"那一段——平台层产 token,调用方拿 token 去调 `authRepository`(或 `authApi`)完成登录。 **范围限定**: - 客户端 SDK 接入层,**不**写 React Context/useAuth,**不**写 UI - 跳过 Apple(Flutter 端本来就是 stub) - 平台**只产 token**,不调 `authRepository` / `authApi` - 保留 Mock(`NEXT_PUBLIC_USE_MOCK_AUTH=true` 切换,对齐 Dart `kDebugMode`) ## [Architecture: event-bridge 模式] `IAuthPlatform` 必须是 plain TypeScript(不依赖 React),但 React 包装器 SDK(`useGoogleOAuth`、`FacebookLoginClient`)只能在 React 组件里用。解决方案是 **plain-TS 平台类 ↔ React Bridge 组件通过 DOM `CustomEvent` 通信**: ``` WebAuthPlatform (plain TS) AuthPlatformBridge ("use client") ┌──────────────────────┐ ┌──────────────────────────┐ │ googleSignIn() │ │ │ │ ├─ new Promise │ dispatchEvent │ useGoogleOAuth() │ │ │ store requestId │ ────────────▶ │ window.google.accounts │ │ │ → resolver │ │ .id.initialize({...}) │ │ └─ return Promise │ │ on auth event: │ │ │ resolveGoogle │ window.google.accounts│ │ resolveGoogle( │ ◀──────────── │ .id.prompt() │ │ requestId, result)│ │ GIS callback: │ └──────────────────────┘ │ resolve the promise │ └──────────────────────────┘ ``` - `WebAuthPlatform` 暴露公开方法 `resolveGoogle(requestId, result)` / `resolveFacebook(...)` 给 Bridge 回写 Promise - `AuthPlatformBridge` 监听 `cozsweet:auth:google:start` / `cozsweet:auth:facebook:start` 事件,触发对应 SDK 流程 - **v1 限制**:单 popup 语义——一次只追踪一个待处理 Google / Facebook 请求;并发调用会被覆盖 ## [Files] ### 新建(9 个文件) | 路径 | 作用 | |---|---| | `src/data/services/auth/auth_platform.ts` | `IAuthPlatform` 接口 + `AuthPlatformFactory` + `getAuthPlatform()` 单例 + 命名类型 `GoogleCredential` / `FacebookLoginResult` | | `src/data/services/auth/web_auth_platform.ts` | 真机平台类(plain TS),通过 DOM 事件与 Bridge 通信;含 `resolveGoogle()` / `resolveFacebook()` 公开方法 | | `src/data/services/auth/mock_auth_platform.ts` | 调试用 Mock(返回固定 token + 250ms 模拟延迟) | | `src/data/services/auth/auth_event_bridge.ts` | 跨 plain-TS / React 边界的 DOM 事件通道(无 React 依赖;导出事件名常量 + dispatch / subscribe 助手) | | `src/data/services/auth/auth_bridge_component.tsx` | `"use client"` 组件,承载 `useGoogleOAuth` + `FacebookLoginClient`,监听事件并 resolve Promise | | `src/data/services/auth/initialize_google_auth.ts` | Google GIS 预热辅助(占位) | | `src/data/services/auth/initialize_facebook_auth.ts` | Facebook SDK 预热辅助(占位) | | `src/data/schemas/auth/facebook_user_data.ts` | Zod schema + `Input` / `Data` 类型 | | `src/data/dto/auth/facebook_user_data.ts` | `FacebookUserData` DTO 类(`from` / `fromJson` / `toJson`) | | `src/data/services/auth/global.d.ts` | `window.google.accounts.id.*` 的全局类型声明 | ### 修改 | 路径 | 变更 | |---|---| | `package.json` | 新增 `@react-oauth/google@0.13.5`、`@greatsumini/react-facebook-login@3.4.0` | | `.env.example` | 新增 `NEXT_PUBLIC_GOOGLE_CLIENT_ID`、`NEXT_PUBLIC_FACEBOOK_APP_ID`、`NEXT_PUBLIC_FACEBOOK_API_VERSION=v25.0`、`NEXT_PUBLIC_USE_MOCK_AUTH=false` | | `barrelsby.json` | 修整:删除已不存在的 `./src/data/models` 残留;新增 `./src/data/services` + `./src/data/services/auth` | ### 删除 | 路径 | 原因 | |---|---| | `src/data/storage/index.ts` | barrelsby 自动生成的 barrel,把同步 `AuthStorage`(`./auth_storage`)和异步 `AuthStorage`(`./auth/auth_storage`)同时 re-export,触发 TS2308 类型冲突。该 barrel 在仓库内**无消费者**(所有 import 都走直接路径 `@/data/storage/auth_storage` 或 `@/data/storage/auth/auth_storage`),可安全删除;后续若需要 barrel 须先解决命名冲突 | ## [Google Flow 决策(重要)] `@react-oauth/google@0.13.5` 的 `useGoogleLogin(options)` 只支持两种 flow: - `flow: 'implicit'` → 回调 `TokenResponse`,含 `access_token`(**不是** id_token) - `flow: 'auth-code'` → 回调 `CodeResponse`,含 `code`(授权码,需后端交换) **但** 后端 `GoogleLoginRequest.idToken: z.string()` 期望的是 JWT id_token(与 Flutter `GoogleSignInAccount.authentication.idToken` 字段同名同义),所以 `useGoogleLogin` 两种 flow 都不适配。 **采用方案**:直接调用 GIS 全局 API(在 Bridge 的 `useEffect` 中),由 `GoogleOAuthProvider` 加载 GIS 脚本后: - `window.google.accounts.id.initialize({ client_id, callback })` —— 注册 callback - `window.google.accounts.id.prompt()` —— 触发弹窗 - callback 收到 `{ credential: '' }`,`credential` 即 id_token JWT payload 通过 `atob(parts[1])` 解析拿到 `email` / `name` / `picture`,填充 `GoogleCredential` 接口字段。 ## [调用方契约(参考实现,**不在本步交付**)] ```ts import { getAuthPlatform } from "@/data/services/auth"; import { authRepository } from "@/data/repositories"; import { AuthStorage } from "@/data/storage/auth/auth_storage"; const platform = getAuthPlatform({ googleClientId: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!, facebookAppId: process.env.NEXT_PUBLIC_FACEBOOK_APP_ID!, }); async function googleLogin() { const r = await platform.googleSignIn(); if (r.kind === "failure") return handleError(r.error); const guestId = (await AuthStorage.getInstance().getDeviceId()).value ?? ""; await authRepository.googleLogin({ idToken: r.value.idToken, guestId }); // token 持久化由 authRepository._saveLoginData 自动完成 } async function facebookLogin() { const r = await platform.facebookSignIn(); if (r.kind === "failure") return handleError(r.error); await authRepository.facebookLogin({ accessToken: r.value.accessToken, guestId: "" }); // 拉头像 + by-id 登录(与 Flutter AuthBloc._uploadFacebookAvatar 一致) const ud = await platform.getFacebookUserData(); if (ud.kind === "success" && ud.value.id && ud.value.pictureUrl) { await AuthStorage.getInstance().setFacebookId(ud.value.id); await authRepository.facebookIdLogin({ fbId: ud.value.id, avatarUrl: ud.value.pictureUrl }); } } ``` ## [下一步:UI 挂载方式(留给下一轮迁移)] 本步**不**挂载到 `src/app/layout.tsx`。下一轮 UI 接入时按如下方式挂载: ```tsx // src/app/layout.tsx import { GoogleOAuthProvider } from "@react-oauth/google"; import { getAuthPlatform, AuthPlatformBridge } from "@/data/services/auth"; const platform = getAuthPlatform({ googleClientId: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!, facebookAppId: process.env.NEXT_PUBLIC_FACEBOOK_APP_ID!, }); export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ); } ``` ## [Implementation Order] 1. **安装**:`pnpm add @react-oauth/google @greatsumini/react-facebook-login` 2. **追加 env** 到 `.env.example` 3. **修整 `barrelsby.json`**:删除 `./src/data/models` 残留、新增 `./src/data/services` 与 `./src/data/services/auth` 4. **删除** `src/data/storage/index.ts`(命名冲突) 5. **新建 Zod schema** + **DTO 类** for `FacebookUserData` 6. **生成 barrel**:`pnpm generate-barrels` 7. **新建 global.d.ts**:声明 `window.google.accounts.id.*` 类型 8. **依次新建** `auth_event_bridge.ts`、`initialize_*.ts`、`auth_platform.ts`、`web_auth_platform.ts`、`mock_auth_platform.ts`、`auth_bridge_component.tsx` 9. **验证**:`npx tsc --noEmit`、`pnpm lint`、`pnpm build`