# 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:          │
│                      │               │    window.google.accounts│
│  resolveGoogle(      │ resolveGoogle │      .id.prompt()        │
│    requestId, result)│ ◀──────────── │  GIS callback:           │
└──────────────────────┘               │    resolve the promise   │
                                        └──────────────────────────┘
```

- `WebAuthPlatform` 暴露公开方法 `resolveGoogle(requestId, result)` / `resolveFacebook(...)` 给 Bridge 回写 Promise
- `AuthPlatformBridge` 监听 `cozsweet:auth:google:start` / `cozsweet:auth:facebook:start` 事件，触发对应 SDK 流程
- **v1 限制**：单 popup 语义——一次只追踪一个待处理 Google / Facebook 请求；并发调用会被覆盖

## [Files]

### 新建（9 个文件）

| 路径 | 作用 |
|---|---|
| `src/data/services/auth/auth_platform.ts` | `IAuthPlatform` 接口 + `AuthPlatformFactory` + `getAuthPlatform()` 单例 + 命名类型 `GoogleCredential` / `FacebookLoginResult` |
| `src/data/services/auth/web_auth_platform.ts` | 真机平台类（plain TS），通过 DOM 事件与 Bridge 通信；含 `resolveGoogle()` / `resolveFacebook()` 公开方法 |
| `src/data/services/auth/mock_auth_platform.ts` | 调试用 Mock（返回固定 token + 250ms 模拟延迟） |
| `src/data/services/auth/auth_event_bridge.ts` | 跨 plain-TS / React 边界的 DOM 事件通道（无 React 依赖；导出事件名常量 + dispatch / subscribe 助手） |
| `src/data/services/auth/auth_bridge_component.tsx` | `"use client"` 组件，承载 `useGoogleOAuth` + `FacebookLoginClient`，监听事件并 resolve Promise |
| `src/data/services/auth/initialize_google_auth.ts` | Google GIS 预热辅助（占位） |
| `src/data/services/auth/initialize_facebook_auth.ts` | Facebook SDK 预热辅助（占位） |
| `src/data/schemas/auth/facebook_user_data.ts` | Zod schema + `Input` / `Data` 类型 |
| `src/data/dto/auth/facebook_user_data.ts` | `FacebookUserData` DTO 类（`from` / `fromJson` / `toJson`） |
| `src/data/services/auth/global.d.ts` | `window.google.accounts.id.*` 的全局类型声明 |

### 修改

| 路径 | 变更 |
|---|---|
| `package.json` | 新增 `@react-oauth/google@0.13.5`、`@greatsumini/react-facebook-login@3.4.0` |
| `.env.example` | 新增 `NEXT_PUBLIC_GOOGLE_CLIENT_ID`、`NEXT_PUBLIC_FACEBOOK_APP_ID`、`NEXT_PUBLIC_FACEBOOK_API_VERSION=v25.0`、`NEXT_PUBLIC_USE_MOCK_AUTH=false` |
| `barrelsby.json` | 修整：删除已不存在的 `./src/data/models` 残留；新增 `./src/data/services` + `./src/data/services/auth` |

### 删除

| 路径 | 原因 |
|---|---|
| `src/data/storage/index.ts` | barrelsby 自动生成的 barrel，把同步 `AuthStorage`（`./auth_storage`）和异步 `AuthStorage`（`./auth/auth_storage`）同时 re-export，触发 TS2308 类型冲突。该 barrel 在仓库内**无消费者**（所有 import 都走直接路径 `@/data/storage/auth_storage` 或 `@/data/storage/auth/auth_storage`），可安全删除；后续若需要 barrel 须先解决命名冲突 |

## [Google Flow 决策（重要）]

`@react-oauth/google@0.13.5` 的 `useGoogleLogin(options)` 只支持两种 flow：
- `flow: 'implicit'` → 回调 `TokenResponse`，含 `access_token`（**不是** id_token）
- `flow: 'auth-code'` → 回调 `CodeResponse`，含 `code`（授权码，需后端交换）

**但** 后端 `GoogleLoginRequest.idToken: z.string()` 期望的是 JWT id_token（与 Flutter `GoogleSignInAccount.authentication.idToken` 字段同名同义），所以 `useGoogleLogin` 两种 flow 都不适配。

**采用方案**：直接调用 GIS 全局 API（在 Bridge 的 `useEffect` 中），由 `GoogleOAuthProvider` 加载 GIS 脚本后：
- `window.google.accounts.id.initialize({ client_id, callback })` —— 注册 callback
- `window.google.accounts.id.prompt()` —— 触发弹窗
- callback 收到 `{ credential: '<JWT>' }`，`credential` 即 id_token

JWT payload 通过 `atob(parts[1])` 解析拿到 `email` / `name` / `picture`，填充 `GoogleCredential` 接口字段。

## [调用方契约（参考实现，**不在本步交付**）]

```ts
import { getAuthPlatform } from "@/data/services/auth";
import { authRepository } from "@/data/repositories";
import { AuthStorage } from "@/data/storage/auth/auth_storage";

