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` 跳转到目标页面并清理地址栏中的参数。 入口会先保存传入的身份数据,再使用 `replace` 跳转到目标页面并清理地址栏中的参数。
如果进入时已经是 Email、Google 或 Facebook 正式登录用户,并且携带 ASID 或 PSID,入口会额外调用一次 Facebook Identity 绑定接口。普通启动、刷新、Guest 和未登录状态不会调用绑定接口,绑定失败也不会阻止目标页面跳转。
## 参数 ## 参数
| 参数 | 必填 | 可选值 | 说明 | | 参数 | 必填 | 可选值 | 说明 |
@@ -1,12 +1,13 @@
"use client"; "use client";
import { useEffect } from "react"; import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { import {
persistExternalEntryPayload, persistExternalEntryPayload,
resolveExternalEntryPromotionType, resolveExternalEntryPromotionType,
resolveExternalEntryTarget, resolveExternalEntryTarget,
shouldBindExternalFacebookIdentity,
} from "@/lib/navigation/external_entry"; } from "@/lib/navigation/external_entry";
import { import {
clearPendingChatPromotion, clearPendingChatPromotion,
@@ -14,6 +15,7 @@ import {
} from "@/lib/navigation/chat_unlock_session"; } from "@/lib/navigation/chat_unlock_session";
import { ROUTES } from "@/router/routes"; import { ROUTES } from "@/router/routes";
import { Logger } from "@/utils"; import { Logger } from "@/utils";
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
const log = new Logger("ExternalEntryPersist"); const log = new Logger("ExternalEntryPersist");
@@ -37,17 +39,21 @@ export default function ExternalEntryPersist({
promotionType, promotionType,
}: ExternalEntryPersistProps) { }: ExternalEntryPersistProps) {
const router = useRouter(); const router = useRouter();
const authState = useAuthState();
useEffect(() => { const authDispatch = useAuthDispatch();
const [hasPersisted, setHasPersisted] = useState(false);
const hasNavigatedRef = useRef(false);
const destination = resolveExternalEntryTarget({ target }); const destination = resolveExternalEntryTarget({ target });
const resolvedPromotionType = resolveExternalEntryPromotionType({ const resolvedPromotionType = resolveExternalEntryPromotionType({
mode, mode,
promotionType, promotionType,
}); });
useEffect(() => {
let cancelled = false;
void (async () => { void (async () => {
try { const results = await Promise.allSettled([
await Promise.all([
persistExternalEntryPayload({ persistExternalEntryPayload({
deviceId, deviceId,
asid, asid,
@@ -58,21 +64,62 @@ export default function ExternalEntryPersist({
? savePendingChatPromotion(resolvedPromotionType) ? savePendingChatPromotion(resolvedPromotionType)
: clearPendingChatPromotion(), : clearPendingChatPromotion(),
]); ]);
} catch (error) { for (const result of results) {
log.warn("[ExternalEntryPersist] failed to persist payload", error); if (result.status === "rejected") {
} finally { log.warn(
router.replace(destination); "[ExternalEntryPersist] failed to persist payload",
result.reason,
);
} }
}
if (!cancelled) setHasPersisted(true);
})(); })();
return () => {
cancelled = true;
};
}, [ }, [
avatarUrl, avatarUrl,
asid, asid,
destination,
deviceId, deviceId,
mode, mode,
promotionType, promotionType,
psid, 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, router,
target,
]); ]);
return ( return (
@@ -16,7 +16,6 @@ export class SendMessageRequest {
declare readonly imageOriginalUrl: string; declare readonly imageOriginalUrl: string;
declare readonly imageWidth: number; declare readonly imageWidth: number;
declare readonly imageHeight: number; declare readonly imageHeight: number;
declare readonly useWebSocket: boolean;
private constructor(input: SendMessageRequestInput) { private constructor(input: SendMessageRequestInput) {
const data = SendMessageRequestSchema.parse(input); const data = SendMessageRequestSchema.parse(input);
@@ -17,7 +17,7 @@ export class ChatSendResponse {
declare readonly image: ChatImageData; declare readonly image: ChatImageData;
declare readonly lockDetail: ChatLockDetailData; declare readonly lockDetail: ChatLockDetailData;
declare readonly canSendMessage: boolean; declare readonly canSendMessage: boolean;
declare readonly cannotSendReason: string | null; // declare readonly cannotSendReason: string | null;
declare readonly creditBalance: number; declare readonly creditBalance: number;
declare readonly creditsCharged: number; declare readonly creditsCharged: number;
declare readonly requiredCredits: number; declare readonly requiredCredits: number;
@@ -5,6 +5,7 @@ import { ROUTES } from "@/router/routes";
import { import {
resolveExternalEntryPromotionType, resolveExternalEntryPromotionType,
resolveExternalEntryTarget, resolveExternalEntryTarget,
shouldBindExternalFacebookIdentity,
} from "../external_entry"; } from "../external_entry";
describe("external entry navigation", () => { 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", () => { describe("external entry promotion", () => {
it("accepts only explicit promotion mode and supported message types", () => { it("accepts only explicit promotion mode and supported message types", () => {
expect( expect(
+28
View File
@@ -3,6 +3,7 @@
import { AuthStorage } from "@/data/storage/auth/auth_storage"; import { AuthStorage } from "@/data/storage/auth/auth_storage";
import { UserStorage } from "@/data/storage/user/user_storage"; import { UserStorage } from "@/data/storage/user/user_storage";
import type { PendingChatPromotionType } from "@/data/storage/navigation"; import type { PendingChatPromotionType } from "@/data/storage/navigation";
import type { LoginStatus } from "@/data/dto/auth";
import { ROUTES } from "@/router/routes"; import { ROUTES } from "@/router/routes";
export type ExternalEntryTarget = export type ExternalEntryTarget =
@@ -26,6 +27,33 @@ export interface ExternalEntryPromotionInput {
promotionType?: string | null; 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({ export function resolveExternalEntryPromotionType({
mode, mode,
promotionType, promotionType,
+72 -1
View File
@@ -4,6 +4,7 @@ import { createActor, fromPromise, waitFor } from "xstate";
import type { LoginStatus } from "@/data/dto/auth"; import type { LoginStatus } from "@/data/dto/auth";
import type { AuthProvider } from "@/lib/auth/auth_platform"; import type { AuthProvider } from "@/lib/auth/auth_platform";
import { authMachine } from "@/stores/auth/auth-machine"; import { authMachine } from "@/stores/auth/auth-machine";
import type { BindFacebookIdentityInput } from "@/stores/auth/auth-actors";
function createTestAuthMachine( function createTestAuthMachine(
overrides: Partial<{ overrides: Partial<{
@@ -12,16 +13,26 @@ function createTestAuthMachine(
guestLoginSpy: () => void; guestLoginSpy: () => void;
emailLogin: LoginStatus; emailLogin: LoginStatus;
logoutSpy: () => void; logoutSpy: () => void;
bindFacebookIdentitySpy: (input: BindFacebookIdentityInput) => void;
}> = {}, }> = {},
) { ) {
const logoutSpy = overrides.logoutSpy ?? vi.fn<() => void>(); const logoutSpy = overrides.logoutSpy ?? vi.fn<() => void>();
const guestLoginSpy = overrides.guestLoginSpy ?? vi.fn<() => void>(); const guestLoginSpy = overrides.guestLoginSpy ?? vi.fn<() => void>();
const bindFacebookIdentitySpy =
overrides.bindFacebookIdentitySpy ??
vi.fn<(input: BindFacebookIdentityInput) => void>();
return authMachine.provide({ return authMachine.provide({
actors: { actors: {
checkAuthStatus: fromPromise<LoginStatus, void>( checkAuthStatus: fromPromise<LoginStatus, void>(
async () => overrides.checkAuthStatus ?? "notLoggedIn", async () => overrides.checkAuthStatus ?? "notLoggedIn",
), ),
bindFacebookIdentity: fromPromise<
void,
BindFacebookIdentityInput
>(async ({ input }) => {
bindFacebookIdentitySpy(input);
}),
guestLogin: fromPromise<LoginStatus, void>( guestLogin: fromPromise<LoginStatus, void>(
async () => { async () => {
guestLoginSpy(); guestLoginSpy();
@@ -70,8 +81,14 @@ describe("authMachine", () => {
it("initializes from storage without triggering guest login", async () => { it("initializes from storage without triggering guest login", async () => {
const guestLoginSpy = vi.fn<() => void>(); const guestLoginSpy = vi.fn<() => void>();
const bindFacebookIdentitySpy =
vi.fn<(input: BindFacebookIdentityInput) => void>();
const actor = createActor( const actor = createActor(
createTestAuthMachine({ checkAuthStatus: "facebook", guestLoginSpy }), createTestAuthMachine({
checkAuthStatus: "facebook",
guestLoginSpy,
bindFacebookIdentitySpy,
}),
).start(); ).start();
actor.send({ type: "AuthInit" }); actor.send({ type: "AuthInit" });
@@ -81,6 +98,60 @@ describe("authMachine", () => {
expect(context.loginStatus).toBe("facebook"); expect(context.loginStatus).toBe("facebook");
expect(context.hasInitialized).toBe(true); expect(context.hasInitialized).toBe(true);
expect(guestLoginSpy).not.toHaveBeenCalled(); 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(); actor.stop();
}); });
+18 -11
View File
@@ -239,6 +239,24 @@ export const guestLoginActor = fromPromise<LoginStatus, void>(async () => {
return "guest" as LoginStatus; 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(只查不创) ========== // ========== 启动 / 恢复时检查登录态 actor(只查不创) ==========
export const checkAuthStatusActor = fromPromise<LoginStatus, void>(async () => { export const checkAuthStatusActor = fromPromise<LoginStatus, void>(async () => {
@@ -259,17 +277,6 @@ export const checkAuthStatusActor = fromPromise<LoginStatus, void>(async () => {
? providerR.data ? providerR.data
: ("email" as LoginStatus); : ("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; return provider;
} }
+3
View File
@@ -5,6 +5,7 @@
*/ */
import type { AuthProvider } from "@/lib/auth/auth_platform"; import type { AuthProvider } from "@/lib/auth/auth_platform";
import type { AuthPanelMode } from "@/data/dto/auth"; import type { AuthPanelMode } from "@/data/dto/auth";
import type { BindFacebookIdentityInput } from "./auth-actors";
export type AuthEvent = export type AuthEvent =
// 内部 UI 事件 // 内部 UI 事件
@@ -12,6 +13,8 @@ export type AuthEvent =
| { type: "AuthLogoutSubmitted" } | { type: "AuthLogoutSubmitted" }
/** App 启动时一次性派发 —— 读 storage 把 loginStatus 同步到状态机 */ /** App 启动时一次性派发 —— 读 storage 把 loginStatus 同步到状态机 */
| { type: "AuthInit" } | { type: "AuthInit" }
| ({ type: "AuthExternalFacebookIdentityBindRequested" } &
BindFacebookIdentityInput)
// 业务事件(提交) // 业务事件(提交)
| { type: "AuthGuestLoginSubmitted" } | { type: "AuthGuestLoginSubmitted" }
| { type: "AuthEmailLoginSubmitted"; email: string; password: string } | { type: "AuthEmailLoginSubmitted"; email: string; password: string }
+18 -1
View File
@@ -16,6 +16,7 @@ import {
syncFacebookBackendActor, syncFacebookBackendActor,
guestLoginActor, guestLoginActor,
checkAuthStatusActor, checkAuthStatusActor,
bindFacebookIdentityActor,
logoutActor, logoutActor,
} from "./auth-actors"; } from "./auth-actors";
@@ -32,7 +33,8 @@ const toAuthErrorMessage = (error: unknown): string =>
// //
// 设计:所有登录/同步流跑完都回 `idle`(带正确 `loginStatus`)。 // 设计:所有登录/同步流跑完都回 `idle`(带正确 `loginStatus`)。
// //
// 初始化会恢复已有登录态,并按后端 Facebook identity 规则处理绑定 / PSID 直登。 // 初始化会恢复已有登录态,并按后端 Facebook identity 规则处理 PSID 直登。
// Identity 绑定只响应外部入口显式派发的业务事件。
// 普通游客登录仍必须由用户明确操作触发;PSID 未匹配时后端可返回游客态。 // 普通游客登录仍必须由用户明确操作触发;PSID 未匹配时后端可返回游客态。
// ============================================================ // ============================================================
export const authMachine = setup({ export const authMachine = setup({
@@ -48,6 +50,7 @@ export const authMachine = setup({
syncFacebookBackend: syncFacebookBackendActor, syncFacebookBackend: syncFacebookBackendActor,
guestLogin: guestLoginActor, guestLogin: guestLoginActor,
checkAuthStatus: checkAuthStatusActor, checkAuthStatus: checkAuthStatusActor,
bindFacebookIdentity: bindFacebookIdentityActor,
logout: logoutActor, logout: logoutActor,
}, },
guards: { guards: {
@@ -77,6 +80,8 @@ export const authMachine = setup({
}, },
// 启动一次性 init:从 storage 同步 loginStatus 到状态机 —— 由 <AuthStatusChecker /> 派发 // 启动一次性 init:从 storage 同步 loginStatus 到状态机 —— 由 <AuthStatusChecker /> 派发
AuthInit: "initializing", AuthInit: "initializing",
AuthExternalFacebookIdentityBindRequested:
"bindingFacebookIdentity",
AuthGuestLoginSubmitted: { AuthGuestLoginSubmitted: {
guard: "canSubmitGuestLogin", 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: { loadingGuestLogin: {
entry: assign({ errorMessage: null }), entry: assign({ errorMessage: null }),
invoke: { invoke: {
+2 -2
View File
@@ -1,11 +1,11 @@
"use client"; "use client";
/** /**
* AuthInit 派发器 —— App 启动时恢复登录态并同步 Facebook identity * AuthInit 派发器 —— App 启动时恢复登录态
* *
* 派发时机:仅 mount 时一次(`useEffect` deps = `[dispatch]`,永远不重跑)。 * 派发时机:仅 mount 时一次(`useEffect` deps = `[dispatch]`,永远不重跑)。
* *
* 与 <OAuthSessionSync /> 平级: * 与 <OAuthSessionSync /> 平级:
* - AuthStatusChecker: 启动时先跑 storage migration,再恢复登录态、绑定 ASID/PSID、尝试 PSID 直登 * - AuthStatusChecker: 启动时先跑 storage migration,再恢复登录态、尝试 PSID 直登
* - OAuthSessionSync: 持续监听 NextAuth session"别人在写" * - OAuthSessionSync: 持续监听 NextAuth session"别人在写"
*/ */
import { useEffect } from "react"; import { useEffect } from "react";