From fd631168c86f97daee4cb11b169a5e9fee9e9796 Mon Sep 17 00:00:00 2001 From: chenhang Date: Mon, 13 Jul 2026 15:02:01 +0800 Subject: [PATCH] feat(auth): implement Facebook identity binding on external entry --- docs/external-entry.md | 2 + .../external-entry/external-entry-persist.tsx | 93 ++++++++++++++----- .../dto/chat/request/send_message_request.ts | 1 - .../dto/chat/response/chat_send_response.ts | 2 +- .../__tests__/external_entry.test.ts | 64 +++++++++++++ src/lib/navigation/external_entry.ts | 28 ++++++ .../auth/__tests__/auth-machine.test.ts | 73 ++++++++++++++- src/stores/auth/auth-actors.ts | 29 +++--- src/stores/auth/auth-events.ts | 3 + src/stores/auth/auth-machine.ts | 19 +++- src/stores/auth/auth-status-checker.tsx | 4 +- 11 files changed, 278 insertions(+), 40 deletions(-) diff --git a/docs/external-entry.md b/docs/external-entry.md index 6a7d2d11..08b90332 100644 --- a/docs/external-entry.md +++ b/docs/external-entry.md @@ -8,6 +8,8 @@ https:///external-entry 入口会先保存传入的身份数据,再使用 `replace` 跳转到目标页面并清理地址栏中的参数。 +如果进入时已经是 Email、Google 或 Facebook 正式登录用户,并且携带 ASID 或 PSID,入口会额外调用一次 Facebook Identity 绑定接口。普通启动、刷新、Guest 和未登录状态不会调用绑定接口,绑定失败也不会阻止目标页面跳转。 + ## 参数 | 参数 | 必填 | 可选值 | 说明 | diff --git a/src/app/external-entry/external-entry-persist.tsx b/src/app/external-entry/external-entry-persist.tsx index 18ef6de0..20c9c90e 100644 --- a/src/app/external-entry/external-entry-persist.tsx +++ b/src/app/external-entry/external-entry-persist.tsx @@ -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,42 +39,87 @@ export default function ExternalEntryPersist({ promotionType, }: ExternalEntryPersistProps) { const router = useRouter(); + 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(() => { - const destination = resolveExternalEntryTarget({ target }); - const resolvedPromotionType = resolveExternalEntryPromotionType({ - mode, - promotionType, - }); + let cancelled = false; void (async () => { - try { - await Promise.all([ - persistExternalEntryPayload({ - deviceId, - asid, - psid, - avatarUrl, - }), - destination === ROUTES.chat && resolvedPromotionType - ? savePendingChatPromotion(resolvedPromotionType) - : clearPendingChatPromotion(), - ]); - } catch (error) { - log.warn("[ExternalEntryPersist] failed to persist payload", error); - } finally { - router.replace(destination); + const results = await Promise.allSettled([ + persistExternalEntryPayload({ + deviceId, + asid, + psid, + avatarUrl, + }), + destination === ROUTES.chat && resolvedPromotionType + ? savePendingChatPromotion(resolvedPromotionType) + : clearPendingChatPromotion(), + ]); + 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 ( diff --git a/src/data/dto/chat/request/send_message_request.ts b/src/data/dto/chat/request/send_message_request.ts index 41ad8f04..b93b9197 100644 --- a/src/data/dto/chat/request/send_message_request.ts +++ b/src/data/dto/chat/request/send_message_request.ts @@ -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); diff --git a/src/data/dto/chat/response/chat_send_response.ts b/src/data/dto/chat/response/chat_send_response.ts index 36bc5609..c5c0c101 100644 --- a/src/data/dto/chat/response/chat_send_response.ts +++ b/src/data/dto/chat/response/chat_send_response.ts @@ -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; diff --git a/src/lib/navigation/__tests__/external_entry.test.ts b/src/lib/navigation/__tests__/external_entry.test.ts index 3f4a8cc1..f1609f63 100644 --- a/src/lib/navigation/__tests__/external_entry.test.ts +++ b/src/lib/navigation/__tests__/external_entry.test.ts @@ -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( diff --git a/src/lib/navigation/external_entry.ts b/src/lib/navigation/external_entry.ts index bd8bb72e..be45a7f9 100644 --- a/src/lib/navigation/external_entry.ts +++ b/src/lib/navigation/external_entry.ts @@ -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, diff --git a/src/stores/auth/__tests__/auth-machine.test.ts b/src/stores/auth/__tests__/auth-machine.test.ts index 7bb1cc0f..efc73986 100644 --- a/src/stores/auth/__tests__/auth-machine.test.ts +++ b/src/stores/auth/__tests__/auth-machine.test.ts @@ -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( async () => overrides.checkAuthStatus ?? "notLoggedIn", ), + bindFacebookIdentity: fromPromise< + void, + BindFacebookIdentityInput + >(async ({ input }) => { + bindFacebookIdentitySpy(input); + }), guestLogin: fromPromise( 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(); }); diff --git a/src/stores/auth/auth-actors.ts b/src/stores/auth/auth-actors.ts index 0b6d6424..52c26a85 100644 --- a/src/stores/auth/auth-actors.ts +++ b/src/stores/auth/auth-actors.ts @@ -239,6 +239,24 @@ export const guestLoginActor = fromPromise(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(async () => { @@ -259,17 +277,6 @@ export const checkAuthStatusActor = fromPromise(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; } diff --git a/src/stores/auth/auth-events.ts b/src/stores/auth/auth-events.ts index 3c518d27..7cdc254d 100644 --- a/src/stores/auth/auth-events.ts +++ b/src/stores/auth/auth-events.ts @@ -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 } diff --git a/src/stores/auth/auth-machine.ts b/src/stores/auth/auth-machine.ts index 72856977..d33ef3d7 100644 --- a/src/stores/auth/auth-machine.ts +++ b/src/stores/auth/auth-machine.ts @@ -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 到状态机 —— 由 派发 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: { diff --git a/src/stores/auth/auth-status-checker.tsx b/src/stores/auth/auth-status-checker.tsx index 71308e61..c59a2143 100644 --- a/src/stores/auth/auth-status-checker.tsx +++ b/src/stores/auth/auth-status-checker.tsx @@ -1,11 +1,11 @@ "use client"; /** - * AuthInit 派发器 —— App 启动时恢复登录态并同步 Facebook identity + * AuthInit 派发器 —— App 启动时恢复登录态 * * 派发时机:仅 mount 时一次(`useEffect` deps = `[dispatch]`,永远不重跑)。 * * 与 平级: - * - AuthStatusChecker: 启动时先跑 storage migration,再恢复登录态、绑定 ASID/PSID、尝试 PSID 直登 + * - AuthStatusChecker: 启动时先跑 storage migration,再恢复登录态、尝试 PSID 直登 * - OAuthSessionSync: 持续监听 NextAuth session("别人在写") */ import { useEffect } from "react";