From 0bae53bfba171b649b0b128b2e2b2ab3158e7c66 Mon Sep 17 00:00:00 2001 From: chenhang Date: Mon, 29 Jun 2026 17:25:41 +0800 Subject: [PATCH] feat: implement lazy singleton pattern for repository instances and update related imports --- .githooks/{post-commit => pre-push} | 0 src/app/subscription/success/page.tsx | 4 +-- src/data/repositories/auth_repository.ts | 26 +++++++------------ src/data/repositories/chat_repository.ts | 12 ++++++--- src/data/repositories/lazy_singleton.ts | 7 +++++ src/data/repositories/metrics_repository.ts | 7 +++-- src/data/repositories/payment_repository.ts | 7 +++-- src/data/repositories/user_repository.ts | 7 +++-- src/stores/auth/auth-actors.ts | 27 ++++++++------------ src/stores/auth/oauth-session-sync.tsx | 2 +- src/stores/chat/chat-machine.actors.ts | 5 +++- src/stores/chat/chat-machine.helpers.ts | 10 ++------ src/stores/payment/payment-machine.actors.ts | 9 ++++--- src/stores/user/user-machine.actors.ts | 20 ++++----------- 14 files changed, 69 insertions(+), 74 deletions(-) rename .githooks/{post-commit => pre-push} (100%) create mode 100644 src/data/repositories/lazy_singleton.ts diff --git a/.githooks/post-commit b/.githooks/pre-push similarity index 100% rename from .githooks/post-commit rename to .githooks/pre-push diff --git a/src/app/subscription/success/page.tsx b/src/app/subscription/success/page.tsx index 0b11b225..fa234f13 100644 --- a/src/app/subscription/success/page.tsx +++ b/src/app/subscription/success/page.tsx @@ -16,7 +16,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { MobileShell } from "@/app/_components/core/mobile-shell"; -import { paymentRepository } from "@/data/repositories/payment_repository"; +import { getPaymentRepository } from "@/data/repositories/payment_repository"; import { ROUTES } from "@/router/routes"; export default function SubscriptionSuccessPage() { @@ -33,7 +33,7 @@ export default function SubscriptionSuccessPage() { let cancelled = false; const tryRefresh = async (attemptsLeft: number) => { if (cancelled) return; - const r = await paymentRepository.getVipStatus(); + const r = await getPaymentRepository().getVipStatus(); if (cancelled) return; if (r.success) { if (r.data.isVip) { diff --git a/src/data/repositories/auth_repository.ts b/src/data/repositories/auth_repository.ts index 3f9ab922..92eb136a 100644 --- a/src/data/repositories/auth_repository.ts +++ b/src/data/repositories/auth_repository.ts @@ -1,17 +1,5 @@ "use client"; -/** - * AuthRepository - * - * 认证仓库:编排远程 API + 本地 AuthStorage + UserStorage。 - * 行为对齐原 Dart `lib/data/repositories/auth_repository_impl.dart`: - * - 登录成功后调 `_saveLoginData` 持久化 token + User - * - 登出先调 API,再强制清本地态(无论 API 成败) - * - `getCurrentUser` 成功后尽力写本地 User 缓存(不因缓存失败影响主流程) - * - 写 storage 失败用 `Logger.warn` 记录,不让本地缓存失败影响主流程 - * - * - */ import { AuthApi, authApi } from "@/data/services/api"; import { AppleLoginRequest, @@ -32,6 +20,7 @@ import { AppEnvUtil, Result, Logger } from "@/utils"; import type { IAuthRepository } from "@/data/repositories/interfaces"; import { AuthStorage, type IAuthStorage } from "@/data/storage/auth"; import { UserStorage, type IUserStorage } from "@/data/storage/user"; +import { createLazySingleton } from "./lazy_singleton"; const log = new Logger("DataRepositoriesAuthRepository"); @@ -312,11 +301,14 @@ export class AuthRepository implements IAuthRepository { } } -/** 全局单例。 */ -export const authRepository = new AuthRepository( - authApi, - AuthStorage.getInstance(), - UserStorage.getInstance(), +/** 全局懒单例。 */ +export const getAuthRepository = createLazySingleton( + () => + new AuthRepository( + authApi, + AuthStorage.getInstance(), + UserStorage.getInstance(), + ), ); function withTestAccountFlag>( diff --git a/src/data/repositories/chat_repository.ts b/src/data/repositories/chat_repository.ts index 5d664e94..991f5f25 100644 --- a/src/data/repositories/chat_repository.ts +++ b/src/data/repositories/chat_repository.ts @@ -13,6 +13,7 @@ import { import { Result } from "@/utils"; import type { IChatRepository } from "@/data/repositories/interfaces"; import { LocalChatStorage, LocalMessage } from "@/data/storage/chat"; +import { createLazySingleton } from "./lazy_singleton"; export class ChatRepository implements IChatRepository { constructor( @@ -186,8 +187,11 @@ export class ChatRepository implements IChatRepository { } } -/** 全局单例。 */ -export const chatRepository = new ChatRepository( - chatApi, - LocalChatStorage.getInstance(), +/** 全局懒单例。 */ +export const getChatRepository = createLazySingleton( + () => + new ChatRepository( + chatApi, + LocalChatStorage.getInstance(), + ), ); diff --git a/src/data/repositories/lazy_singleton.ts b/src/data/repositories/lazy_singleton.ts new file mode 100644 index 00000000..02ff0ce6 --- /dev/null +++ b/src/data/repositories/lazy_singleton.ts @@ -0,0 +1,7 @@ +export function createLazySingleton(factory: () => T): () => T { + let instance: T | null = null; + return () => { + instance ??= factory(); + return instance; + }; +} diff --git a/src/data/repositories/metrics_repository.ts b/src/data/repositories/metrics_repository.ts index a378e868..e79d7351 100644 --- a/src/data/repositories/metrics_repository.ts +++ b/src/data/repositories/metrics_repository.ts @@ -9,6 +9,7 @@ import { MetricsApi, metricsApi } from "@/data/services/api"; import { AppEvent, PwaEvent } from "@/data/dto/metrics"; import { Result } from "@/utils"; import type { IMetricsRepository } from "@/data/repositories/interfaces"; +import { createLazySingleton } from "./lazy_singleton"; export class MetricsRepository implements IMetricsRepository { constructor(private readonly api: MetricsApi) {} @@ -56,5 +57,7 @@ export class MetricsRepository implements IMetricsRepository { } } -/** 全局单例。 */ -export const metricsRepository = new MetricsRepository(metricsApi); +/** 全局懒单例。 */ +export const getMetricsRepository = createLazySingleton( + () => new MetricsRepository(metricsApi), +); diff --git a/src/data/repositories/payment_repository.ts b/src/data/repositories/payment_repository.ts index 2ca07895..ab26a6ce 100644 --- a/src/data/repositories/payment_repository.ts +++ b/src/data/repositories/payment_repository.ts @@ -18,6 +18,7 @@ import { PaymentApi, paymentApi } from "@/data/services/api"; import { PaymentPlansStorage } from "@/data/storage/payment"; import type { IPaymentRepository } from "@/data/repositories/interfaces"; import { Result } from "@/utils/result"; +import { createLazySingleton } from "./lazy_singleton"; export class PaymentRepository implements IPaymentRepository { constructor(private readonly api: PaymentApi) {} @@ -83,8 +84,10 @@ export class PaymentRepository implements IPaymentRepository { } } -/** 全局单例。 */ -export const paymentRepository = new PaymentRepository(paymentApi); +/** 全局懒单例。 */ +export const getPaymentRepository = createLazySingleton( + () => new PaymentRepository(paymentApi), +); export function isVipPaymentPlan(plan: PaymentPlan): boolean { return plan.vipDays !== null; diff --git a/src/data/repositories/user_repository.ts b/src/data/repositories/user_repository.ts index 59c4cef6..6590f214 100644 --- a/src/data/repositories/user_repository.ts +++ b/src/data/repositories/user_repository.ts @@ -10,6 +10,7 @@ import { } from "@/data/dto/user"; import { Result } from "@/utils"; import type { IUserRepository } from "@/data/repositories/interfaces"; +import { createLazySingleton } from "./lazy_singleton"; export class UserRepository implements IUserRepository { constructor(private readonly api: UserApi) {} @@ -48,5 +49,7 @@ export class UserRepository implements IUserRepository { } } -/** 全局单例。 */ -export const userRepository = new UserRepository(userApi); +/** 全局懒单例。 */ +export const getUserRepository = createLazySingleton( + () => new UserRepository(userApi), +); diff --git a/src/stores/auth/auth-actors.ts b/src/stores/auth/auth-actors.ts index 1e71df64..b189c58e 100644 --- a/src/stores/auth/auth-actors.ts +++ b/src/stores/auth/auth-actors.ts @@ -5,8 +5,7 @@ import { fromPromise } from "xstate"; import { AuthPlatform, type AuthProvider } from "@/lib/auth/auth_platform"; import type { LoginStatus } from "@/data/dto/auth"; -import { authRepository } from "@/data/repositories/auth_repository"; -import type { IAuthRepository } from "@/data/repositories/interfaces"; +import { getAuthRepository } from "@/data/repositories/auth_repository"; import { fetchFacebookUserData } from "@/data/services"; import { AuthStorage } from "@/data/storage/auth/auth_storage"; import { UserStorage } from "@/data/storage/user/user_storage"; @@ -16,11 +15,9 @@ import { readGuestId } from "./auth-helpers"; const log = new Logger("AuthActors"); -// 仓库以接口类型注入:调用面只看接口,运行时仍是同一单例 -const authRepo: IAuthRepository = authRepository; - export const emailLoginActor = fromPromise( async ({ input }) => { + const authRepo = getAuthRepository(); log.debug("[auth-machine] emailLoginActor ENTRY", { emailLength: input.email.length, emailPreview: input.email.slice(0, 8), @@ -46,6 +43,7 @@ export const emailRegisterThenLoginActor = fromPromise< LoginStatus, { email: string; password: string; username: string; confirmPassword: string } >(async ({ input }) => { + const authRepo = getAuthRepository(); log.debug("[auth-machine] emailRegisterThenLoginActor ENTRY", { emailLength: input.email.length, usernameLength: input.username.length, @@ -96,6 +94,7 @@ export const oauthSignInActor = fromPromise(async ({ input } }); export const logoutActor = fromPromise(async () => { + const authRepo = getAuthRepository(); const result = await authRepo.logout(); if (Result.isErr(result)) throw result.error; }); @@ -103,10 +102,11 @@ export const logoutActor = fromPromise(async () => { // ========== OAuth token → 后端业务 token sync actors ========== // 由 在 NextAuth 回调后派发(监听 useSession())。 // 用 OAuth provider 的 idToken / accessToken 调后端换业务 token + user。 -// 后端返回的 LoginResponse 已经被 authRepository._saveLoginData 持久化。 +// 后端返回的 LoginResponse 已经被 AuthRepository._saveLoginData 持久化。 export const syncGoogleBackendActor = fromPromise( async ({ input }) => { + const authRepo = getAuthRepository(); const guestId = await readGuestId(); const result = await authRepo.googleLogin({ idToken: input.idToken, @@ -121,6 +121,7 @@ export const syncFacebookBackendActor = fromPromise< LoginStatus, { accessToken: string } >(async ({ input }) => { + const authRepo = getAuthRepository(); const guestId = await readGuestId(); const result = await authRepo.facebookLogin({ accessToken: input.accessToken, @@ -193,13 +194,6 @@ async function persistFacebookProfile(accessToken: string): Promise { // ========== 显式游客登录 actor(opt-in) ========== // 由 splash UI 派发 `AuthGuestLoginSubmitted` 触发,不自动跑。 -// 取代了之前 checkAuthStatusActor 的 auto-guest-login 行为 —— 避免每次 -// 访 splash 都自动创建游客账号污染后端。 -// -// 短路优化:本地已有 guestToken 时直接 return "guest",跳过 deviceId -// 探测 + 后端 round-trip(splash 每打开一次就可能点一次 Skip,命中率高)。 -// 已知 trade-off:若后端已吊销该 token,actor 仍返回 "guest" —— 任何后续 -// 受保护 API 401 会由 HTTP 层清掉 token 回到 splash,无需在此处主动校验。 export const guestLoginActor = fromPromise(async () => { log.debug("[auth-machine] guestLoginActor ENTRY"); @@ -223,9 +217,9 @@ export const guestLoginActor = fromPromise(async () => { prefix: deviceId.slice(0, 8), }); - log.debug("[auth-machine] guestLoginActor calling authRepository.guestLogin"); - const result = await authRepository.guestLogin(deviceId); - log.debug("[auth-machine] guestLoginActor authRepository.guestLogin DONE", { + log.debug("[auth-machine] guestLoginActor calling authRepo.guestLogin"); + const result = await getAuthRepository().guestLogin(deviceId); + log.debug("[auth-machine] guestLoginActor authRepo.guestLogin DONE", { success: result.success, hasData: result.success ? !!result.data : null, errorName: result.success ? null : result.error?.name, @@ -260,6 +254,7 @@ export const checkAuthStatusActor = fromPromise(async () => { // 3. 外部浏览器深链同步:仅 fbid 落地时,用它换业务 token。 const facebookIdR = await storage.getFacebookId(); if (facebookIdR.success && facebookIdR.data) { + const authRepo = getAuthRepository(); const avatarUrlR = await UserStorage.getInstance().getAvatarUrl(); const result = await authRepo.facebookIdLogin({ fbId: facebookIdR.data, diff --git a/src/stores/auth/oauth-session-sync.tsx b/src/stores/auth/oauth-session-sync.tsx index 2dec85e0..38d54994 100644 --- a/src/stores/auth/oauth-session-sync.tsx +++ b/src/stores/auth/oauth-session-sync.tsx @@ -6,7 +6,7 @@ * - 有 NextAuth OAuth session(provider + token) * - 且 auth machine 还没有该 provider 的业务 token * → 派发 `AuthGoogleSyncSubmitted` / `AuthFacebookSyncSubmitted` 事件 - * → auth machine 调 `authRepository.googleLogin/facebookLogin` 换业务 token + * → auth machine 调 `AuthRepository.googleLogin/facebookLogin` 换业务 token * → 业务 token 写入本地 storage * → **清掉 NextAuth session**(`signOut({ redirect: false })`): * idToken / accessToken 已交给后端换业务 token,浏览器侧不再需要保留 OAuth 凭证 diff --git a/src/stores/chat/chat-machine.actors.ts b/src/stores/chat/chat-machine.actors.ts index 5bd9bafe..e6547306 100644 --- a/src/stores/chat/chat-machine.actors.ts +++ b/src/stores/chat/chat-machine.actors.ts @@ -6,11 +6,11 @@ import { fromPromise, fromCallback } from "xstate"; import { MessageQueue } from "@/core/net/message-queue"; import type { ChatSendResponse } from "@/data/dto/chat"; +import { getChatRepository } from "@/data/repositories/chat_repository"; import { Result, Logger } from "@/utils"; import { PAGE_SIZE, - chatRepo, localMessagesToUi, readAndSyncHistory, sendResponseToUiMessage, @@ -60,6 +60,7 @@ export const loadMoreHistoryActor = fromPromise< { messages: UiMessage[]; hasMore: boolean; newOffset: number }, { offset: number } >(async ({ input }) => { + const chatRepo = getChatRepository(); const result = await chatRepo.getHistory(PAGE_SIZE, input.offset); if (Result.isErr(result)) { log.error("[chat-machine] loadMoreHistoryActor failed", { error: result.error }); @@ -81,6 +82,7 @@ export const unlockHistoryActor = fromPromise<{ hasMore: boolean; newOffset: number; }>(async () => { + const chatRepo = getChatRepository(); const unlockResult = await chatRepo.unlockHistory(); if (Result.isErr(unlockResult)) { log.error("[chat-machine] unlockHistoryActor failed", { @@ -148,6 +150,7 @@ async function sendMessageViaHttp(content: string): Promise<{ response: ChatSendResponse; reply: UiMessage | null; }> { + const chatRepo = getChatRepository(); const result = await chatRepo.sendMessage(content); if (Result.isErr(result)) { log.error("[chat-machine] sendMessageHttpActor failed", { error: result.error }); diff --git a/src/stores/chat/chat-machine.helpers.ts b/src/stores/chat/chat-machine.helpers.ts index ccba777b..c7f4f773 100644 --- a/src/stores/chat/chat-machine.helpers.ts +++ b/src/stores/chat/chat-machine.helpers.ts @@ -1,6 +1,5 @@ import type { UiMessage } from "@/data/dto/chat"; -import { chatRepository } from "@/data/repositories/chat_repository"; -import type { IChatRepository } from "@/data/repositories/interfaces"; +import { getChatRepository } from "@/data/repositories/chat_repository"; import { ChatSendResponse } from "@/data/dto/chat/chat_send_response"; import { todayString, Result, Logger } from "@/utils"; @@ -14,12 +13,6 @@ const log = new Logger("StoresChatChatMachineHelpers"); /** 翻历史每页大小(仅 `loadMoreHistoryActor` 用) */ export const PAGE_SIZE = 50; -// ============================================================ -// Repository injection -// ============================================================ -// 仓库以接口类型注入:调用面只看接口,运行时仍是同一单例 -export const chatRepo: IChatRepository = chatRepository; - // ============================================================ // DTO ↔ UiMessage 映射 // ============================================================ @@ -279,6 +272,7 @@ export async function readAndSyncHistory(): Promise<{ * - 用户从未发过消息就关掉 app 再开,仍然没 network 消息 → 会再次显示本条 * ("每次冷启动给个温暖的起点" —— 若要"只显示一次",需额外存 local) */ + const chatRepo = getChatRepository(); const greetingMessage: UiMessage = { content: "You're here! Facebook is so restrictive, " + diff --git a/src/stores/payment/payment-machine.actors.ts b/src/stores/payment/payment-machine.actors.ts index 41813b3b..471e8ebb 100644 --- a/src/stores/payment/payment-machine.actors.ts +++ b/src/stores/payment/payment-machine.actors.ts @@ -9,14 +9,12 @@ import { PaymentOrderStatusResponse, PaymentPlansResponse, } from "@/data/dto/payment"; -import { paymentRepository } from "@/data/repositories/payment_repository"; -import type { IPaymentRepository } from "@/data/repositories/interfaces"; +import { getPaymentRepository } from "@/data/repositories/payment_repository"; import { Result } from "@/utils"; -const paymentRepo: IPaymentRepository = paymentRepository; - export const loadCachedPaymentPlansActor = fromPromise(async () => { + const paymentRepo = getPaymentRepository(); const result = await paymentRepo.getCachedPlans(); if (Result.isErr(result)) throw result.error; return result.data; @@ -24,6 +22,7 @@ export const loadCachedPaymentPlansActor = export const refreshPaymentPlansActor = fromPromise( async () => { + const paymentRepo = getPaymentRepository(); const result = await paymentRepo.getPlans(); if (Result.isErr(result)) throw result.error; return result.data; @@ -34,6 +33,7 @@ export const createPaymentOrderActor = fromPromise< CreatePaymentOrderResponse, { planId: string; payChannel: PayChannel; autoRenew: boolean } >(async ({ input }) => { + const paymentRepo = getPaymentRepository(); const result = await paymentRepo.createOrder( input.planId, input.payChannel, @@ -47,6 +47,7 @@ export const pollPaymentOrderStatusActor = fromPromise< PaymentOrderStatusResponse, { orderId: string } >(async ({ input }) => { + const paymentRepo = getPaymentRepository(); const result = await paymentRepo.getOrderStatus(input.orderId); if (Result.isErr(result)) throw result.error; return result.data; diff --git a/src/stores/user/user-machine.actors.ts b/src/stores/user/user-machine.actors.ts index 5da06677..4db5a5ca 100644 --- a/src/stores/user/user-machine.actors.ts +++ b/src/stores/user/user-machine.actors.ts @@ -8,18 +8,14 @@ * * 设计: * - 依赖 `user-machine.helpers.ts`(toView, readInitData, userStorage) - * - 仓库以接口类型注入(调用面只看接口,运行时仍是同一单例) + * - 仓库使用懒单例 getter,actor 执行时才创建实例 * - 命名约定:`[user-machine]` 前缀日志(如未来加)—— 与 chat 模块对齐 */ import { fromPromise } from "xstate"; -import { userRepository } from "@/data/repositories/user_repository"; -import { authRepository } from "@/data/repositories/auth_repository"; -import type { - IAuthRepository, - IUserRepository, -} from "@/data/repositories/interfaces"; +import { getUserRepository } from "@/data/repositories/user_repository"; +import { getAuthRepository } from "@/data/repositories/auth_repository"; import { Result } from "@/utils"; import { @@ -32,13 +28,6 @@ import { userStorage, } from "./user-machine.helpers"; -// ============================================================ -// 仓库注入 -// ============================================================ -// 仓库以接口类型注入:调用面只看接口,运行时仍是同一单例 -const userRepo: IUserRepository = userRepository; -const authRepo: IAuthRepository = authRepository; - // ============================================================ // Init actor:从 UserStorage 读 user + avatarUrl // ============================================================ @@ -59,6 +48,7 @@ export const userInitActor = fromPromise(async () => readInitData()); * - 任一失败都不阻断另一方,machine 层负责保留已有 context */ export const userFetchActor = fromPromise(async () => { + const userRepo = getUserRepository(); const [userResult, entitlementsResult] = await Promise.all([ userRepo.getCurrentUser(), userRepo.getEntitlements(), @@ -97,5 +87,5 @@ export const userFetchActor = fromPromise(async () => { * - onDone / onError 都跑 `clearUser` action(machine 层)—— 失败也清本地 */ export const userLogoutActor = fromPromise(async () => { - await authRepo.logout(); + await getAuthRepository().logout(); });