feat(auth): add facebook identity psid login
This commit is contained in:
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AuthApi>) {
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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<Result<void>> {
|
||||
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<Result<LoginStatusT>> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 全局懒单例。 */
|
||||
|
||||
@@ -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<Result<LoginResponse>>;
|
||||
|
||||
/** 绑定 Facebook ASID / PSID 到当前登录用户。 */
|
||||
bindFacebookIdentity(input: {
|
||||
asid?: string;
|
||||
psid?: string;
|
||||
}): Promise<Result<void>>;
|
||||
|
||||
/** 通过 Facebook PSID 直接登录或返回游客态。 */
|
||||
facebookPsidLogin(input: {
|
||||
psid: string;
|
||||
deviceId?: string;
|
||||
bindToGuest?: boolean;
|
||||
}): Promise<Result<LoginStatus>>;
|
||||
|
||||
/** 刷新 token:先读本地的 refresh token,空则返回错误。 */
|
||||
refreshToken(): Promise<Result<RefreshTokenResponse>>;
|
||||
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
@@ -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
|
||||
>;
|
||||
@@ -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
|
||||
>;
|
||||
@@ -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
|
||||
>;
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Facebook PSID 登录
|
||||
*/
|
||||
async facebookPsidLogin(
|
||||
body: FacebookPsidLoginRequest,
|
||||
): Promise<FacebookPsidLoginResponse> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(
|
||||
ApiPath.facebookPsidLogin,
|
||||
{ method: "POST", body: body.toJson() },
|
||||
);
|
||||
return FacebookPsidLoginResponse.fromJson(
|
||||
unwrap(env) as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定 Facebook ASID / PSID 到当前用户
|
||||
*/
|
||||
async bindFacebookIdentity(
|
||||
body: FacebookIdentityRequest,
|
||||
): Promise<FacebookIdentityResponse> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(
|
||||
ApiPath.userFacebookIdentity,
|
||||
{ method: "POST", body: body.toJson() },
|
||||
);
|
||||
return FacebookIdentityResponse.fromJson(
|
||||
unwrap(env) as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出登录(fire-and-forget)
|
||||
*
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -242,29 +242,60 @@ export const guestLoginActor = fromPromise<LoginStatus, void>(async () => {
|
||||
// ========== 启动 / 恢复时检查登录态 actor(只查不创) ==========
|
||||
|
||||
export const checkAuthStatusActor = fromPromise<LoginStatus, void>(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<LoginStatus, void>(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;
|
||||
});
|
||||
|
||||
|
||||
@@ -32,7 +32,8 @@ const toAuthErrorMessage = (error: unknown): string =>
|
||||
//
|
||||
// 设计:所有登录/同步流跑完都回 `idle`(带正确 `loginStatus`)。
|
||||
//
|
||||
// 初始化只恢复已有登录态;游客登录必须由用户明确操作触发。
|
||||
// 初始化会恢复已有登录态,并按后端 Facebook identity 规则处理绑定 / PSID 直登。
|
||||
// 普通游客登录仍必须由用户明确操作触发;PSID 未匹配时后端可返回游客态。
|
||||
// ============================================================
|
||||
export const authMachine = setup({
|
||||
types: {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
/**
|
||||
* AuthInit 派发器 —— App 启动时读 storage 同步 loginStatus 到状态机
|
||||
* AuthInit 派发器 —— App 启动时恢复登录态并同步 Facebook identity
|
||||
*
|
||||
* 派发时机:仅 mount 时一次(`useEffect` deps = `[dispatch]`,永远不重跑)。
|
||||
*
|
||||
* 与 <OAuthSessionSync /> 平级:
|
||||
* - AuthStatusChecker: 启动时一次性 init("自己启动时同步")
|
||||
* - AuthStatusChecker: 启动时一次性 init,恢复登录态、绑定 ASID/PSID、尝试 PSID 直登
|
||||
* - OAuthSessionSync: 持续监听 NextAuth session("别人在写")
|
||||
*/
|
||||
import { useEffect } from "react";
|
||||
|
||||
Reference in New Issue
Block a user