From 2f53fa01712473a594639f57e2b098baf562e88e Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Thu, 24 Sep 2026 16:09:44 +0800 Subject: [PATCH] perf: streamline billing panel bootstrap Remove the redundant billing account read, cache packages for one minute, prefetch the billing chunk, and parallelize independent package reads. Co-Authored-By: Claude Code --- frontend/src/app/(app)/page.tsx | 9 +-- .../src/app/api/payment/packages/route.ts | 26 +++---- frontend/src/components/app-sidebar.tsx | 9 ++- frontend/src/hooks/use-billing-panel.ts | 25 +++---- frontend/src/lib/billing-panel-loader.ts | 7 ++ frontend/src/lib/payment-packages-cache.ts | 68 +++++++++++++++++++ frontend/tests/billing-panel.test.ts | 31 ++++++--- 7 files changed, 134 insertions(+), 41 deletions(-) create mode 100644 frontend/src/lib/billing-panel-loader.ts create mode 100644 frontend/src/lib/payment-packages-cache.ts diff --git a/frontend/src/app/(app)/page.tsx b/frontend/src/app/(app)/page.tsx index bb75fe1f..20134405 100644 --- a/frontend/src/app/(app)/page.tsx +++ b/frontend/src/app/(app)/page.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import dynamic from "next/dynamic"; +import { loadBillingPanel } from "@/lib/billing-panel-loader"; import { Sparkles } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; import type { FormEvent, KeyboardEvent } from "react"; @@ -238,10 +239,10 @@ const BirthTimeRectification = dynamic( loading: () =>

正在加载出生时间评估...

, }, ); -const BillingPanel = dynamic( - () => import("@/components/billing-panel").then((module) => module.BillingPanel), - { ssr: false }, -); +const BillingPanel = dynamic(loadBillingPanel, { + ssr: false, + loading: () =>

账户信息还没拿到。

, +}); export default function Home() { const sessionList = useSessionList(); diff --git a/frontend/src/app/api/payment/packages/route.ts b/frontend/src/app/api/payment/packages/route.ts index 98bbd901..6728e32d 100644 --- a/frontend/src/app/api/payment/packages/route.ts +++ b/frontend/src/app/api/payment/packages/route.ts @@ -38,20 +38,22 @@ export async function GET() { if (!availability.enabled) return NextResponse.json({ enabled: false, packages: [] }); - const flags = await loadRuntimeFeatureFlags(["billing.subscriptions"]); + const admin = createAdminSupabaseClient(); + const [flags, productsResult] = await Promise.all([ + loadRuntimeFeatureFlags(["billing.subscriptions"]), + admin + .from("billing_products") + .select( + "id,code,version,name,description,product_type,billing_period,interval_count,price_cents,currency,sort_order,effective_from,effective_to", + ) + .eq("enabled", true) + .eq("status", "published") + .order("sort_order") + .order("created_at"), + ]); const subscriptionsEnabled = flags.get("billing.subscriptions")?.enabled ?? false; - - const admin = createAdminSupabaseClient(); - const { data, error } = await admin - .from("billing_products") - .select( - "id,code,version,name,description,product_type,billing_period,interval_count,price_cents,currency,sort_order,effective_from,effective_to", - ) - .eq("enabled", true) - .eq("status", "published") - .order("sort_order") - .order("created_at"); + const { data, error } = productsResult; if (error) return NextResponse.json({ enabled: false, packages: [] }); const now = Date.now(); diff --git a/frontend/src/components/app-sidebar.tsx b/frontend/src/components/app-sidebar.tsx index 0fcfe6b2..020fc5d7 100644 --- a/frontend/src/components/app-sidebar.tsx +++ b/frontend/src/components/app-sidebar.tsx @@ -21,6 +21,8 @@ import type { Ref } from "react"; import { AppLink } from "@/components/app-link"; import { newChatHref, persistLoginSessionReturn, sessionHref } from "@/lib/chat-session-url"; import { prefetchSecondaryPage } from "@/lib/secondary-page-data"; +import { prefetchBillingPanel } from "@/lib/billing-panel-loader"; +import { prefetchPaymentPackages } from "@/lib/payment-packages-cache"; import { Sidebar, SidebarContent, @@ -368,7 +370,12 @@ export function AppSidebar({ - + { prefetchBillingPanel(); prefetchPaymentPackages(); }} + onPointerDown={() => { prefetchBillingPanel(); prefetchPaymentPackages(); }} + onClick={onOpenBilling} + > diff --git a/frontend/src/hooks/use-billing-panel.ts b/frontend/src/hooks/use-billing-panel.ts index 58268d19..3bb17374 100644 --- a/frontend/src/hooks/use-billing-panel.ts +++ b/frontend/src/hooks/use-billing-panel.ts @@ -10,6 +10,7 @@ import { type MembershipPlanAlias, type MembershipProduct, } from "@/lib/membership"; +import { fetchPaymentPackages } from "@/lib/payment-packages-cache"; export type BillingAccount = { user: { id: string; email: string | null }; @@ -137,18 +138,14 @@ export function useBillingPanel(input: { const fetchPackages = useCallback(async () => { try { - const response = await fetch("/api/payment/packages", { cache: "no-store" }); - const payload = await response.json().catch(() => null); - if (response.ok && payload?.enabled === true) { - setPaymentEnabled(true); - setPaymentPackages(payload.packages || []); + const payload = await fetchPaymentPackages((next) => { + setPaymentEnabled(next.enabled); + setPaymentPackages(next.packages); setPackagesError(""); - return; - } - if (!response.ok) { - setPackagesError("套餐支付暂时不可用,请稍后重试"); - resetPaymentState(); - } + }); + setPaymentEnabled(payload.enabled); + setPaymentPackages(payload.packages); + setPackagesError(""); } catch { setPackagesError("套餐支付暂时不可用,请稍后重试"); resetPaymentState(); @@ -181,10 +178,8 @@ export function useBillingPanel(input: { }, []); useEffect(() => { - void (async () => { - await Promise.allSettled([fetchAccountData(), fetchPackages()]); - })(); - }, [fetchAccountData, fetchPackages]); + void fetchPackages(); + }, [fetchPackages]); useEffect(() => { if (!input.highlightedPlan || planHighlightScrolled.current || paymentPackages.length === 0) return; diff --git a/frontend/src/lib/billing-panel-loader.ts b/frontend/src/lib/billing-panel-loader.ts new file mode 100644 index 00000000..a5db5617 --- /dev/null +++ b/frontend/src/lib/billing-panel-loader.ts @@ -0,0 +1,7 @@ +export function loadBillingPanel() { + return import("@/components/billing-panel").then((module) => module.BillingPanel); +} + +export function prefetchBillingPanel(): void { + void loadBillingPanel(); +} diff --git a/frontend/src/lib/payment-packages-cache.ts b/frontend/src/lib/payment-packages-cache.ts new file mode 100644 index 00000000..4f767947 --- /dev/null +++ b/frontend/src/lib/payment-packages-cache.ts @@ -0,0 +1,68 @@ +import type { MembershipProduct } from "./membership"; + +export const PAYMENT_PACKAGES_CACHE_TTL_MS = 60_000; + +export type PaymentPackagesPayload = Readonly<{ + enabled: boolean; + packages: MembershipProduct[]; +}>; + +type CacheEntry = Readonly<{ + value: PaymentPackagesPayload; + fetchedAt: number; +}>; + +let cache: CacheEntry | null = null; +let inflight: Promise | null = null; + +async function loadPaymentPackages(): Promise { + const response = await fetch("/api/payment/packages", { cache: "no-store" }); + const payload = await response.json().catch(() => null); + if (!response.ok || !payload || typeof payload !== "object") { + throw new Error("套餐支付暂时不可用,请稍后重试"); + } + const record = payload as { enabled?: unknown; packages?: unknown }; + return { + enabled: record.enabled === true, + packages: Array.isArray(record.packages) ? record.packages as MembershipProduct[] : [], + }; +} + +function refreshPaymentPackages(): Promise { + if (inflight) return inflight; + const request = loadPaymentPackages().then((value) => { + cache = { value, fetchedAt: Date.now() }; + return value; + }).finally(() => { + if (inflight === request) inflight = null; + }); + inflight = request; + return request; +} + +/** + * Return a fresh package response, or serve the last response while refreshing + * it in the background after the 60-second freshness window expires. + */ +export function fetchPaymentPackages( + onBackgroundRefresh?: (value: PaymentPackagesPayload) => void, +): Promise { + if (!cache) return refreshPaymentPackages(); + if (Date.now() - cache.fetchedAt < PAYMENT_PACKAGES_CACHE_TTL_MS) { + return Promise.resolve(cache.value); + } + const request = refreshPaymentPackages(); + void request.then((value) => onBackgroundRefresh?.(value)).catch(() => { + // Stale package data remains usable when a background refresh fails. + }); + return Promise.resolve(cache.value); +} + +export function prefetchPaymentPackages(): void { + void fetchPaymentPackages().catch(() => undefined); +} + +export function resetPaymentPackagesCacheForTests(): void { + cache = null; + inflight = null; +} diff --git a/frontend/tests/billing-panel.test.ts b/frontend/tests/billing-panel.test.ts index e5fd583e..d1a72438 100644 --- a/frontend/tests/billing-panel.test.ts +++ b/frontend/tests/billing-panel.test.ts @@ -14,6 +14,7 @@ const membershipLib = readProjectFile("src/lib/membership.ts"); const nextConfig = readProjectFile("next.config.ts"); const pageSource = readProjectFile("src/app/(app)/page.tsx"); const tabsSource = readProjectFile("src/components/ui/tabs.tsx"); +const packagesRouteSource = readProjectFile("src/app/api/payment/packages/route.ts"); function countMatches(source: string, pattern: RegExp) { return source.match(pattern)?.length ?? 0; @@ -34,21 +35,33 @@ test("membership routes are gone and billing loads without a spinner", () => { assert.match(nextConfig, /destination: "\/\?settings=billing&tab=orders"/); }); -test("membership page loads account, packages and redeem endpoints without fetching orders", () => { - // 原值: 会员页不请求 /api/payment/orders - // 新值: 挂载只并发账户与套餐;订单仅在 orders 页签拉取 - // 原因: 订单记录改成分区内页签,不能一打开就打列表接口 +test("membership page loads packages and redeem endpoints without an unused account request", () => { + // 原值: 挂载并发 /api/account 与套餐接口。 + // 新值: 挂载只取有实际用途的套餐;账户余额由首页 bootstrap 提供,兑换/支付成功后才刷新账户。 + // 原因: BUG-1022;重复账户请求拖慢首开且结果不作为初始渲染来源。 assert.match(hookSource, /fetch\("\/api\/account"/); - assert.match(hookSource, /fetch\("\/api\/payment\/packages"/); + assert.match(hookSource, /fetchPaymentPackages/); + assert.match(hookSource, /const refreshed = await fetchAccountData\(\);/); assert.match(hookSource, /fetch\("\/api\/payment\/epay\/create"/); assert.match(hookSource, /fetch\(`\/api\/payment\/epay\/status\?orderNo=/); assert.match(hookSource, /fetch\("\/api\/redeem"/); - assert.match(hookSource, /await Promise\.allSettled\(\[fetchAccountData\(\), fetchPackages\(\)\]\)/); - const mountStart = hookSource.indexOf("await Promise.allSettled"); - const mountEnd = hookSource.indexOf("}, [fetchAccountData, fetchPackages]);"); + const mountStart = hookSource.indexOf("void fetchPackages()"); + const mountEnd = hookSource.indexOf("}, [fetchPackages]);"); const mountEffect = hookSource.slice(mountStart, mountEnd); - assert.doesNotMatch(mountEffect, /fetchOrders|\/api\/payment\/orders/); + assert.doesNotMatch(mountEffect, /fetchAccountData|fetchOrders|\/api\/payment\/orders/); assert.match(panelSource, /if \(value === "orders"\) void fetchOrders\(\)/); + assert.match(readProjectFile("src/lib/payment-packages-cache.ts"), /PAYMENT_PACKAGES_CACHE_TTL_MS = 60_000/); +}); + +test("packages route reads feature flags and products in parallel before dependent entitlements", () => { + // 原值: feature flags 与商品查询串行。 + // 新值: 两项互不依赖的读取用 Promise.all;权益仍在拿到 productIds 后查询。 + // 原因: BUG-1022;缩短套餐接口首响应,不改变权限与权益依赖顺序。 + assert.match(packagesRouteSource, /const \[flags, productsResult\] = await Promise\.all\(\[/); + assert.match(packagesRouteSource, /loadRuntimeFeatureFlags\(\["billing\.subscriptions"\]\)/); + assert.match(packagesRouteSource, /createAdminSupabaseClient\(\)/); + assert.match(packagesRouteSource, /const productIds = products\.map/); + assert.ok(packagesRouteSource.indexOf("const productIds = products.map") < packagesRouteSource.indexOf("product_entitlements")); }); test("creates orders with productId and opens the signed cashier URL safely", () => {