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}
+ >
账户与点数{account.credits} 点
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", () => {