feat(auth): implement Facebook identity binding on external entry

This commit is contained in:
2026-07-13 15:02:01 +08:00
parent 2ebf5b9e97
commit fd631168c8
11 changed files with 278 additions and 40 deletions
+72 -1
View File
@@ -4,6 +4,7 @@ import { createActor, fromPromise, waitFor } from "xstate";
import type { LoginStatus } from "@/data/dto/auth";
import type { AuthProvider } from "@/lib/auth/auth_platform";
import { authMachine } from "@/stores/auth/auth-machine";
import type { BindFacebookIdentityInput } from "@/stores/auth/auth-actors";
function createTestAuthMachine(
overrides: Partial<{
@@ -12,16 +13,26 @@ function createTestAuthMachine(
guestLoginSpy: () => void;
emailLogin: LoginStatus;
logoutSpy: () => void;
bindFacebookIdentitySpy: (input: BindFacebookIdentityInput) => void;
}> = {},
) {
const logoutSpy = overrides.logoutSpy ?? vi.fn<() => void>();
const guestLoginSpy = overrides.guestLoginSpy ?? vi.fn<() => void>();
const bindFacebookIdentitySpy =
overrides.bindFacebookIdentitySpy ??
vi.fn<(input: BindFacebookIdentityInput) => void>();
return authMachine.provide({
actors: {
checkAuthStatus: fromPromise<LoginStatus, void>(
async () => overrides.checkAuthStatus ?? "notLoggedIn",
),
bindFacebookIdentity: fromPromise<
void,
BindFacebookIdentityInput
>(async ({ input }) => {
bindFacebookIdentitySpy(input);
}),
guestLogin: fromPromise<LoginStatus, void>(
async () => {
guestLoginSpy();
@@ -70,8 +81,14 @@ describe("authMachine", () => {
it("initializes from storage without triggering guest login", async () => {
const guestLoginSpy = vi.fn<() => void>();
const bindFacebookIdentitySpy =
vi.fn<(input: BindFacebookIdentityInput) => void>();
const actor = createActor(
createTestAuthMachine({ checkAuthStatus: "facebook", guestLoginSpy }),
createTestAuthMachine({
checkAuthStatus: "facebook",
guestLoginSpy,
bindFacebookIdentitySpy,
}),
).start();
actor.send({ type: "AuthInit" });
@@ -81,6 +98,60 @@ describe("authMachine", () => {
expect(context.loginStatus).toBe("facebook");
expect(context.hasInitialized).toBe(true);
expect(guestLoginSpy).not.toHaveBeenCalled();
expect(bindFacebookIdentitySpy).not.toHaveBeenCalled();
actor.stop();
});
it("binds facebook identity only after an explicit external-entry event", async () => {
const bindFacebookIdentitySpy =
vi.fn<(input: BindFacebookIdentityInput) => void>();
const actor = createActor(
createTestAuthMachine({
checkAuthStatus: "email",
bindFacebookIdentitySpy,
}),
).start();
actor.send({ type: "AuthInit" });
await waitFor(actor, (snapshot) => snapshot.matches("idle"));
actor.send({
type: "AuthExternalFacebookIdentityBindRequested",
asid: "asid-1",
psid: "psid-1",
});
await waitFor(actor, (snapshot) => snapshot.matches("idle"));
expect(bindFacebookIdentitySpy).toHaveBeenCalledOnce();
expect(bindFacebookIdentitySpy).toHaveBeenCalledWith({
asid: "asid-1",
psid: "psid-1",
});
expect(actor.getSnapshot().context.loginStatus).toBe("email");
actor.stop();
});
it("keeps the current login status when external identity binding fails", async () => {
const actor = createActor(
createTestAuthMachine({
checkAuthStatus: "google",
bindFacebookIdentitySpy: () => {
throw new Error("bind failed");
},
}),
).start();
actor.send({ type: "AuthInit" });
await waitFor(actor, (snapshot) => snapshot.matches("idle"));
actor.send({
type: "AuthExternalFacebookIdentityBindRequested",
psid: "psid-1",
});
await waitFor(actor, (snapshot) => snapshot.matches("idle"));
expect(actor.getSnapshot().context.loginStatus).toBe("google");
expect(actor.getSnapshot().context.errorMessage).toBeNull();
actor.stop();
});
+18 -11
View File
@@ -239,6 +239,24 @@ export const guestLoginActor = fromPromise<LoginStatus, void>(async () => {
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 () => {
@@ -259,17 +277,6 @@ export const checkAuthStatusActor = fromPromise<LoginStatus, void>(async () => {
? providerR.data
: ("email" as LoginStatus);
const bindResult = await authRepo.bindFacebookIdentity({
asid: asid ?? undefined,
psid: psid ?? undefined,
});
if (Result.isErr(bindResult)) {
log.warn(
{ err: bindResult.error.message },
"[auth-machine] checkAuthStatusActor: bindFacebookIdentity failed",
);
}
return provider;
}
+3
View File
@@ -5,6 +5,7 @@
*/
import type { AuthProvider } from "@/lib/auth/auth_platform";
import type { AuthPanelMode } from "@/data/dto/auth";
import type { BindFacebookIdentityInput } from "./auth-actors";
export type AuthEvent =
// 内部 UI 事件
@@ -12,6 +13,8 @@ export type AuthEvent =
| { type: "AuthLogoutSubmitted" }
/** App 启动时一次性派发 —— 读 storage 把 loginStatus 同步到状态机 */
| { type: "AuthInit" }
| ({ type: "AuthExternalFacebookIdentityBindRequested" } &
BindFacebookIdentityInput)
// 业务事件(提交)
| { type: "AuthGuestLoginSubmitted" }
| { type: "AuthEmailLoginSubmitted"; email: string; password: string }
+18 -1
View File
@@ -16,6 +16,7 @@ import {
syncFacebookBackendActor,
guestLoginActor,
checkAuthStatusActor,
bindFacebookIdentityActor,
logoutActor,
} from "./auth-actors";
@@ -32,7 +33,8 @@ const toAuthErrorMessage = (error: unknown): string =>
//
// 设计:所有登录/同步流跑完都回 `idle`(带正确 `loginStatus`)。
//
// 初始化会恢复已有登录态,并按后端 Facebook identity 规则处理绑定 / PSID 直登。
// 初始化会恢复已有登录态,并按后端 Facebook identity 规则处理 PSID 直登。
// Identity 绑定只响应外部入口显式派发的业务事件。
// 普通游客登录仍必须由用户明确操作触发;PSID 未匹配时后端可返回游客态。
// ============================================================
export const authMachine = setup({
@@ -48,6 +50,7 @@ export const authMachine = setup({
syncFacebookBackend: syncFacebookBackendActor,
guestLogin: guestLoginActor,
checkAuthStatus: checkAuthStatusActor,
bindFacebookIdentity: bindFacebookIdentityActor,
logout: logoutActor,
},
guards: {
@@ -77,6 +80,8 @@ export const authMachine = setup({
},
// 启动一次性 init:从 storage 同步 loginStatus 到状态机 —— 由 <AuthStatusChecker /> 派发
AuthInit: "initializing",
AuthExternalFacebookIdentityBindRequested:
"bindingFacebookIdentity",
AuthGuestLoginSubmitted: {
guard: "canSubmitGuestLogin",
@@ -115,6 +120,18 @@ export const authMachine = setup({
},
},
bindingFacebookIdentity: {
invoke: {
src: "bindFacebookIdentity",
input: ({ event }) =>
event.type === "AuthExternalFacebookIdentityBindRequested"
? { asid: event.asid, psid: event.psid }
: {},
onDone: { target: "idle" },
onError: { target: "idle" },
},
},
loadingGuestLogin: {
entry: assign({ errorMessage: null }),
invoke: {
+2 -2
View File
@@ -1,11 +1,11 @@
"use client";
/**
* AuthInit 派发器 —— App 启动时恢复登录态并同步 Facebook identity
* AuthInit 派发器 —— App 启动时恢复登录态
*
* 派发时机:仅 mount 时一次(`useEffect` deps = `[dispatch]`,永远不重跑)。
*
* 与 <OAuthSessionSync /> 平级:
* - AuthStatusChecker: 启动时先跑 storage migration,再恢复登录态、绑定 ASID/PSID、尝试 PSID 直登
* - AuthStatusChecker: 启动时先跑 storage migration,再恢复登录态、尝试 PSID 直登
* - OAuthSessionSync: 持续监听 NextAuth session"别人在写"
*/
import { useEffect } from "react";