From 662e5e67ea45f77e175fbe6f521a7e88d7bcd303 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 27 Jul 2026 17:16:27 +0800 Subject: [PATCH] feat(auth): add Messenger topup handoff entry --- e2e/fixtures/api-mocks.ts | 6 +- e2e/fixtures/api/auth.ts | 11 ++- e2e/fixtures/data/auth.ts | 22 ++--- .../psid-login-from-external-entry.spec.ts | 73 ++++++++++------ .../external-entry/external-entry-persist.tsx | 79 +++++++++++++++--- src/app/external-entry/page.tsx | 2 + .../auth_repository.facebook_identity.test.ts | 83 ------------------- .../__tests__/auth_repository.session.test.ts | 23 +++++ src/data/repositories/auth_repository.ts | 57 ++----------- .../interfaces/iauth_repository.ts | 8 +- .../auth/__tests__/facebook_identity.test.ts | 52 ++++-------- src/data/schemas/auth/index.ts | 4 +- src/data/schemas/auth/login_status.ts | 1 + .../request/facebook_psid_login_request.ts | 22 ----- src/data/schemas/auth/request/index.ts | 2 +- .../auth/request/top_up_handoff_request.ts | 11 +++ .../response/facebook_psid_login_response.ts | 34 -------- src/data/schemas/auth/response/index.ts | 2 +- .../auth/response/top_up_handoff_response.ts | 23 +++++ src/data/services/api/api_contract.json | 2 +- src/data/services/api/api_path.ts | 4 +- src/data/services/api/auth_api.ts | 20 ++--- src/data/services/index.ts | 4 +- src/lib/auth/top_up_handoff.ts | 10 +++ .../__tests__/external_entry.test.ts | 9 ++ src/lib/navigation/external_entry.ts | 10 ++- .../__tests__/navigation-resolver.test.ts | 1 + src/stores/auth/machine/actors/session.ts | 15 +--- 28 files changed, 271 insertions(+), 319 deletions(-) delete mode 100644 src/data/schemas/auth/request/facebook_psid_login_request.ts create mode 100644 src/data/schemas/auth/request/top_up_handoff_request.ts delete mode 100644 src/data/schemas/auth/response/facebook_psid_login_response.ts create mode 100644 src/data/schemas/auth/response/top_up_handoff_response.ts create mode 100644 src/lib/auth/top_up_handoff.ts diff --git a/e2e/fixtures/api-mocks.ts b/e2e/fixtures/api-mocks.ts index 9460179b..8c49dcf7 100644 --- a/e2e/fixtures/api-mocks.ts +++ b/e2e/fixtures/api-mocks.ts @@ -12,7 +12,7 @@ export interface MockCoreApisOptions { paidImageFlow?: boolean; paidImageInsufficientCreditsFlow?: boolean; paidVoiceInsufficientCreditsFlow?: boolean; - psidLoginFlow?: boolean; + topUpHandoffFlow?: boolean; } export async function mockCoreApis(page: Page, options: MockCoreApisOptions = {}) { @@ -22,14 +22,14 @@ export async function mockCoreApis(page: Page, options: MockCoreApisOptions = {} paidImageFlow: options.paidImageFlow ?? false, paidImageInsufficientCreditsFlow: options.paidImageInsufficientCreditsFlow ?? false, paidVoiceInsufficientCreditsFlow: options.paidVoiceInsufficientCreditsFlow ?? false, - psidLoginFlow: options.psidLoginFlow ?? false, + topUpHandoffFlow: options.topUpHandoffFlow ?? false, }; const chatState = { hasExpiredChatSend: false, paidImageRequested: false, paidImageUnlocked: false }; await registerAuthMocks(page, { chatSendTokenRefreshFlow: chatOptions.chatSendTokenRefreshFlow, isChatSendTokenExpired: () => chatState.hasExpiredChatSend, - psidLoginFlow: chatOptions.psidLoginFlow, + topUpHandoffFlow: chatOptions.topUpHandoffFlow, }); await registerCharacterMocks(page); await registerUserMocks(page); diff --git a/e2e/fixtures/api/auth.ts b/e2e/fixtures/api/auth.ts index 7ac5a257..799cdf97 100644 --- a/e2e/fixtures/api/auth.ts +++ b/e2e/fixtures/api/auth.ts @@ -1,22 +1,21 @@ import type { Page } from "@playwright/test"; import { apiEnvelope } from "../data/common"; -import { emailLoginResponse, guestLoginResponse, psidLoginResponse, refreshedEmailLoginResponse, refreshedGuestLoginResponse } from "../data/auth"; +import { emailLoginResponse, guestLoginResponse, refreshedEmailLoginResponse, refreshedGuestLoginResponse, topUpHandoffResponse } from "../data/auth"; export interface AuthMockState { chatSendTokenRefreshFlow: boolean; isChatSendTokenExpired: () => boolean; - psidLoginFlow: boolean; + topUpHandoffFlow: boolean; } export async function registerAuthMocks(page: Page, state: AuthMockState) { - await page.route("**/api/auth/login/facebook/psid", async (route) => { - if (!state.psidLoginFlow) { + await page.route("**/api/auth/handoff/topup/consume", async (route) => { + if (!state.topUpHandoffFlow) { await route.continue(); return; } - - await route.fulfill({ json: apiEnvelope(psidLoginResponse) }); + await route.fulfill({ json: apiEnvelope(topUpHandoffResponse) }); }); await page.route("**/api/auth/guest", async (route) => { diff --git a/e2e/fixtures/data/auth.ts b/e2e/fixtures/data/auth.ts index 984a85cc..5f5c195e 100644 --- a/e2e/fixtures/data/auth.ts +++ b/e2e/fixtures/data/auth.ts @@ -50,14 +50,16 @@ export const refreshedEmailLoginResponse = { refreshToken: "e2e-refreshed-email-refresh-token", }; -export const psidLoginResponse = { - token: "e2e-psid-guest-token", - refreshToken: "", - matchedBy: "psid", - fbAsid: "", - fbPsid: "e2e-facebook-psid", - hasCompleteFacebookIdentity: false, - isGuest: true, - user: e2eUser, - userId: e2eUser.id, +export const topUpHandoffResponse = { + token: "e2e-messenger-token", + refreshToken: "e2e-messenger-refresh-token", + loginStatus: "facebookMessenger", + merged: false, + user: { + ...e2eEmailUser, + id: "user_e2e_messenger", + username: "E2E Messenger User", + email: "messenger_hash@messenger.cozsweet.invalid", + loginProvider: "facebook_messenger", + }, }; diff --git a/e2e/specs/mock/auth/psid-login-from-external-entry.spec.ts b/e2e/specs/mock/auth/psid-login-from-external-entry.spec.ts index 052fe9bb..13d888d3 100644 --- a/e2e/specs/mock/auth/psid-login-from-external-entry.spec.ts +++ b/e2e/specs/mock/auth/psid-login-from-external-entry.spec.ts @@ -1,49 +1,74 @@ import { expect, test } from "@playwright/test"; import { mockCoreApis } from "@e2e/fixtures/api-mocks"; -import { - clearBrowserState, - defaultCharacterChatUrl, -} from "@e2e/fixtures/test-helpers"; +import { clearBrowserState } from "@e2e/fixtures/test-helpers"; -const psid = "e2e-facebook-psid"; +const handoffToken = "e2e-handoff-token-that-is-at-least-32-characters"; test.beforeEach(async ({ baseURL, context, page }) => { await clearBrowserState(context, page, baseURL); - await mockCoreApis(page, { psidLoginFlow: true }); + await mockCoreApis(page, { topUpHandoffFlow: true }); }); -test("user enters from an external PSID link and becomes a guest", async ({ +test("user consumes a top-up handoff and opens credit packages as a formal Messenger user", async ({ page, }) => { - const psidLoginRequestPromise = page.waitForRequest( - "**/api/auth/login/facebook/psid", + const handoffRequestPromise = page.waitForRequest( + "**/api/auth/handoff/topup/consume", ); - await page.goto(`/external-entry?target=chat&psid=${psid}`); + await page.goto( + `/external-entry?target=topup&handoffToken=${encodeURIComponent(handoffToken)}`, + ); - const psidLoginRequest = await psidLoginRequestPromise; - expect(psidLoginRequest.method()).toBe("POST"); - expect(psidLoginRequest.postDataJSON()).toMatchObject({ - psid, - bindToGuest: true, - }); - expect(psidLoginRequest.postDataJSON().deviceId).toEqual(expect.any(String)); + const handoffRequest = await handoffRequestPromise; + expect(handoffRequest.method()).toBe("POST"); + expect(handoffRequest.postDataJSON()).toEqual({ handoffToken }); - await expect(page).toHaveURL(defaultCharacterChatUrl); - await expect(page.getByRole("textbox", { name: "Message" })).toBeVisible(); + await expect(page).toHaveURL("/subscription?type=topup"); + await expect( + page.getByRole("button", { name: /Pay and Top Up/i }), + ).toBeVisible(); await expect .poll(() => page.evaluate(() => ({ loginProvider: localStorage.getItem("cozsweet:login_provider"), - guestToken: localStorage.getItem("cozsweet:guest_token"), - psid: localStorage.getItem("cozsweet:psid"), + loginToken: localStorage.getItem("cozsweet:login_token"), + refreshToken: localStorage.getItem("cozsweet:refresh_token"), })), ) .toMatchObject({ - loginProvider: "guest", - guestToken: "e2e-psid-guest-token", - psid, + loginProvider: "facebookMessenger", + loginToken: "e2e-messenger-token", + refreshToken: "e2e-messenger-refresh-token", }); }); + +test("invalid top-up handoff does not log in and is removed from the URL", async ({ + page, +}) => { + await page.route("**/api/auth/handoff/topup/consume", async (route) => { + await route.fulfill({ + status: 410, + json: { + detail: { + code: "HANDOFF_EXPIRED", + message: "充值链接已过期", + }, + }, + }); + }); + + await page.goto( + `/external-entry?target=topup&handoffToken=${encodeURIComponent(handoffToken)}`, + ); + + await expect(page).toHaveURL("/external-entry?target=topup"); + await expect( + page.getByText(/invalid or has expired/i), + ).toBeVisible(); + await expect + .poll(() => page.evaluate(() => localStorage.getItem("cozsweet:login_provider"))) + .not.toBe("facebookMessenger"); +}); diff --git a/src/app/external-entry/external-entry-persist.tsx b/src/app/external-entry/external-entry-persist.tsx index f81ff22e..ff96ab9e 100644 --- a/src/app/external-entry/external-entry-persist.tsx +++ b/src/app/external-entry/external-entry-persist.tsx @@ -21,6 +21,8 @@ import { } from "@/data/constants/character"; import { Logger } from "@/utils/logger"; import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context"; +import { consumeTopUpHandoff } from "@/lib/auth/top_up_handoff"; +import { Result } from "@/utils/result"; import { isFavoriteEntryRequest, persistFavoriteEntryIntent, @@ -38,6 +40,7 @@ interface ExternalEntryPersistProps { mode: string | null; promotionType: string | null; favorite: string | null; + handoffToken: string | null; } export default function ExternalEntryPersist({ @@ -50,13 +53,16 @@ export default function ExternalEntryPersist({ mode, promotionType, favorite, + handoffToken, }: ExternalEntryPersistProps) { const router = useRouter(); const authState = useAuthState(); const authDispatch = useAuthDispatch(); const [hasPersisted, setHasPersisted] = useState(false); + const [handoffError, setHandoffError] = useState(null); + const [handoffCompleted, setHandoffCompleted] = useState(false); const hasNavigatedRef = useRef(false); - const hasReinitializedForPsidRef = useRef(false); + const handoffStartedRef = useRef(false); const targetRoute = resolveExternalEntryTarget({ target }); const destination = resolveExternalEntryDestination({ target, character }); const characterId = @@ -65,6 +71,12 @@ export default function ExternalEntryPersist({ mode, promotionType, }); + const isTopUpHandoff = targetRoute === ROUTES.subscription; + const displayedHandoffError = + handoffError ?? + (isTopUpHandoff && !hasValue(handoffToken) + ? "This top-up link is invalid. Please request a new link in Messenger." + : null); useEffect(() => { let cancelled = false; @@ -116,12 +128,39 @@ export default function ExternalEntryPersist({ useEffect(() => { if (hasNavigatedRef.current || !hasPersisted) return; - // AuthStatusChecker can initialize before this entry has persisted PSID. - // Re-run the check so PSID direct login is not skipped by that race. - if (hasValue(psid) && !hasReinitializedForPsidRef.current) { + if (isTopUpHandoff) { if (!authState.hasInitialized || authState.isLoading) return; - hasReinitializedForPsidRef.current = true; - authDispatch({ type: "AuthInit" }); + if (handoffCompleted) { + if ( + authState.loginStatus === "notLoggedIn" || + authState.loginStatus === "guest" + ) { + return; + } + hasNavigatedRef.current = true; + router.replace(destination); + return; + } + if (handoffStartedRef.current) return; + if (!hasValue(handoffToken)) return; + handoffStartedRef.current = true; + void (async () => { + const result = await consumeTopUpHandoff(handoffToken); + if (Result.isErr(result)) { + log.warn("[ExternalEntryPersist] top-up handoff failed", result.error); + window.history.replaceState( + window.history.state, + "", + "/external-entry?target=topup", + ); + setHandoffError( + "This top-up link is invalid or has expired. Please request a new link in Messenger.", + ); + return; + } + setHandoffCompleted(true); + authDispatch({ type: "AuthInit" }); + })(); return; } @@ -152,6 +191,9 @@ export default function ExternalEntryPersist({ authState.loginStatus, destination, hasPersisted, + handoffCompleted, + handoffToken, + isTopUpHandoff, psid, router, ]); @@ -161,11 +203,26 @@ export default function ExternalEntryPersist({ className="flex flex-1 items-center justify-center" style={{ minHeight: "100dvh" }} > -
+ {displayedHandoffError ? ( +
+

+ {displayedHandoffError} +

+ +
+ ) : ( +
+ )}
); } diff --git a/src/app/external-entry/page.tsx b/src/app/external-entry/page.tsx index 5303631c..6dfc6319 100644 --- a/src/app/external-entry/page.tsx +++ b/src/app/external-entry/page.tsx @@ -7,6 +7,7 @@ * `/external-entry?target=chat&character=maya` * `/external-entry?target=tip` * `/external-entry?target=private-zone&character=nayeli` + * `/external-entry?target=topup&handoffToken=` * * 页面不直接 `redirect()`,而是把数据交给 Client 组件先写入本地存储, * 再通过 `router.replace()` 清理 URL 并跳转到最终页面。 @@ -36,6 +37,7 @@ export default async function ExternalEntryPage({ mode={pickParam(params.mode)} promotionType={pickParam(params.promotion_type)} favorite={pickParam(params.favorite)} + handoffToken={pickParam(params.handoffToken)} /> ); } diff --git a/src/data/repositories/__tests__/auth_repository.facebook_identity.test.ts b/src/data/repositories/__tests__/auth_repository.facebook_identity.test.ts index c340c4b1..cdace6d1 100644 --- a/src/data/repositories/__tests__/auth_repository.facebook_identity.test.ts +++ b/src/data/repositories/__tests__/auth_repository.facebook_identity.test.ts @@ -3,8 +3,6 @@ import { describe, expect, it, vi } from "vitest"; import { AuthRepository } from "@/data/repositories/auth_repository"; import { FacebookIdentityResponseSchema, - FacebookPsidLoginResponseSchema, - LoginStatus, } from "@/data/schemas/auth"; import type { AuthApi } from "@/data/services/api"; import type { IAuthStorage } from "@/data/storage/auth"; @@ -54,85 +52,4 @@ describe("AuthRepository facebook identity", () => { expect(storage.setAsid).toHaveBeenCalledWith("asid-1"); expect(storage.setPsid).toHaveBeenCalledWith("psid-1"); }); - - it("saves real login data from complete psid login responses", async () => { - const facebookPsidLogin = vi.fn(async () => - FacebookPsidLoginResponseSchema.parse({ - token: "login-token", - refreshToken: "refresh-token", - matchedBy: "psid", - fbAsid: "asid-1", - fbPsid: "psid-1", - hasCompleteFacebookIdentity: true, - isGuest: false, - user: { id: "user-1", username: "Elio" }, - }), - ); - const { repository, storage, userStorage } = createRepository({ - facebookPsidLogin, - }); - - const result = await repository.facebookPsidLogin({ - psid: "psid-1", - deviceId: "device-1", - }); - - expect(result).toEqual(Result.ok(LoginStatus.Facebook)); - expect(storage.setLoginToken).toHaveBeenCalledWith("login-token"); - expect(storage.setRefreshToken).toHaveBeenCalledWith("refresh-token"); - expect(storage.setLoginProvider).toHaveBeenCalledWith(LoginStatus.Facebook); - expect(userStorage.setUserId).toHaveBeenCalledWith("user-1"); - expect(storage.setAsid).toHaveBeenCalledWith("asid-1"); - expect(storage.setPsid).toHaveBeenCalledWith("psid-1"); - }); - - it("saves guest data from incomplete psid login responses", async () => { - const facebookPsidLogin = vi.fn(async () => - FacebookPsidLoginResponseSchema.parse({ - token: "guest-token", - matchedBy: "psid_incomplete", - fbPsid: "psid-1", - hasCompleteFacebookIdentity: false, - isGuest: true, - userId: "guest-1", - }), - ); - const { repository, storage, userStorage } = createRepository({ - facebookPsidLogin, - }); - - const result = await repository.facebookPsidLogin({ - psid: "psid-1", - deviceId: "device-1", - }); - - expect(result).toEqual(Result.ok(LoginStatus.Guest)); - expect(storage.setGuestToken).toHaveBeenCalledWith("guest-token"); - expect(storage.setLoginProvider).toHaveBeenCalledWith(LoginStatus.Guest); - expect(storage.setDeviceId).toHaveBeenCalledWith("device-1"); - expect(userStorage.setUserId).toHaveBeenCalledWith("guest-1"); - }); - - it("does not report guest psid login success when session persistence fails", async () => { - const storageError = new Error("guest token write failed"); - const facebookPsidLogin = vi.fn(async () => - FacebookPsidLoginResponseSchema.parse({ - token: "guest-token", - matchedBy: "psid_incomplete", - fbPsid: "psid-1", - hasCompleteFacebookIdentity: false, - isGuest: true, - userId: "guest-1", - }), - ); - const { repository, storage } = createRepository({ facebookPsidLogin }); - vi.mocked(storage.setGuestToken).mockResolvedValueOnce( - Result.err(storageError), - ); - - const result = await repository.facebookPsidLogin({ psid: "psid-1" }); - - expect(Result.isErr(result)).toBe(true); - expect(storage.setLoginProvider).not.toHaveBeenCalled(); - }); }); diff --git a/src/data/repositories/__tests__/auth_repository.session.test.ts b/src/data/repositories/__tests__/auth_repository.session.test.ts index dc20d7ed..84087b44 100644 --- a/src/data/repositories/__tests__/auth_repository.session.test.ts +++ b/src/data/repositories/__tests__/auth_repository.session.test.ts @@ -239,4 +239,27 @@ describe("AuthRepository session Result handling", () => { expect(result).toEqual(Result.ok(loginResponse.user)); }); + + it("persists a top-up handoff as a formal Messenger session", async () => { + const handoffResponse = { + ...loginResponse, + loginStatus: LoginStatus.FacebookMessenger, + merged: false, + } as const; + const { repository, storage, userStorage } = createRepository({ + api: { + consumeTopUpHandoff: vi.fn(async () => handoffResponse), + }, + }); + + const result = await repository.consumeTopUpHandoff("x".repeat(32)); + + expect(result).toEqual(Result.ok(LoginStatus.FacebookMessenger)); + expect(storage.setLoginToken).toHaveBeenCalledWith("login-token"); + expect(storage.setRefreshToken).toHaveBeenCalledWith("refresh-token"); + expect(userStorage.setUserId).toHaveBeenCalledWith("user-1"); + expect(storage.setLoginProvider).toHaveBeenCalledWith( + LoginStatus.FacebookMessenger, + ); + }); }); diff --git a/src/data/repositories/auth_repository.ts b/src/data/repositories/auth_repository.ts index d7242815..c581a79b 100644 --- a/src/data/repositories/auth_repository.ts +++ b/src/data/repositories/auth_repository.ts @@ -3,18 +3,17 @@ import type { IAuthRepository } from "@/data/repositories/interfaces"; import { FacebookIdentityRequestSchema, FacebookLoginRequestSchema, - FacebookPsidLoginRequestSchema, FbIdLoginRequestSchema, GoogleLoginRequestSchema, GuestLoginRequestSchema, GuestLoginResponse, LoginRequestSchema, LoginResponse, - LoginResponseSchema, LoginStatus, RefreshTokenRequestSchema, RefreshTokenResponse, RegisterRequestSchema, + TopUpHandoffRequestSchema, type LoginStatus as LoginStatusT, } from "@/data/schemas/auth"; import { User } from "@/data/schemas/user"; @@ -264,54 +263,16 @@ export class AuthRepository implements IAuthRepository { }); } - /** 通过 Facebook PSID 直接登录或返回游客态。 */ - async facebookPsidLogin(input: { - psid: string; - deviceId?: string; - bindToGuest?: boolean; - }): Promise> { + /** 消费一次性充值凭证并按后端返回的 canonical 账号建立正式会话。 */ + async consumeTopUpHandoff( + handoffToken: string, + ): Promise> { return Result.wrap(async () => { - const response = await this.api.facebookPsidLogin( - FacebookPsidLoginRequestSchema.parse({ - psid: input.psid, - deviceId: input.deviceId ?? "", - bindToGuest: input.bindToGuest ?? true, - }), + const response = await this.api.consumeTopUpHandoff( + TopUpHandoffRequestSchema.parse({ handoffToken }), ); - const data = response; - await this._saveFacebookIdentity({ - asid: data.fbAsid, - psid: data.fbPsid, - }); - - if (data.isGuest || !data.hasCompleteFacebookIdentity) { - const tokenResult = await this.storage.setGuestToken(data.token); - if (Result.isErr(tokenResult)) throw tokenResult.error; - if (input.deviceId) { - const deviceResult = await this.storage.setDeviceId(input.deviceId); - if (Result.isErr(deviceResult)) throw deviceResult.error; - } - const userId = data.userId || data.user?.id || ""; - if (userId) { - const userIdResult = await this.userStorage.setUserId(userId); - if (Result.isErr(userIdResult)) throw userIdResult.error; - } - const providerResult = await this.storage.setLoginProvider( - LoginStatus.Guest, - ); - if (Result.isErr(providerResult)) throw providerResult.error; - return LoginStatus.Guest; - } - - await this._saveLoginData( - LoginResponseSchema.parse({ - token: data.token, - refreshToken: data.refreshToken, - user: data.user ?? { id: data.userId }, - }), - LoginStatus.Facebook, - ); - return LoginStatus.Facebook; + await this._saveLoginData(response, response.loginStatus); + return response.loginStatus; }); } diff --git a/src/data/repositories/interfaces/iauth_repository.ts b/src/data/repositories/interfaces/iauth_repository.ts index 79f90b7c..4a2e55a0 100644 --- a/src/data/repositories/interfaces/iauth_repository.ts +++ b/src/data/repositories/interfaces/iauth_repository.ts @@ -71,12 +71,8 @@ export interface IAuthRepository { psid?: string; }): Promise>; - /** 通过 Facebook PSID 直接登录或返回游客态。 */ - facebookPsidLogin(input: { - psid: string; - deviceId?: string; - bindToGuest?: boolean; - }): Promise>; + /** 消费一次性充值登录凭证并建立正式会话。 */ + consumeTopUpHandoff(handoffToken: string): Promise>; /** 刷新 token:先读本地的 refresh token,空则返回错误。 */ refreshToken(): Promise>; diff --git a/src/data/schemas/auth/__tests__/facebook_identity.test.ts b/src/data/schemas/auth/__tests__/facebook_identity.test.ts index e7b9d5d5..23605fc7 100644 --- a/src/data/schemas/auth/__tests__/facebook_identity.test.ts +++ b/src/data/schemas/auth/__tests__/facebook_identity.test.ts @@ -2,8 +2,8 @@ import { describe, expect, it } from "vitest"; import { FacebookIdentityResponseSchema, - FacebookPsidLoginResponseSchema, LoginRequestSchema, + TopUpHandoffResponseSchema, } from "@/data/schemas/auth"; import { UserSchema } from "@/data/schemas/user/user"; @@ -28,41 +28,6 @@ describe("facebook identity schema models", () => { }); }); - it("parses facebook psid login responses for real users and guests", () => { - expect( - FacebookPsidLoginResponseSchema.parse({ - token: "login-token", - refreshToken: "refresh-token", - matchedBy: "psid", - fbAsid: "asid-1", - fbPsid: "psid-1", - hasCompleteFacebookIdentity: true, - isGuest: false, - user: { id: "user-1", username: "Elio" }, - }), - ).toMatchObject({ - isGuest: false, - hasCompleteFacebookIdentity: true, - user: { id: "user-1" }, - }); - - expect( - FacebookPsidLoginResponseSchema.parse({ - token: "guest-token", - matchedBy: "guest", - fbPsid: "psid-1", - hasCompleteFacebookIdentity: false, - isGuest: true, - userId: "guest-1", - }), - ).toMatchObject({ - refreshToken: "", - isGuest: true, - userId: "guest-1", - user: null, - }); - }); - it("keeps psid in request serialization without a public field declaration", () => { const request = LoginRequestSchema.parse({ email: "user@example.com", @@ -75,6 +40,21 @@ describe("facebook identity schema models", () => { expect(request).toMatchObject({ psid: "psid-1" }); }); + it("parses a formal Messenger top-up handoff session", () => { + expect( + TopUpHandoffResponseSchema.parse({ + token: "login-token", + refreshToken: "refresh-token", + loginStatus: "facebookMessenger", + merged: false, + user: { id: "user-1", username: "Messenger User" }, + }), + ).toMatchObject({ + loginStatus: "facebookMessenger", + user: { id: "user-1" }, + }); + }); + it("accepts facebook identity fields in user schema", () => { expect( UserSchema.parse({ diff --git a/src/data/schemas/auth/index.ts b/src/data/schemas/auth/index.ts index d9e3eb62..0446d39b 100644 --- a/src/data/schemas/auth/index.ts +++ b/src/data/schemas/auth/index.ts @@ -6,16 +6,16 @@ export * from "./facebook_user_data"; export * from "./login_status"; export * from "./request/facebook_identity_request"; export * from "./request/facebook_login_request"; -export * from "./request/facebook_psid_login_request"; export * from "./request/fb_id_login_request"; export * from "./request/google_login_request"; export * from "./request/guest_login_request"; export * from "./request/login_request"; export * from "./request/refresh_token_request"; export * from "./request/register_request"; +export * from "./request/top_up_handoff_request"; export * from "./response/facebook_identity_response"; -export * from "./response/facebook_psid_login_response"; export * from "./response/guest_login_response"; export * from "./response/login_response"; export * from "./response/logout_response"; export * from "./response/refresh_token_response"; +export * from "./response/top_up_handoff_response"; diff --git a/src/data/schemas/auth/login_status.ts b/src/data/schemas/auth/login_status.ts index 5800214c..e4087cd0 100644 --- a/src/data/schemas/auth/login_status.ts +++ b/src/data/schemas/auth/login_status.ts @@ -3,6 +3,7 @@ export const LoginStatus = { NotLoggedIn: "notLoggedIn", Guest: "guest", Facebook: "facebook", + FacebookMessenger: "facebookMessenger", Google: "google", Email: "email", } as const; diff --git a/src/data/schemas/auth/request/facebook_psid_login_request.ts b/src/data/schemas/auth/request/facebook_psid_login_request.ts deleted file mode 100644 index dc95d96d..00000000 --- a/src/data/schemas/auth/request/facebook_psid_login_request.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Facebook PSID 登录请求 - */ -import { z } from "zod"; - -import { booleanOrTrue, stringOrEmpty } from "../../nullable-defaults"; - -export const FacebookPsidLoginRequestSchema = z - .object({ - psid: z.string(), - deviceId: stringOrEmpty, - bindToGuest: booleanOrTrue, - }) - .readonly(); - -export type FacebookPsidLoginRequestInput = z.input< - typeof FacebookPsidLoginRequestSchema ->; -export type FacebookPsidLoginRequestData = z.output< - typeof FacebookPsidLoginRequestSchema ->; -export type FacebookPsidLoginRequest = FacebookPsidLoginRequestData; diff --git a/src/data/schemas/auth/request/index.ts b/src/data/schemas/auth/request/index.ts index a603be8b..0889a742 100644 --- a/src/data/schemas/auth/request/index.ts +++ b/src/data/schemas/auth/request/index.ts @@ -4,10 +4,10 @@ export * from "./facebook_identity_request"; export * from "./facebook_login_request"; -export * from "./facebook_psid_login_request"; export * from "./fb_id_login_request"; export * from "./google_login_request"; export * from "./guest_login_request"; export * from "./login_request"; export * from "./refresh_token_request"; export * from "./register_request"; +export * from "./top_up_handoff_request"; diff --git a/src/data/schemas/auth/request/top_up_handoff_request.ts b/src/data/schemas/auth/request/top_up_handoff_request.ts new file mode 100644 index 00000000..df0f5e56 --- /dev/null +++ b/src/data/schemas/auth/request/top_up_handoff_request.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +export const TopUpHandoffRequestSchema = z + .object({ + handoffToken: z.string().min(32).max(512), + }) + .readonly(); + +export type TopUpHandoffRequest = z.output< + typeof TopUpHandoffRequestSchema +>; diff --git a/src/data/schemas/auth/response/facebook_psid_login_response.ts b/src/data/schemas/auth/response/facebook_psid_login_response.ts deleted file mode 100644 index 10217c26..00000000 --- a/src/data/schemas/auth/response/facebook_psid_login_response.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Facebook PSID 登录响应 - */ -import { z } from "zod"; - -import { - booleanOrFalse, - schemaOrNull, - stringOrEmpty, -} from "../../nullable-defaults"; -import { UserSchema } from "../../user/user"; - -export const FacebookPsidLoginResponseSchema = z - .object({ - token: z.string(), - refreshToken: stringOrEmpty, - matchedBy: stringOrEmpty, - fbAsid: stringOrEmpty, - fbPsid: stringOrEmpty, - hasCompleteFacebookIdentity: booleanOrFalse, - isGuest: booleanOrFalse, - user: schemaOrNull(UserSchema), - userId: stringOrEmpty, - }) - .readonly(); - -export type FacebookPsidLoginResponseInput = z.input< - typeof FacebookPsidLoginResponseSchema ->; -export type FacebookPsidLoginResponseData = z.output< - typeof FacebookPsidLoginResponseSchema ->; - -export type FacebookPsidLoginResponse = FacebookPsidLoginResponseData; diff --git a/src/data/schemas/auth/response/index.ts b/src/data/schemas/auth/response/index.ts index 3d1742fe..57124030 100644 --- a/src/data/schemas/auth/response/index.ts +++ b/src/data/schemas/auth/response/index.ts @@ -3,8 +3,8 @@ */ export * from "./facebook_identity_response"; -export * from "./facebook_psid_login_response"; export * from "./guest_login_response"; export * from "./login_response"; export * from "./logout_response"; export * from "./refresh_token_response"; +export * from "./top_up_handoff_response"; diff --git a/src/data/schemas/auth/response/top_up_handoff_response.ts b/src/data/schemas/auth/response/top_up_handoff_response.ts new file mode 100644 index 00000000..d76be36a --- /dev/null +++ b/src/data/schemas/auth/response/top_up_handoff_response.ts @@ -0,0 +1,23 @@ +import { z } from "zod"; + +import { stringOrEmpty } from "../../nullable-defaults"; +import { UserSchema } from "../../user/user"; + +export const TopUpHandoffResponseSchema = z + .object({ + user: UserSchema, + token: z.string(), + refreshToken: stringOrEmpty, + loginStatus: z.enum([ + "email", + "google", + "facebook", + "facebookMessenger", + ]), + merged: z.boolean().default(false), + }) + .readonly(); + +export type TopUpHandoffResponse = z.output< + typeof TopUpHandoffResponseSchema +>; diff --git a/src/data/services/api/api_contract.json b/src/data/services/api/api_contract.json index ff1aa5d7..c78af6d7 100644 --- a/src/data/services/api/api_contract.json +++ b/src/data/services/api/api_contract.json @@ -5,7 +5,7 @@ "googleLogin": { "method": "post", "path": "/api/auth/login/google" }, "facebookLogin": { "method": "post", "path": "/api/auth/login/facebook" }, "facebookIdLogin": { "method": "post", "path": "/api/auth/login/facebook/by-id" }, - "facebookPsidLogin": { "method": "post", "path": "/api/auth/login/facebook/psid" }, + "topUpHandoffConsume": { "method": "post", "path": "/api/auth/handoff/topup/consume" }, "refresh": { "method": "post", "path": "/api/auth/refresh" }, "logout": { "method": "post", "path": "/api/auth/logout" }, "getCurrentUser": { "method": "get", "path": "/api/auth/me" }, diff --git a/src/data/services/api/api_path.ts b/src/data/services/api/api_path.ts index 885777a2..22b7e7d2 100644 --- a/src/data/services/api/api_path.ts +++ b/src/data/services/api/api_path.ts @@ -27,8 +27,8 @@ export class ApiPath { /** Facebook ID 登录(v7.0 新增) */ static readonly facebookIdLogin = apiContract.facebookIdLogin.path; - /** Facebook PSID 登录 */ - static readonly facebookPsidLogin = apiContract.facebookPsidLogin.path; + /** 消费一次性充值登录凭证 */ + static readonly topUpHandoffConsume = apiContract.topUpHandoffConsume.path; /** 刷新 Token */ static readonly refresh = apiContract.refresh.path; diff --git a/src/data/services/api/auth_api.ts b/src/data/services/api/auth_api.ts index 3a973610..c4f67254 100644 --- a/src/data/services/api/auth_api.ts +++ b/src/data/services/api/auth_api.ts @@ -11,9 +11,6 @@ import { FacebookIdentityResponse, FacebookIdentityResponseSchema, FacebookLoginRequest, - FacebookPsidLoginRequest, - FacebookPsidLoginResponse, - FacebookPsidLoginResponseSchema, FbIdLoginRequest, GoogleLoginRequest, GuestLoginRequest, @@ -26,6 +23,9 @@ import { RefreshTokenResponse, RefreshTokenResponseSchema, RegisterRequest, + TopUpHandoffRequest, + TopUpHandoffResponse, + TopUpHandoffResponseSchema, } from "@/data/schemas/auth"; import { User, UserSchema } from "@/data/schemas/user"; import type { UserData } from "@/data/schemas/user/user"; @@ -91,17 +91,15 @@ export class AuthApi { return LoginResponseSchema.parse(unwrap(env) as Record); } - /** - * Facebook PSID 登录 - */ - async facebookPsidLogin( - body: FacebookPsidLoginRequest, - ): Promise { + /** 消费一次性充值登录凭证。当前正式登录 token 会由拦截器自动携带。 */ + async consumeTopUpHandoff( + body: TopUpHandoffRequest, + ): Promise { const env = await httpClient>( - ApiPath.facebookPsidLogin, + ApiPath.topUpHandoffConsume, { method: "POST", body }, ); - return FacebookPsidLoginResponseSchema.parse( + return TopUpHandoffResponseSchema.parse( unwrap(env) as Record, ); } diff --git a/src/data/services/index.ts b/src/data/services/index.ts index a6c99f48..7e51d83d 100644 --- a/src/data/services/index.ts +++ b/src/data/services/index.ts @@ -18,8 +18,6 @@ export * from "../../core/net/interceptor/token_interceptor"; export * from "../schemas/auth/request/facebook_login_request"; export * from "../schemas/auth/request/facebook_identity_request"; export * from "../schemas/auth/response/facebook_identity_response"; -export * from "../schemas/auth/request/facebook_psid_login_request"; -export * from "../schemas/auth/response/facebook_psid_login_response"; export * from "../schemas/auth/facebook_user_data"; export * from "../schemas/auth/request/fb_id_login_request"; export * from "../schemas/auth/request/google_login_request"; @@ -31,6 +29,8 @@ export * from "../schemas/auth/response/logout_response"; export * from "../schemas/auth/request/refresh_token_request"; export * from "../schemas/auth/response/refresh_token_response"; export * from "../schemas/auth/request/register_request"; +export * from "../schemas/auth/request/top_up_handoff_request"; +export * from "../schemas/auth/response/top_up_handoff_response"; export * from "../schemas/chat/response/chat_history_response"; export * from "../schemas/chat/chat_message"; export * from "../schemas/chat/response/chat_send_response"; diff --git a/src/lib/auth/top_up_handoff.ts b/src/lib/auth/top_up_handoff.ts new file mode 100644 index 00000000..d4ffb06c --- /dev/null +++ b/src/lib/auth/top_up_handoff.ts @@ -0,0 +1,10 @@ +import type { LoginStatus } from "@/data/schemas/auth"; +import { getAuthRepository } from "@/data/repositories/auth_repository"; +import type { Result } from "@/utils/result"; + +/** UI-independent use case for establishing a top-up handoff session. */ +export function consumeTopUpHandoff( + handoffToken: string, +): Promise> { + return getAuthRepository().consumeTopUpHandoff(handoffToken); +} diff --git a/src/lib/navigation/__tests__/external_entry.test.ts b/src/lib/navigation/__tests__/external_entry.test.ts index d7f7f72c..8baeb76e 100644 --- a/src/lib/navigation/__tests__/external_entry.test.ts +++ b/src/lib/navigation/__tests__/external_entry.test.ts @@ -23,6 +23,9 @@ describe("external entry navigation", () => { expect(resolveExternalEntryTarget({ target: "private-room" })).toBe( ROUTES.privateZone, ); + expect(resolveExternalEntryTarget({ target: "topup" })).toBe( + ROUTES.subscription, + ); }); it("rejects removed aliases and unsupported targets", () => { @@ -41,6 +44,12 @@ describe("external entry navigation", () => { ); }); + it("routes a top-up handoff directly to the credit packages", () => { + expect(resolveExternalEntryDestination({ target: "topup" })).toBe( + "/subscription?type=topup", + ); + }); + it("uses a known character slug and falls back to Elio", () => { expect( resolveExternalEntryDestination({ target: "chat", character: "maya" }), diff --git a/src/lib/navigation/external_entry.ts b/src/lib/navigation/external_entry.ts index ce562ea8..8dcee9d3 100644 --- a/src/lib/navigation/external_entry.ts +++ b/src/lib/navigation/external_entry.ts @@ -13,7 +13,8 @@ import { getCharacterRoutes, ROUTES } from "@/router/routes"; export type ExternalEntryTarget = | typeof ROUTES.chat | typeof ROUTES.tip - | typeof ROUTES.privateZone; + | typeof ROUTES.privateZone + | typeof ROUTES.subscription; export interface ExternalEntryPayload { deviceId?: string | null; @@ -50,7 +51,8 @@ export function shouldBindExternalFacebookIdentity({ const isRealUser = loginStatus === "email" || loginStatus === "google" || - loginStatus === "facebook"; + loginStatus === "facebook" || + loginStatus === "facebookMessenger"; return ( hasInitialized && !isLoading && @@ -90,6 +92,9 @@ export function resolveExternalEntryDestination({ getCharacterBySlug(character)?.slug ?? DEFAULT_CHARACTER_SLUG; const routes = getCharacterRoutes(characterSlug); const resolvedTarget = resolveExternalEntryTarget({ target }); + if (resolvedTarget === ROUTES.subscription) { + return `${ROUTES.subscription}?type=topup`; + } if (resolvedTarget === ROUTES.tip) return routes.tip; if (resolvedTarget === ROUTES.privateZone) return routes.privateZone; return routes.chat; @@ -132,6 +137,7 @@ function resolveTarget( if (target === "private-zone" || target === "private-room") { return ROUTES.privateZone; } + if (target === "topup") return ROUTES.subscription; return null; } diff --git a/src/router/__tests__/navigation-resolver.test.ts b/src/router/__tests__/navigation-resolver.test.ts index cd638454..f1bbf3a8 100644 --- a/src/router/__tests__/navigation-resolver.test.ts +++ b/src/router/__tests__/navigation-resolver.test.ts @@ -13,6 +13,7 @@ describe("navigation resolver", () => { expect(isAuthenticatedUser("guest")).toBe(false); expect(isAuthenticatedUser("email")).toBe(true); expect(isAuthenticatedUser("facebook")).toBe(true); + expect(isAuthenticatedUser("facebookMessenger")).toBe(true); }); it("wraps protected navigation with auth redirect for guests", () => { diff --git a/src/stores/auth/machine/actors/session.ts b/src/stores/auth/machine/actors/session.ts index f3b03017..9ead5521 100644 --- a/src/stores/auth/machine/actors/session.ts +++ b/src/stores/auth/machine/actors/session.ts @@ -49,7 +49,6 @@ export const guestLoginActor = fromPromise(async () => { }); export const checkAuthStatusActor = fromPromise(async () => { - const deviceId = await deviceIdentifier.getDeviceId(); const storage = AuthStorage.getInstance(); const asidR = await storage.getAsid(); const psidR = await storage.getPsid(); @@ -64,19 +63,6 @@ export const checkAuthStatusActor = fromPromise(async () => { : ("email" as LoginStatus); } - if (psid) { - const psidLoginResult = await authRepo.facebookPsidLogin({ - psid, - deviceId, - bindToGuest: true, - }); - if (Result.isOk(psidLoginResult)) return psidLoginResult.data; - log.warn( - { err: psidLoginResult.error.message }, - "[auth-machine] checkAuthStatusActor: facebookPsidLogin failed", - ); - } - if (asid) { const avatarUrlR = await UserStorage.getInstance().getAvatarUrl(); const result = await authRepo.facebookAsidLogin({ @@ -105,6 +91,7 @@ function isBusinessLoginProvider( return ( provider === "email" || provider === "facebook" || + provider === "facebookMessenger" || provider === "google" ); }