const platform = getAuthPlatform({
  googleClientId: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!,
  facebookAppId: process.env.NEXT_PUBLIC_FACEBOOK_APP_ID!,
});

async function googleLogin() {
  const r = await platform.googleSignIn();
  if (r.kind === "failure") return handleError(r.error);
  const guestId = (await AuthStorage.getInstance().getDeviceId()).value ?? "";
  await authRepository.googleLogin({ idToken: r.value.idToken, guestId });
  // token 持久化由 authRepository._saveLoginData 自动完成
}

async function facebookLogin() {
  const r = await platform.facebookSignIn();
  if (r.kind === "failure") return handleError(r.error);
  await authRepository.facebookLogin({ accessToken: r.value.accessToken, guestId: "" });
  // 拉头像 + by-id 登录（与 Flutter AuthBloc._uploadFacebookAvatar 一致）
  const ud = await platform.getFacebookUserData();
  if (ud.kind === "success" && ud.value.id && ud.value.pictureUrl) {
    await AuthStorage.getInstance().setFacebookId(ud.value.id);
    await authRepository.facebookIdLogin({ fbId: ud.value.id, avatarUrl: ud.value.pictureUrl });
  }
}
```

## [下一步：UI 挂载方式（留给下一轮迁移）]

本步**不**挂载到 `src/app/layout.tsx`。下一轮 UI 接入时按如下方式挂载：

```tsx
// src/app/layout.tsx
import { GoogleOAuthProvider } from "@react-oauth/google";
import { getAuthPlatform, AuthPlatformBridge } from "@/data/services/auth";

const platform = getAuthPlatform({
  googleClientId: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!,
  facebookAppId: process.env.NEXT_PUBLIC_FACEBOOK_APP_ID!,
});

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <GoogleOAuthProvider clientId={process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!}>
          <AuthPlatformBridge platform={platform}>
            {children}
          </AuthPlatformBridge>
        </GoogleOAuthProvider>
      </body>
    </html>
  );
}
```

## [Implementation Order]

1. **安装**：`pnpm add @react-oauth/google @greatsumini/react-facebook-login`
2. **追加 env** 到 `.env.example`
3. **修整 `barrelsby.json`**：删除 `./src/data/models` 残留、新增 `./src/data/services` 与 `./src/data/services/auth`
4. **删除** `src/data/storage/index.ts`（命名冲突）
5. **新建 Zod schema** + **DTO 类** for `FacebookUserData`
6. **生成 barrel**：`pnpm generate-barrels`
7. **新建 global.d.ts**：声明 `window.google.accounts.id.*` 类型
8. **依次新建** `auth_event_bridge.ts`、`initialize_*.ts`、`auth_platform.ts`、`web_auth_platform.ts`、`mock_auth_platform.ts`、`auth_bridge_component.tsx`
9. **验证**：`npx tsc --noEmit`、`pnpm lint`、`pnpm build`

---

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

## [Overview]

把 `new AuthPlatform(provider).signIn()` 的直接调用从 UI 组件移到 Auth 状态机里。改完后所有 OAuth 入口都通过 `useAuthDispatch()` 派发 `AuthGoogleLoginSubmitted` / `AuthFacebookLoginSubmitted` 事件，状态机自己用 `next-auth/react` 的 `signIn(provider)` 触发流程。NextAuth 重定向期间用一个 `loadingOAuth` 子状态表示"正在跳走"，期间 `isLoading` 为 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，可保留

---

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

## [Overview]

把 `chatInitActor`（一个**怪**聚合了**配**额 + 本地 history **两**件事的 actor）**拆**成**两**个**独**立 actor（`loadQuotaActor` + `loadHistoryActor`），**每**个**都**是**独**立函数 / 独立 actor。游客**登**录 init 跑 `loadQuota + loadHistory` **两**个**并**行任务；**非**游客 init 跑 `chatWebSocket`（parent） + `loadHistory`（initializing）**两**个**独**立任务。**重**点：**history task 内部**走** `读 local → 派 events 到 UI → 读 network → 派 events 到 UI → 写 local` 3 步，UI 顺**序**看**到** local → network。

## [需求（**用**户**原**话）]

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

## [Types]

### ChatEvent 增量（`src/stores/chat/chat-events.ts`）

```typescript
// 新增
| { type: "ChatQuotaLoaded"; remaining: number; total: number }
| { type: "ChatHistoryLocalLoaded"; messages: UiMessage[] }
| { type: "ChatHistoryNetworkLoaded"; messages: UiMessage[]; hasMore: boolean; newOffset: number }
| { type: "ChatHistorySyncDone" }  // local 已被 network 数据覆盖

