diff --git a/src/stores/auth/auth-actors.ts b/src/stores/auth/auth-actors.ts new file mode 100644 index 00000000..33602007 --- /dev/null +++ b/src/stores/auth/auth-actors.ts @@ -0,0 +1,167 @@ +/** + * Auth 状态机:Actors(异步服务) + * + * 原始 Dart: lib/ui/auth/bloc/auth_bloc.dart + auth_state.dart + auth_event.dart + * + * 本轮迁移:所有认证登录调用(含 OAuth 社交登录)统一收口到状态机内。 + * 业务层只派发事件(`AuthEmailLoginSubmitted` / `AuthGoogleLoginSubmitted` / 等), + * 状态机通过 actors 调 `authRepository`(邮箱 / OAuth token sync)或 + * `next-auth/react.signIn`(OAuth 跳转)。 + */ +import { fromPromise } from "xstate"; + +import { AuthPlatform, type AuthProvider } from "@/lib/auth/auth_platform"; +import type { LoginStatus } from "@/models/auth/login-status"; +import { authRepository } from "@/data/repositories/auth_repository"; +import type { IAuthRepository } from "@/data/repositories/interfaces"; +import { AuthStorage } from "@/data/storage/auth/auth_storage"; +import { deviceIdentifier } from "@/utils/device_identifier"; +import { Result } from "@/utils/result"; + +import { readGuestId } from "./auth-helpers"; + +// 仓库以接口类型注入:调用面只看接口,运行时仍是同一单例 +const authRepo: IAuthRepository = authRepository; + +export const emailLoginActor = fromPromise( + async ({ input }) => { + const guestId = await readGuestId(); + const result = await authRepo.emailLogin({ ...input, guestId }); + if (Result.isErr(result)) throw result.error; + return "email" as LoginStatus; + }, +); + +export const emailRegisterThenLoginActor = fromPromise< + LoginStatus, + { email: string; password: string; username: string; confirmPassword: string } +>(async ({ input }) => { + const guestId = await readGuestId(); + + const registerResult = await authRepo.register({ ...input, guestId }); + if (Result.isErr(registerResult)) throw registerResult.error; + + // 注册后自动登录(对齐 Dart 行为) + const loginResult = await authRepo.emailLogin({ + email: input.email, + password: input.password, + guestId, + }); + if (Result.isErr(loginResult)) throw loginResult.error; + return "email" as LoginStatus; +}); + +// OAuth 登录 actor:调 AuthPlatform 触发 OAuth 跳转。 +// 成功路径下 NextAuth 会重定向离开本应用,actor 通常不会 resolve; +// onDone 仍保留以处理 SDK 同步返回(极少见)。 +export const oauthSignInActor = fromPromise(async ({ input }) => { + if (input === "google") { + await AuthPlatform.googleSignIn(); + } else { + await AuthPlatform.facebookSignIn(); + } +}); + +// ========== OAuth token → 后端业务 token sync actors ========== +// 由 在 NextAuth 回调后派发(监听 useSession())。 +// 用 OAuth provider 的 idToken / accessToken 调后端换业务 token + user。 +// 后端返回的 LoginResponse 已经被 authRepository._saveLoginData 持久化。 + +export const syncGoogleBackendActor = fromPromise( + async ({ input }) => { + const guestId = await readGuestId(); + const result = await authRepo.googleLogin({ + idToken: input.idToken, + guestId, + }); + if (Result.isErr(result)) throw result.error; + return "google" as LoginStatus; + }, +); + +export const syncFacebookBackendActor = fromPromise< + LoginStatus, + { accessToken: string } +>(async ({ input }) => { + const guestId = await readGuestId(); + const result = await authRepo.facebookLogin({ + accessToken: input.accessToken, + guestId, + }); + if (Result.isErr(result)) throw result.error; + return "facebook" as LoginStatus; +}); + +// ========== 显式游客登录 actor(opt-in) ========== +// 由 splash UI 派发 `AuthGuestLoginSubmitted` 触发,**不**自动跑。 +// 取代了之前 checkAuthStatusActor 的 auto-guest-login 行为 —— 避免每次 +// 访 splash 都自动创建游客账号污染后端。 +// +// 短路优化:本地已有 guestToken 时直接 return "guest",跳过 deviceId +// 探测 + 后端 round-trip(splash 每打开一次就可能点一次 Skip,命中率高)。 +// 已知 trade-off:若后端已吊销该 token,actor 仍返回 "guest" —— 任何后续 +// 受保护 API 401 会由 HTTP 层清掉 token 回到 splash,无需在此处主动校验。 + +export const guestLoginActor = fromPromise(async () => { + console.log("[auth-machine] guestLoginActor ENTRY"); + + // 1. 先查本地 guestToken —— 已有就直接进 idle(带 pendingRedirect) + const hasTokenR = await AuthStorage.getInstance().hasGuestToken(); + console.log("[auth-machine] guestLoginActor hasGuestToken", { + success: hasTokenR.success, + hasData: hasTokenR.success ? !!hasTokenR.data : null, + }); + if (hasTokenR.success && hasTokenR.data) { + console.log("[auth-machine] guestLoginActor SHORT-CIRCUIT (has local guestToken) → return guest"); + return "guest" as LoginStatus; + } + // Result.err(_) 时**不**抛 —— 落到 API 路径让真正的错误信号出现 + + // 2. 没有(或读不出)才走完整流程:拿 deviceId → 调后端 + const deviceId = await deviceIdentifier.getDeviceId(); + console.log("[auth-machine] guestLoginActor deviceId", { + length: deviceId.length, + prefix: deviceId.slice(0, 8), + }); + + console.log("[auth-machine] guestLoginActor calling authRepository.guestLogin"); + const result = await authRepository.guestLogin(deviceId); + console.log("[auth-machine] guestLoginActor authRepository.guestLogin DONE", { + success: result.success, + hasData: result.success ? !!result.data : null, + errorName: result.success ? null : result.error?.name, + errorMessage: result.success ? null : result.error?.message, + }); + if (Result.isErr(result)) throw result.error; + return "guest" as LoginStatus; +}); + +// ========== 启动 / 恢复时检查登录态 actor(只查不创) ========== +// 由 在 mount 时派发(一次性)。 +// 流程(**纯只读**): +// 1. deviceIdentifier.getDeviceId() —— 无则通过 fingerprintjs 生成 + 落盘 +// (给后续 OAuth/email 登录准备 deviceId,**不**在 splash 首次访问就 auto-create 游客) +// 2. AuthStorage.getLoginToken() —— 有 → "email" +// 3. AuthStorage.getGuestToken() —— 有 → "guest" +// 4. 都没有 → "notLoggedIn"(让用户**显式**选 OAuth / Email / Guest 入口) + +export const checkAuthStatusActor = fromPromise(async () => { + // 1. 拿 deviceId(**不**用于 auto-guest-login,仅供后续登录用) + await deviceIdentifier.getDeviceId(); + const storage = AuthStorage.getInstance(); + + // 2. 查 loginToken + const loginTokenR = await storage.getLoginToken(); + if (loginTokenR.success && loginTokenR.data) { + return "email" as LoginStatus; + } + + // 3. 查 guestToken + const guestTokenR = await storage.getGuestToken(); + if (guestTokenR.success && guestTokenR.data) { + return "guest" as LoginStatus; + } + + // 4. 都没有 → "notLoggedIn"(保持 splash 页面 + 登录入口) + return "notLoggedIn" as LoginStatus; +}); diff --git a/src/stores/auth/auth-helpers.ts b/src/stores/auth/auth-helpers.ts new file mode 100644 index 00000000..a1f2cacc --- /dev/null +++ b/src/stores/auth/auth-helpers.ts @@ -0,0 +1,15 @@ +/** + * Auth 状态机:Helpers + * + * 状态机内复用的小函数 —— 独立成文件便于测试 / 复用。 + */ +import { AuthStorage } from "@/data/storage/auth/auth_storage"; + +/** 从本地存储读 guest deviceId;读不到 / 失败时返回 undefined(不抛)。 */ +export async function readGuestId(): Promise { + const r = await AuthStorage.getInstance().getDeviceId(); + if (r.success && r.data != null) { + return r.data; + } + return undefined; +} diff --git a/src/stores/auth/auth-machine.ts b/src/stores/auth/auth-machine.ts index efc2ce37..73cb98ac 100644 --- a/src/stores/auth/auth-machine.ts +++ b/src/stores/auth/auth-machine.ts @@ -7,185 +7,30 @@ * 业务层只派发事件(`AuthEmailLoginSubmitted` / `AuthGoogleLoginSubmitted` / 等), * 状态机通过 actors 调 `authRepository`(邮箱 / OAuth token sync)或 * `next-auth/react.signIn`(OAuth 跳转)。 + * + * 异步服务(actors)已抽到 `./auth-actors`,小工具抽到 `./auth-helpers`。 */ -import { setup, fromPromise, assign } from "xstate"; +import { setup, assign } from "xstate"; -import { AuthPlatform, type AuthProvider } from "@/lib/auth/auth_platform"; -import type { LoginStatus } from "@/models/auth/login-status"; -import { authRepository } from "@/data/repositories/auth_repository"; -import type { IAuthRepository } from "@/data/repositories/interfaces"; -import { AuthStorage } from "@/data/storage/auth/auth_storage"; -import { deviceIdentifier } from "@/utils/device_identifier"; -import { Result } from "@/utils/result"; +import type { AuthProvider } from "@/lib/auth/auth_platform"; import { AuthState, initialState } from "./auth-state"; import type { AuthEvent } from "./auth-events"; +import { + emailLoginActor, + emailRegisterThenLoginActor, + oauthSignInActor, + syncGoogleBackendActor, + syncFacebookBackendActor, + guestLoginActor, + checkAuthStatusActor, +} from "./auth-actors"; // 重新导出 State / Event 类型,保持 machine 文件的公共 API 不变 export type { AuthState } from "./auth-state"; export { initialState } from "./auth-state"; export type { AuthEvent } from "./auth-events"; -// ============================================================ -// Helpers -// ============================================================ -async function readGuestId(): Promise { - const r = await AuthStorage.getInstance().getDeviceId(); - if (r.success && r.data != null) { - return r.data; - } - return undefined; -} - -// ============================================================ -// Actors(异步服务) -// ============================================================ -// 仓库以接口类型注入:调用面只看接口,运行时仍是同一单例 -const authRepo: IAuthRepository = authRepository; - -const emailLoginActor = fromPromise( - async ({ input }) => { - const guestId = await readGuestId(); - const result = await authRepo.emailLogin({ ...input, guestId }); - if (Result.isErr(result)) throw result.error; - return "email" as LoginStatus; - }, -); - -const emailRegisterThenLoginActor = fromPromise< - LoginStatus, - { email: string; password: string; username: string; confirmPassword: string } ->(async ({ input }) => { - const guestId = await readGuestId(); - - const registerResult = await authRepo.register({ ...input, guestId }); - if (Result.isErr(registerResult)) throw registerResult.error; - - // 注册后自动登录(对齐 Dart 行为) - const loginResult = await authRepo.emailLogin({ - email: input.email, - password: input.password, - guestId, - }); - if (Result.isErr(loginResult)) throw loginResult.error; - return "email" as LoginStatus; -}); - -// OAuth 登录 actor:调 AuthPlatform 触发 OAuth 跳转。 -// 成功路径下 NextAuth 会重定向离开本应用,actor 通常不会 resolve; -// onDone 仍保留以处理 SDK 同步返回(极少见)。 -const oauthSignInActor = fromPromise(async ({ input }) => { - if (input === "google") { - await AuthPlatform.googleSignIn(); - } else { - await AuthPlatform.facebookSignIn(); - } -}); - -// ========== OAuth token → 后端业务 token sync actors ========== -// 由 在 NextAuth 回调后派发(监听 useSession())。 -// 用 OAuth provider 的 idToken / accessToken 调后端换业务 token + user。 -// 后端返回的 LoginResponse 已经被 authRepository._saveLoginData 持久化。 - -const syncGoogleBackendActor = fromPromise( - async ({ input }) => { - const guestId = await readGuestId(); - const result = await authRepo.googleLogin({ - idToken: input.idToken, - guestId, - }); - if (Result.isErr(result)) throw result.error; - return "google" as LoginStatus; - }, -); - -const syncFacebookBackendActor = fromPromise< - LoginStatus, - { accessToken: string } ->(async ({ input }) => { - const guestId = await readGuestId(); - const result = await authRepo.facebookLogin({ - accessToken: input.accessToken, - guestId, - }); - if (Result.isErr(result)) throw result.error; - return "facebook" as LoginStatus; -}); - -// ========== 显式游客登录 actor(opt-in) ========== -// 由 splash UI 派发 `AuthGuestLoginSubmitted` 触发,**不**自动跑。 -// 取代了之前 checkAuthStatusActor 的 auto-guest-login 行为 —— 避免每次 -// 访 splash 都自动创建游客账号污染后端。 -// -// 短路优化:本地已有 guestToken 时直接 return "guest",跳过 deviceId -// 探测 + 后端 round-trip(splash 每打开一次就可能点一次 Skip,命中率高)。 -// 已知 trade-off:若后端已吊销该 token,actor 仍返回 "guest" —— 任何后续 -// 受保护 API 401 会由 HTTP 层清掉 token 回到 splash,无需在此处主动校验。 - -const guestLoginActor = fromPromise(async () => { - console.log("[auth-machine] guestLoginActor ENTRY"); - - // 1. 先查本地 guestToken —— 已有就直接进 idle(带 pendingRedirect) - const hasTokenR = await AuthStorage.getInstance().hasGuestToken(); - console.log("[auth-machine] guestLoginActor hasGuestToken", { - success: hasTokenR.success, - hasData: hasTokenR.success ? !!hasTokenR.data : null, - }); - if (hasTokenR.success && hasTokenR.data) { - console.log("[auth-machine] guestLoginActor SHORT-CIRCUIT (has local guestToken) → return guest"); - return "guest" as LoginStatus; - } - // Result.err(_) 时**不**抛 —— 落到 API 路径让真正的错误信号出现 - - // 2. 没有(或读不出)才走完整流程:拿 deviceId → 调后端 - const deviceId = await deviceIdentifier.getDeviceId(); - console.log("[auth-machine] guestLoginActor deviceId", { - length: deviceId.length, - prefix: deviceId.slice(0, 8), - }); - - console.log("[auth-machine] guestLoginActor calling authRepository.guestLogin"); - const result = await authRepository.guestLogin(deviceId); - console.log("[auth-machine] guestLoginActor authRepository.guestLogin DONE", { - success: result.success, - hasData: result.success ? !!result.data : null, - errorName: result.success ? null : result.error?.name, - errorMessage: result.success ? null : result.error?.message, - }); - if (Result.isErr(result)) throw result.error; - return "guest" as LoginStatus; -}); - -// ========== 启动 / 恢复时检查登录态 actor(只查不创) ========== -// 由 在 mount 时派发(一次性)。 -// 流程(**纯只读**): -// 1. deviceIdentifier.getDeviceId() —— 无则通过 fingerprintjs 生成 + 落盘 -// (给后续 OAuth/email 登录准备 deviceId,**不**在 splash 首次访问就 auto-create 游客) -// 2. AuthStorage.getLoginToken() —— 有 → "email" -// 3. AuthStorage.getGuestToken() —— 有 → "guest" -// 4. 都没有 → "notLoggedIn"(让用户**显式**选 OAuth / Email / Guest 入口) - -const checkAuthStatusActor = fromPromise(async () => { - // 1. 拿 deviceId(**不**用于 auto-guest-login,仅供后续登录用) - await deviceIdentifier.getDeviceId(); - const storage = AuthStorage.getInstance(); - - // 2. 查 loginToken - const loginTokenR = await storage.getLoginToken(); - if (loginTokenR.success && loginTokenR.data) { - return "email" as LoginStatus; - } - - // 3. 查 guestToken - const guestTokenR = await storage.getGuestToken(); - if (guestTokenR.success && guestTokenR.data) { - return "guest" as LoginStatus; - } - - // 4. 都没有 → "notLoggedIn"(保持 splash 页面 + 登录入口) - return "notLoggedIn" as LoginStatus; -}); - // ============================================================ // Machine //