447 lines
14 KiB
TypeScript
447 lines
14 KiB
TypeScript
import { ExceptionHandler } from "@/core/errors";
|
|
import type { IAuthRepository } from "@/data/repositories/interfaces";
|
|
import {
|
|
CheckoutHandoffConsumeRequestSchema,
|
|
CheckoutHandoffCreateRequestSchema,
|
|
type CheckoutHandoffCreateResponse,
|
|
type CheckoutIntent,
|
|
FacebookIdentityRequestSchema,
|
|
FacebookLoginRequestSchema,
|
|
FbIdLoginRequestSchema,
|
|
GoogleLoginRequestSchema,
|
|
GuestLoginRequestSchema,
|
|
GuestLoginResponse,
|
|
LoginRequestSchema,
|
|
LoginResponse,
|
|
LoginStatus,
|
|
RefreshTokenRequestSchema,
|
|
RefreshTokenResponse,
|
|
RegisterRequestSchema,
|
|
TopUpHandoffRequestSchema,
|
|
type LoginStatus as LoginStatusT,
|
|
} from "@/data/schemas/auth";
|
|
import { User } from "@/data/schemas/user";
|
|
import { ApiError, AuthApi, authApi, ErrorCode } from "@/data/services/api";
|
|
import { AuthStorage, type IAuthStorage } from "@/data/storage/auth";
|
|
import { UserStorage, type IUserStorage } from "@/data/storage/user";
|
|
import { AppEnvUtil } from "@/utils/app-env";
|
|
import { deviceIdentifier } from "@/utils/device_identifier";
|
|
import { Logger } from "@/utils/logger";
|
|
import { PlatformDetector } from "@/utils/platform-detect";
|
|
import { Result } from "@/utils/result";
|
|
import { createLazySingleton } from "./lazy_singleton";
|
|
|
|
const log = new Logger("DataRepositoriesAuthRepository");
|
|
|
|
const TEST_ACCOUNT_FLAG = "isTestAccount";
|
|
|
|
export class AuthRepository implements IAuthRepository {
|
|
constructor(
|
|
private readonly api: AuthApi,
|
|
private readonly storage: IAuthStorage,
|
|
private readonly userStorage: IUserStorage,
|
|
) {}
|
|
|
|
// ============ 公共方法 ============
|
|
|
|
/**
|
|
* 用户注册。
|
|
* 注册成功不会自动登录(与 Dart 行为一致),调用方需自行调 `emailLogin`。
|
|
*/
|
|
async register(input: {
|
|
username: string;
|
|
email: string;
|
|
password: string;
|
|
guestId?: string;
|
|
}): Promise<Result<void>> {
|
|
return Result.wrap(async () => {
|
|
await this.api.register(
|
|
RegisterRequestSchema.parse(
|
|
withTestAccountFlag({
|
|
username: input.username,
|
|
email: input.email,
|
|
password: input.password,
|
|
platform: getAuthPlatform(),
|
|
guestId: input.guestId ?? "",
|
|
}),
|
|
),
|
|
);
|
|
});
|
|
}
|
|
|
|
/** 邮箱/用户名 + 密码登录。 */
|
|
async emailLogin(input: {
|
|
email?: string;
|
|
username?: string;
|
|
password: string;
|
|
guestId?: string;
|
|
psid?: string;
|
|
}): Promise<Result<LoginResponse>> {
|
|
return Result.wrap(async () => {
|
|
const email = input.email?.trim() ?? "";
|
|
const response = await this.api.emailLogin(
|
|
LoginRequestSchema.parse(
|
|
withTestAccountFlag({
|
|
email,
|
|
username: input.username,
|
|
password: input.password,
|
|
platform: getAuthPlatform(),
|
|
guestId: input.guestId ?? "",
|
|
psid: input.psid ?? "",
|
|
}),
|
|
),
|
|
);
|
|
await this._saveLoginData(response, LoginStatus.Email);
|
|
return response;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 退出真实用户登录并恢复游客态。
|
|
*
|
|
* 注意:业务上不允许从其他登录态退出到未登录态,所以这里不会删除
|
|
* guestToken / deviceId。退出后会尝试重新跑游客登录,以便恢复游客 userId;
|
|
* 如果游客登录接口失败但本地仍有 guestToken,则降级回本地游客态,避免
|
|
* 用户从聊天页被打回 splash。
|
|
*/
|
|
async logout(): Promise<Result<void>> {
|
|
try {
|
|
await this.api.logout();
|
|
} catch (e) {
|
|
log.warn("[AuthRepository] logout API failed, restoring guest anyway", e);
|
|
}
|
|
|
|
try {
|
|
const authClearResult = await this.storage.clearBusinessAuthData();
|
|
const userClearResult = await this.userStorage.clearUserData();
|
|
if (Result.isErr(authClearResult)) return authClearResult;
|
|
if (Result.isErr(userClearResult)) return userClearResult;
|
|
} catch (e) {
|
|
return Result.err(e);
|
|
}
|
|
|
|
try {
|
|
const deviceId = await deviceIdentifier.getDeviceId();
|
|
const guestResult = await this.guestLogin(deviceId);
|
|
if (Result.isOk(guestResult)) return Result.ok(undefined);
|
|
|
|
const hasGuestToken = await this.storage.hasGuestToken();
|
|
if (Result.isErr(hasGuestToken)) return hasGuestToken;
|
|
if (hasGuestToken.data) {
|
|
const providerResult = await this.storage.setLoginProvider(
|
|
LoginStatus.Guest,
|
|
);
|
|
if (Result.isErr(providerResult)) return providerResult;
|
|
log.warn(
|
|
"[AuthRepository] guest login failed after logout, falling back to local guest token",
|
|
guestResult.error,
|
|
);
|
|
return Result.ok(undefined);
|
|
}
|
|
|
|
return Result.err(guestResult.error);
|
|
} catch (e) {
|
|
return Result.err(e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 游客登录:使用 deviceId 换取 guest token。
|
|
* 成功后写 guest token + deviceId + userId。
|
|
*/
|
|
async guestLogin(deviceId: string): Promise<Result<GuestLoginResponse>> {
|
|
log.debug("[AuthRepository.guestLogin] API call START", {
|
|
deviceIdLength: deviceId.length,
|
|
deviceIdPrefix: deviceId.slice(0, 8),
|
|
});
|
|
return Result.wrap(async () => {
|
|
const response = await this.api.guestLogin(
|
|
GuestLoginRequestSchema.parse(withTestAccountFlag({ deviceId })),
|
|
);
|
|
log.debug("[AuthRepository.guestLogin] API call SUCCESS (Zod parsed)", {
|
|
hasToken: !!response.token,
|
|
hasUser: !!response.user,
|
|
userLastMessageAt: response.user?.lastMessageAt,
|
|
});
|
|
const tokenResult = await this.storage.setGuestToken(response.token);
|
|
if (Result.isErr(tokenResult)) throw tokenResult.error;
|
|
const providerResult = await this.storage.setLoginProvider(
|
|
LoginStatus.Guest,
|
|
);
|
|
if (Result.isErr(providerResult)) throw providerResult.error;
|
|
const deviceResult = await this.storage.setDeviceId(deviceId);
|
|
if (Result.isErr(deviceResult)) throw deviceResult.error;
|
|
if (response.userId) {
|
|
const userIdResult = await this.userStorage.setUserId(response.userId);
|
|
if (Result.isErr(userIdResult)) throw userIdResult.error;
|
|
}
|
|
return response;
|
|
}).catch((e) => {
|
|
const errorResult = ExceptionHandler.handle(e);
|
|
log.error("[AuthRepository.guestLogin] API call FAILED", {
|
|
errorCode: errorResult.code,
|
|
errorMessage: errorResult.message,
|
|
});
|
|
return Result.err(e);
|
|
});
|
|
}
|
|
|
|
/** Google 登录。 */
|
|
async googleLogin(input: {
|
|
idToken: string;
|
|
guestId?: string;
|
|
psid?: string;
|
|
}): Promise<Result<LoginResponse>> {
|
|
return this._socialLogin(LoginStatus.Google, () =>
|
|
this.api.googleLogin(
|
|
GoogleLoginRequestSchema.parse(
|
|
withTestAccountFlag({
|
|
idToken: input.idToken,
|
|
platform: getAuthPlatform(),
|
|
guestId: input.guestId ?? "",
|
|
psid: input.psid ?? "",
|
|
}),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/** Facebook 登录(accessToken 流程)。 */
|
|
async facebookLogin(input: {
|
|
accessToken: string;
|
|
guestId?: string;
|
|
psid?: string;
|
|
}): Promise<Result<LoginResponse>> {
|
|
return this._socialLogin(LoginStatus.Facebook, () =>
|
|
this.api.facebookLogin(
|
|
FacebookLoginRequestSchema.parse(
|
|
withTestAccountFlag({
|
|
accessToken: input.accessToken,
|
|
platform: getAuthPlatform(),
|
|
guestId: input.guestId ?? "",
|
|
psid: input.psid ?? "",
|
|
}),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/** 通过 ASID 登录。 */
|
|
async facebookAsidLogin(input: {
|
|
asid: string;
|
|
avatarUrl?: string;
|
|
psid?: string;
|
|
}): Promise<Result<LoginResponse>> {
|
|
return this._socialLogin(LoginStatus.Facebook, () =>
|
|
this.api.facebookIdLogin(
|
|
FbIdLoginRequestSchema.parse(
|
|
withTestAccountFlag({
|
|
fbId: input.asid,
|
|
avatarUrl: input.avatarUrl ?? "",
|
|
psid: input.psid ?? "",
|
|
}),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/** 绑定 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(
|
|
FacebookIdentityRequestSchema.parse({
|
|
asid: input.asid ?? "",
|
|
psid: input.psid ?? "",
|
|
}),
|
|
);
|
|
const data = response;
|
|
await this._saveFacebookIdentity({
|
|
asid: data.fbAsid,
|
|
psid: data.fbPsid,
|
|
});
|
|
});
|
|
}
|
|
|
|
/** 消费一次性充值凭证并按后端返回的 canonical 账号建立正式会话。 */
|
|
async consumeTopUpHandoff(
|
|
handoffToken: string,
|
|
): Promise<Result<LoginStatusT>> {
|
|
return Result.wrap(async () => {
|
|
const response = await this.api.consumeTopUpHandoff(
|
|
TopUpHandoffRequestSchema.parse({ handoffToken }),
|
|
);
|
|
await this._saveLoginData(response, response.loginStatus);
|
|
return response.loginStatus;
|
|
});
|
|
}
|
|
|
|
/** 创建订单前生成一次性外部浏览器支付交接链接。 */
|
|
async createCheckoutHandoff(
|
|
checkoutIntent: CheckoutIntent,
|
|
): Promise<Result<CheckoutHandoffCreateResponse>> {
|
|
return Result.wrap(() =>
|
|
this.api.createCheckoutHandoff(
|
|
CheckoutHandoffCreateRequestSchema.parse(checkoutIntent),
|
|
),
|
|
);
|
|
}
|
|
|
|
/** 消费支付交接凭证,保存后端签发的正式登录态并返回购买意图。 */
|
|
async consumeCheckoutHandoff(
|
|
handoffToken: string,
|
|
): Promise<Result<CheckoutIntent>> {
|
|
return Result.wrap(async () => {
|
|
const response = await this.api.consumeCheckoutHandoff(
|
|
CheckoutHandoffConsumeRequestSchema.parse({ handoffToken }),
|
|
);
|
|
await this._saveLoginData(response, response.loginStatus);
|
|
return response.checkoutIntent;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 刷新 token:先读本地的 refresh token,空则直接返回错误;
|
|
* 调用 API 成功后写回新的 login token + refresh token。
|
|
*/
|
|
async refreshToken(): Promise<Result<RefreshTokenResponse>> {
|
|
return Result.wrap(async () => {
|
|
const existing = await this.storage.getRefreshToken();
|
|
if (Result.isErr(existing)) throw existing.error;
|
|
if (!existing.data) {
|
|
throw new ApiError(
|
|
"HTTP_UNAUTHORIZED",
|
|
"No refresh token available",
|
|
ErrorCode.httpUnauthorized,
|
|
);
|
|
}
|
|
const response = await this.api.refreshToken(
|
|
RefreshTokenRequestSchema.parse({ refreshToken: existing.data }),
|
|
);
|
|
const tokenResult = await this.storage.setLoginToken(response.token);
|
|
if (Result.isErr(tokenResult)) throw tokenResult.error;
|
|
if (response.refreshToken) {
|
|
const refreshTokenResult = await this.storage.setRefreshToken(
|
|
response.refreshToken,
|
|
);
|
|
if (Result.isErr(refreshTokenResult)) throw refreshTokenResult.error;
|
|
}
|
|
return response;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 获取当前登录用户。成功后尽力写本地 User 缓存(offline hydrate),
|
|
* 缓存失败用 `Logger.warn` 记录,不影响主流程。
|
|
*/
|
|
async getCurrentUser(): Promise<Result<User>> {
|
|
return Result.wrap(async () => {
|
|
const user = await this.api.getCurrentUser();
|
|
await this._cacheUser(user);
|
|
return user;
|
|
});
|
|
}
|
|
|
|
// ============ 私有助手 ============
|
|
|
|
/**
|
|
* 社交登录的公共包装:调 API → 调 `_saveLoginData` 持久化。
|
|
* 会话关键数据写入失败时,登录 Result 必须失败。
|
|
*/
|
|
private async _socialLogin(
|
|
provider: LoginStatusT,
|
|
call: () => Promise<LoginResponse>,
|
|
): Promise<Result<LoginResponse>> {
|
|
return Result.wrap(async () => {
|
|
const response = await call();
|
|
await this._saveLoginData(response, provider);
|
|
return response;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 持久化登录态:login token → refresh token → userId → provider。
|
|
* 这些字段共同决定会话是否可恢复,任一写入失败都会让登录 Result 失败;
|
|
* provider 最后写入,作为会话完成标记。完整 User 对象仅用于离线展示,
|
|
* 缓存失败只记录告警,不影响已经建立的会话。
|
|
*/
|
|
private async _saveLoginData(
|
|
data: LoginResponse,
|
|
provider: LoginStatusT,
|
|
): Promise<void> {
|
|
const tokenResult = await this.storage.setLoginToken(data.token);
|
|
if (Result.isErr(tokenResult)) throw tokenResult.error;
|
|
|
|
if (data.refreshToken) {
|
|
const refreshTokenResult = await this.storage.setRefreshToken(
|
|
data.refreshToken,
|
|
);
|
|
if (Result.isErr(refreshTokenResult)) throw refreshTokenResult.error;
|
|
}
|
|
|
|
const userIdResult = await this.userStorage.setUserId(data.user.id);
|
|
if (Result.isErr(userIdResult)) throw userIdResult.error;
|
|
|
|
const providerResult = await this.storage.setLoginProvider(provider);
|
|
if (Result.isErr(providerResult)) throw providerResult.error;
|
|
|
|
await this._cacheUser(data.user);
|
|
}
|
|
|
|
private async _cacheUser(user: User): Promise<void> {
|
|
try {
|
|
const userResult = await this.userStorage.setUser(user);
|
|
if (Result.isErr(userResult)) {
|
|
log.warn("[AuthRepository] setUser failed", userResult.error);
|
|
}
|
|
} catch (error) {
|
|
log.warn("[AuthRepository] setUser threw", 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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/** 全局懒单例。 */
|
|
export const getAuthRepository = createLazySingleton<IAuthRepository>(
|
|
() =>
|
|
new AuthRepository(
|
|
authApi,
|
|
AuthStorage.getInstance(),
|
|
UserStorage.getInstance(),
|
|
),
|
|
);
|
|
|
|
function withTestAccountFlag<T extends Record<string, unknown>>(
|
|
payload: T,
|
|
): T & { isTestAccount: boolean } {
|
|
return {
|
|
...payload,
|
|
[TEST_ACCOUNT_FLAG]: !AppEnvUtil.isProduction(),
|
|
};
|
|
}
|
|
|
|
function getAuthPlatform(): string {
|
|
return PlatformDetector.getPlatform();
|
|
}
|