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

340 lines
12 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 } from "@/utils/device_identifier";
import { Logger } from "@/utils/logger";
import { Result } from "@/utils/result";
import { hasCompleteBusinessAuthSession } from "@/lib/auth/auth_session";
import { readGuestId, readPsid } 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();
const psid = await readPsid();
log.debug("[auth-machine] emailLoginActor calling authRepo.emailLogin", {
hasGuestId: !!guestId,
hasPsid: !!psid,
});
const result = await authRepo.emailLogin({ ...input, guestId, psid });
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;
const psid = await readPsid();
// 注册后自动登录(对齐 Dart 行为)
log.debug(
"[auth-machine] emailRegisterThenLoginActor calling authRepo.emailLogin (post-register)",
);
const loginResult = await authRepo.emailLogin({
email: input.email,
password: input.password,
guestId,
psid,
});
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 psid = await readPsid();
const result = await authRepo.googleLogin({
idToken: input.idToken,
guestId,
psid,
});
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 psid = await readPsid();
const result = await authRepo.facebookLogin({
accessToken: input.accessToken,
guestId,
psid,
});
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.setAsid()`(独立于后端 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.setAsid(fbUser.id);
if (!r.success) {
log.warn(
{ err: r.error.message },
"[auth-machine] syncFacebookBackendActor: setAsid 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;
});
export interface BindFacebookIdentityInput {
asid?: string;
psid?: string;
}
export const bindFacebookIdentityActor = fromPromise<
void,
BindFacebookIdentityInput
>(async ({ input }) => {
const result = await getAuthRepository().bindFacebookIdentity(input);
if (Result.isErr(result)) {
log.warn(
{ err: result.error.message },
"[auth-machine] bindFacebookIdentityActor failed",
);
}
});
// ========== 启动 / 恢复时检查登录态 actor(只查不创) ==========
export const checkAuthStatusActor = fromPromise<LoginStatus, void>(async () => {
// 1. 拿启动期可能参与身份同步的本地标识。
const deviceId = await deviceIdentifier.getDeviceId();
const storage = AuthStorage.getInstance();
const asidR = await storage.getAsid();
const psidR = await storage.getPsid();
const asid = asidR.success ? asidR.data : null;
const psid = psidR.success ? psidR.data : null;
const authRepo = getAuthRepository();
// 2. 查业务登录 tokenloginToken 和 refreshToken 必须同时存在。
if (await hasCompleteBusinessAuthSession(storage)) {
const providerR = await storage.getLoginProvider();
const provider =
providerR.success && isBusinessLoginProvider(providerR.data)
? providerR.data
: ("email" as LoginStatus);
return provider;
}
// 3. 有 PSID 时优先让后端用本地绑定关系直接登录;未匹配则返回游客态。
if (psid) {
const psidLoginResult = await authRepo.facebookPsidLogin({
psid,
deviceId,
bindToGuest: true,
});
if (Result.isOk(psidLoginResult)) {
return psidLoginResult.data;
}
log.warn(
{ err: psidLoginResult.error.message },
"[auth-machine] checkAuthStatusActor: facebookPsidLogin failed",
);
}
// 4. 无 PSID 或 PSID 直登失败时,继续用 ASID fallback 换业务 token。
if (asid) {
const avatarUrlR = await UserStorage.getInstance().getAvatarUrl();
const result = await authRepo.facebookAsidLogin({
asid,
avatarUrl: avatarUrlR.success ? avatarUrlR.data ?? undefined : undefined,
psid: psid ?? undefined,
});
if (Result.isErr(result)) {
log.warn(
{ err: result.error.message },
"[auth-machine] checkAuthStatusActor: facebookAsidLogin failed",
);
return "notLoggedIn" as LoginStatus;
}
return "facebook" as LoginStatus;
}
// 5. 查 guestToken
const guestTokenR = await storage.getGuestToken();
if (guestTokenR.success && guestTokenR.data) {
return "guest" as LoginStatus;
}
// 6. 都没有 → "notLoggedIn"(保持 splash 页面 + 登录入口)
return "notLoggedIn" as LoginStatus;
});
function isBusinessLoginProvider(
provider: LoginStatus | null,
): provider is LoginStatus {
return (
provider === "email" ||
provider === "facebook" ||
provider === "google"
);
}