Files
cozsweet-frontend-nextjs/implementation_plan.md
T
admin fa694af723 docs(plan): add chat state machine refactor design
Update implementation_plan.md with a detailed refactor plan that splits
`chatInitActor` into two independent actors (`loadQuotaActor` +
`loadHistoryActor`) and redesigns the history loading flow to follow
local → network → save semantics so the UI sees local data first,
then network data.

Key changes outlined in the plan:
- Add new events: `ChatQuotaLoaded`, `ChatHistoryLocalLoaded`,
  `ChatHistoryNetworkLoaded`, `ChatHistorySyncDone`
- Remove dead-code `ChatInit` event
- Extend `ChatState` with `quotaLoaded` and `historyLoaded` flags
- Use an `always` barrier in `guestSession.initializing` and
  `userSession.initializing` so both tasks must complete before
  transitioning to `ready`
- Guest init runs `loadQuota` + `loadHistory` in parallel; non-guest
  init runs `chatWebSocket` + `loadHistory` as independent tasks

Also adds `implementation_plan` to .gitignore.
2026-06-15 17:05:32 +08:00

977 lines
45 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<T> =
| { 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<void>;
onResponse?: (ctx: { request: Request; response: Response; options: FetchOptions }) => void | Promise<void>;
onResponseError?: (ctx: { request: Request; response: Response; options: FetchOptions; error: FetchError }) => void | Promise<void>;
onError?: (ctx: { request: Request; error: Error; options: FetchOptions }) => void | Promise<void>;
```
## [Files]
### 新增
| 路径 | 用途 |
|---|---|
| `src/data/api/api_path.ts` | 路径常量(40+ endpoints |
| `src/data/api/api_result.ts` | 统一响应包装 `ApiResult<T>` + `ApiError` |
| `src/data/api/config/api_config.ts` | 环境 → baseUrl/超时 配置 |
| `src/data/api/storage/auth_storage.ts` | localStorage 包装(token 持久化) |
| `src/data/api/storage/device_storage.ts` | 设备 ID 持久化 |
| `src/data/api/http_client.ts` | ofetch 实例 + 拦截器装配 |
| `src/data/api/interceptor/token_interceptor.ts` | 注入 `Authorization: Bearer` |
| `src/data/api/interceptor/auth_refresh_interceptor.ts` | 401 → 自动刷新 token |
| `src/data/api/interceptor/logging_interceptor.ts` | 请求/响应日志(开发模式) |
| `src/data/api/auth_api.ts` | 11 个 auth 接口 |
| `src/data/api/chat_api.ts` | 5 个 chat 接口(含 multipart |
| `src/data/api/user_api.ts` | 5 个 user 接口 |
| `src/data/api/metrics_api.ts` | 2 个 metrics 接口 |
| `src/data/api/index.ts` | barrel 导出 |
| `.env.local` | 本地环境变量(不提交) |
| `.env.example` | 环境变量模板(提交) |
### 修改
| 路径 | 修改 |
|---|---|
| `package.json` | 添加 `ofetch` 依赖 |
| `pnpm-lock.yaml` | `pnpm install` 自动更新 |
| `.gitignore` | 确保 `.env.local` 已被忽略(Next.js 默认) |
### 不迁移
-`websocket_handler.dart` / `websocket_manager.dart` — WebSocket 独立项
-`message_queue.dart` — 离线消息队列
-`service_manager.dart` 的 dio 特定部分(已被 ofetch 实例替代)
-`payment_api_client.dart` — 原始 Dart 也不存在客户端类
## [Functions]
### 新增
| 函数 | 签名 | 文件 | 用途 |
|---|---|---|---|
| `getAuthToken()` | `() => Promise<string \| null>` | `storage/auth_storage.ts` | 优先登录 token,回退游客 token |
| `setLoginToken(t)` | `(token: string) => Promise<void>` | 同上 | 持久化登录 token |
| `setGuestToken(t)` | `(token: string) => Promise<void>` | 同上 | 持久化游客 token |
| `clearAuthData()` | `() => Promise<void>` | 同上 | 清除所有 auth 数据 |
| `getDeviceId()` | `() => string` | `storage/device_storage.ts` | 读取/生成设备 ID |
| `getApiConfig()` | `() => ApiConfig` | `config/api_config.ts` | 当前环境配置 |
| `createHttpClient()` | `() => typeof ofetch` | `http_client.ts` | ofetch 实例工厂 |
### 删除/重写
| 函数 | 旧实现 | 新实现 |
|---|---|---|
| `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. **批次 CAPI clients**4 个文件,独立)
- `auth_api.ts`11 个方法)
- `chat_api.ts`5 个方法)
- `user_api.ts`5 个方法)
- `metrics_api.ts`2 个方法)
6. **批次 Dbarrel 导出**1 个文件)
- `index.ts`
7. **最终验证**
- `npx tsc --noEmit`
- `pnpm lint`
- 端到端 smoke test
---
# 后续迁移:第三方登录平台(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
- 跳过 AppleFlutter 端本来就是 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: '<JWT>' }``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 (
<html>
<body>
<GoogleOAuthProvider clientId={process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!}>
<AuthPlatformBridge platform={platform}>
{children}
</AuthPlatformBridge>
</GoogleOAuthProvider>
</body>
</html>
);
}
```
## [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` 为 trueUI 复用现有的 `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<void, AuthProvider>` 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 事件保留无 payloadprovider 由事件类型决定
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<void, "google" | "facebook">(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 | "facebook" | "google">(null);
const [error, setError] = useState<string | null>(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 用 isLoadingerror 用 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` actorXState v5 支持 promise actor 在浏览器环境跑
- ⚠️ `auth-facebook-panel` 失去"区分两个 spinner"的能力(已知取舍)
## [Testing]
- `npx tsc --noEmit`0 错误
- `pnpm lint`:通过
- 手动 smoke:点 Facebook / Google 按钮 → 状态机进入 `loadingOAuth` → NextAuth 重定向到 provider
- 检查:刷新 / 失败场景下 `errorMessage` 正确显示在 `<AuthErrorMessage />`
## [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``<AuthErrorMessage message={error} />` 改为 `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`fromCallbacksendBack 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<void>` | `chat-machine.helpers.ts` | 3 步:读 local → sendBack → 读 network → sendBack → save network 到 local → sendBack |
| `loadQuotaActor` | `fromPromise<{ remaining, total }>` | `chat-machine.actors.ts` | 包装 `readGuestQuota` |
| `loadHistoryActor` | `fromCallback<ChatEvent, void>` | `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(游客 init2 **并**行任务)
```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:拉 historyfromCallback3 **步** 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` APIinitializing 阶**段** 收到**的**事件**延**后**到 ready 处理
**采**用**方**案 A****最**简**)—— fromPromise + 单**一**返**回**值**。**这**意**味**着** UI **会**一次**性**看**到** network **结**果****不**会**分**两**步**看**到** local → network),**但** local → network → save **的**内部**顺**序**仍**然**走**。**如**果**用**户**强**调** UI **必**须**分**两**步**看**到****改**用**方**案 Bready **后**再**触**发** history actor)。
### userSession.initializing(非游客 initWS 父级 + history 子级)
```typescript
userSession: {
initial: "initializing",
exit: "clearWsConnected",
invoke: { // 父级:WS 长**连**接
src: "chatWebSocket",
input: ({ event }) => ({
token: event.type === "ChatNonGuestLogin" ? event.token : "",
}),
},
states: {
initializing: {
// barrierWS **连**上** + 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<number | null>, 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 **覆**盖**
- **非**游客**进** /chatWS **连**接** + 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**不**变**
- ⚠️ **方**案 AUI **不**会**看**到** 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