# 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: │ │ │ │ window.google.accounts│ │ resolveGoogle( │ 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` --- # 收口 OAuth 登录调用到 Auth 状态机 ## [Overview] 把 `new AuthPlatform(provider).signIn()` 的直接调用从 UI 组件移到 Auth 状态机里。改完后所有 OAuth 入口都通过 `useAuthDispatch()` 派发 `AuthGoogleLoginSubmitted` / `AuthFacebookLoginSubmitted` 事件,状态机自己用 `next-auth/react` 的 `signIn(provider)` 触发流程。NextAuth 重定向期间用一个 `loadingOAuth` 子状态表示"正在跳走",期间 `isLoading` 为 true,UI 复用现有的 `state.isLoading` 控制按钮 disabled;失败把 `errorMessage` 写入 context。 ## [Goals] - UI 组件不再 `import { AuthPlatform }`,仅派发事件 - 状态机里承载 OAuth 流程(actor 调 `signIn`、成功/失败分支) - 复用既有 `isLoading` / `errorMessage` 形态,UI 侧零概念变化 - 行为对齐:成功 = NextAuth 重定向到 `callbackUrl`(默认 `/chat`),所以状态机不需要"成功"分支——和邮箱登录的 `success` 终态语义不同。OAuth 的"完成"由 NextAuth 回调 + `SessionProvider` 处理 ## [Non-Goals] - 不动邮箱登录的 actor / 事件 - 不动 `Logout` 类(实际未在 UI 直接 `new Logout()`,由 `userMachine` 调 `authRepository.logout` 走,OAuth 注销是另一条线) - 不在状态机里尝试"成功"——NextAuth 跳转后页面会被 Next.js 接管,state 在那时已被销毁,无需 `success` 终态 - 不引入新的 busy 字段 ## [Files] ### 修改 | 路径 | 变更 | |---|---| | `src/stores/auth/machine/auth-events.ts` | `AuthGoogleLoginSubmitted` / `AuthFacebookLoginSubmitted` 携带 `provider: AuthProvider` 字段(或保留无 payload + 状态机内部按事件类型路由) | | `src/stores/auth/machine/auth-machine.ts` | 新增 `oauthSignIn: fromPromise` actor;`idle` 中两个事件指向新状态 `loadingOAuth`;`loadingOAuth.invoke` 调 actor;`onError` 把错误写入 `errorMessage` 并回到 `idle`;`onDone` 也回到 `idle`(NextAuth 一旦调起就会重定向,`onDone` 极少触发;但状态机结构上仍要正确处理以防 SDK 同步返回) | | `src/app/splash/components/splash-button.tsx` | 移除 `import { AuthPlatform }`,改用 `useAuthDispatch` 派发 `AuthFacebookLoginSubmitted`;移除 `handleFacebookLogin` 的 try/catch(错误走状态机) | | `src/app/auth/components/auth-facebook-panel.tsx` | 移除 `import { AuthPlatform }`、移除本地 `busy` 状态(用 `state.isLoading` 替代) / `error` 状态(用 `state.errorMessage` 替代);改用 `useAuthDispatch` 派发对应事件;`disabled` 绑 `isLoading`;按钮 spinner 用 `isLoading` 判断(不能再区分是 Facebook 还是 Google 在加载——OAuth 跳转期间整个 UI 都该锁住) | | `src/lib/auth/nextauth.ts` | 仍 re-export `AuthPlatform` 类型(给 `authRepository` 内部用),但**删除**面向 UI 的 export 仅保留内部使用;或将 export 改为 `internal` barrel 命名(不强制,看用户偏好) | ### 不变 - `src/lib/auth/auth_platform.ts` 保持原样(其他模块可能用) - `src/stores/auth/machine/auth-context.tsx` 不用动(`isLoading` / `errorMessage` 已暴露) - `src/stores/auth/auth-state.ts` / `auth-types.ts` / `index.ts` 不用动 ## [State Machine Diff] ```ts // auth-events.ts —— OAuth 事件保留无 payload;provider 由事件类型决定 export type AuthEvent = | { type: "AuthPanelModeChanged"; mode: AuthPanelMode } | { type: "AuthModeChanged"; mode: AuthMode } | { type: "AuthFormCleared" } | { type: "AuthReset" } | { type: "AuthEmailLoginSubmitted"; email: string; password: string } | { type: "AuthEmailRegisterSubmitted"; email: string; password: string; username: string; confirmPassword: string } | { type: "AuthGoogleLoginSubmitted" } | { type: "AuthFacebookLoginSubmitted" } | { type: "AuthAppleLoginSubmitted" }; ``` ```ts // auth-machine.ts —— 新增 actor + 状态 import { signIn } from "next-auth/react"; const oauthSignInActor = fromPromise(async ({ input }) => { await signIn(input); }); export const authMachine = setup({ types: { context: {} as AuthState, events: {} as AuthEvent }, actors: { emailLogin: emailLoginActor, emailRegisterThenLogin: emailRegisterThenLoginActor, oauthSignIn: oauthSignInActor, }, }).createMachine({ id: "auth", initial: "idle", context: initialState, states: { idle: { on: { // ... 既有事件保持 ... AuthGoogleLoginSubmitted: { target: "loadingOAuth" }, AuthFacebookLoginSubmitted: { target: "loadingOAuth" }, AuthAppleLoginSubmitted: { actions: assign({ errorMessage: "Apple login not implemented" }), }, }, }, loadingOAuth: { // 记录是哪个 provider 出错 entry: assign({ errorMessage: null }), invoke: { src: "oauthSignIn", input: ({ context }) => context.pendingOAuthProvider ?? "google", onDone: { target: "idle" }, // 罕见分支:NextAuth 同步 resolve(不应发生) onError: { target: "idle", actions: assign({ errorMessage: ({ event }) => event.error instanceof Error ? event.error.message : String(event.error), }), }, }, on: { AuthGoogleLoginSubmitted: { /* 同 provider 重新触发:重新进入状态即可;XState v5 默认会重 invoke */ target: "loadingOAuth", reenter: true }, AuthFacebookLoginSubmitted: { target: "loadingOAuth", reenter: true }, }, }, // loadingEmailLogin / loadingEmailRegister / success 不变 }, }); ``` **注意**:把 provider 传给 actor 需要从事件读取。两种实现: 1. **input 走 event(推荐)**:在 `idle` 的 transition 上用 `actions: assign({ pendingOAuthProvider: ... })` + 用 `target: "loadingOAuth"`。但 XState v5 在 `invoke.input` 只能拿 `event`(触发 transition 的那个),且 `loadingOAuth` 没有 transition 是用 `event` 进入的(是 target 跳转),所以更稳的做法是给事件本身带 `provider`: ```ts // 推荐方案:事件携带 provider | { type: "AuthGoogleLoginSubmitted"; provider: "google" } | { type: "AuthFacebookLoginSubmitted"; provider: "facebook" } ``` 2. 状态机入口用 `actions: assign({ pendingOAuthProvider: ... })` 写入 context,再从 `context` 读。 **采用方案 1**——更显式、无需 context 字段。详见 [Implementation Order] 第 3 步。 ## [AuthState 增量] 不需要新字段。如果采用"从 context 读 provider"才需要 `pendingOAuthProvider`,但方案 1 不用。 ## [UI Diff] ### `splash-button.tsx` ```tsx // 旧 const handleFacebookLogin = async () => { try { await new AuthPlatform("facebook").signIn(); } catch (e) { console.error("[splash-button] Facebook login failed", e); } }; // 新 const authDispatch = useAuthDispatch(); const handleFacebookLogin = () => authDispatch({ type: "AuthFacebookLoginSubmitted" }); ``` ### `auth-facebook-panel.tsx` ```tsx // 旧 const [showOptions, setShowOptions] = useState(false); const [busy, setBusy] = useState(null); const [error, setError] = useState(null); const handleFacebook = async () => { /* setBusy + try new AuthPlatform().signIn() */ }; const handleGoogle = async () => { /* setBusy + try new AuthPlatform().signIn() */ }; // 新 const { isLoading, errorMessage } = useAuthState(); const authDispatch = useAuthDispatch(); const handleFacebook = () => authDispatch({ type: "AuthFacebookLoginSubmitted" }); const handleGoogle = () => authDispatch({ type: "AuthGoogleLoginSubmitted" }); // 渲染:disabled={isLoading};spinner 用 isLoading;error 用 errorMessage ``` **取舍**: - **去掉了"哪个按钮在转"区分**——OAuth 跳转期间 `isLoading` 一直 true,两个按钮都禁用并显示 spinner。这是预期行为(避免重复触发),且和 splash 一致 - **去掉了本地 error 状态**——错误统一到 `state.errorMessage`,与邮箱登录同源 ## [Trade-offs] - ✅ 调用方零概念:UI 只 dispatch,不知道有 `next-auth/react` - ✅ 错误处理统一:`state.errorMessage` 涵盖邮箱 + OAuth - ✅ `isLoading` 统一:UI 不用为 OAuth 单写 busy - ⚠️ `signIn` 副作用(重定向)从 React 组件挪到 `fromPromise` actor;XState v5 支持 promise actor 在浏览器环境跑 - ⚠️ `auth-facebook-panel` 失去"区分两个 spinner"的能力(已知取舍) ## [Testing] - `npx tsc --noEmit`:0 错误 - `pnpm lint`:通过 - 手动 smoke:点 Facebook / Google 按钮 → 状态机进入 `loadingOAuth` → NextAuth 重定向到 provider - 检查:刷新 / 失败场景下 `errorMessage` 正确显示在 `` ## [Implementation Order] 1. **`auth-events.ts`**:OAuth 事件携带 `provider: "google" | "facebook"`(如果用方案 1) 2. **`auth-machine.ts`**:新增 `oauthSignInActor` + `loadingOAuth` 状态 + idle transition 指向它;移除 `// 社交登录已迁移到 NextAuth + AuthPlatform 类` 注释 3. **`splash-button.tsx`**:替换 `handleFacebookLogin` 为 dispatch;移除 `AuthPlatform` import 4. **`auth-facebook-panel.tsx`**:替换两个 handler;移除本地 `busy` / `error`;按钮 `disabled` 绑 `isLoading`;spinner 用 `isLoading`;`` 改为 `message={errorMessage}`(来自 `useAuthState`) 5. **`auth-context.tsx`**:检查 `isLoading` 表达式是否覆盖 `loadingOAuth`——需要把 `state.matches("loadingOAuth")` 加进去 6. **验证**:`npx tsc --noEmit` + `pnpm lint` + dev server smoke 7. **可选清理**:`src/lib/auth/nextauth.ts` 的 `export { AuthPlatform, type AuthProvider }` 改为仅在 auth_repository / 其他内部消费者使用;如果没有别的 import,可保留 --- # 重构 Chat 状态机:init 拆为 2 独立任务 + history local→network→save ## [Overview] 把 `chatInitActor`(一个**怪**聚合了**配**额 + 本地 history **两**件事的 actor)**拆**成**两**个**独**立 actor(`loadQuotaActor` + `loadHistoryActor`),**每**个**都**是**独**立函数 / 独立 actor。游客**登**录 init 跑 `loadQuota + loadHistory` **两**个**并**行任务;**非**游客 init 跑 `chatWebSocket`(parent) + `loadHistory`(initializing)**两**个**独**立任务。**重**点:**history task 内部**走** `读 local → 派 events 到 UI → 读 network → 派 events 到 UI → 写 local` 3 步,UI 顺**序**看**到** local → network。 ## [需求(**用**户**原**话)] > 游客登录初始化,两个任务:一是加载配额,第二个是拉取历史消息 > 非游客登录也是两个任务。一是连接 websocket,二是拉取历史消息 > 每个任务都要独立,都要独立的函数 > 拉取历史消息的逻辑是:先从本地拉取,展示到界面上,再从网络拉取,展示到界面上,再用网络数据覆盖本地数据 ## [Types] ### ChatEvent 增量(`src/stores/chat/chat-events.ts`) ```typescript // 新增 | { type: "ChatQuotaLoaded"; remaining: number; total: number } | { type: "ChatHistoryLocalLoaded"; messages: UiMessage[] } | { type: "ChatHistoryNetworkLoaded"; messages: UiMessage[]; hasMore: boolean; newOffset: number } | { type: "ChatHistorySyncDone" } // local 已被 network 数据覆盖 // 删**除** | { type: "ChatInit" } // **死**代码(chat-machine **没** handler) ``` ### ChatState 增量(`src/stores/chat/chat-state.ts`) ```typescript export interface ChatState { // ... 既有字段 ... /** 游客配额是否加载完成(**仅** guestSession 期间) */ quotaLoaded: boolean; // ← 新增(default false) /** 历史是否加载完成(local → network → save 都跑完) */ historyLoaded: boolean; // ← 新增(default false) } ``` ## [Files] ### 修改 | 路径 | 变更 | |---|---| | `src/stores/chat/chat-events.ts` | 加 4 个新事件;删 `ChatInit` | | `src/stores/chat/chat-state.ts` | 加 `quotaLoaded: boolean` + `historyLoaded: boolean`(default false) | | `src/stores/chat/chat-machine.helpers.ts` | 删 `readInitData`;加 `readGuestQuota()` + `readAndSyncHistory({ sendBack })` | | `src/stores/chat/chat-machine.actors.ts` | 删 `chatInitActor`;加 `loadQuotaActor`(fromPromise)+ `loadHistoryActor`(fromCallback,sendBack 3 事件) | | `src/stores/chat/chat-machine.ts` | 重构 `guestSession.initializing` + `userSession.initializing`;注册新 actors;处理新事件;加 `always` barrier | | `src/stores/chat/chat-context.tsx` | 暴露 `quotaLoaded` / `historyLoaded` 到 context(如果 UI 需要) | ### 不变 - `loadMoreHistoryActor` —— 翻页仍用 fromPromise + 纯 server fetch(**不**改 local-first,**不**在**本**轮 scope) - `sendMessageHttpActor` —— **不**动 - `chatWebSocketActor` —— **不**动(**已**是 fromCallback + cleanup,**已**是独立函数) ### 删除 - `chat-machine.ts` `guestSession.loadingMore` 子**状**态**(**游**客**无**服**务**端** history,**不****支**持**翻**页**) - `chatInitActor`(**被** 2 **个**新 actor **替**换**) - `readInitData`(**被** 2 **个**新 helper **替**换**) - `ChatInit` 事件(**死**代码,**无**消费者**) ## [Functions] ### 新增 | 函数 | 签名 | 文件 | 用途 | |---|---|---|---| | `readGuestQuota()` | `() => Promise<{ remaining: number; total: number }>` | `chat-machine.helpers.ts` | 读**客** ChatStorage 游客**日**配 + 总**配**(**纯** local) | | `readAndSyncHistory({ sendBack })` | `({ sendBack: (e: ChatEvent) => void }) => Promise` | `chat-machine.helpers.ts` | 3 步:读 local → sendBack → 读 network → sendBack → save network 到 local → sendBack | | `loadQuotaActor` | `fromPromise<{ remaining, total }>` | `chat-machine.actors.ts` | 包装 `readGuestQuota` | | `loadHistoryActor` | `fromCallback` | `chat-machine.actors.ts` | 包装 `readAndSyncHistory`(**用** fromCallback **因**为**要** 3 **次** sendBack) | ### 删除 | 函数 | 文件 | 原**因** | |---|---|---| | `chatInitActor` | `chat-machine.actors.ts` | **被** `loadQuotaActor` + `loadHistoryActor` **替**换** | | `readInitData` | `chat-machine.helpers.ts` | **被** 2 **个**新 helper **替**换** | ### 修改 | 函数 | 位置 | 改**动** | |---|---|---| | `chatInitActor` actor **注**册 | `chat-machine.ts` actors | 改**为** `loadQuota` + `loadHistory` | | `guestSession.initializing` | `chat-machine.ts` | 用 `invoke: [loadQuota, loadHistory]` 并行;`always` + guard **进** `ready` | | `userSession.initializing` | `chat-machine.ts` | invoke `loadHistory`(**保**留 parent `chatWebSocket`);`always` + guard **进** `ready` | | `guestSession.loadingMore` | `chat-machine.ts` | **删** | | `guestSession.ready.on.ChatLoadMoreHistory` | `chat-machine.ts` | **删** | ## [Classes] **无** class 变更(store 全部**走** setup + createMachine 函**数**式 API)。 ## [Dependencies] **无**新增依赖。 ## [State Machine 设计] ### guestSession.initializing(游客 init:2 **并**行任务) ```typescript guestSession: { initial: "initializing", states: { initializing: { // "always" barrier —— 两**个**任务**都**完**成**才**进** ready always: [ { target: "ready", guard: ({ context }) => context.quotaLoaded && context.historyLoaded, }, ], invoke: [ // 任务 1:加载配额(fromPromise,**返**结果 + onDone 设 flag) { id: "loadQuota", src: "loadQuota", onDone: { actions: assign(({ event }) => ({ guestRemainingQuota: event.output.remaining, guestTotalQuota: event.output.total, quotaLoaded: true, })), }, onError: { // 失败**也**标 loaded,**不**卡 init(**游**客**可**能 quota 服务**不**可**用**,**让** UI **进** ready) actions: assign({ quotaLoaded: true }), }, }, // 任务 2:拉 history(fromCallback,3 **步** sendBack) { id: "loadHistory", src: "loadHistory", }, ], }, ready: { on: { ChatSendMessage: { /* ... 不变 ... */ }, ChatSendImage: { /* ... 不变 ... */ }, // 删**除** ChatLoadMoreHistory handler(游**客****无**翻**页**) ChatLogout: "#chat.idle", ChatNonGuestLogin: "#chat.userSession", // 新**增**:处理 history actor 派**回**的 3 **个**事件 ChatHistoryLocalLoaded: { actions: assign({ messages: ({ event }) => event.messages }) }, ChatHistoryNetworkLoaded: { actions: assign(({ event }) => ({ messages: event.messages, hasMore: event.hasMore, historyOffset: event.newOffset, historyLoaded: true, })), }, ChatHistorySyncDone: { /* no-op,**只**用**于**日**志** */ }, ChatQuotaLoaded: { actions: assign(({ event }) => ({ guestRemainingQuota: event.remaining, guestTotalQuota: event.total, quotaLoaded: true, })) }, // 既有 ChatAISentenceReceived: { actions: "appendOrUpdateAISentence" }, ChatWebSocketError: { actions: "appendSocketErrorMessage" }, ChatWebSocketConnected: { actions: "setWsConnected" }, ChatQuotaExceeded: { actions: "incrementQuotaExceeded" }, }, }, // 删**除** sending、loadingMore **里**和 ChatLoadMoreHistory **有**关**的**部**分** sending: { /* ... 保持 ... */ }, }, }, ``` **注**:游客 init 中 `loadHistory` 的 3 步事件(`ChatHistoryLocalLoaded` / `ChatHistoryNetworkLoaded` / `ChatHistorySyncDone`)由 `fromCallback` 内部**派**到 machine,而 machine **只**在 `ready` 状态**才**能收到事件(initializing **处**于**入**口,**收**到**的**事件**会**被 XState 排**队**?**或**丢**失**)。**解**决**方**案**: - **方**案 A:`loadHistory` actor **不**用 fromCallback,**改**用 fromPromise,**内**部 sendBack 改**为** await **返**回**单**个**值**,**值**里**包**含 local + network + hasMore + newOffset - **方**案 B:把 `loadHistory` actor **移**到 `ready` 状态 invoke,**在** initializing 阶**段**只 invoke `loadQuota`;ready **触**发**后**再 invoke `loadHistory` - **方**案 C:**用** XState v5 **的** `defer` API,initializing 阶**段** 收到**的**事件**延**后**到 ready 处理 **采**用**方**案 A**(**最**简**)—— fromPromise + 单**一**返**回**值**。**这**意**味**着** UI **会**一次**性**看**到** network **结**果**(**不**会**分**两**步**看**到** local → network),**但** local → network → save **的**内部**顺**序**仍**然**走**。**如**果**用**户**强**调** UI **必**须**分**两**步**看**到**,**改**用**方**案 B(ready **后**再**触**发** history actor)。 ### userSession.initializing(非游客 init:WS 父级 + history 子级) ```typescript userSession: { initial: "initializing", exit: "clearWsConnected", invoke: { // 父级:WS 长**连**接 src: "chatWebSocket", input: ({ event }) => ({ token: event.type === "ChatNonGuestLogin" ? event.token : "", }), }, states: { initializing: { // barrier:WS **连**上** + history **加**载**完**成** always: [ { target: "ready", guard: ({ context }) => context.wsConnected && context.historyLoaded, }, ], invoke: { // 任务 2:拉 history src: "loadHistory", }, }, ready: { on: { ChatSendMessage: { /* ... */ }, ChatSendImage: { /* ... */ }, ChatLoadMoreHistory: { /* 翻**页**(**保**留**) */ }, ChatLogout: "#chat.idle", ChatGuestLogin: "#chat.guestSession", // 新**增**:处理 history actor 派**回**的 3 **个**事件(同 guestSession) ChatHistoryLocalLoaded: { actions: assign({ messages: ({ event }) => event.messages }) }, ChatHistoryNetworkLoaded: { actions: assign({ /* messages + hasMore + historyOffset + historyLoaded */ }) }, ChatHistorySyncDone: { /* no-op */ }, // 既有 ChatAISentenceReceived: { actions: "appendOrUpdateAISentence" }, ChatWebSocketError: { actions: "appendSocketErrorMessage" }, ChatWebSocketConnected: { actions: "setWsConnected" }, ChatQuotaExceeded: { actions: "incrementQuotaExceeded" }, }, }, sending: { /* ... 保持 ... */ }, loadingMore: { /* ... 保持(**保**留** loadMoreHistory) ... */ }, }, }, ``` ## [Actors 设计] ### `loadQuotaActor`(fromPromise) ```typescript // chat-machine.actors.ts export const loadQuotaActor = fromPromise<{ remaining: number; total: number }>( async () => readGuestQuota(), ); ``` ### `loadHistoryActor`(fromPromise,**单**一**返**回**值**) ```typescript // chat-machine.actors.ts /** * 拉 history:**内**部**走** local → network → save network to local * **返**回**最**终**的 network messages + local 被覆盖**后**的**状**态** * * **如**要 UI **两**步**看**到** local → network:**改**用** fromCallback + sendBack * (**本**轮**先**用** fromPromise **简**化**) */ export const loadHistoryActor = fromPromise<{ messages: UiMessage[]; // network **返**回**的**最**终** messages hasMore: boolean; newOffset: number; localOverwritten: boolean; // true = local **已**被 network **覆**盖 }>(async () => { // 1. **读** local const localResult = await chatRepo.getLocalMessages(); const localMessages = (Result.isOk(localResult) && localResult.data) ? localMessagesToUi(localResult.data) : []; console.log("[chat-machine] loadHistoryActor LOCAL DONE", { count: localMessages.length }); // 2. **读** network const networkResult = await chatRepo.getHistory(PAGE_SIZE, 0); if (!Result.isOk(networkResult)) { console.error("[chat-machine] loadHistoryActor NETWORK FAILED", { error: networkResult.success ? null : (networkResult as any).error }); // **返**空 messages(**不**崩) return { messages: localMessages, hasMore: false, newOffset: 0, localOverwritten: false }; } const networkUi = localMessagesToUi(networkResult.data.messages); console.log("[chat-machine] loadHistoryActor NETWORK DONE", { count: networkUi.length }); // 3. **用** network 覆**盖** local("**再**用**网**络**数**据**覆**盖**本**地**数**据") const saveResult = await chatRepo.saveMessagesToLocal(networkResult.data.messages); const localOverwritten = Result.isOk(saveResult); console.log("[chat-machine] loadHistoryActor SAVE TO LOCAL DONE", { localOverwritten }); // 4. **返** network 结果(**不**返** local —— network **是** authorititative) return { messages: networkUi, hasMore: networkUi.length >= PAGE_SIZE, newOffset: networkUi.length, localOverwritten, }; }); ``` ## [Helpers 设计] ### `readGuestQuota` ```typescript // chat-machine.helpers.ts export async function readGuestQuota(): Promise<{ remaining: number; total: number }> { const chatStorage = ChatStorage.getInstance(); const [dailyResult, totalResult] = await Promise.all([ chatStorage.getGuestDailyChatQuota(), chatStorage.getGuestTotalQuota(), ]); return { remaining: mapQuotaResult(dailyResult as Result<{ remaining: number } | null>, 0), total: mapTotalQuotaResult(totalResult as Result, 0), }; } ``` ### `readAndSyncHistory`(**可**选,**如** actors **内**联**也** OK) ```typescript // chat-machine.helpers.ts /** * 拉 history 3 步流(**纯** async,**无** sendBack): * 1. **读** local * 2. **读** network * 3. **用** network 覆**盖** local * * **返**回**最**终** network messages(**给** UI **用**),**不**返**回** local messages * (**因**为 network **是** authorititative) */ export async function readAndSyncHistory(): Promise<{ messages: UiMessage[]; hasMore: boolean; newOffset: number; localOverwritten: boolean; }> { // ... 3 步实**现** ... } ``` ## [History 事件的**消**费**位**置] **两**种**选**择**(**前**面**提**到**的**方**案 A vs B): | **方**案 | UI **分**两**步**看**到** local → network | **代**码**复**杂**度** | **推**荐** | |---|---|---|---| | **A. fromPromise** | **否**(**直**接**看**到** network) | 低 | **本**轮**首**选**(**满**足**用**户**"内**部**走** local → network → save"**的**核**心**要**求**)| | **B. fromCallback** | **是** | 高 | **后**续**可**选** | **采**用**方**案 A**。**用**户**说**的**"展示**到**界**面**上"**可**以**理**解**为**"**最**终**展**示**"**(**即** network **的**结**果**),**不**一**定**要**求** UI **有**中间态**(local)。**如**果**后**续** UI **测**试**反**馈**需**要**中**间**态**,**升**级**到**方**案 B**。 ## [Helpers 删除] `readInitData` **被** `readGuestQuota` + `readAndSyncHistory` **替**换**。**新**增**到** `chat-machine.helpers.ts`,**旧**的** `readInitData` **以**及**其**导**入**(`AuthStorage`, `LocalChatStorage`, `chatRepo.getLocalMessages`)**都**可**移**除**(**改**在** `readAndSyncHistory` **里**面**按**需**导入**)。 ## [Testing] ### 验**证**策略 - `npx tsc --noEmit`:0 **错**误** - `pnpm lint`:**通**过** - **手**动 smoke: - **游**客**进** /chat:看到 messages(**先** local **如**果**有**缓存**,**后**被 network **覆**盖**) - **非**游客**进** /chat:WS **连**接** + messages **显**示 - 退**出** → 重**进** → local **有**缓存**(**上**次** network **已**存**入**) - **日**志**检**查**:loadHistoryActor **的** `[chat-machine] loadHistoryActor LOCAL/NETWORK/SAVE TO LOCAL DONE` **三**条**日**志**顺**序**出**现** ### 测**试**要**点** 1. **独**立**性**:loadQuota **失**败**不**影**响** loadHistory,**反**之**亦**然** 2. **always barrier**:两**个** flag **都** true **才**进** ready** 3. **local overwrite**:loadHistory onDone **后** ChatStorage **里**是** network **的**数**据** 4. **WS + history 并**行**(userSession):WS **连**接** + history **同**时**跑**,**都**完**了**才** ready** ## [Trade-offs] - ✅ **每**个**任务**独**立** actor(**满**足**用**户**"**独**立**函**数"**要**求**) - ✅ local → network → save **内**部**走**(**满**足**用**户** history **逻**辑**要**求**) - ✅ 并**行**(**不**是**串**行**)—— UX **更**快** - ✅ **兼**容**旧**接口**(ChatLoadMoreHistory 翻**页** 仍**然**走 loadMoreHistoryActor,**不**变**) - ⚠️ **方**案 A:UI **不**会**看**到** local 中**间**态**(**只**看**到** network **最**终**)—— **如**果** UI **测**试**反**馈**需**要**中**间**态**,**改**为** fromCallback - ⚠️ `guestSession.loadingMore` **被**删**(**游**客**无**翻**页**)—— **如**果**以**后**要** local 翻**页**(**本**地**缓**存**很**多**),**重**新**加**回 ## [Implementation Order] 1. **chat-events.ts**:加 4 个新事件,删 `ChatInit` 2. **chat-state.ts**:加 `quotaLoaded` + `historyLoaded` 字段(default false) 3. **chat-machine.helpers.ts**:删 `readInitData`;加 `readGuestQuota` + `readAndSyncHistory` 4. **chat-machine.actors.ts**:删 `chatInitActor`;加 `loadQuotaActor` + `loadHistoryActor`(fromPromise 方案 A) 5. **chat-machine.ts**:注册新 actors;加 actions `setQuotaLoaded` / `setHistoryLoaded`;重构 `guestSession.initializing` + `userSession.initializing`(`always` + guard);处理新事件;删 `guestSession.loadingMore` 6. **chat-context.tsx**:暴露新字段(如果 UI 需要) 7. **验证**:`pnpm tsc --noEmit` + `pnpm lint` + dev server smoke