e2a57386e7
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>
584 lines
27 KiB
Markdown
584 lines
27 KiB
Markdown
# 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. **批次 C:API clients**(4 个文件,独立)
|
||
- `auth_api.ts`(11 个方法)
|
||
- `chat_api.ts`(5 个方法)
|
||
- `user_api.ts`(5 个方法)
|
||
- `metrics_api.ts`(2 个方法)
|
||
6. **批次 D:barrel 导出**(1 个文件)
|
||
- `index.ts`
|
||
7. **最终验证**:
|
||
- `npx tsc --noEmit`
|
||
- `pnpm lint`
|
||
- 端到端 smoke test
|
||
|
||
---
|
||
|
||
# 后续迁移:第三方登录平台(IAuthPlatform)
|
||
|
||
## [Overview]
|
||
|
||
将 Flutter `IAuthPlatform`(`/Users/chase/Documents/cozsweet/lib/data/services/auth/`)迁移为 Next.js 16 项目的纯 TypeScript 服务层。SDK 选用 `@react-oauth/google`(Google Identity Services React 包装器)+ `@greatsumini/react-facebook-login`(`react-facebook` 的活跃 fork,兼容 React 19)。
|
||
|
||
**Flutter 端的 `AuthRepository` 已经完整迁移**(`src/data/repositories/auth_repository.ts`,含 `googleLogin`/`facebookLogin`/`facebookIdLogin`/`appleLogin`),本步只补齐"生产第三方 identity token"那一段——平台层产 token,调用方拿 token 去调 `authRepository`(或 `authApi`)完成登录。
|
||
|
||
**范围限定**:
|
||
- 客户端 SDK 接入层,**不**写 React Context/useAuth,**不**写 UI
|
||
- 跳过 Apple(Flutter 端本来就是 stub)
|
||
- 平台**只产 token**,不调 `authRepository` / `authApi`
|
||
- 保留 Mock(`NEXT_PUBLIC_USE_MOCK_AUTH=true` 切换,对齐 Dart `kDebugMode`)
|
||
|
||
## [Architecture: event-bridge 模式]
|
||
|
||
`IAuthPlatform` 必须是 plain TypeScript(不依赖 React),但 React 包装器 SDK(`useGoogleOAuth`、`FacebookLoginClient`)只能在 React 组件里用。解决方案是 **plain-TS 平台类 ↔ React Bridge 组件通过 DOM `CustomEvent` 通信**:
|
||
|
||
```
|
||
WebAuthPlatform (plain TS) AuthPlatformBridge ("use client")
|
||
┌──────────────────────┐ ┌──────────────────────────┐
|
||
│ googleSignIn() │ │ │
|
||
│ ├─ new Promise │ dispatchEvent │ useGoogleOAuth() │
|
||
│ │ store requestId │ ────────────▶ │ window.google.accounts │
|
||
│ │ → resolver │ │ .id.initialize({...}) │
|
||
│ └─ return Promise │ │ on auth event: │
|
||
│ │ 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` 为 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]
|
||
|
||
```ts
|
||
// auth-events.ts —— OAuth 事件保留无 payload;provider 由事件类型决定
|
||
export type AuthEvent =
|
||
| { type: "AuthPanelModeChanged"; mode: AuthPanelMode }
|
||
| { type: "AuthModeChanged"; mode: AuthMode }
|
||
| { type: "AuthFormCleared" }
|
||
| { type: "AuthReset" }
|
||
| { type: "AuthEmailLoginSubmitted"; email: string; password: string }
|
||
| { type: "AuthEmailRegisterSubmitted"; email: string; password: string; username: string; confirmPassword: string }
|
||
| { type: "AuthGoogleLoginSubmitted" }
|
||
| { type: "AuthFacebookLoginSubmitted" }
|
||
| { type: "AuthAppleLoginSubmitted" };
|
||
```
|
||
|
||
```ts
|
||
// auth-machine.ts —— 新增 actor + 状态
|
||
import { signIn } from "next-auth/react";
|
||
|
||
const oauthSignInActor = fromPromise<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 用 isLoading;error 用 errorMessage
|
||
```
|
||
|
||
**取舍**:
|
||
|
||
- **去掉了"哪个按钮在转"区分**——OAuth 跳转期间 `isLoading` 一直 true,两个按钮都禁用并显示 spinner。这是预期行为(避免重复触发),且和 splash 一致
|
||
- **去掉了本地 error 状态**——错误统一到 `state.errorMessage`,与邮箱登录同源
|
||
|
||
## [Trade-offs]
|
||
|
||
- ✅ 调用方零概念:UI 只 dispatch,不知道有 `next-auth/react`
|
||
- ✅ 错误处理统一:`state.errorMessage` 涵盖邮箱 + OAuth
|
||
- ✅ `isLoading` 统一:UI 不用为 OAuth 单写 busy
|
||
- ⚠️ `signIn` 副作用(重定向)从 React 组件挪到 `fromPromise` actor;XState v5 支持 promise actor 在浏览器环境跑
|
||
- ⚠️ `auth-facebook-panel` 失去"区分两个 spinner"的能力(已知取舍)
|
||
|
||
## [Testing]
|
||
|
||
- `npx tsc --noEmit`:0 错误
|
||
- `pnpm lint`:通过
|
||
- 手动 smoke:点 Facebook / Google 按钮 → 状态机进入 `loadingOAuth` → NextAuth 重定向到 provider
|
||
- 检查:刷新 / 失败场景下 `errorMessage` 正确显示在 `<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,可保留
|