From 53801490e5693283c699d33a041e8ad80b90336f Mon Sep 17 00:00:00 2001 From: chenhang Date: Fri, 10 Jul 2026 17:07:22 +0800 Subject: [PATCH] feat(auth): add facebook identity psid login --- .../auth/__tests__/facebook_identity.test.ts | 91 ++++++++++++++ .../auth/request/facebook_identity_request.ts | 28 +++++ .../auth/request/facebook_login_request.ts | 1 - .../request/facebook_psid_login_request.ts | 30 +++++ .../dto/auth/request/fb_id_login_request.ts | 1 - .../dto/auth/request/google_login_request.ts | 1 - src/data/dto/auth/request/index.ts | 2 + src/data/dto/auth/request/login_request.ts | 1 - .../response/facebook_identity_response.ts | 30 +++++ .../response/facebook_psid_login_response.ts | 32 +++++ src/data/dto/auth/response/index.ts | 2 + src/data/dto/user/user.ts | 9 +- .../auth_repository.facebook_identity.test.ts | 115 ++++++++++++++++++ src/data/repositories/auth_repository.ts | 87 +++++++++++++ .../interfaces/iauth_repository.ts | 14 +++ .../schemas/auth/facebook_identity_request.ts | 18 +++ .../auth/facebook_identity_response.ts | 27 ++++ .../auth/facebook_psid_login_request.ts | 19 +++ .../auth/facebook_psid_login_response.ts | 30 +++++ src/data/schemas/auth/index.ts | 4 + src/data/schemas/user/user.ts | 2 + src/data/services/api/api_path.ts | 6 + src/data/services/api/auth_api.ts | 34 ++++++ src/data/services/index.ts | 8 ++ src/lib/navigation/external_entry.ts | 10 +- src/stores/auth/auth-actors.ts | 63 +++++++--- src/stores/auth/auth-machine.ts | 3 +- src/stores/auth/auth-status-checker.tsx | 6 +- 28 files changed, 637 insertions(+), 37 deletions(-) create mode 100644 src/data/dto/auth/__tests__/facebook_identity.test.ts create mode 100644 src/data/dto/auth/request/facebook_identity_request.ts create mode 100644 src/data/dto/auth/request/facebook_psid_login_request.ts create mode 100644 src/data/dto/auth/response/facebook_identity_response.ts create mode 100644 src/data/dto/auth/response/facebook_psid_login_response.ts create mode 100644 src/data/repositories/__tests__/auth_repository.facebook_identity.test.ts create mode 100644 src/data/schemas/auth/facebook_identity_request.ts create mode 100644 src/data/schemas/auth/facebook_identity_response.ts create mode 100644 src/data/schemas/auth/facebook_psid_login_request.ts create mode 100644 src/data/schemas/auth/facebook_psid_login_response.ts diff --git a/src/data/dto/auth/__tests__/facebook_identity.test.ts b/src/data/dto/auth/__tests__/facebook_identity.test.ts new file mode 100644 index 00000000..9faf251c --- /dev/null +++ b/src/data/dto/auth/__tests__/facebook_identity.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; + +import { + FacebookIdentityResponse, + FacebookPsidLoginResponse, + LoginRequest, +} from "@/data/dto/auth"; +import { UserSchema } from "@/data/schemas/user/user"; + +describe("facebook identity DTO/schema", () => { + it("parses facebook identity binding responses", () => { + const response = FacebookIdentityResponse.fromJson({ + fbAsid: "asid-1", + fbPsid: "psid-1", + facebookBinding: { + conflicts: [], + bound: [{ field: "psid" }], + }, + }); + + expect(response.toJson()).toMatchObject({ + fbAsid: "asid-1", + fbPsid: "psid-1", + facebookBinding: { + conflicts: [], + bound: [{ field: "psid" }], + }, + }); + }); + + it("parses facebook psid login responses for real users and guests", () => { + expect( + FacebookPsidLoginResponse.fromJson({ + token: "login-token", + refreshToken: "refresh-token", + matchedBy: "psid", + fbAsid: "asid-1", + fbPsid: "psid-1", + hasCompleteFacebookIdentity: true, + isGuest: false, + user: { id: "user-1", username: "Elio" }, + }).toJson(), + ).toMatchObject({ + isGuest: false, + hasCompleteFacebookIdentity: true, + user: { id: "user-1" }, + }); + + expect( + FacebookPsidLoginResponse.fromJson({ + token: "guest-token", + matchedBy: "guest", + fbPsid: "psid-1", + hasCompleteFacebookIdentity: false, + isGuest: true, + userId: "guest-1", + }).toJson(), + ).toMatchObject({ + refreshToken: "", + isGuest: true, + userId: "guest-1", + user: null, + }); + }); + + it("keeps psid in request serialization without requiring DTO field declarations", () => { + const request = LoginRequest.from({ + email: "user@example.com", + password: "password", + platform: "web", + guestId: "", + psid: "psid-1", + }); + + expect(request.toJson()).toMatchObject({ psid: "psid-1" }); + }); + + it("accepts facebook identity fields in user schema", () => { + expect( + UserSchema.parse({ + id: "user-1", + username: "Elio", + fbAsid: "asid-1", + fbPsid: "psid-1", + }), + ).toMatchObject({ + fbAsid: "asid-1", + fbPsid: "psid-1", + }); + }); +}); diff --git a/src/data/dto/auth/request/facebook_identity_request.ts b/src/data/dto/auth/request/facebook_identity_request.ts new file mode 100644 index 00000000..7395ffbd --- /dev/null +++ b/src/data/dto/auth/request/facebook_identity_request.ts @@ -0,0 +1,28 @@ +/** + * Facebook identity 绑定请求 DTO + */ +import { + FacebookIdentityRequestSchema, + type FacebookIdentityRequestInput, + type FacebookIdentityRequestData, +} from "@/data/schemas/auth/facebook_identity_request"; + +export class FacebookIdentityRequest { + private constructor(input: FacebookIdentityRequestInput) { + const data = FacebookIdentityRequestSchema.parse(input); + Object.assign(this, data); + Object.freeze(this); + } + + static from(input: FacebookIdentityRequestInput): FacebookIdentityRequest { + return new FacebookIdentityRequest(input); + } + + static fromJson(json: unknown): FacebookIdentityRequest { + return FacebookIdentityRequest.from(json as FacebookIdentityRequestInput); + } + + toJson(): FacebookIdentityRequestData { + return FacebookIdentityRequestSchema.parse(this); + } +} diff --git a/src/data/dto/auth/request/facebook_login_request.ts b/src/data/dto/auth/request/facebook_login_request.ts index 2c491b61..fdff17ad 100644 --- a/src/data/dto/auth/request/facebook_login_request.ts +++ b/src/data/dto/auth/request/facebook_login_request.ts @@ -11,7 +11,6 @@ export class FacebookLoginRequest { declare readonly accessToken: string; declare readonly platform: string; declare readonly guestId: string; - declare readonly psid: string; declare readonly isTestAccount: boolean; private constructor(input: FacebookLoginRequestInput) { diff --git a/src/data/dto/auth/request/facebook_psid_login_request.ts b/src/data/dto/auth/request/facebook_psid_login_request.ts new file mode 100644 index 00000000..ac6a2dc0 --- /dev/null +++ b/src/data/dto/auth/request/facebook_psid_login_request.ts @@ -0,0 +1,30 @@ +/** + * Facebook PSID 登录请求 DTO + */ +import { + FacebookPsidLoginRequestSchema, + type FacebookPsidLoginRequestInput, + type FacebookPsidLoginRequestData, +} from "@/data/schemas/auth/facebook_psid_login_request"; + +export class FacebookPsidLoginRequest { + private constructor(input: FacebookPsidLoginRequestInput) { + const data = FacebookPsidLoginRequestSchema.parse(input); + Object.assign(this, data); + Object.freeze(this); + } + + static from(input: FacebookPsidLoginRequestInput): FacebookPsidLoginRequest { + return new FacebookPsidLoginRequest(input); + } + + static fromJson(json: unknown): FacebookPsidLoginRequest { + return FacebookPsidLoginRequest.from( + json as FacebookPsidLoginRequestInput, + ); + } + + toJson(): FacebookPsidLoginRequestData { + return FacebookPsidLoginRequestSchema.parse(this); + } +} diff --git a/src/data/dto/auth/request/fb_id_login_request.ts b/src/data/dto/auth/request/fb_id_login_request.ts index ee91fe6c..82642114 100644 --- a/src/data/dto/auth/request/fb_id_login_request.ts +++ b/src/data/dto/auth/request/fb_id_login_request.ts @@ -10,7 +10,6 @@ import { export class FbIdLoginRequest { declare readonly fbId: string; declare readonly avatarUrl: string; - declare readonly psid: string; declare readonly isTestAccount: boolean; private constructor(input: FbIdLoginRequestInput) { diff --git a/src/data/dto/auth/request/google_login_request.ts b/src/data/dto/auth/request/google_login_request.ts index 0841bfb1..964f72a3 100644 --- a/src/data/dto/auth/request/google_login_request.ts +++ b/src/data/dto/auth/request/google_login_request.ts @@ -11,7 +11,6 @@ export class GoogleLoginRequest { declare readonly idToken: string; declare readonly platform: string; declare readonly guestId: string; - declare readonly psid: string; declare readonly isTestAccount: boolean; private constructor(input: GoogleLoginRequestInput) { diff --git a/src/data/dto/auth/request/index.ts b/src/data/dto/auth/request/index.ts index 81b89442..f664f27d 100644 --- a/src/data/dto/auth/request/index.ts +++ b/src/data/dto/auth/request/index.ts @@ -1,4 +1,6 @@ +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"; diff --git a/src/data/dto/auth/request/login_request.ts b/src/data/dto/auth/request/login_request.ts index 70836f59..a5f84a74 100644 --- a/src/data/dto/auth/request/login_request.ts +++ b/src/data/dto/auth/request/login_request.ts @@ -13,7 +13,6 @@ export class LoginRequest { declare readonly password: string; declare readonly platform: string; declare readonly guestId: string; - declare readonly psid: string; declare readonly isTestAccount: boolean; private constructor(input: LoginRequestInput) { diff --git a/src/data/dto/auth/response/facebook_identity_response.ts b/src/data/dto/auth/response/facebook_identity_response.ts new file mode 100644 index 00000000..55f7456e --- /dev/null +++ b/src/data/dto/auth/response/facebook_identity_response.ts @@ -0,0 +1,30 @@ +/** + * Facebook identity 绑定响应 DTO + */ +import { + FacebookIdentityResponseSchema, + type FacebookIdentityResponseInput, + type FacebookIdentityResponseData, +} from "@/data/schemas/auth/facebook_identity_response"; + +export class FacebookIdentityResponse { + private constructor(input: FacebookIdentityResponseInput) { + const data = FacebookIdentityResponseSchema.parse(input); + Object.assign(this, data); + Object.freeze(this); + } + + static from(input: FacebookIdentityResponseInput): FacebookIdentityResponse { + return new FacebookIdentityResponse(input); + } + + static fromJson(json: unknown): FacebookIdentityResponse { + return FacebookIdentityResponse.from( + json as FacebookIdentityResponseInput, + ); + } + + toJson(): FacebookIdentityResponseData { + return FacebookIdentityResponseSchema.parse(this); + } +} diff --git a/src/data/dto/auth/response/facebook_psid_login_response.ts b/src/data/dto/auth/response/facebook_psid_login_response.ts new file mode 100644 index 00000000..422d1860 --- /dev/null +++ b/src/data/dto/auth/response/facebook_psid_login_response.ts @@ -0,0 +1,32 @@ +/** + * Facebook PSID 登录响应 DTO + */ +import { + FacebookPsidLoginResponseSchema, + type FacebookPsidLoginResponseInput, + type FacebookPsidLoginResponseData, +} from "@/data/schemas/auth/facebook_psid_login_response"; + +export class FacebookPsidLoginResponse { + private constructor(input: FacebookPsidLoginResponseInput) { + const data = FacebookPsidLoginResponseSchema.parse(input); + Object.assign(this, data); + Object.freeze(this); + } + + static from( + input: FacebookPsidLoginResponseInput, + ): FacebookPsidLoginResponse { + return new FacebookPsidLoginResponse(input); + } + + static fromJson(json: unknown): FacebookPsidLoginResponse { + return FacebookPsidLoginResponse.from( + json as FacebookPsidLoginResponseInput, + ); + } + + toJson(): FacebookPsidLoginResponseData { + return FacebookPsidLoginResponseSchema.parse(this); + } +} diff --git a/src/data/dto/auth/response/index.ts b/src/data/dto/auth/response/index.ts index c58e0078..da8cc91e 100644 --- a/src/data/dto/auth/response/index.ts +++ b/src/data/dto/auth/response/index.ts @@ -1,3 +1,5 @@ +export * from "./facebook_identity_response"; +export * from "./facebook_psid_login_response"; export * from "./guest_login_response"; export * from "./login_response"; export * from "./logout_response"; diff --git a/src/data/dto/user/user.ts b/src/data/dto/user/user.ts index 40369902..6a0932b7 100644 --- a/src/data/dto/user/user.ts +++ b/src/data/dto/user/user.ts @@ -54,10 +54,9 @@ export class User { } toJson(): UserData { - const { personalityTraits, ...rest } = this; - return { - ...rest, - personalityTraits: personalityTraits.toJson(), - }; + return UserSchema.parse({ + ...this, + personalityTraits: this.personalityTraits.toJson(), + }); } } diff --git a/src/data/repositories/__tests__/auth_repository.facebook_identity.test.ts b/src/data/repositories/__tests__/auth_repository.facebook_identity.test.ts new file mode 100644 index 00000000..2b5e82f9 --- /dev/null +++ b/src/data/repositories/__tests__/auth_repository.facebook_identity.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + FacebookIdentityResponse, + FacebookPsidLoginResponse, + LoginStatus, +} from "@/data/dto/auth"; +import { AuthRepository } from "@/data/repositories/auth_repository"; +import type { AuthApi } from "@/data/services/api"; +import type { IAuthStorage } from "@/data/storage/auth"; +import type { IUserStorage } from "@/data/storage/user"; +import { Result } from "@/utils"; + +function createRepository(overrides: Partial) { + const storage = { + setAsid: vi.fn(async () => Result.ok(undefined)), + setPsid: vi.fn(async () => Result.ok(undefined)), + setLoginToken: vi.fn(async () => Result.ok(undefined)), + setRefreshToken: vi.fn(async () => Result.ok(undefined)), + setLoginProvider: vi.fn(async () => Result.ok(undefined)), + setGuestToken: vi.fn(async () => Result.ok(undefined)), + setDeviceId: vi.fn(async () => Result.ok(undefined)), + } as unknown as IAuthStorage; + const userStorage = { + setUser: vi.fn(async () => Result.ok(undefined)), + setUserId: vi.fn(async () => Result.ok(undefined)), + } as unknown as IUserStorage; + const repository = new AuthRepository( + overrides as AuthApi, + storage, + userStorage, + ); + return { repository, storage, userStorage }; +} + +describe("AuthRepository facebook identity", () => { + it("binds facebook identity and stores returned ids", async () => { + const bindFacebookIdentity = vi.fn(async () => + FacebookIdentityResponse.fromJson({ + fbAsid: "asid-1", + fbPsid: "psid-1", + facebookBinding: { conflicts: [], bound: [] }, + }), + ); + const { repository, storage } = createRepository({ bindFacebookIdentity }); + + const result = await repository.bindFacebookIdentity({ + asid: "asid-1", + psid: "psid-1", + }); + + expect(result.success).toBe(true); + expect(bindFacebookIdentity).toHaveBeenCalledOnce(); + 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 () => + FacebookPsidLoginResponse.fromJson({ + 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 () => + FacebookPsidLoginResponse.fromJson({ + 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"); + }); +}); diff --git a/src/data/repositories/auth_repository.ts b/src/data/repositories/auth_repository.ts index dbede875..41e9cbfc 100644 --- a/src/data/repositories/auth_repository.ts +++ b/src/data/repositories/auth_repository.ts @@ -3,6 +3,8 @@ import { ExceptionHandler } from "@/core/errors"; import { ApiError, AuthApi, authApi, ErrorCode } from "@/data/services/api"; import { + FacebookIdentityRequest, + FacebookPsidLoginRequest, FacebookLoginRequest, FbIdLoginRequest, GoogleLoginRequest, @@ -230,6 +232,73 @@ export class AuthRepository implements IAuthRepository { ); } + /** 绑定 Facebook ASID / PSID 到当前登录用户。 */ + async bindFacebookIdentity(input: { + asid?: string; + psid?: string; + }): Promise> { + if (!input.asid && !input.psid) return Result.ok(undefined); + + return Result.wrap(async () => { + const response = await this.api.bindFacebookIdentity( + FacebookIdentityRequest.from({ + asid: input.asid ?? "", + psid: input.psid ?? "", + }), + ); + const data = response.toJson(); + await this._saveFacebookIdentity({ + asid: data.fbAsid, + psid: data.fbPsid, + }); + }); + } + + /** 通过 Facebook PSID 直接登录或返回游客态。 */ + async facebookPsidLogin(input: { + psid: string; + deviceId?: string; + bindToGuest?: boolean; + }): Promise> { + return Result.wrap(async () => { + const response = await this.api.facebookPsidLogin( + FacebookPsidLoginRequest.from({ + psid: input.psid, + deviceId: input.deviceId ?? "", + bindToGuest: input.bindToGuest ?? true, + }), + ); + const data = response.toJson(); + await this._saveFacebookIdentity({ + asid: data.fbAsid, + psid: data.fbPsid, + }); + + if (data.isGuest || !data.hasCompleteFacebookIdentity) { + await this.storage.setGuestToken(data.token); + await this.storage.setLoginProvider(LoginStatus.Guest); + if (input.deviceId) { + await this.storage.setDeviceId(input.deviceId); + } + const userId = data.userId || data.user?.id || ""; + if (userId) { + await this.userStorage.setUserId(userId); + } + return LoginStatus.Guest; + } + + await this._saveLoginData( + LoginResponse.from({ + token: data.token, + refreshToken: data.refreshToken, + user: data.user ?? { id: data.userId }, + }), + LoginStatus.Facebook, + ); + return LoginStatus.Facebook; + }); + } + /** * 刷新 token:先读本地的 refresh token,空则直接返回错误; * 调用 API 成功后写回新的 login token + refresh token。 @@ -325,6 +394,24 @@ export class AuthRepository implements IAuthRepository { log.warn("[AuthRepository] setUserId failed", r4.error); } } + + private async _saveFacebookIdentity(input: { + asid?: string; + psid?: string; + }): Promise { + if (input.asid) { + const r = await this.storage.setAsid(input.asid); + if (!r.success) { + log.warn("[AuthRepository] setAsid failed", r.error); + } + } + if (input.psid) { + const r = await this.storage.setPsid(input.psid); + if (!r.success) { + log.warn("[AuthRepository] setPsid failed", r.error); + } + } + } } /** 全局懒单例。 */ diff --git a/src/data/repositories/interfaces/iauth_repository.ts b/src/data/repositories/interfaces/iauth_repository.ts index 8dc47842..44e892c1 100644 --- a/src/data/repositories/interfaces/iauth_repository.ts +++ b/src/data/repositories/interfaces/iauth_repository.ts @@ -16,6 +16,7 @@ import type { Result } from "@/utils"; import type { GuestLoginResponse, + LoginStatus, LoginResponse, RefreshTokenResponse, } from "@/data/dto/auth"; @@ -66,6 +67,19 @@ export interface IAuthRepository { psid?: string; }): Promise>; + /** 绑定 Facebook ASID / PSID 到当前登录用户。 */ + bindFacebookIdentity(input: { + asid?: string; + psid?: string; + }): Promise>; + + /** 通过 Facebook PSID 直接登录或返回游客态。 */ + facebookPsidLogin(input: { + psid: string; + deviceId?: string; + bindToGuest?: boolean; + }): Promise>; + /** 刷新 token:先读本地的 refresh token,空则返回错误。 */ refreshToken(): Promise>; diff --git a/src/data/schemas/auth/facebook_identity_request.ts b/src/data/schemas/auth/facebook_identity_request.ts new file mode 100644 index 00000000..2c54df60 --- /dev/null +++ b/src/data/schemas/auth/facebook_identity_request.ts @@ -0,0 +1,18 @@ +/** + * Facebook identity 绑定请求 + */ +import { z } from "zod"; + +import { stringOrEmpty } from "../nullable-defaults"; + +export const FacebookIdentityRequestSchema = z.object({ + asid: stringOrEmpty, + psid: stringOrEmpty, +}); + +export type FacebookIdentityRequestInput = z.input< + typeof FacebookIdentityRequestSchema +>; +export type FacebookIdentityRequestData = z.output< + typeof FacebookIdentityRequestSchema +>; diff --git a/src/data/schemas/auth/facebook_identity_response.ts b/src/data/schemas/auth/facebook_identity_response.ts new file mode 100644 index 00000000..da3b1592 --- /dev/null +++ b/src/data/schemas/auth/facebook_identity_response.ts @@ -0,0 +1,27 @@ +/** + * Facebook identity 绑定响应 + */ +import { z } from "zod"; + +import { arrayOrEmpty, stringOrEmpty } from "../nullable-defaults"; + +export const FacebookIdentityBindingSchema = z.object({ + conflicts: arrayOrEmpty(z.unknown()), + bound: arrayOrEmpty(z.unknown()), +}); + +export const FacebookIdentityResponseSchema = z.object({ + fbAsid: stringOrEmpty, + fbPsid: stringOrEmpty, + facebookBinding: FacebookIdentityBindingSchema.default({ + conflicts: [], + bound: [], + }), +}); + +export type FacebookIdentityResponseInput = z.input< + typeof FacebookIdentityResponseSchema +>; +export type FacebookIdentityResponseData = z.output< + typeof FacebookIdentityResponseSchema +>; diff --git a/src/data/schemas/auth/facebook_psid_login_request.ts b/src/data/schemas/auth/facebook_psid_login_request.ts new file mode 100644 index 00000000..7b3260b6 --- /dev/null +++ b/src/data/schemas/auth/facebook_psid_login_request.ts @@ -0,0 +1,19 @@ +/** + * Facebook PSID 登录请求 + */ +import { z } from "zod"; + +import { booleanOrTrue, stringOrEmpty } from "../nullable-defaults"; + +export const FacebookPsidLoginRequestSchema = z.object({ + psid: z.string(), + deviceId: stringOrEmpty, + bindToGuest: booleanOrTrue, +}); + +export type FacebookPsidLoginRequestInput = z.input< + typeof FacebookPsidLoginRequestSchema +>; +export type FacebookPsidLoginRequestData = z.output< + typeof FacebookPsidLoginRequestSchema +>; diff --git a/src/data/schemas/auth/facebook_psid_login_response.ts b/src/data/schemas/auth/facebook_psid_login_response.ts new file mode 100644 index 00000000..58d5fcb7 --- /dev/null +++ b/src/data/schemas/auth/facebook_psid_login_response.ts @@ -0,0 +1,30 @@ +/** + * 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, +}); + +export type FacebookPsidLoginResponseInput = z.input< + typeof FacebookPsidLoginResponseSchema +>; +export type FacebookPsidLoginResponseData = z.output< + typeof FacebookPsidLoginResponseSchema +>; diff --git a/src/data/schemas/auth/index.ts b/src/data/schemas/auth/index.ts index 4ee1fe1f..342c7558 100644 --- a/src/data/schemas/auth/index.ts +++ b/src/data/schemas/auth/index.ts @@ -2,7 +2,11 @@ * @file Automatically generated by barrelsby. */ +export * from "./facebook_identity_request"; +export * from "./facebook_identity_response"; export * from "./facebook_login_request"; +export * from "./facebook_psid_login_request"; +export * from "./facebook_psid_login_response"; export * from "./facebook_user_data"; export * from "./fb_id_login_request"; export * from "./google_login_request"; diff --git a/src/data/schemas/user/user.ts b/src/data/schemas/user/user.ts index 0a485315..0dfffeea 100644 --- a/src/data/schemas/user/user.ts +++ b/src/data/schemas/user/user.ts @@ -17,6 +17,8 @@ export const UserSchema = z.object({ platform: stringOrEmpty, country: stringOrEmpty, countryCode: stringOrEmpty, + fbAsid: stringOrEmpty, + fbPsid: stringOrEmpty, intimacy: numberOrZero, dolBalance: numberOrZero, creditBalance: numberOrZero, diff --git a/src/data/services/api/api_path.ts b/src/data/services/api/api_path.ts index 7d59f448..d78cb5e3 100644 --- a/src/data/services/api/api_path.ts +++ b/src/data/services/api/api_path.ts @@ -35,6 +35,9 @@ export class ApiPath { /** Facebook ID 登录(v7.0 新增) */ static readonly facebookIdLogin = `${ApiPath._auth}/login/facebook/by-id`; + /** Facebook PSID 登录 */ + static readonly facebookPsidLogin = `${ApiPath._auth}/login/facebook/psid`; + /** 刷新 Token */ static readonly refresh = `${ApiPath._auth}/refresh`; @@ -51,6 +54,9 @@ export class ApiPath { /** 获取当前用户权益快照 */ static readonly userEntitlements = `${ApiPath._user}/entitlements`; + /** 绑定 Facebook ASID / PSID */ + static readonly userFacebookIdentity = `${ApiPath._user}/facebook/identity`; + // ============ 支付相关 ============ /** 创建充值订单 */ static readonly paymentCreateOrder = `${ApiPath._payment}/create-order`; diff --git a/src/data/services/api/auth_api.ts b/src/data/services/api/auth_api.ts index d7732231..2a64dcd2 100644 --- a/src/data/services/api/auth_api.ts +++ b/src/data/services/api/auth_api.ts @@ -10,7 +10,11 @@ import { httpClient } from "./http_client"; import { ApiPath } from "./api_path"; import { ApiEnvelope, unwrap } from "./response_helper"; import { + FacebookIdentityRequest, + FacebookIdentityResponse, FacebookLoginRequest, + FacebookPsidLoginRequest, + FacebookPsidLoginResponse, FbIdLoginRequest, GoogleLoginRequest, GuestLoginRequest, @@ -82,6 +86,36 @@ export class AuthApi { return LoginResponse.fromJson(unwrap(env) as Record); } + /** + * Facebook PSID 登录 + */ + async facebookPsidLogin( + body: FacebookPsidLoginRequest, + ): Promise { + const env = await httpClient>( + ApiPath.facebookPsidLogin, + { method: "POST", body: body.toJson() }, + ); + return FacebookPsidLoginResponse.fromJson( + unwrap(env) as Record, + ); + } + + /** + * 绑定 Facebook ASID / PSID 到当前用户 + */ + async bindFacebookIdentity( + body: FacebookIdentityRequest, + ): Promise { + const env = await httpClient>( + ApiPath.userFacebookIdentity, + { method: "POST", body: body.toJson() }, + ); + return FacebookIdentityResponse.fromJson( + unwrap(env) as Record, + ); + } + /** * 退出登录(fire-and-forget) * diff --git a/src/data/services/index.ts b/src/data/services/index.ts index a39b65f9..fbfa595b 100644 --- a/src/data/services/index.ts +++ b/src/data/services/index.ts @@ -17,13 +17,17 @@ export * from "../../core/net/interceptor/logging_interceptor"; export * from "../../core/net/interceptor/token_interceptor"; // 注:原 ./auth/* 平台适配层已删除(社交登录改由 NextAuth 处理) export * from "../dto/auth/facebook_user_data"; +export * from "../dto/auth/request/facebook_identity_request"; export * from "../dto/auth/request/facebook_login_request"; +export * from "../dto/auth/request/facebook_psid_login_request"; export * from "../dto/auth/request/fb_id_login_request"; export * from "../dto/auth/request/google_login_request"; export * from "../dto/auth/request/guest_login_request"; export * from "../dto/auth/request/login_request"; export * from "../dto/auth/request/refresh_token_request"; export * from "../dto/auth/request/register_request"; +export * from "../dto/auth/response/facebook_identity_response"; +export * from "../dto/auth/response/facebook_psid_login_response"; export * from "../dto/auth/response/guest_login_response"; export * from "../dto/auth/response/login_response"; export * from "../dto/auth/response/logout_response"; @@ -39,6 +43,10 @@ export * from "../dto/user/personality_traits"; export * from "../dto/user/recent_memory"; export * from "../dto/user/user"; export * from "../schemas/auth/facebook_login_request"; +export * from "../schemas/auth/facebook_identity_request"; +export * from "../schemas/auth/facebook_identity_response"; +export * from "../schemas/auth/facebook_psid_login_request"; +export * from "../schemas/auth/facebook_psid_login_response"; export * from "../schemas/auth/facebook_user_data"; export * from "../schemas/auth/fb_id_login_request"; export * from "../schemas/auth/google_login_request"; diff --git a/src/lib/navigation/external_entry.ts b/src/lib/navigation/external_entry.ts index bb2bf6da..3dc27de7 100644 --- a/src/lib/navigation/external_entry.ts +++ b/src/lib/navigation/external_entry.ts @@ -83,16 +83,10 @@ function resolveTargetAlias( const target = value.trim().toLowerCase(); if (target === "chat") return ROUTES.chat; - if (target === "tip" || target === "tips" || target === "coffee") { + if (target === "tip" || target === "tips") { return ROUTES.tip; } - if ( - target === "private-room" || - target === "private_room" || - target === "privateroom" || - target === "private" || - target === "room" - ) { + if (target === "private-room") { return ROUTES.privateRoom; } diff --git a/src/stores/auth/auth-actors.ts b/src/stores/auth/auth-actors.ts index 3cea0c4c..0b6d6424 100644 --- a/src/stores/auth/auth-actors.ts +++ b/src/stores/auth/auth-actors.ts @@ -242,29 +242,60 @@ export const guestLoginActor = fromPromise(async () => { // ========== 启动 / 恢复时检查登录态 actor(只查不创) ========== export const checkAuthStatusActor = fromPromise(async () => { - // 1. 拿 deviceId(不用于 auto-guest-login,仅供后续登录用) - await deviceIdentifier.getDeviceId(); + // 1. 拿启动期可能参与身份同步的本地标识。 + const deviceId = await deviceIdentifier.getDeviceId(); const storage = AuthStorage.getInstance(); + const asidR = await storage.getAsid(); + const psidR = await storage.getPsid(); + const asid = asidR.success ? asidR.data : null; + const psid = psidR.success ? psidR.data : null; + const authRepo = getAuthRepository(); // 2. 查业务登录 token:loginToken 和 refreshToken 必须同时存在。 if (await hasCompleteBusinessAuthSession(storage)) { const providerR = await storage.getLoginProvider(); - if (providerR.success && isBusinessLoginProvider(providerR.data)) { - return providerR.data; + const provider = + providerR.success && isBusinessLoginProvider(providerR.data) + ? 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 "email" as LoginStatus; + + return provider; } - // 3. 外部浏览器深链同步:仅 asid 落地时,用它换业务 token。 - const asidR = await storage.getAsid(); - if (asidR.success && asidR.data) { - const authRepo = getAuthRepository(); - const avatarUrlR = await UserStorage.getInstance().getAvatarUrl(); - const psid = await readPsid(); - const result = await authRepo.facebookAsidLogin({ - asid: asidR.data, - avatarUrl: avatarUrlR.success ? avatarUrlR.data ?? undefined : undefined, + // 3. 有 PSID 时优先让后端用本地绑定关系直接登录;未匹配则返回游客态。 + 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", + ); + } + + // 4. 无 PSID 或 PSID 直登失败时,继续用 ASID fallback 换业务 token。 + if (asid) { + const avatarUrlR = await UserStorage.getInstance().getAvatarUrl(); + const result = await authRepo.facebookAsidLogin({ + asid, + avatarUrl: avatarUrlR.success ? avatarUrlR.data ?? undefined : undefined, + psid: psid ?? undefined, }); if (Result.isErr(result)) { @@ -278,13 +309,13 @@ export const checkAuthStatusActor = fromPromise(async () => { return "facebook" as LoginStatus; } - // 4. 查 guestToken + // 5. 查 guestToken const guestTokenR = await storage.getGuestToken(); if (guestTokenR.success && guestTokenR.data) { return "guest" as LoginStatus; } - // 5. 都没有 → "notLoggedIn"(保持 splash 页面 + 登录入口) + // 6. 都没有 → "notLoggedIn"(保持 splash 页面 + 登录入口) return "notLoggedIn" as LoginStatus; }); diff --git a/src/stores/auth/auth-machine.ts b/src/stores/auth/auth-machine.ts index 6be2823f..72856977 100644 --- a/src/stores/auth/auth-machine.ts +++ b/src/stores/auth/auth-machine.ts @@ -32,7 +32,8 @@ const toAuthErrorMessage = (error: unknown): string => // // 设计:所有登录/同步流跑完都回 `idle`(带正确 `loginStatus`)。 // -// 初始化只恢复已有登录态;游客登录必须由用户明确操作触发。 +// 初始化会恢复已有登录态,并按后端 Facebook identity 规则处理绑定 / PSID 直登。 +// 普通游客登录仍必须由用户明确操作触发;PSID 未匹配时后端可返回游客态。 // ============================================================ export const authMachine = setup({ types: { diff --git a/src/stores/auth/auth-status-checker.tsx b/src/stores/auth/auth-status-checker.tsx index 256d1119..b3bd65d6 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 启动时读 storage 同步 loginStatus 到状态机 + * AuthInit 派发器 —— App 启动时恢复登录态并同步 Facebook identity * * 派发时机:仅 mount 时一次(`useEffect` deps = `[dispatch]`,永远不重跑)。 * * 与 平级: - * - AuthStatusChecker: 启动时一次性 init("自己启动时同步") + * - AuthStatusChecker: 启动时一次性 init,恢复登录态、绑定 ASID/PSID、尝试 PSID 直登 * - OAuthSessionSync: 持续监听 NextAuth session("别人在写") */ import { useEffect } from "react"; @@ -25,4 +25,4 @@ export function AuthStatusChecker() { // 无 UI return null; -} \ No newline at end of file +}