Files
cozsweet-frontend-nextjs/implementation_plan.md
T
kanban e2a57386e7 refactor(auth): funnel OAuth signIn into auth state machine
Move new AuthPlatform(provider).signIn() out of UI components and into
the auth state machine. UI now dispatches AuthGoogle/FacebookLoginSubmitted
events; a new oauthSignIn actor inside the machine calls next-auth/react's
signIn(provider). Errors and the OAuth redirect window are exposed via
the existing state.isLoading / state.errorMessage surface, removing the
local busy / error state in auth-facebook-panel.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-11 10:05:48 +08:00

584 lines
27 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: │
│ │ resolveGoogle │ window.google.accounts│
│ resolveGoogle( │ ◀──────────── │ .id.prompt() │
│ requestId, result)│ │ GIS callback: │
└──────────────────────┘ │ resolve the promise │
└──────────────────────────┘
```
- `WebAuthPlatform` 暴露公开方法 `resolveGoogle(requestId, result)` / `resolveFacebook(...)` 给 Bridge 回写 Promise
- `AuthPlatformBridge` 监听 `cozsweet:auth:google:start` / `cozsweet:auth:facebook:start` 事件,触发对应 SDK 流程
- **v1 限制**:单 popup 语义——一次只追踪一个待处理 Google / Facebook 请求;并发调用会被覆盖
## [Files]
### 新建(9 个文件)
| 路径 | 作用 |
|---|---|
| `src/data/services/auth/auth_platform.ts` | `IAuthPlatform` 接口 + `AuthPlatformFactory` + `getAuthPlatform()` 单例 + 命名类型 `GoogleCredential` / `FacebookLoginResult` |
| `src/data/services/auth/web_auth_platform.ts` | 真机平台类(plain TS),通过 DOM 事件与 Bridge 通信;含 `resolveGoogle()` / `resolveFacebook()` 公开方法 |
| `src/data/services/auth/mock_auth_platform.ts` | 调试用 Mock(返回固定 token + 250ms 模拟延迟) |
| `src/data/services/auth/auth_event_bridge.ts` | 跨 plain-TS / React 边界的 DOM 事件通道(无 React 依赖;导出事件名常量 + dispatch / subscribe 助手) |
| `src/data/services/auth/auth_bridge_component.tsx` | `"use client"` 组件,承载 `useGoogleOAuth` + `FacebookLoginClient`,监听事件并 resolve Promise |
| `src/data/services/auth/initialize_google_auth.ts` | Google GIS 预热辅助(占位) |
| `src/data/services/auth/initialize_facebook_auth.ts` | Facebook SDK 预热辅助(占位) |
| `src/data/schemas/auth/facebook_user_data.ts` | Zod schema + `Input` / `Data` 类型 |
| `src/data/dto/auth/facebook_user_data.ts` | `FacebookUserData` DTO 类(`from` / `fromJson` / `toJson` |
| `src/data/services/auth/global.d.ts` | `window.google.accounts.id.*` 的全局类型声明 |
### 修改
| 路径 | 变更 |
|---|---|
| `package.json` | 新增 `@react-oauth/google@0.13.5``@greatsumini/react-facebook-login@3.4.0` |
| `.env.example` | 新增 `NEXT_PUBLIC_GOOGLE_CLIENT_ID``NEXT_PUBLIC_FACEBOOK_APP_ID``NEXT_PUBLIC_FACEBOOK_API_VERSION=v25.0``NEXT_PUBLIC_USE_MOCK_AUTH=false` |
| `barrelsby.json` | 修整:删除已不存在的 `./src/data/models` 残留;新增 `./src/data/services` + `./src/data/services/auth` |
### 删除
| 路径 | 原因 |
|---|---|
| `src/data/storage/index.ts` | barrelsby 自动生成的 barrel,把同步 `AuthStorage``./auth_storage`)和异步 `AuthStorage``./auth/auth_storage`)同时 re-export,触发 TS2308 类型冲突。该 barrel 在仓库内**无消费者**(所有 import 都走直接路径 `@/data/storage/auth_storage``@/data/storage/auth/auth_storage`),可安全删除;后续若需要 barrel 须先解决命名冲突 |
## [Google Flow 决策(重要)]
`@react-oauth/google@0.13.5``useGoogleLogin(options)` 只支持两种 flow
- `flow: 'implicit'` → 回调 `TokenResponse`,含 `access_token`**不是** id_token
- `flow: 'auth-code'` → 回调 `CodeResponse`,含 `code`(授权码,需后端交换)
**但** 后端 `GoogleLoginRequest.idToken: z.string()` 期望的是 JWT id_token(与 Flutter `GoogleSignInAccount.authentication.idToken` 字段同名同义),所以 `useGoogleLogin` 两种 flow 都不适配。
**采用方案**:直接调用 GIS 全局 API(在 Bridge 的 `useEffect` 中),由 `GoogleOAuthProvider` 加载 GIS 脚本后:
- `window.google.accounts.id.initialize({ client_id, callback })` —— 注册 callback
- `window.google.accounts.id.prompt()` —— 触发弹窗
- callback 收到 `{ credential: '<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,可保留