// 删**除**
| { type: "ChatInit" }  // **死**代码（chat-machine **没** handler）
```

### ChatState 增量（`src/stores/chat/chat-state.ts`）

```typescript
export interface ChatState {
  // ... 既有字段 ...
  /** 游客配额是否加载完成（**仅** guestSession 期间） */
  quotaLoaded: boolean;     // ← 新增（default false）
  /** 历史是否加载完成（local → network → save 都跑完） */
  historyLoaded: boolean;   // ← 新增（default false）
}
```

## [Files]

### 修改

| 路径 | 变更 |
|---|---|
| `src/stores/chat/chat-events.ts` | 加 4 个新事件；删 `ChatInit` |
| `src/stores/chat/chat-state.ts` | 加 `quotaLoaded: boolean` + `historyLoaded: boolean`（default false） |
| `src/stores/chat/chat-machine.helpers.ts` | 删 `readInitData`；加 `readGuestQuota()` + `readAndSyncHistory({ sendBack })` |
| `src/stores/chat/chat-machine.actors.ts` | 删 `chatInitActor`；加 `loadQuotaActor`（fromPromise）+ `loadHistoryActor`（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.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（游客 init：2 **并**行任务）

```typescript
guestSession: {
  initial: "initializing",
  states: {
    initializing: {
      // "always" barrier —— 两**个**任务**都**完**成**才**进** ready
      always: [
        {
          target: "ready",
          guard: ({ context }) => context.quotaLoaded && context.historyLoaded,
        },
      ],
      invoke: [
        // 任务 1：加载配额（fromPromise，**返**结果 + onDone 设 flag）
        {
          id: "loadQuota",
          src: "loadQuota",
          onDone: {
            actions: assign(({ event }) => ({
              guestRemainingQuota: event.output.remaining,
              guestTotalQuota: event.output.total,
              quotaLoaded: true,
            })),
          },
          onError: {
            // 失败**也**标 loaded，**不**卡 init（**游**客**可**能 quota 服务**不**可**用**，**让** UI **进** ready）
            actions: assign({ quotaLoaded: true }),
          },
        },
        // 任务 2：拉 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：`loadHistory` actor **不**用 fromCallback，**改**用 fromPromise，**内**部 sendBack 改**为** await **返**回**单**个**值**，**值**里**包**含 local + network + hasMore + newOffset
- **方**案 B：把 `loadHistory` actor **移**到 `ready` 状态 invoke，**在** initializing 阶**段**只 invoke `loadQuota`；ready **触**发**后**再 invoke `loadHistory`
- **方**案 C：**用** XState v5 **的** `defer` API，initializing 阶**段** 收到**的**事件**延**后**到 ready 处理

**采**用**方**案 A**（**最**简**）—— fromPromise + 单**一**返**回**值**。**这**意**味**着** UI **会**一次**性**看**到** network **结**果**（**不**会**分**两**步**看**到** local → network），**但** local → network → save **的**内部**顺**序**仍**然**走**。**如**果**用**户**强**调** UI **必**须**分**两**步**看**到**，**改**用**方**案 B（ready **后**再**触**发** history actor）。

### userSession.initializing（非游客 init：WS 父级 + history 子级）

```typescript
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）

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

### `loadHistoryActor`（fromPromise，**单**一**返**回**值**）

```typescript
// chat-machine.actors.ts
/**
 * 拉 history：**内**部**走** local → network → save network to local
 * **返**回**最**终**的 network messages + local 被覆盖**后**的**状**态**
 *
 * **如**要 UI **两**步**看**到** local → network：**改**用** fromCallback + sendBack
 * （**本**轮**先**用** fromPromise **简**化**）
 */
export const loadHistoryActor = fromPromise<{
  messages: UiMessage[];       // network **返**回**的**最**终** messages
  hasMore: boolean;
  newOffset: number;
  localOverwritten: boolean;   // true = local **已**被 network **覆**盖
}>(async () => {
  // 1. **读** local
  const localResult = await chatRepo.getLocalMessages();
  const localMessages = (Result.isOk(localResult) && localResult.data)
    ? localMessagesToUi(localResult.data)
    : [];
  console.log("[chat-machine] loadHistoryActor LOCAL DONE", { count: localMessages.length });

  // 2. **读** network
  const networkResult = await chatRepo.getHistory(PAGE_SIZE, 0);
  if (!Result.isOk(networkResult)) {
    console.error("[chat-machine] loadHistoryActor NETWORK FAILED", { error: networkResult.success ? null : (networkResult as any).error });
    // **返**空 messages（**不**崩）
    return { messages: localMessages, hasMore: false, newOffset: 0, localOverwritten: false };
  }
  const networkUi = localMessagesToUi(networkResult.data.messages);
  console.log("[chat-machine] loadHistoryActor NETWORK DONE", { count: networkUi.length });

  // 3. **用** network 覆**盖** local（"**再**用**网**络**数**据**覆**盖**本**地**数**据"）
  const saveResult = await chatRepo.saveMessagesToLocal(networkResult.data.messages);
  const localOverwritten = Result.isOk(saveResult);
  console.log("[chat-machine] loadHistoryActor SAVE TO LOCAL DONE", { localOverwritten });

  // 4. **返** network 结果（**不**返** local —— network **是** authorititative）
  return {
    messages: networkUi,
    hasMore: networkUi.length >= PAGE_SIZE,
    newOffset: networkUi.length,
    localOverwritten,
  };
});
```

## [Helpers 设计]

### `readGuestQuota`

```typescript
// chat-machine.helpers.ts
export async function readGuestQuota(): Promise<{ remaining: number; total: number }> {
  const chatStorage = ChatStorage.getInstance();
  const [dailyResult, totalResult] = await Promise.all([
    chatStorage.getGuestDailyChatQuota(),
    chatStorage.getGuestTotalQuota(),
  ]);
  return {
    remaining: mapQuotaResult(dailyResult as Result<{ remaining: number } | null>, 0),
    total: mapTotalQuotaResult(totalResult as Result<number | null>, 0),
  };
}
```

### `readAndSyncHistory`（**可**选，**如** actors **内**联**也** OK）

```typescript
// chat-machine.helpers.ts
/**
 * 拉 history 3 步流（**纯** async，**无** sendBack）：
 *   1. **读** local
 *   2. **读** network
 *   3. **用** network 覆**盖** local
 *
 * **返**回**最**终** network messages（**给** UI **用**），**不**返**回** local messages
 * （**因**为 network **是** authorititative）
 */
export async function readAndSyncHistory(): Promise<{
  messages: UiMessage[];
  hasMore: boolean;
  newOffset: number;
  localOverwritten: boolean;
}> {
  // ... 3 步实**现** ...
}
```

## [History 事件的**消**费**位**置]

**两**种**选**择**（**前**面**提**到**的**方**案 A vs B）：

| **方**案 | UI **分**两**步**看**到** local → network | **代**码**复**杂**度** | **推**荐** |
|---|---|---|---|
| **A. fromPromise** | **否**（**直**接**看**到** network） | 低 | **本**轮**首**选**（**满**足**用**户**"内**部**走** local → network → save"**的**核**心**要**求**）|
| **B. fromCallback** | **是** | 高 | **后**续**可**选** |

**采**用**方**案 A**。**用**户**说**的**"展示**到**界**面**上"**可**以**理**解**为**"**最**终**展**示**"**（**即** network **的**结**果**），**不**一**定**要**求** UI **有**中间态**（local）。**如**果**后**续** UI **测**试**反**馈**需**要**中**间**态**，**升**级**到**方**案 B**。

## [Helpers 删除]

`readInitData` **被** `readGuestQuota` + `readAndSyncHistory` **替**换**。**新**增**到** `chat-machine.helpers.ts`，**旧**的** `readInitData` **以**及**其**导**入**（`AuthStorage`, `LocalChatStorage`, `chatRepo.getLocalMessages`）**都**可**移**除**（**改**在** `readAndSyncHistory` **里**面**按**需**导入**）。

## [Testing]

### 验**证**策略

- `npx tsc --noEmit`：0 **错**误**
- `pnpm lint`：**通**过**
- **手**动 smoke：
  - **游**客**进** /chat：看到 messages（**先** local **如**果**有**缓存**，**后**被 network **覆**盖**）
  - **非**游客**进** /chat：WS **连**接** + messages **显**示
  - 退**出** → 重**进** → local **有**缓存**（**上**次** network **已**存**入**）
- **日**志**检**查**：loadHistoryActor **的** `[chat-machine] loadHistoryActor LOCAL/NETWORK/SAVE TO LOCAL DONE` **三**条**日**志**顺**序**出**现**

### 测**试**要**点**

1. **独**立**性**：loadQuota **失**败**不**影**响** loadHistory，**反**之**亦**然**
2. **always barrier**：两**个** flag **都** true **才**进** ready**
3. **local overwrite**：loadHistory onDone **后** ChatStorage **里**是** network **的**数**据**
4. **WS + history 并**行**（userSession）：WS **连**接** + history **同**时**跑**，**都**完**了**才** ready**

## [Trade-offs]

- ✅ **每**个**任务**独**立** actor（**满**足**用**户**"**独**立**函**数"**要**求**）
- ✅ local → network → save **内**部**走**（**满**足**用**户** history **逻**辑**要**求**）
- ✅ 并**行**（**不**是**串**行**）—— UX **更**快**
- ✅ **兼**容**旧**接口**（ChatLoadMoreHistory 翻**页** 仍**然**走 loadMoreHistoryActor，**不**变**）
- ⚠️ **方**案 A：UI **不**会**看**到** local 中**间**态**（**只**看**到** network **最**终**）—— **如**果** UI **测**试**反**馈**需**要**中**间**态**，**改**为** fromCallback
- ⚠️ `guestSession.loadingMore` **被**删**（**游**客**无**翻**页**）—— **如**果**以**后**要** local 翻**页**（**本**地**缓**存**很**多**），**重**新**加**回

## [Implementation Order]

1. **chat-events.ts**：加 4 个新事件，删 `ChatInit`
2. **chat-state.ts**：加 `quotaLoaded` + `historyLoaded` 字段（default false）
3. **chat-machine.helpers.ts**：删 `readInitData`；加 `readGuestQuota` + `readAndSyncHistory`
4. **chat-machine.actors.ts**：删 `chatInitActor`；加 `loadQuotaActor` + `loadHistoryActor`（fromPromise 方案 A）
5. **chat-machine.ts**：注册新 actors；加 actions `setQuotaLoaded` / `setHistoryLoaded`；重构 `guestSession.initializing` + `userSession.initializing`（`always` + guard）；处理新事件；删 `guestSession.loadingMore`
6. **chat-context.tsx**：暴露新字段（如果 UI 需要）
7. **验证**：`pnpm tsc --noEmit` + `pnpm lint` + dev server smoke
