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 <noreply@anthropic.com>
This commit is contained in:
jesse-ux
2026-09-24 18:22:44 +08:00
co-authored by Claude Code
parent e1452aaea5
commit 2f53fa0171
7 changed files with 134 additions and 41 deletions
+5 -4
View File
@@ -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: () => <p className="birth-time-assistant-intent" role="status">正在加载出生时间评估...</p>,
},
);
const BillingPanel = dynamic(
() => import("@/components/billing-panel").then((module) => module.BillingPanel),
{ ssr: false },
);
const BillingPanel = dynamic(loadBillingPanel, {
ssr: false,
loading: () => <p className="secondary-page-waiting">账户信息还没拿到。</p>,
});
export default function Home() {
const sessionList = useSessionList();
+14 -12
View File
@@ -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();
+8 -1
View File
@@ -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({
<Menu.Item className="account-menu-item" onClick={onOpenGeneral}>
<Settings aria-hidden="true" /><span>通用设置</span><ChevronRight aria-hidden="true" />
</Menu.Item>
<Menu.Item className="account-menu-item" onClick={onOpenBilling}>
<Menu.Item
className="account-menu-item"
onPointerEnter={() => { prefetchBillingPanel(); prefetchPaymentPackages(); }}
onPointerDown={() => { prefetchBillingPanel(); prefetchPaymentPackages(); }}
onClick={onOpenBilling}
>
<WalletCards aria-hidden="true" /><span>账户与点数</span><small>{account.credits} 点</small>
</Menu.Item>
<Menu.Separator className="account-menu-separator" />
+10 -15
View File
@@ -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;
+7
View File
@@ -0,0 +1,7 @@
export function loadBillingPanel() {
return import("@/components/billing-panel").then((module) => module.BillingPanel);
}
export function prefetchBillingPanel(): void {
void loadBillingPanel();
}
@@ -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<PaymentPackagesPayload> | null = null;
async function loadPaymentPackages(): Promise<PaymentPackagesPayload> {
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<PaymentPackagesPayload> {
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<PaymentPackagesPayload> {
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;
}