Files
cozsweet-frontend-nextjs/src/stores/auth/auth-actors.ts
T

269 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Auth 状态机:Actors(异步服务)
*/
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 { UserStorage } from "@/data/storage/user/user_storage";
import { deviceIdentifier } from "@/utils/device_identifier";
import { fetchFacebookUserData } from "@/utils/facebook-graph";
import { Logger } from "@/utils/logger";
import { Result } from "@/utils/result";
import { readGuestId } from "./auth-helpers";
const log = new Logger("AuthActors");
// 仓库以接口类型注入:调用面只看接口,运行时仍是同一单例
const authRepo: IAuthRepository = authRepository;
export const emailLoginActor = fromPromise<LoginStatus, { email: string; password: string }>(
async ({ input }) => {
console.log("[auth-machine] emailLoginActor ENTRY", {
emailLength: input.email.length,
emailPreview: input.email.slice(0, 8),
passwordLength: input.password.length,
});
const guestId = await readGuestId();
console.log("[auth-machine] emailLoginActor calling authRepo.emailLogin", {
hasGuestId: !!guestId,
});
const result = await authRepo.emailLogin({ ...input, guestId });
console.log("[auth-machine] emailLoginActor authRepo.emailLogin 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 "email" as LoginStatus;
},
);
export const emailRegisterThenLoginActor = fromPromise<
LoginStatus,
{ email: string; password: string; username: string; confirmPassword: string }
>(async ({ input }) => {
console.log("[auth-machine] emailRegisterThenLoginActor ENTRY", {
emailLength: input.email.length,
usernameLength: input.username.length,
passwordLength: input.password.length,
confirmPasswordLength: input.confirmPassword.length,
});
const guestId = await readGuestId();
console.log("[auth-machine] emailRegisterThenLoginActor calling authRepo.register");
const registerResult = await authRepo.register({ ...input, guestId });
console.log("[auth-machine] emailRegisterThenLoginActor authRepo.register DONE", {
success: registerResult.success,
errorName: registerResult.success ? null : registerResult.error?.name,
errorMessage: registerResult.success ? null : registerResult.error?.message,
});
if (Result.isErr(registerResult)) throw registerResult.error;
// 注册后自动登录(对齐 Dart 行为)
console.log(
"[auth-machine] emailRegisterThenLoginActor calling authRepo.emailLogin (post-register)",
);
const loginResult = await authRepo.emailLogin({
email: input.email,
password: input.password,
guestId,
});
console.log(
"[auth-machine] emailRegisterThenLoginActor authRepo.emailLogin DONE",
{
success: loginResult.success,
errorName: loginResult.success ? null : loginResult.error?.name,
errorMessage: loginResult.success ? null : loginResult.error?.message,
},
);
if (Result.isErr(loginResult)) throw loginResult.error;
return "email" as LoginStatus;
});
// OAuth 登录 actor:调 AuthPlatform 触发 OAuth 跳转。
// 成功路径下 NextAuth 会重定向离开本应用,actor 通常不会 resolve
// onDone 仍保留以处理 SDK 同步返回(极少见)。
export const oauthSignInActor = fromPromise<void, AuthProvider>(async ({ input }) => {
if (input === "google") {
await AuthPlatform.googleSignIn();
} else {
await AuthPlatform.facebookSignIn();
}
});
export const logoutActor = fromPromise<void>(async () => {
const result = await authRepo.logout();
if (Result.isErr(result)) throw result.error;
});
// ========== OAuth token → 后端业务 token sync actors ==========
// 由 <OAuthSessionSync /> 在 NextAuth 回调后派发(监听 useSession())。
// 用 OAuth provider 的 idToken / accessToken 调后端换业务 token + user。
// 后端返回的 LoginResponse 已经被 authRepository._saveLoginData 持久化。
export const syncGoogleBackendActor = fromPromise<LoginStatus, { idToken: string }>(
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;
// 业务 token 已到手 —— 顺带用同一个 accessToken 调 Facebook Graph `/me`
// 拉用户资料(id / name / email / picture),把 pictureUrl + Facebook ID
// 写本地。
//
// 为什么要这一步?后端 LoginResponse.user.avatarUrl 不一定总有(依赖
// 后端在 OAuth 流程里有没有抓过);前端用 accessToken 直接调 Graph
// 拿到的 picture 是最新的,独立于后端缓存。
//
// 失败策略:best-effort。Graph 调用挂掉 / 用户没头像 / token 过期都不
// 影响 Facebook 登录主流程 —— 业务 token 已经在 result 里。
await persistFacebookProfile(input.accessToken);
return "facebook" as LoginStatus;
});
/**
* 用 accessToken 调 Facebook Graph `/me` 拉用户资料,写本地:
* - `id` → `AuthStorage.setFacebookId()`(独立于后端 userId
* - `pictureUrl` → `UserStorage.setAvatarUrl()`(头像专用 slot,便于快速读取)
*
* 全部 best-effort:单条失败只 warn,不抛错。`pictureUrl == null` 也算成功
* (用户没设头像),只是不写入。
*/
async function persistFacebookProfile(accessToken: string): Promise<void> {
const fbR = await fetchFacebookUserData(accessToken);
if (Result.isErr(fbR)) {
log.warn(
{ err: fbR.error.message },
"[auth-machine] syncFacebookBackendActor: fetchFacebookUserData failed (continuing anyway)",
);
return;
}
const fbUser = fbR.data;
log.info(
{
hasId: !!fbUser.id,
hasPicture: !!fbUser.pictureUrl,
},
"[auth-machine] syncFacebookBackendActor: Facebook profile fetched",
);
const authStorage = AuthStorage.getInstance();
if (fbUser.id) {
const r = await authStorage.setFacebookId(fbUser.id);
if (!r.success) {
log.warn(
{ err: r.error.message },
"[auth-machine] syncFacebookBackendActor: setFacebookId failed",
);
}
}
if (fbUser.pictureUrl) {
const r = await UserStorage.getInstance().setAvatarUrl(fbUser.pictureUrl);
if (!r.success) {
log.warn(
{ err: r.error.message },
"[auth-machine] syncFacebookBackendActor: setAvatarUrl failed",
);
}
}
}
// ========== 显式游客登录 actoropt-in ==========
// 由 splash UI 派发 `AuthGuestLoginSubmitted` 触发,不自动跑。
// 取代了之前 checkAuthStatusActor 的 auto-guest-login 行为 —— 避免每次
// 访 splash 都自动创建游客账号污染后端。
//
// 短路优化:本地已有 guestToken 时直接 return "guest",跳过 deviceId
// 探测 + 后端 round-tripsplash 每打开一次就可能点一次 Skip,命中率高)。
// 已知 trade-off:若后端已吊销该 tokenactor 仍返回 "guest" —— 任何后续
// 受保护 API 401 会由 HTTP 层清掉 token 回到 splash,无需在此处主动校验。
export const guestLoginActor = fromPromise<LoginStatus, void>(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(只查不创) ==========
// 由 <AuthStatusChecker /> 在 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<LoginStatus, void>(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;
});