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.
45 KiB
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,触发实际请求)
测试要点
- 环境变量加载:
process.env.NEXT_PUBLIC_API_BASE_URL正确解析 - Token 注入:
getCurrentUser自动携带 Authorization 头 - 401 刷新:模拟 401 响应,验证 token 刷新队列与重试
- Multipart 上传:
uploadImage正确发送 FormData - 超时控制:30s/60s 配置生效
[Implementation Order]
按依赖关系,9 个步骤:
- 安装 ofetch:
pnpm add ofetch(修改package.json+pnpm-lock.yaml) - 创建
.env.example+.env.local:环境变量模板 - 批次 A:基础设施(5 个文件)
api_path.ts(路径常量)api_result.ts(响应包装)config/api_config.ts(环境配置)storage/auth_storage.ts(localStorage 包装)storage/device_storage.ts(设备 ID)
- 批次 B:拦截器 + http_client(4 个文件)
interceptor/token_interceptor.tsinterceptor/auth_refresh_interceptor.tsinterceptor/logging_interceptor.tshttp_client.ts(ofetch 实例装配)
- 批次 C:API clients(4 个文件,独立)
auth_api.ts(11 个方法)chat_api.ts(5 个方法)user_api.ts(5 个方法)metrics_api.ts(2 个方法)
- 批次 D:barrel 导出(1 个文件)
index.ts
- 最终验证:
npx tsc --noEmitpnpm 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切换,对齐 DartkDebugMode)
[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 回写 PromiseAuthPlatformBridge监听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 })—— 注册 callbackwindow.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]
- 安装:
pnpm add @react-oauth/google @greatsumini/react-facebook-login - 追加 env 到
.env.example - 修整
barrelsby.json:删除./src/data/models残留、新增./src/data/services与./src/data/services/auth - 删除
src/data/storage/index.ts(命名冲突) - 新建 Zod schema + DTO 类 for
FacebookUserData - 生成 barrel:
pnpm generate-barrels - 新建 global.d.ts:声明
window.google.accounts.id.*类型 - 依次新建
auth_event_bridge.ts、initialize_*.ts、auth_platform.ts、web_auth_platform.ts、mock_auth_platform.ts、auth_bridge_component.tsx - 验证:
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<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]
// 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" };
// 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 需要从事件读取。两种实现:
- 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" }
- 状态机入口用
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 用 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 组件挪到fromPromiseactor;XState v5 支持 promise actor 在浏览器环境跑 - ⚠️
auth-facebook-panel失去"区分两个 spinner"的能力(已知取舍)
[Testing]
npx tsc --noEmit:0 错误pnpm lint:通过- 手动 smoke:点 Facebook / Google 按钮 → 状态机进入
loadingOAuth→ NextAuth 重定向到 provider - 检查:刷新 / 失败场景下
errorMessage正确显示在<AuthErrorMessage />
[Implementation Order]
auth-events.ts:OAuth 事件携带provider: "google" | "facebook"(如果用方案 1)auth-machine.ts:新增oauthSignInActor+loadingOAuth状态 + idle transition 指向它;移除// 社交登录已迁移到 NextAuth + AuthPlatform 类注释splash-button.tsx:替换handleFacebookLogin为 dispatch;移除AuthPlatformimportauth-facebook-panel.tsx:替换两个 handler;移除本地busy/error;按钮disabled绑isLoading;spinner 用isLoading;<AuthErrorMessage message={error} />改为message={errorMessage}(来自useAuthState)auth-context.tsx:检查isLoading表达式是否覆盖loadingOAuth——需要把state.matches("loadingOAuth")加进去- 验证:
npx tsc --noEmit+pnpm lint+ dev server smoke - 可选清理:
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)
// 新增
| { 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: 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.tsguestSession.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(游客 init:2 并行任务)
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:
loadHistoryactor 不用 fromCallback,改用 fromPromise,内部 sendBack 改为 await 返回单个值,值里包含 local + network + hasMore + newOffset - 方案 B:把
loadHistoryactor 移到ready状态 invoke,在 initializing 阶段只 invokeloadQuota;ready 触发后再 invokeloadHistory - 方案 C:用 XState v5 的
deferAPI,initializing 阶段 收到的事件延后**到 ready 处理
采用方案 A**(最简**)—— fromPromise + 单一返回值**。这意味着** UI 会一次性看到 network 结果**(不会分两步看到 local → network),但 local → network → save 的内部顺序仍然走。如果用户强调** UI 必须分两步看到,改用方案 B(ready 后再触发** history actor)。
userSession.initializing(非游客 init:WS 父级 + history 子级)
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)
// chat-machine.actors.ts
export const loadQuotaActor = fromPromise<{ remaining: number; total: number }>(
async () => readGuestQuota(),
);
loadHistoryActor(fromPromise,单一返回值)
// 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 --noEmit:0 错误**pnpm lint:通过**- 手动 smoke:
- 游客进 /chat:看到 messages(先 local 如果有缓存**,后被 network 覆盖**)
- 非游客进 /chat:WS 连接** + messages 显示
- 退出 → 重进 → local 有缓存**(上次** network 已存入)
- 日志检查**:loadHistoryActor 的
[chat-machine] loadHistoryActor LOCAL/NETWORK/SAVE TO LOCAL DONE三条日志顺序出现**
测试要点
- 独立性:loadQuota 失败不影响 loadHistory,反之亦然**
- always barrier:两个 flag 都 true 才进** ready**
- local overwrite:loadHistory onDone 后 ChatStorage 里是** network 的数据
- 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]
- chat-events.ts:加 4 个新事件,删
ChatInit - chat-state.ts:加
quotaLoaded+historyLoaded字段(default false) - chat-machine.helpers.ts:删
readInitData;加readGuestQuota+readAndSyncHistory - chat-machine.actors.ts:删
chatInitActor;加loadQuotaActor+loadHistoryActor(fromPromise 方案 A) - chat-machine.ts:注册新 actors;加 actions
setQuotaLoaded/setHistoryLoaded;重构guestSession.initializing+userSession.initializing(always+ guard);处理新事件;删guestSession.loadingMore - chat-context.tsx:暴露新字段(如果 UI 需要)
- 验证:
pnpm tsc --noEmit+pnpm lint+ dev server smoke