feat(auth): implement Facebook identity binding on external entry
This commit is contained in:
@@ -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,42 +39,87 @@ export default function ExternalEntryPersist({
|
|||||||
promotionType,
|
promotionType,
|
||||||
}: ExternalEntryPersistProps) {
|
}: ExternalEntryPersistProps) {
|
||||||
const router = useRouter();
|
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(() => {
|
useEffect(() => {
|
||||||
const destination = resolveExternalEntryTarget({ target });
|
let cancelled = false;
|
||||||
const resolvedPromotionType = resolveExternalEntryPromotionType({
|
|
||||||
mode,
|
|
||||||
promotionType,
|
|
||||||
});
|
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
const results = await Promise.allSettled([
|
||||||
await Promise.all([
|
persistExternalEntryPayload({
|
||||||
persistExternalEntryPayload({
|
deviceId,
|
||||||
deviceId,
|
asid,
|
||||||
asid,
|
psid,
|
||||||
psid,
|
avatarUrl,
|
||||||
avatarUrl,
|
}),
|
||||||
}),
|
destination === ROUTES.chat && resolvedPromotionType
|
||||||
destination === ROUTES.chat && resolvedPromotionType
|
? savePendingChatPromotion(resolvedPromotionType)
|
||||||
? savePendingChatPromotion(resolvedPromotionType)
|
: clearPendingChatPromotion(),
|
||||||
: clearPendingChatPromotion(),
|
]);
|
||||||
]);
|
for (const result of results) {
|
||||||
} catch (error) {
|
if (result.status === "rejected") {
|
||||||
log.warn("[ExternalEntryPersist] failed to persist payload", error);
|
log.warn(
|
||||||
} finally {
|
"[ExternalEntryPersist] failed to persist payload",
|
||||||
router.replace(destination);
|
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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user