From b3629451956915334b1987e74a8c095ed89fd4ac Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 11:02:55 +0800 Subject: [PATCH] feat(auth): migrate Flutter IAuthPlatform to Next.js 16 SDK service layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the third-party social login SDK integration layer (Google + Facebook), mirroring the Flutter `IAuthPlatform` interface from `lib/data/services/auth/`. The platform produces identity tokens only; callers use the existing `authRepository.googleLogin / facebookLogin / facebookIdLogin` (already migrated) to complete the login. Architecture (event-bridge pattern): - `IAuthPlatform` is plain TypeScript (no React dependency) - `WebAuthPlatform` dispatches DOM `CustomEvent`s; `AuthPlatformBridge` ("use client" component) hosts the React SDK hooks and resolves promises - `MockAuthPlatform` returns hardcoded tokens when `NEXT_PUBLIC_USE_MOCK_AUTH=true` (mirrors Dart `kDebugMode` branch) SDK choices: - `@react-oauth/google@0.13.5` for Google - `@greatsumini/react-facebook-login@3.4.0` (maintained fork of archived `react-facebook`, React 19 compatible) for Facebook - For Google: uses raw GIS `google.accounts.id.initialize` + `prompt` API since `@react-oauth/google` does not expose an `id_token` flow variant (existing `GoogleLoginRequest.idToken` field expects JWT id_token) Files added: - src/data/services/auth/{auth_platform,web_auth_platform, mock_auth_platform,auth_event_bridge,auth_bridge_component, initialize_google_auth,initialize_facebook_auth,global.d.ts,index}.ts(x) - src/data/schemas/auth/facebook_user_data.ts (Zod schema) - src/data/dto/auth/facebook_user_data.ts (DTO class) Files modified: - package.json, pnpm-lock.yaml: added 2 deps - .env.example: 4 new NEXT_PUBLIC_* env vars - barrelsby.json: registered src/data/services[/auth] directories - implementation_plan.md: appended third-party auth section - src/data/dto/{auth,chat,metrics,user}/index.ts: regenerated barrels - src/data/schemas/{auth,chat,metrics,user}/index.ts: regenerated barrels Files deleted: - src/data/storage/index.ts: pre-existing TS2308 conflict (sync + async `AuthStorage` both re-exported from auto-generated barrel; no consumer uses the barrel — all imports use direct paths) Scope (per user direction): - Client SDK integration only; no React hook/context, no UI, no Apple - Platform produces tokens; caller drives `authRepository` / `authApi` - v1 single-popup semantics (concurrent popups overwrite) - Bridge component is code-ready but NOT mounted in `src/app/layout.tsx`; next-step UI migration wires the provider chain Verified: `npx tsc --noEmit` (0 errors), `pnpm lint` (0 errors), `pnpm build` (Next.js 16.2.7 + Turbopack compiled successfully). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- barrelsby.json | 18 +- implementation_plan.md | 159 ++++++++++++ package.json | 2 + pnpm-lock.yaml | 26 ++ src/data/dto/auth/facebook_user_data.ts | 35 +++ src/data/dto/auth/index.ts | 18 ++ src/data/dto/chat/index.ts | 14 + src/data/dto/index.ts | 10 +- src/data/dto/metrics/index.ts | 6 + src/data/dto/user/index.ts | 12 + src/data/schemas/auth/facebook_user_data.ts | 17 ++ src/data/schemas/auth/index.ts | 18 ++ src/data/schemas/chat/index.ts | 14 + src/data/schemas/index.ts | 10 +- src/data/schemas/metrics/index.ts | 6 + src/data/schemas/user/index.ts | 12 + .../services/auth/auth_bridge_component.tsx | 242 ++++++++++++++++++ src/data/services/auth/auth_event_bridge.ts | 47 ++++ src/data/services/auth/auth_platform.ts | 91 +++++++ src/data/services/auth/global.d.ts | 50 ++++ src/data/services/auth/index.ts | 12 + .../services/auth/initialize_facebook_auth.ts | 27 ++ .../services/auth/initialize_google_auth.ts | 23 ++ src/data/services/auth/mock_auth_platform.ts | 62 +++++ src/data/services/auth/web_auth_platform.ts | 195 ++++++++++++++ src/data/services/index.ts | 12 + 26 files changed, 1118 insertions(+), 20 deletions(-) create mode 100644 src/data/dto/auth/facebook_user_data.ts create mode 100644 src/data/dto/auth/index.ts create mode 100644 src/data/dto/chat/index.ts create mode 100644 src/data/dto/metrics/index.ts create mode 100644 src/data/dto/user/index.ts create mode 100644 src/data/schemas/auth/facebook_user_data.ts create mode 100644 src/data/schemas/auth/index.ts create mode 100644 src/data/schemas/chat/index.ts create mode 100644 src/data/schemas/metrics/index.ts create mode 100644 src/data/schemas/user/index.ts create mode 100644 src/data/services/auth/auth_bridge_component.tsx create mode 100644 src/data/services/auth/auth_event_bridge.ts create mode 100644 src/data/services/auth/auth_platform.ts create mode 100644 src/data/services/auth/global.d.ts create mode 100644 src/data/services/auth/index.ts create mode 100644 src/data/services/auth/initialize_facebook_auth.ts create mode 100644 src/data/services/auth/initialize_google_auth.ts create mode 100644 src/data/services/auth/mock_auth_platform.ts create mode 100644 src/data/services/auth/web_auth_platform.ts create mode 100644 src/data/services/index.ts diff --git a/barrelsby.json b/barrelsby.json index 64e7a065..5ab555c9 100644 --- a/barrelsby.json +++ b/barrelsby.json @@ -1,17 +1,23 @@ { "directory": [ - "./src/data/models", - "./src/data/models/auth", - "./src/data/models/chat", - "./src/data/models/metrics", - "./src/data/models/user", + "./src/data/dto", + "./src/data/dto/auth", + "./src/data/dto/chat", + "./src/data/dto/metrics", + "./src/data/dto/user", + "./src/data/schemas", + "./src/data/schemas/auth", + "./src/data/schemas/chat", + "./src/data/schemas/metrics", + "./src/data/schemas/user", "./src/data/api", - "./src/data/storage", "./src/data/storage/auth", "./src/data/storage/user", "./src/data/storage/chat", "./src/data/storage/app", "./src/data/repositories", + "./src/data/services", + "./src/data/services/auth", "./src/design" ] } diff --git a/implementation_plan.md b/implementation_plan.md index fb8df9e9..3311ed84 100644 --- a/implementation_plan.md +++ b/implementation_plan.md @@ -229,3 +229,162 @@ pnpm add ofetch - `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: '' }`,`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 ( + + + + + {children} + + + + + ); +} +``` + +## [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` diff --git a/package.json b/package.json index 02e152ef..2c19da93 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ }, "dependencies": { "@fingerprintjs/fingerprintjs": "^5.2.0", + "@greatsumini/react-facebook-login": "^3.4.0", + "@react-oauth/google": "^0.13.5", "dexie": "^4.4.3", "next": "16.2.7", "ofetch": "^1.5.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 27a145f5..04e4c39d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,12 @@ importers: '@fingerprintjs/fingerprintjs': specifier: ^5.2.0 version: 5.2.0 + '@greatsumini/react-facebook-login': + specifier: ^3.4.0 + version: 3.4.0(react@19.2.4) + '@react-oauth/google': + specifier: ^0.13.5 + version: 0.13.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) dexie: specifier: ^4.4.3 version: 4.4.3 @@ -181,6 +187,11 @@ packages: '@fingerprintjs/fingerprintjs@5.2.0': resolution: {integrity: sha512-j+2nInkwCQNTJcNhOjvkGM/nLRTuGJTC6xai4quqvUpjob2ssrGwBZjS7k55nOmKvge7qvJT2nS3i/IRvQSTQA==} + '@greatsumini/react-facebook-login@3.4.0': + resolution: {integrity: sha512-NJYqTOcDghZ6zLppC+LJVLep7Xi3gfx9ssQFRd7EhDTU07lC15SG/eZ3ZyvUPsuPBkbhVl6E/Py0w4M5m7Lh9Q==} + peerDependencies: + react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^20.0.0 || ^21.0.0 + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -450,6 +461,12 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@react-oauth/google@0.13.5': + resolution: {integrity: sha512-xQWri2s/3nNekZJ4uuov2aAfQYu83bN3864KcFqw2pK1nNbFurQIjPFDXhWaKH3IjYJ2r/9yyIIpsn5lMqrheQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -2317,6 +2334,10 @@ snapshots: '@fingerprintjs/fingerprintjs@5.2.0': {} + '@greatsumini/react-facebook-login@3.4.0(react@19.2.4)': + dependencies: + react: 19.2.4 + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -2500,6 +2521,11 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@react-oauth/google@0.13.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + '@rtsao/scc@1.1.0': {} '@swc/helpers@0.5.15': diff --git a/src/data/dto/auth/facebook_user_data.ts b/src/data/dto/auth/facebook_user_data.ts new file mode 100644 index 00000000..29361bc9 --- /dev/null +++ b/src/data/dto/auth/facebook_user_data.ts @@ -0,0 +1,35 @@ +/** + * Facebook 用户数据 DTO + * + * 原始 Dart: FacebookUserData (lib/data/services/auth/auth_platform.dart) + */ +import { + FacebookUserDataSchema, + type FacebookUserDataInput, + type FacebookUserDataData, +} from "@/data/schemas/auth/facebook_user_data"; + +export class FacebookUserData { + declare readonly id: string | null; + declare readonly name: string | null; + declare readonly email: string | null; + declare readonly pictureUrl: string | null; + + private constructor(input: FacebookUserDataInput) { + const data = FacebookUserDataSchema.parse(input); + Object.assign(this, data); + Object.freeze(this); + } + + static from(input: FacebookUserDataInput): FacebookUserData { + return new FacebookUserData(input); + } + + static fromJson(json: unknown): FacebookUserData { + return FacebookUserData.from(json as FacebookUserDataInput); + } + + toJson(): FacebookUserDataData { + return FacebookUserDataSchema.parse(this); + } +} diff --git a/src/data/dto/auth/index.ts b/src/data/dto/auth/index.ts new file mode 100644 index 00000000..5fc5ae40 --- /dev/null +++ b/src/data/dto/auth/index.ts @@ -0,0 +1,18 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from "./apple_login_request"; +export * from "./facebook_login_request"; +export * from "./facebook_user_data"; +export * from "./fb_id_login_request"; +export * from "./google_login_request"; +export * from "./guest_login_request"; +export * from "./guest_login_response"; +export * from "./login_request"; +export * from "./login_response"; +export * from "./logout_response"; +export * from "./refresh_token_request"; +export * from "./refresh_token_response"; +export * from "./register_request"; +export * from "./send_code_request"; diff --git a/src/data/dto/chat/index.ts b/src/data/dto/chat/index.ts new file mode 100644 index 00000000..b11282a1 --- /dev/null +++ b/src/data/dto/chat/index.ts @@ -0,0 +1,14 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from "./chat_history_response"; +export * from "./chat_message"; +export * from "./chat_send_response"; +export * from "./chat_sync_data"; +export * from "./chat_sync_request"; +export * from "./guest_chat_quota"; +export * from "./image_upload_response"; +export * from "./send_message_request"; +export * from "./stt_data"; +export * from "./sync_message"; diff --git a/src/data/dto/index.ts b/src/data/dto/index.ts index bf51c511..21abc149 100644 --- a/src/data/dto/index.ts +++ b/src/data/dto/index.ts @@ -1,11 +1,10 @@ /** - * DTO - 集中导出 - * - * 不可变运行时类(API 层使用) - * 原始 Dart: lib/data/models/ + * @file Automatically generated by barrelsby. */ + export * from "./auth/apple_login_request"; export * from "./auth/facebook_login_request"; +export * from "./auth/facebook_user_data"; export * from "./auth/fb_id_login_request"; export * from "./auth/google_login_request"; export * from "./auth/guest_login_request"; @@ -17,7 +16,6 @@ export * from "./auth/refresh_token_request"; export * from "./auth/refresh_token_response"; export * from "./auth/register_request"; export * from "./auth/send_code_request"; - export * from "./chat/chat_history_response"; export * from "./chat/chat_message"; export * from "./chat/chat_send_response"; @@ -28,10 +26,8 @@ export * from "./chat/image_upload_response"; export * from "./chat/send_message_request"; export * from "./chat/stt_data"; export * from "./chat/sync_message"; - export * from "./metrics/app_event"; export * from "./metrics/pwa_event"; - export * from "./user/avatar_data"; export * from "./user/credits_data"; export * from "./user/credits_history_data"; diff --git a/src/data/dto/metrics/index.ts b/src/data/dto/metrics/index.ts new file mode 100644 index 00000000..5213df40 --- /dev/null +++ b/src/data/dto/metrics/index.ts @@ -0,0 +1,6 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from "./app_event"; +export * from "./pwa_event"; diff --git a/src/data/dto/user/index.ts b/src/data/dto/user/index.ts new file mode 100644 index 00000000..287d1254 --- /dev/null +++ b/src/data/dto/user/index.ts @@ -0,0 +1,12 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from "./avatar_data"; +export * from "./credits_data"; +export * from "./credits_history_data"; +export * from "./personality_traits"; +export * from "./recent_memory"; +export * from "./update_profile_request"; +export * from "./user"; +export * from "./user_stats_response"; diff --git a/src/data/schemas/auth/facebook_user_data.ts b/src/data/schemas/auth/facebook_user_data.ts new file mode 100644 index 00000000..0389221b --- /dev/null +++ b/src/data/schemas/auth/facebook_user_data.ts @@ -0,0 +1,17 @@ +/** + * Facebook 用户数据 schema + * 原始 Dart: FacebookUserData (lib/data/services/auth/auth_platform.dart) + * + * 字段对齐 Dart 端 `String?` 可空语义:缺字段时存 `null`。 + */ +import { z } from "zod"; + +export const FacebookUserDataSchema = z.object({ + id: z.string().nullable().default(null), + name: z.string().nullable().default(null), + email: z.string().nullable().default(null), + pictureUrl: z.string().nullable().default(null), +}); + +export type FacebookUserDataInput = z.input; +export type FacebookUserDataData = z.output; diff --git a/src/data/schemas/auth/index.ts b/src/data/schemas/auth/index.ts new file mode 100644 index 00000000..5fc5ae40 --- /dev/null +++ b/src/data/schemas/auth/index.ts @@ -0,0 +1,18 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from "./apple_login_request"; +export * from "./facebook_login_request"; +export * from "./facebook_user_data"; +export * from "./fb_id_login_request"; +export * from "./google_login_request"; +export * from "./guest_login_request"; +export * from "./guest_login_response"; +export * from "./login_request"; +export * from "./login_response"; +export * from "./logout_response"; +export * from "./refresh_token_request"; +export * from "./refresh_token_response"; +export * from "./register_request"; +export * from "./send_code_request"; diff --git a/src/data/schemas/chat/index.ts b/src/data/schemas/chat/index.ts new file mode 100644 index 00000000..b11282a1 --- /dev/null +++ b/src/data/schemas/chat/index.ts @@ -0,0 +1,14 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from "./chat_history_response"; +export * from "./chat_message"; +export * from "./chat_send_response"; +export * from "./chat_sync_data"; +export * from "./chat_sync_request"; +export * from "./guest_chat_quota"; +export * from "./image_upload_response"; +export * from "./send_message_request"; +export * from "./stt_data"; +export * from "./sync_message"; diff --git a/src/data/schemas/index.ts b/src/data/schemas/index.ts index 3a07cd5d..21abc149 100644 --- a/src/data/schemas/index.ts +++ b/src/data/schemas/index.ts @@ -1,11 +1,10 @@ /** - * Schemas - 集中导出 - * - * Zod schema + 类型(Source of Truth) - * 原始 Dart: lib/data/models/ + * @file Automatically generated by barrelsby. */ + export * from "./auth/apple_login_request"; export * from "./auth/facebook_login_request"; +export * from "./auth/facebook_user_data"; export * from "./auth/fb_id_login_request"; export * from "./auth/google_login_request"; export * from "./auth/guest_login_request"; @@ -17,7 +16,6 @@ export * from "./auth/refresh_token_request"; export * from "./auth/refresh_token_response"; export * from "./auth/register_request"; export * from "./auth/send_code_request"; - export * from "./chat/chat_history_response"; export * from "./chat/chat_message"; export * from "./chat/chat_send_response"; @@ -28,10 +26,8 @@ export * from "./chat/image_upload_response"; export * from "./chat/send_message_request"; export * from "./chat/stt_data"; export * from "./chat/sync_message"; - export * from "./metrics/app_event"; export * from "./metrics/pwa_event"; - export * from "./user/avatar_data"; export * from "./user/credits_data"; export * from "./user/credits_history_data"; diff --git a/src/data/schemas/metrics/index.ts b/src/data/schemas/metrics/index.ts new file mode 100644 index 00000000..5213df40 --- /dev/null +++ b/src/data/schemas/metrics/index.ts @@ -0,0 +1,6 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from "./app_event"; +export * from "./pwa_event"; diff --git a/src/data/schemas/user/index.ts b/src/data/schemas/user/index.ts new file mode 100644 index 00000000..287d1254 --- /dev/null +++ b/src/data/schemas/user/index.ts @@ -0,0 +1,12 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from "./avatar_data"; +export * from "./credits_data"; +export * from "./credits_history_data"; +export * from "./personality_traits"; +export * from "./recent_memory"; +export * from "./update_profile_request"; +export * from "./user"; +export * from "./user_stats_response"; diff --git a/src/data/services/auth/auth_bridge_component.tsx b/src/data/services/auth/auth_bridge_component.tsx new file mode 100644 index 00000000..925ea901 --- /dev/null +++ b/src/data/services/auth/auth_bridge_component.tsx @@ -0,0 +1,242 @@ +"use client"; + +/** + * 第三方登录 Bridge 组件。 + * + * 原始 Dart: lib/utils/google_auth_initializer.dart + lib/utils/facebook_auth_initializer.dart + * + * 设计: + * - 不渲染任何 UI(`children` 透传)。 + * - 通过 `@react-oauth/google` 的 `GoogleOAuthProvider` 加载的 GIS 脚本, + * 调用 `window.google.accounts.id.initialize` + `prompt` 实现 Google 登录弹窗; + * callback 收到 id_token 后解析 JWT payload 拿到 email/displayName/photoUrl。 + * - 通过 `@greatsumini/react-facebook-login` 的 `FacebookLoginClient.init` + + * `FacebookLoginClient.login` 实现 Facebook 登录弹窗。 + * - 监听 `auth_event_bridge` 派发的 `cozsweet:auth:*:start` 事件,触发对应 + * SDK 流程;拿到凭证后回调 `platform.resolveGoogle()` / `platform.resolveFacebook()`。 + * - 单 popup 语义:v1 一次只追踪一个待处理 Google / Facebook 请求,多个并发 + * 调用会被覆盖。后续如需并发支持,需把 ref 改为 `Map`。 + * + * 部署:本步骤**不**挂载到 `src/app/layout.tsx`。下一轮 UI 接入时按如下方式挂载: + * + * import { GoogleOAuthProvider } from "@react-oauth/google"; + * import { AuthPlatformBridge } from "@/data/services/auth"; + * + * + * + * {children} + * + * + */ +import { useEffect, useRef } from "react"; +import { useGoogleOAuth } from "@react-oauth/google"; +import { FacebookLoginClient, LoginStatus } from "@greatsumini/react-facebook-login"; +import { Result, toError, type Result as ResultT } from "@/utils/result"; +import type { + IAuthPlatform, + IAuthPlatformOptions, + GoogleCredential, + FacebookLoginResult, +} from "./auth_platform"; +import { AUTH_EVENTS, onAuthEvent } from "./auth_event_bridge"; + +/** + * Bridge 要求 platform 是带 `opts` + `resolveGoogle` + `resolveFacebook` 的 + * WebAuthPlatform。结构化类型便于 mock / 子类扩展。 + */ +export interface AuthPlatformBridgeProps { + platform: IAuthPlatform & { + readonly opts: IAuthPlatformOptions; + resolveGoogle( + requestId: string, + result: ResultT, + ): void; + resolveFacebook( + requestId: string, + result: ResultT, + ): void; + }; + children?: React.ReactNode; +} + +export function AuthPlatformBridge({ + platform, + children, +}: AuthPlatformBridgeProps) { + const platformRef = useRef(platform); + // 在 effect 中更新 ref(react-hooks/refs 规则禁止 render 期更新 ref.current)。 + // 这样后续 effect / callback 闭包总能拿到最新的 platform 引用。 + useEffect(() => { + platformRef.current = platform; + }); + + // ======== Google GIS ======== + + const { scriptLoadedSuccessfully } = useGoogleOAuth(); + const pendingGoogleRef = useRef<{ requestId: string } | null>(null); + + // 初始化 GIS callback(一次性;GIS 全局只允许一个 id initialize 回调) + useEffect(() => { + if (!scriptLoadedSuccessfully) return; + if (typeof window === "undefined") return; + const gis = window.google?.accounts?.id; + if (!gis) return; + + gis.initialize({ + client_id: platformRef.current.opts.googleClientId, + callback: (response) => { + const pending = pendingGoogleRef.current; + pendingGoogleRef.current = null; + if (!pending) return; + if (response.credential) { + const decoded = decodeJwtPayload(response.credential); + platformRef.current.resolveGoogle( + pending.requestId, + Result.ok({ + idToken: response.credential, + email: decoded?.email ?? null, + displayName: decoded?.name ?? null, + photoUrl: decoded?.picture ?? null, + }), + ); + // 全局成功回调 + platformRef.current.opts.onGoogleSignInSuccess?.( + response.credential, + decoded?.email ?? null, + ); + } else { + platformRef.current.resolveGoogle( + pending.requestId, + Result.err( + new Error("Google sign-in failed: no credential"), + ), + ); + } + }, + }); + }, [scriptLoadedSuccessfully]); + + // 监听 googleStart 事件 → 触发 prompt + useEffect(() => { + return onAuthEvent(AUTH_EVENTS.googleStart, (detail) => { + pendingGoogleRef.current = { requestId: detail.requestId }; + const gis = + typeof window !== "undefined" ? window.google?.accounts?.id : undefined; + if (gis) { + gis.prompt(); + } else { + // GIS 未加载完成:立即 reject + pendingGoogleRef.current = null; + platformRef.current.resolveGoogle( + detail.requestId, + Result.err( + new Error("Google Identity Services script not loaded"), + ), + ); + } + }); + }, []); + + // ======== Facebook SDK ======== + + const pendingFacebookRef = useRef<{ requestId: string } | null>(null); + + // 初始化 Facebook SDK(一次性) + useEffect(() => { + if (typeof window === "undefined") return; + try { + // 版本联合类型截止 v21.0,但底层 FB JS SDK 支持 v25.0;用类型断言绕过。 + FacebookLoginClient.init({ + appId: platformRef.current.opts.facebookAppId, + version: (platformRef.current.opts.facebookApiVersion ?? + "v25.0") as "v21.0", + cookie: true, + xfbml: true, + }); + } catch (e) { + console.warn("[auth] FacebookLoginClient.init failed", e); + } + }, []); + + // 监听 facebookStart 事件 → 触发 login + useEffect(() => { + return onAuthEvent(AUTH_EVENTS.facebookStart, (detail) => { + pendingFacebookRef.current = { requestId: detail.requestId }; + try { + FacebookLoginClient.login( + (response) => { + const pending = pendingFacebookRef.current; + pendingFacebookRef.current = null; + if (!pending) return; + if ( + response.status === LoginStatus.Connected && + response.authResponse + ) { + const expiresIn = Number(response.authResponse.expiresIn); + const expiresAt = Number.isFinite(expiresIn) + ? Date.now() + expiresIn * 1000 + : null; + platformRef.current.resolveFacebook( + pending.requestId, + Result.ok({ + status: "success", + accessToken: response.authResponse.accessToken, + userId: response.authResponse.userID, + expiresAt, + }), + ); + } else { + // NotAuthorized / Unknown / loginCancelled / facebookNotLoaded + platformRef.current.resolveFacebook( + pending.requestId, + Result.ok({ + status: "cancelled", + accessToken: "", + userId: "", + expiresAt: null, + }), + ); + } + }, + { scope: "public_profile,email" }, + ); + } catch (e) { + const pending = pendingFacebookRef.current; + pendingFacebookRef.current = null; + if (pending) { + platformRef.current.resolveFacebook( + pending.requestId, + Result.err(toError(e)), + ); + } + } + }); + }, []); + + return <>{children}; +} + +// ======== Helpers ======== + +/** + * 解码 JWT payload 段。不做签名校验——签名由后端校验。 + * 返回 null 表示解码失败。 + */ +function decodeJwtPayload( + idToken: string, +): { email?: string; name?: string; picture?: string } | null { + try { + const parts = idToken.split("."); + if (parts.length !== 3) return null; + const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const padded = payload + "=".repeat((4 - (payload.length % 4)) % 4); + const decoded = atob(padded); + return JSON.parse(decoded) as { + email?: string; + name?: string; + picture?: string; + }; + } catch { + return null; + } +} diff --git a/src/data/services/auth/auth_event_bridge.ts b/src/data/services/auth/auth_event_bridge.ts new file mode 100644 index 00000000..7757c92b --- /dev/null +++ b/src/data/services/auth/auth_event_bridge.ts @@ -0,0 +1,47 @@ +"use client"; + +/** + * 跨 plain-TS / React 边界的 DOM 事件通道。 + * + * 用途: + * - `WebAuthPlatform`(plain TS class,挂在 `getAuthPlatform()` 单例上)dispatch + * `cozsweet:auth:*:start` 事件,并等待 `AuthPlatformBridge`("use client" 组件) + * 调回 `platform.resolveGoogle()` / `platform.resolveFacebook()` 完成 Promise。 + * - 这样既满足"接口保持 plain TS、可以被非 React 上下文消费"的需求,又让 + * `@react-oauth/google` 的 `GoogleOAuthProvider`(脚本加载)和 + * `window.google.accounts.id.*` GIS API 都在 React 侧执行。 + * + * 本文件不依赖 React,可以被 `WebAuthPlatform`(无 React 引用)安全 import。 + */ +export const AUTH_EVENTS = { + googleStart: "cozsweet:auth:google:start", + facebookStart: "cozsweet:auth:facebook:start", +} as const; + +export type AuthEventName = (typeof AUTH_EVENTS)[keyof typeof AUTH_EVENTS]; + +/** Payload attached to *:start events. */ +export interface AuthStartDetail { + readonly requestId: string; +} + +export function dispatchAuthEvent( + name: AuthEventName, + detail: AuthStartDetail, +): void { + if (typeof window === "undefined") return; + window.dispatchEvent(new CustomEvent(name, { detail })); +} + +export function onAuthEvent( + name: AuthEventName, + handler: (detail: AuthStartDetail) => void, +): () => void { + if (typeof window === "undefined") return () => {}; + const listener = (e: Event) => { + const detail = (e as CustomEvent).detail; + handler(detail); + }; + window.addEventListener(name, listener); + return () => window.removeEventListener(name, listener); +} diff --git a/src/data/services/auth/auth_platform.ts b/src/data/services/auth/auth_platform.ts new file mode 100644 index 00000000..546d53dc --- /dev/null +++ b/src/data/services/auth/auth_platform.ts @@ -0,0 +1,91 @@ +/** + * 第三方登录平台抽象接口 + 工厂 + 单例 + * + * 原始 Dart: lib/data/services/auth/auth_platform.dart + * + * 设计: + * - `IAuthPlatform` 是 plain TypeScript 接口(不依赖 React)。 + * - 真机实现 `WebAuthPlatform` 通过 DOM 事件与 `AuthPlatformBridge`("use client" + * 组件)通信,由 Bridge 持有 React SDK 钩子。 + * - Mock 实现 `MockAuthPlatform` 跳过 SDK,直接返回固定 token。 + * - 工厂根据 `NEXT_PUBLIC_USE_MOCK_AUTH` 环境变量二选一(对齐 Dart `kDebugMode`)。 + * + * 调用方契约: + * - 平台**只产 token**,不调 `authRepository` / `authApi`。 + * - 拿到 `Result` / `Result` 后, + * 由调用方自行 `authRepository.googleLogin({ idToken })` 完成登录。 + */ +import { Result, type Result as ResultT } from "@/utils/result"; +import type { FacebookUserData } from "@/data/dto/auth/facebook_user_data"; +import { WebAuthPlatform } from "./web_auth_platform"; +import { MockAuthPlatform } from "./mock_auth_platform"; + +/** 归一化的 Google 凭证(id_token + 解析自 JWT payload 的展示字段)。 */ +export interface GoogleCredential { + readonly idToken: string; + readonly email: string | null; + readonly displayName: string | null; + readonly photoUrl: string | null; +} + +/** 归一化的 Facebook 登录结果。 */ +export interface FacebookLoginResult { + readonly status: "success" | "cancelled" | "error"; + readonly accessToken: string; + readonly userId: string; + /** epoch ms;null 表示后端未返回 expiresIn。 */ + readonly expiresAt: number | null; +} + +export interface IAuthPlatform { + /** Google 登录。Web 平台通过 GIS 弹窗流程。 */ + googleSignIn(): Promise>; + /** 是否支持 Google "authenticate" 流程。Web 平台恒为 false。 */ + supportsGoogleAuthenticate(): boolean; + /** Facebook 登录。 */ + facebookSignIn(): Promise>; + /** 拉取 Facebook 用户数据(id / name / email / pictureUrl)。 */ + getFacebookUserData(): Promise>; +} + +export interface IAuthPlatformOptions { + readonly googleClientId: string; + readonly facebookAppId: string; + /** Facebook Graph API 版本,默认 "v25.0"(对齐 Flutter 端)。 */ + readonly facebookApiVersion?: string; + /** 全局 Google 登录成功回调(对齐 Dart `onGoogleSignInSuccess`)。 */ + readonly onGoogleSignInSuccess?: ( + idToken: string, + email: string | null, + ) => void; +} + +export class AuthPlatformFactory { + private constructor() {} + + /** + * 根据 `NEXT_PUBLIC_USE_MOCK_AUTH` 二选一返回平台实现。 + * 对齐 Dart `AuthPlatformFactory.create()` 的 `kDebugMode` 分支。 + */ + static create(opts: IAuthPlatformOptions): IAuthPlatform { + if (process.env.NEXT_PUBLIC_USE_MOCK_AUTH === "true") { + return new MockAuthPlatform(); + } + return new WebAuthPlatform(opts); + } +} + +/** 顶层单例(懒加载)。 */ +let _instance: IAuthPlatform | null = null; +export function getAuthPlatform(opts: IAuthPlatformOptions): IAuthPlatform { + if (!_instance) _instance = AuthPlatformFactory.create(opts); + return _instance; +} + +/** @internal 测试用:重置单例。 */ +export function _resetAuthPlatformForTests(): void { + _instance = null; +} + +// re-export 让 barrelsby 抓得到 +export { Result }; diff --git a/src/data/services/auth/global.d.ts b/src/data/services/auth/global.d.ts new file mode 100644 index 00000000..0b6a17f9 --- /dev/null +++ b/src/data/services/auth/global.d.ts @@ -0,0 +1,50 @@ +/** + * 全局类型声明:Google Identity Services (`gis`) 添加的 `window.google` 全局对象。 + * + * 由 `` / `useGoogleOAuth()` 注入的 GIS 脚本会向 `window` + * 挂载 `google.accounts.id` / `google.accounts.oauth2` 等命名空间。本文件让 + * TypeScript 知道这些属性的存在,避免在 Bridge 组件中用 `as unknown as ...` + * 强转。 + * + * 仅声明我们用到的子集;未列出的 GIS 属性走 `unknown`。 + */ +export {}; + +declare global { + interface Window { + google?: { + accounts?: { + id?: { + initialize(config: { + client_id: string; + callback: (response: { credential?: string }) => void; + auto_select?: boolean; + cancel_on_tap_outside?: boolean; + itp_support?: boolean; + login_hint?: string; + hosted_domain?: string; + use_fedcm_for_prompt?: boolean; + nonce?: string; + state_cookie_domain?: string; + prompt_parent_id?: string; + }): void; + prompt(): void; + renderButton( + parent: HTMLElement, + options: { + type?: "standard" | "icon"; + theme?: "outline" | "filled_blue" | "filled_black"; + size?: "large" | "medium" | "small"; + text?: "signin_with" | "signup_with" | "continue_with" | "signin"; + shape?: "rectangular" | "pill" | "circle" | "square"; + logo_alignment?: "left" | "center"; + width?: string | number; + }, + ): void; + cancel(): void; + }; + oauth2?: unknown; + }; + }; + } +} diff --git a/src/data/services/auth/index.ts b/src/data/services/auth/index.ts new file mode 100644 index 00000000..0e13acc7 --- /dev/null +++ b/src/data/services/auth/index.ts @@ -0,0 +1,12 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from "./auth_bridge_component"; +export * from "./auth_event_bridge"; +export * from "./auth_platform"; +export * from "./global"; +export * from "./initialize_facebook_auth"; +export * from "./initialize_google_auth"; +export * from "./mock_auth_platform"; +export * from "./web_auth_platform"; diff --git a/src/data/services/auth/initialize_facebook_auth.ts b/src/data/services/auth/initialize_facebook_auth.ts new file mode 100644 index 00000000..fcef3777 --- /dev/null +++ b/src/data/services/auth/initialize_facebook_auth.ts @@ -0,0 +1,27 @@ +"use client"; + +/** + * Facebook SDK 预热辅助。 + * + * 原始 Dart: `lib/utils/facebook_auth_initializer.dart` 的 `initializeFacebookAuth()` + * + * 行为: + * - 对齐 Dart `FacebookAuth.i.webAndDesktopInitialize(appId, cookie: true, + * xfbml: true, version: 'v25.0')` 的参数语义。 + * - 真实 SDK 初始化(注入 `
` + 调用 `FB.init`)由 + * `@greatsumini/react-facebook-login` 的 `FacebookLoginClient.init()` 完成, + * 见 `auth_bridge_component.tsx`。本函数仅做存在性校验 + console.warn。 + */ +export function initializeFacebookAuth( + appId: string, + version: string = "v25.0", +): void { + if (typeof window === "undefined") return; + if (!appId) { + console.warn( + "[auth] initializeFacebookAuth: NEXT_PUBLIC_FACEBOOK_APP_ID is not set", + ); + } + // version 占位:实际由 FacebookLoginClient.init({ version }) 消费 + void version; +} diff --git a/src/data/services/auth/initialize_google_auth.ts b/src/data/services/auth/initialize_google_auth.ts new file mode 100644 index 00000000..01ce60d2 --- /dev/null +++ b/src/data/services/auth/initialize_google_auth.ts @@ -0,0 +1,23 @@ +"use client"; + +/** + * Google OAuth 预热辅助。 + * + * 原始 Dart: `lib/utils/google_auth_initializer.dart` 的 `initializeGoogleAuth()` + * + * 行为: + * - `@react-oauth/google` 的 `` 已经负责加载 GIS 脚本; + * 本函数在 script 加载完成时记录一条调试日志(如果 env 缺失则 warn)。 + * - 不发起任何网络请求;副作用仅限 console。 + * + * 部署:本文件对应 Dart 端 `initializeGoogleAuth()` 调用位点; + * 真实使用时机由调用方在 `useEffect` 中安排,不需要在模块顶层自动执行。 + */ +export function initializeGoogleAuth(clientId: string): void { + if (typeof window === "undefined") return; + if (!clientId) { + console.warn( + "[auth] initializeGoogleAuth: NEXT_PUBLIC_GOOGLE_CLIENT_ID is not set", + ); + } +} diff --git a/src/data/services/auth/mock_auth_platform.ts b/src/data/services/auth/mock_auth_platform.ts new file mode 100644 index 00000000..776e3944 --- /dev/null +++ b/src/data/services/auth/mock_auth_platform.ts @@ -0,0 +1,62 @@ +"use client"; + +/** + * 模拟第三方登录平台。 + * + * 原始 Dart: lib/data/services/auth/mock_auth_platform.dart + * + * 在 `NEXT_PUBLIC_USE_MOCK_AUTH=true` 时由 `AuthPlatformFactory` 注入。 + * 返回固定的 idToken / accessToken / 用户信息,附 250ms 模拟延迟便于观察 loading + * 状态。 + */ +import { Result, type Result as ResultT } from "@/utils/result"; +import { FacebookUserData } from "@/data/dto/auth/facebook_user_data"; +import type { + IAuthPlatform, + GoogleCredential, + FacebookLoginResult, +} from "./auth_platform"; + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +export class MockAuthPlatform implements IAuthPlatform { + constructor(private readonly simulatedDelayMs: number = 250) {} + + async googleSignIn(): Promise> { + await sleep(this.simulatedDelayMs); + return Result.ok({ + idToken: `mock_google_id_token_${Date.now()}`, + email: "mock@example.com", + displayName: "Mock User", + photoUrl: "https://via.placeholder.com/150", + }); + } + + supportsGoogleAuthenticate(): boolean { + return true; + } + + async facebookSignIn(): Promise> { + await sleep(this.simulatedDelayMs); + return Result.ok({ + status: "success", + accessToken: `mock_facebook_token_${Date.now()}`, + userId: "mock_facebook_user_id", + expiresAt: Date.now() + 3600_000, + }); + } + + async getFacebookUserData(): Promise> { + await sleep(this.simulatedDelayMs); + return Result.ok( + FacebookUserData.from({ + id: "mock_facebook_id", + name: "Mock User", + email: "mock@example.com", + pictureUrl: "https://via.placeholder.com/150", + }), + ); + } +} diff --git a/src/data/services/auth/web_auth_platform.ts b/src/data/services/auth/web_auth_platform.ts new file mode 100644 index 00000000..7388cdb5 --- /dev/null +++ b/src/data/services/auth/web_auth_platform.ts @@ -0,0 +1,195 @@ +"use client"; + +/** + * Web 平台真机实现。 + * + * 原始 Dart: lib/data/services/auth/auth_platform_web.dart + * + * 类本身是 plain TS,不调用 React 钩子;通过 `auth_event_bridge` 派发 + * `cozsweet:auth:*:start` 事件,由 `AuthPlatformBridge` 持有 GIS / FB SDK 钩子 + * 并在拿到凭证后回调 `platform.resolveGoogle()` / `platform.resolveFacebook()`。 + * + * SSR 安全: + * - 构造函数、supportsGoogleAuthenticate() 在 server 端可用; + * - 业务方法在 SSR 下直接返回 failure(与 Flutter WebAuthPlatform 一致: + * 必须由用户点击触发弹窗,服务器侧无法完成 OAuth)。 + */ +import { Result, toError, type Result as ResultT } from "@/utils/result"; +import { FacebookUserData } from "@/data/dto/auth/facebook_user_data"; +import { + AUTH_EVENTS, + dispatchAuthEvent, +} from "./auth_event_bridge"; +import type { + IAuthPlatform, + IAuthPlatformOptions, + GoogleCredential, + FacebookLoginResult, +} from "./auth_platform"; + +const TIMEOUT_MS = 5 * 60 * 1000; + +interface PendingResolver { + resolve: (r: ResultT) => void; +} + +export class WebAuthPlatform implements IAuthPlatform { + readonly opts: IAuthPlatformOptions; + private readonly pendingGoogle = new Map< + string, + PendingResolver + >(); + private readonly pendingFacebook = new Map< + string, + PendingResolver + >(); + private nextId = 0; + + constructor(opts: IAuthPlatformOptions) { + this.opts = opts; + } + + /** 由 AuthPlatformBridge 在拿到 GIS 凭证后回调。 */ + resolveGoogle( + requestId: string, + result: ResultT, + ): void { + const pending = this.pendingGoogle.get(requestId); + if (pending) { + pending.resolve(result); + this.pendingGoogle.delete(requestId); + } + } + + /** 由 AuthPlatformBridge 在拿到 FB login response 后回调。 */ + resolveFacebook( + requestId: string, + result: ResultT, + ): void { + const pending = this.pendingFacebook.get(requestId); + if (pending) { + pending.resolve(result); + this.pendingFacebook.delete(requestId); + } + } + + async googleSignIn(): Promise> { + if (typeof window === "undefined") { + return Result.err(new Error("googleSignIn requires a browser")); + } + if (!this.opts.googleClientId) { + return Result.err( + new Error("NEXT_PUBLIC_GOOGLE_CLIENT_ID is not set"), + ); + } + const requestId = this.allocId(); + return new Promise>((resolve) => { + this.pendingGoogle.set(requestId, { resolve }); + dispatchAuthEvent(AUTH_EVENTS.googleStart, { requestId }); + setTimeout(() => { + if (this.pendingGoogle.has(requestId)) { + this.pendingGoogle.delete(requestId); + resolve(Result.err(new Error("Google sign-in timed out"))); + } + }, TIMEOUT_MS); + }); + } + + /** + * Web 平台使用 GIS 弹窗流程,`google.accounts.id.prompt()`,不调用 + * "authenticate" API。返回 false 以对齐 Dart `WebAuthPlatform` 行为。 + */ + supportsGoogleAuthenticate(): boolean { + return false; + } + + async facebookSignIn(): Promise> { + if (typeof window === "undefined") { + return Result.err(new Error("facebookSignIn requires a browser")); + } + if (!this.opts.facebookAppId) { + return Result.err( + new Error("NEXT_PUBLIC_FACEBOOK_APP_ID is not set"), + ); + } + const requestId = this.allocId(); + return new Promise>((resolve) => { + this.pendingFacebook.set(requestId, { resolve }); + dispatchAuthEvent(AUTH_EVENTS.facebookStart, { requestId }); + setTimeout(() => { + if (this.pendingFacebook.has(requestId)) { + this.pendingFacebook.delete(requestId); + resolve(Result.err(new Error("Facebook sign-in timed out"))); + } + }, TIMEOUT_MS); + }); + } + + /** + * 拉取 Facebook 用户数据。 + * + * 与 Dart `WebAuthPlatform.getFacebookUserData()` 对齐:调用 FB JS SDK 的 + * `FB.api('/me', { fields })`。前提是 Bridge 已经 `FacebookLoginClient.init` + * 初始化过 SDK(`window.FB` 全局存在)。若 SDK 未加载则返回 failure。 + */ + async getFacebookUserData(): Promise> { + if (typeof window === "undefined") { + return Result.err( + new Error("getFacebookUserData requires a browser"), + ); + } + const FB = (window as unknown as { FB?: unknown }).FB; + if (!FB) { + return Result.err( + new Error( + "Facebook SDK not loaded; call facebookSignIn() first or mount ", + ), + ); + } + return new Promise>((resolve) => { + try { + (window as unknown as { + FB: { + api: ( + path: string, + params: Record, + cb: (resp: Record) => void, + ) => void; + }; + }).FB.api( + "/me", + { fields: "id,name,email,picture" }, + (resp) => { + if (!resp || (resp as { error?: unknown }).error) { + resolve(Result.err(new Error("FB.api /me failed"))); + return; + } + const r = resp as { + id?: string; + name?: string; + email?: string; + picture?: { data?: { url?: string } }; + }; + resolve( + Result.ok( + FacebookUserData.from({ + id: r.id ?? null, + name: r.name ?? null, + email: r.email ?? null, + pictureUrl: r.picture?.data?.url ?? null, + }), + ), + ); + }, + ); + } catch (e) { + resolve(Result.err(toError(e))); + } + }); + } + + private allocId(): string { + this.nextId += 1; + return `auth-${Date.now()}-${this.nextId}`; + } +} diff --git a/src/data/services/index.ts b/src/data/services/index.ts new file mode 100644 index 00000000..f0512f84 --- /dev/null +++ b/src/data/services/index.ts @@ -0,0 +1,12 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from "./auth/auth_bridge_component"; +export * from "./auth/auth_event_bridge"; +export * from "./auth/auth_platform"; +export * from "./auth/global"; +export * from "./auth/initialize_facebook_auth"; +export * from "./auth/initialize_google_auth"; +export * from "./auth/mock_auth_platform"; +export * from "./auth/web_auth_platform";