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:
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user