302 lines
11 KiB
TypeScript
302 lines
11 KiB
TypeScript
/**
|
||
* 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. 查业务登录 token:loginToken 和 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"
|
||
);
|
||
}
|