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
+2
View File
@@ -8,6 +8,8 @@ https://<APP_HOST>/external-entry
入口会先保存传入的身份数据,再使用 `replace` 跳转到目标页面并清理地址栏中的参数。
如果进入时已经是 Email、Google 或 Facebook 正式登录用户,并且携带 ASID 或 PSID,入口会额外调用一次 Facebook Identity 绑定接口。普通启动、刷新、Guest 和未登录状态不会调用绑定接口,绑定失败也不会阻止目标页面跳转。
## 参数
| 参数 | 必填 | 可选值 | 说明 |
@@ -1,12 +1,13 @@
"use client";
import { useEffect } from "react";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import {
persistExternalEntryPayload,
resolveExternalEntryPromotionType,
resolveExternalEntryTarget,
shouldBindExternalFacebookIdentity,
} from "@/lib/navigation/external_entry";
import {
clearPendingChatPromotion,
@@ -14,6 +15,7 @@ import {
} from "@/lib/navigation/chat_unlock_session";
import { ROUTES } from "@/router/routes";
import { Logger } from "@/utils";
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
const log = new Logger("ExternalEntryPersist");
@@ -37,17 +39,21 @@ export default function ExternalEntryPersist({
promotionType,
}: ExternalEntryPersistProps) {
const router = useRouter();
useEffect(() => {
const authState = useAuthState();
const authDispatch = useAuthDispatch();
const [hasPersisted, setHasPersisted] = useState(false);
const hasNavigatedRef = useRef(false);
const destination = resolveExternalEntryTarget({ target });
const resolvedPromotionType = resolveExternalEntryPromotionType({
mode,
promotionType,
});
useEffect(() => {
let cancelled = false;
void (async () => {
try {
await Promise.all([
const results = await Promise.allSettled([
persistExternalEntryPayload({
deviceId,
asid,
@@ -58,21 +64,62 @@ export default function ExternalEntryPersist({
? savePendingChatPromotion(resolvedPromotionType)
: clearPendingChatPromotion(),
]);
} catch (error) {
log.warn("[ExternalEntryPersist] failed to persist payload", error);
} finally {
router.replace(destination);
for (const result of results) {
if (result.status === "rejected") {
log.warn(
"[ExternalEntryPersist] failed to persist payload",
result.reason,
);
}
}
if (!cancelled) setHasPersisted(true);
})();
return () => {
cancelled = true;
};
}, [
avatarUrl,
asid,
destination,
deviceId,
mode,
promotionType,
psid,
resolvedPromotionType,
]);
useEffect(() => {
if (hasNavigatedRef.current || !hasPersisted) return;
if (!authState.hasInitialized || authState.isLoading) return;
hasNavigatedRef.current = true;
if (
shouldBindExternalFacebookIdentity({
hasInitialized: authState.hasInitialized,
isLoading: authState.isLoading,
loginStatus: authState.loginStatus,
asid,
psid,
})
) {
authDispatch({
type: "AuthExternalFacebookIdentityBindRequested",
asid: asid ?? undefined,
psid: psid ?? undefined,
});
}
router.replace(destination);
}, [
asid,
authDispatch,
authState.hasInitialized,
authState.isLoading,
authState.loginStatus,
destination,
hasPersisted,
psid,
router,
target,
]);
return (
@@ -16,7 +16,6 @@ export class SendMessageRequest {
declare readonly imageOriginalUrl: string;
declare readonly imageWidth: number;
declare readonly imageHeight: number;
declare readonly useWebSocket: boolean;
private constructor(input: SendMessageRequestInput) {
const data = SendMessageRequestSchema.parse(input);
@@ -17,7 +17,7 @@ export class ChatSendResponse {
declare readonly image: ChatImageData;
declare readonly lockDetail: ChatLockDetailData;
declare readonly canSendMessage: boolean;
declare readonly cannotSendReason: string | null;
// declare readonly cannotSendReason: string | null;
declare readonly creditBalance: number;
declare readonly creditsCharged: number;
declare readonly requiredCredits: number;
@@ -5,6 +5,7 @@ import { ROUTES } from "@/router/routes";
import {
resolveExternalEntryPromotionType,
resolveExternalEntryTarget,
shouldBindExternalFacebookIdentity,
} from "../external_entry";
describe("external entry navigation", () => {
@@ -31,6 +32,69 @@ describe("external entry navigation", () => {
});
});
describe("external entry facebook identity binding", () => {
it("binds only a ready real-user session with an external id", () => {
expect(
shouldBindExternalFacebookIdentity({
hasInitialized: true,
isLoading: false,
loginStatus: "email",
asid: "asid-1",
}),
).toBe(true);
expect(
shouldBindExternalFacebookIdentity({
hasInitialized: true,
isLoading: false,
loginStatus: "google",
asid: "asid-1",
}),
).toBe(true);
expect(
shouldBindExternalFacebookIdentity({
hasInitialized: true,
isLoading: false,
loginStatus: "facebook",
psid: "psid-1",
}),
).toBe(true);
});
it("skips guest, notLoggedIn, loading, and empty identity inputs", () => {
expect(
shouldBindExternalFacebookIdentity({
hasInitialized: true,
isLoading: false,
loginStatus: "guest",
asid: "asid-1",
}),
).toBe(false);
expect(
shouldBindExternalFacebookIdentity({
hasInitialized: true,
isLoading: false,
loginStatus: "notLoggedIn",
psid: "psid-1",
}),
).toBe(false);
expect(
shouldBindExternalFacebookIdentity({
hasInitialized: true,
isLoading: true,
loginStatus: "google",
asid: "asid-1",
}),
).toBe(false);
expect(
shouldBindExternalFacebookIdentity({
hasInitialized: true,
isLoading: false,
loginStatus: "google",
}),
).toBe(false);
});
});
describe("external entry promotion", () => {
it("accepts only explicit promotion mode and supported message types", () => {
expect(
+28
View File
@@ -3,6 +3,7 @@
import { AuthStorage } from "@/data/storage/auth/auth_storage";
import { UserStorage } from "@/data/storage/user/user_storage";
import type { PendingChatPromotionType } from "@/data/storage/navigation";
import type { LoginStatus } from "@/data/dto/auth";
import { ROUTES } from "@/router/routes";
export type ExternalEntryTarget =
@@ -26,6 +27,33 @@ export interface ExternalEntryPromotionInput {
promotionType?: string | null;
}
export interface ExternalFacebookIdentityBindingInput {
hasInitialized: boolean;
isLoading: boolean;
loginStatus: LoginStatus;
asid?: string | null;
psid?: string | null;
}
export function shouldBindExternalFacebookIdentity({
hasInitialized,
isLoading,
loginStatus,
asid,
psid,
}: ExternalFacebookIdentityBindingInput): boolean {
const isRealUser =
loginStatus === "email" ||
loginStatus === "google" ||
loginStatus === "facebook";
return (
hasInitialized &&
!isLoading &&
isRealUser &&
(hasValue(asid) || hasValue(psid))
);
}
export function resolveExternalEntryPromotionType({
mode,
promotionType,
+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";