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

302 lines
11 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 "@/data/dto/auth";
import { getAuthRepository } from "@/data/repositories/auth_repository";
import { fetchFacebookUserData } from "@/data/services";
import { AuthStorage } from "@/data/storage/auth/auth_storage";
import { UserStorage } from "@/data/storage/user/user_storage";
import { deviceIdentifier, Logger, Result } from "@/utils";
import { hasCompleteBusinessAuthSession } from "@/lib/auth/auth_session";
import { readGuestId } from "./auth-helpers";
const log = new Logger("AuthActors");
export const emailLoginActor = fromPromise<LoginStatus, { email: string; password: string }>(
async ({ input }) => {
const authRepo = getAuthRepository();
log.debug("[auth-machine] emailLoginActor ENTRY", {
emailLength: input.email.length,
emailPreview: input.email.slice(0, 8),
passwordLength: input.password.length,
});
const guestId = await readGuestId();
log.debug("[auth-machine] emailLoginActor calling authRepo.emailLogin", {
hasGuestId: !!guestId,
});
const result = await authRepo.emailLogin({ ...input, guestId });
log.debug("[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 }) => {
const authRepo = getAuthRepository();
log.debug("[auth-machine] emailRegisterThenLoginActor ENTRY", {
emailLength: input.email.length,
usernameLength: input.username.length,
passwordLength: input.password.length,
confirmPasswordLength: input.confirmPassword.length,
});
const guestId = await readGuestId();
log.debug("[auth-machine] emailRegisterThenLoginActor calling authRepo.register");
const registerResult = await authRepo.register({ ...input, guestId });
log.debug("[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 行为)
log.debug(
"[auth-machine] emailRegisterThenLoginActor calling authRepo.emailLogin (post-register)",
);
const loginResult = await authRepo.emailLogin({
email: input.email,
password: input.password,
guestId,
});
log.debug(
"[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<LoginStatus>(async () => {
const authRepo = getAuthRepository();
const result = await authRepo.logout();
if (Result.isErr(result)) throw result.error;
return "guest" as LoginStatus;
});
// ========== 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 authRepo = getAuthRepository();
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 authRepo = getAuthRepository();
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",
);
}
}
}
// ========== 自动游客登录 actor ==========
// AuthInit 检查不到任何本地登录态时触发,用于自动补齐游客会话。
export const guestLoginActor = fromPromise<LoginStatus, void>(async () => {
log.debug("[auth-machine] guestLoginActor ENTRY");
// 1. 先查本地 guestToken —— 已有就直接进入游客态
const hasTokenR = await AuthStorage.getInstance().hasGuestToken();
log.debug("[auth-machine] guestLoginActor hasGuestToken", {
success: hasTokenR.success,
hasData: hasTokenR.success ? !!hasTokenR.data : null,
});
if (hasTokenR.success && hasTokenR.data) {
log.debug("[auth-machine] guestLoginActor SHORT-CIRCUIT (has local guestToken) → return guest");
return "guest" as LoginStatus;
}
// Result.err(_) 时不抛 —— 落到 API 路径让真正的错误信号出现
// 2. 没有(或读不出)才走完整流程:拿 deviceId → 调后端
const deviceId = await deviceIdentifier.getDeviceId();
log.debug("[auth-machine] guestLoginActor deviceId", {
length: deviceId.length,
prefix: deviceId.slice(0, 8),
});
log.debug("[auth-machine] guestLoginActor calling authRepo.guestLogin");
const result = await getAuthRepository().guestLogin(deviceId);
log.debug("[auth-machine] guestLoginActor authRepo.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;非生产环境使用本地 UUID)
// (给后续 OAuth/email 登录准备 deviceId,不在 splash 首次访问就 auto-create 游客)
// 2. AuthStorage loginToken + refreshToken
// —— 两者都有 → 读取 loginProvider,缺省回退 "email"
// —— 任一缺失 → 不算业务登录态
// 3. AuthStorage.getFacebookId() —— 有 → facebookIdLogin → "facebook"
// 4. AuthStorage.getGuestToken() —— 有 → "guest"
// 5. 都没有 → "notLoggedIn"(让用户显式选 OAuth / Email / Guest 入口)
export const checkAuthStatusActor = fromPromise<LoginStatus, void>(async () => {
// 1. 拿 deviceId(不用于 auto-guest-login,仅供后续登录用)
await deviceIdentifier.getDeviceId();
const storage = AuthStorage.getInstance();
// 2. 查业务登录 tokenloginToken 和 refreshToken 必须同时存在。
if (await hasCompleteBusinessAuthSession(storage)) {
const providerR = await storage.getLoginProvider();
if (providerR.success && isBusinessLoginProvider(providerR.data)) {
return providerR.data;
}
return "email" as LoginStatus;
}
// 3. 外部浏览器深链同步:仅 fbid 落地时,用它换业务 token。
const facebookIdR = await storage.getFacebookId();
if (facebookIdR.success && facebookIdR.data) {
const authRepo = getAuthRepository();
const avatarUrlR = await UserStorage.getInstance().getAvatarUrl();
const result = await authRepo.facebookIdLogin({
fbId: facebookIdR.data,
avatarUrl: avatarUrlR.success ? avatarUrlR.data ?? undefined : undefined,
});
if (Result.isErr(result)) {
log.warn(
{ err: result.error.message },
"[auth-machine] checkAuthStatusActor: facebookIdLogin failed",
);
return "notLoggedIn" as LoginStatus;
}
return "facebook" as LoginStatus;
}
// 4. 查 guestToken
const guestTokenR = await storage.getGuestToken();
if (guestTokenR.success && guestTokenR.data) {
return "guest" as LoginStatus;
}
// 5. 都没有 → "notLoggedIn"(保持 splash 页面 + 登录入口)
return "notLoggedIn" as LoginStatus;
});
function isBusinessLoginProvider(
provider: LoginStatus | null,
): provider is LoginStatus {
return (
provider === "email" ||
provider === "facebook" ||
provider === "google" ||
provider === "apple"
);
}