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

45 KiB
Raw Blame History

Implementation Plan: HTTP API 层迁移

[Overview]

将 Flutter 项目 /lib/core/net//lib/data/services/api/ 下的 HTTP 网络层迁移至 Next.js 16,使用 ofetch 作为 HTTP 客户端(轻量、同构、拦截器钩子丰富),通过 .env.local 配置环境变量,复用已迁移的 Zod 数据模型进行响应验证。

[Types]

ApiResult 统一响应包装

// 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);
}

环境配置类型

// 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
}

拦截器钩子签名

// 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,拦截器以函数形式实现:

// 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

安装

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. 安装 ofetchpnpm 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.tslocalStorage 包装)
    • storage/device_storage.ts(设备 ID
  4. 批次 B:拦截器 + http_client4 个文件)
    • interceptor/token_interceptor.ts
    • interceptor/auth_refresh_interceptor.ts
    • interceptor/logging_interceptor.ts
    • http_client.tsofetch 实例装配)
  5. 批次 CAPI clients4 个文件,独立)
    • auth_api.ts11 个方法)
    • chat_api.ts5 个方法)
    • user_api.ts5 个方法)
    • metrics_api.ts2 个方法)
  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/googleGoogle Identity Services React 包装器)+ @greatsumini/react-facebook-loginreact-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
  • 保留 MockNEXT_PUBLIC_USE_MOCK_AUTH=true 切换,对齐 Dart kDebugMode

[Architecture: event-bridge 模式]

IAuthPlatform 必须是 plain TypeScript(不依赖 React),但 React 包装器 SDKuseGoogleOAuthFacebookLoginClient)只能在 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_IDNEXT_PUBLIC_FACEBOOK_APP_IDNEXT_PUBLIC_FACEBOOK_API_VERSION=v25.0NEXT_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.5useGoogleLogin(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 接口字段。

[调用方契约(参考实现,不在本步交付]

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 接入时按如下方式挂载:

// 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. 生成 barrelpnpm generate-barrels
  7. 新建 global.d.ts:声明 window.google.accounts.id.* 类型
  8. 依次新建 auth_event_bridge.tsinitialize_*.tsauth_platform.tsweb_auth_platform.tsmock_auth_platform.tsauth_bridge_component.tsx
  9. 验证npx tsc --noEmitpnpm lintpnpm build

收口 OAuth 登录调用到 Auth 状态机

[Overview]

new AuthPlatform(provider).signIn() 的直接调用从 UI 组件移到 Auth 状态机里。改完后所有 OAuth 入口都通过 useAuthDispatch() 派发 AuthGoogleLoginSubmitted / AuthFacebookLoginSubmitted 事件,状态机自己用 next-auth/reactsignIn(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(),由 userMachineauthRepository.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> actoridle 中两个事件指向新状态 loadingOAuthloadingOAuth.invoke 调 actoronError 把错误写入 errorMessage 并回到 idleonDone 也回到 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 派发对应事件;disabledisLoading;按钮 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]

// 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" };
// 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
// 推荐方案:事件携带 provider
| { type: "AuthGoogleLoginSubmitted"; provider: "google" }
| { type: "AuthFacebookLoginSubmitted"; provider: "facebook" }
  1. 状态机入口用 actions: assign({ pendingOAuthProvider: ... }) 写入 context,再从 context 读。

采用方案 1——更显式、无需 context 字段。详见 [Implementation Order] 第 3 步。

[AuthState 增量]

不需要新字段。如果采用"从 context 读 provider"才需要 pendingOAuthProvider,但方案 1 不用。

[UI Diff]

splash-button.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

// 旧
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 --noEmit0 错误
  • pnpm lint:通过
  • 手动 smoke:点 Facebook / Google 按钮 → 状态机进入 loadingOAuth → NextAuth 重定向到 provider
  • 检查:刷新 / 失败场景下 errorMessage 正确显示在 <AuthErrorMessage />

[Implementation Order]

  1. auth-events.tsOAuth 事件携带 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;按钮 disabledisLoadingspinner 用 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.tsexport { AuthPlatform, type AuthProvider } 改为仅在 auth_repository / 其他内部消费者使用;如果没有别的 import,可保留

重构 Chat 状态机:init 拆为 2 独立任务 + history local→network→save

[Overview]

chatInitActor(一个聚合了额 + 本地 history 件事的 actor立 actorloadQuotaActor + loadHistoryActor),立函数 / 独立 actor。游客录 init 跑 loadQuota + loadHistory 行任务;游客 init 跑 chatWebSocketparent + loadHistoryinitializing立任务。点:history task 内部走** 读 local → 派 events 到 UI → 读 network → 派 events 到 UI → 写 local 3 步,UI 顺 local → network。

[需求(话)]

游客登录初始化,两个任务:一是加载配额,第二个是拉取历史消息
非游客登录也是两个任务。一是连接 websocket,二是拉取历史消息
每个任务都要独立,都要独立的函数
拉取历史消息的逻辑是:先从本地拉取,展示到界面上,再从网络拉取,展示到界面上,再用网络数据覆盖本地数据

[Types]

ChatEvent 增量(src/stores/chat/chat-events.ts

// 新增
| { 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

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: booleandefault false
src/stores/chat/chat-machine.helpers.ts readInitData;加 readGuestQuota() + readAndSyncHistory({ sendBack })
src/stores/chat/chat-machine.actors.ts chatInitActor;加 loadQuotaActorfromPromise+ loadHistoryActorfromCallbacksendBack 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 行任务)

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 排)。案**

  • 案 AloadHistory actor 用 fromCallback用 fromPromise部 sendBack 改 await 含 local + network + hasMore + newOffset
  • 案 B:把 loadHistory actor ready 状态 invoke initializing 阶只 invoke loadQuotaready 再 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 子级)

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 设计]

loadQuotaActorfromPromise

// chat-machine.actors.ts
export const loadQuotaActor = fromPromise<{ remaining: number; total: number }>(
  async () => readGuestQuota(),
);

loadHistoryActorfromPromise

// 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

// 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

// 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 --noEmit0 误**
  • 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 overwriteloadHistory 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 + loadHistoryActorfromPromise 方案 A
  5. chat-machine.ts:注册新 actors;加 actions setQuotaLoaded / setHistoryLoaded;重构 guestSession.initializing + userSession.initializingalways + guard);处理新事件;删 guestSession.loadingMore
  6. chat-context.tsx:暴露新字段(如果 UI 需要)
  7. 验证pnpm tsc --noEmit + pnpm lint + dev server smoke