feat(auth): implement Facebook identity binding on external entry
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user