Files
cozsweet-frontend-nextjs/src/data/repositories/auth_repository.ts
T
Codex 59e4eac736
Docker Image / Build and Push Docker Image (push) Successful in 2m10s
feat(payment): expand Stripe methods and checkout handoff
2026-07-28 16:48:11 +08:00

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();
}