diff --git a/frontend/src/hooks/use-billing-panel.ts b/frontend/src/hooks/use-billing-panel.ts index 3bb17374..1c11e19e 100644 --- a/frontend/src/hooks/use-billing-panel.ts +++ b/frontend/src/hooks/use-billing-panel.ts @@ -109,24 +109,6 @@ export function useBillingPanel(input: { ? activeSubscription.productCode : null; - const fetchAccountData = useCallback(async (): Promise => { - try { - const response = await fetch("/api/account", { cache: "no-store" }); - if (response.status === 401) { - window.location.assign("/login"); - return null; - } - const payload = await response.json().catch(() => null); - if (!response.ok) throw new Error(payload?.error || "暂时无法读取账户信息"); - const next = toBillingAccount(payload as Account); - setAccountError(""); - return next; - } catch (caught) { - setAccountError(caught instanceof Error ? caught.message : "暂时无法读取账户信息"); - return null; - } - }, []); - const resetPaymentState = useCallback(() => { setPaymentEnabled(false); setPaymentPackages([]); @@ -216,8 +198,7 @@ export function useBillingPanel(input: { setRedeemCode(""); setRedeemDone(true); setRedeemMessage(result.message || `本次到账 ${awarded} 点,最新余额 ${result.credits} 点。`); - const refreshed = await fetchAccountData(); - notifyBalanceChanged(refreshed?.credits ?? result.credits); + notifyBalanceChanged(result.credits); } catch (caught) { setRedeemError(caught instanceof Error ? caught.message : "兑换失败,请稍后重试"); } finally { @@ -281,8 +262,9 @@ export function useBillingPanel(input: { status: paid ? "paid" : failed ? "failed" : "pending", } : current); if (paid) { - const refreshed = await fetchAccountData(); - notifyBalanceChanged(refreshed?.credits ?? 0); + if (typeof payload.credits === "number") { + notifyBalanceChanged(payload.credits); + } } }; let timer = 0; @@ -308,7 +290,7 @@ export function useBillingPanel(input: { stopPolling(); document.removeEventListener("visibilitychange", onVisibilityChange); }; - }, [fetchAccountData, paymentOrder]); + }, [paymentOrder]); return { account: toBillingAccount(seedAccount), diff --git a/frontend/src/lib/billing-panel-loader.ts b/frontend/src/lib/billing-panel-loader.ts index a5db5617..d4f669a2 100644 --- a/frontend/src/lib/billing-panel-loader.ts +++ b/frontend/src/lib/billing-panel-loader.ts @@ -3,5 +3,7 @@ export function loadBillingPanel() { } export function prefetchBillingPanel(): void { - void loadBillingPanel(); + // Hover/pointer prefetch is opportunistic. An offline chunk failure must not + // become an unhandled rejection handled by StaleClientRecovery as a reload. + void loadBillingPanel().catch(() => undefined); } diff --git a/frontend/tests/billing-panel.test.ts b/frontend/tests/billing-panel.test.ts index d1a72438..398008a0 100644 --- a/frontend/tests/billing-panel.test.ts +++ b/frontend/tests/billing-panel.test.ts @@ -15,11 +15,16 @@ 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"); +const billingLoaderSource = readProjectFile("src/lib/billing-panel-loader.ts"); function countMatches(source: string, pattern: RegExp) { return source.match(pattern)?.length ?? 0; } +test("billing chunk prefetch isolates offline failures", () => { + assert.match(billingLoaderSource, /void loadBillingPanel\(\)\.catch\(\(\) => undefined\)/); +}); + test("membership routes are gone and billing loads without a spinner", () => { // 原值: src/app/membership/page.tsx 存在且含 Suspense / membership-loading // 新值: 页面删除;billing-panel 是 client 组件,无 spinner / 骨架 / 「正在加载」 @@ -37,11 +42,11 @@ test("membership routes are gone and billing loads without a spinner", () => { test("membership page loads packages and redeem endpoints without an unused account request", () => { // 原值: 挂载并发 /api/account 与套餐接口。 - // 新值: 挂载只取有实际用途的套餐;账户余额由首页 bootstrap 提供,兑换/支付成功后才刷新账户。 - // 原因: BUG-1022;重复账户请求拖慢首开且结果不作为初始渲染来源。 - assert.match(hookSource, /fetch\("\/api\/account"/); + // 新值: 挂载只取套餐;账户余额由首页 bootstrap 提供,兑换/支付成功使用接口返回 credits 通过共享事件同步。 + // 原因: BUG-1022;删除无用的完整账户请求,避免重复数据库读取,同时保留余额更新行为。 + assert.doesNotMatch(hookSource, /fetchAccountData|fetch\("\/api\/account"/); assert.match(hookSource, /fetchPaymentPackages/); - assert.match(hookSource, /const refreshed = await fetchAccountData\(\);/); + assert.match(hookSource, /notifyBalanceChanged\(result\.credits\)/); assert.match(hookSource, /fetch\("\/api\/payment\/epay\/create"/); assert.match(hookSource, /fetch\(`\/api\/payment\/epay\/status\?orderNo=/); assert.match(hookSource, /fetch\("\/api\/redeem"/); @@ -98,8 +103,12 @@ test("polls order status and refreshes only the balance on paid", () => { assert.match(poll, /const paid = payload\.status === "paid" && !failed;/); assert.match(poll, /if \(paid\) \{/); assert.match(poll, /status: paid \? "paid" : failed \? "failed" : "pending"/); - assert.match(poll, /fetchAccountData\(\)/); - assert.match(poll, /notifyBalanceChanged\(refreshed\?\.credits/); + // 原值: 支付成功后再次 fetchAccountData(),并用 refreshed.credits 通知。 + // 新值: /api/payment/epay/status 已返回 credits,直接通过共享余额事件通知。 + // 原因: T2 要求源码不再请求 /api/account;避免重复账户查询但保留支付后余额同步。 + assert.doesNotMatch(poll, /fetchAccountData|\/api\/account/); + assert.match(poll, /typeof payload\.credits === "number"/); + assert.match(poll, /notifyBalanceChanged\(payload\.credits\)/); assert.doesNotMatch(poll, /fetchOrders/); }); @@ -145,8 +154,11 @@ test("redeem success reports awarded credits, latest balance and completes", () assert.match(hookSource, /最新余额/); assert.match(hookSource, /setRedeemDone\(true\)/); assert.match(hookSource, /setRedeemCode\(""\)/); - assert.match(hookSource, /const refreshed = await fetchAccountData\(\);/); - assert.match(hookSource, /notifyBalanceChanged\(refreshed\?\.credits \?\? result\.credits\)/); + // 原值: 兑换成功后重新请求 /api/account,再以 refreshed.credits 回传。 + // 新值: 兑换接口响应的 result.credits 直接广播共享余额事件。 + // 原因: T2 删除无用账户请求;保留兑换后的余额同步,不新增网络往返。 + assert.doesNotMatch(hookSource, /fetchAccountData|\/api\/account/); + assert.match(hookSource, /notifyBalanceChanged\(result\.credits\)/); assert.match(panelSource, /完成/); assert.match(panelSource, /onClick=\{closeRedeem\}/); }); @@ -363,6 +375,9 @@ test("redeem modal surface contains title, balance, input, redeem, rules and clo test("401 redirects stay hard navigations so no client state survives the sign-out", () => { assert.match(hookSource, /if \(response\.status === 401\) \{\s*window\.location\.assign\("\/login"\);/); - assert.equal(countMatches(hookSource, /window\.location\.assign\("\/login"\)/g), 4); + // 原值: 4 次认证硬跳转(挂载账户、订单、兑换、支付状态)。 + // 新值: 3 次(删除挂载账户请求后,仅订单、兑换、支付状态保留认证跳转)。 + // 原因: T2 删除无用 /api/account 请求,认证失败仍保持硬跳转。 + assert.equal(countMatches(hookSource, /window\.location\.assign\("\/login"\)/g), 3); assert.doesNotMatch(billingSource, /router\.(push|replace)\("\/login"\)/); }); diff --git a/frontend/tests/epay-settings.test.ts b/frontend/tests/epay-settings.test.ts index 42ba326a..b2209952 100644 --- a/frontend/tests/epay-settings.test.ts +++ b/frontend/tests/epay-settings.test.ts @@ -7,6 +7,7 @@ import { EpayConfigurationError, resolveEpayConfig } from "../src/lib/epay/confi import { assertConfiguredEpayUrl, assertPublicEpayGateway, assertPublicGatewayUrl, isPublicEpayAddress } from "../src/lib/epay/gateway-policy"; const root = new URL("../", import.meta.url); +const readProjectFile = (path: string) => readFileSync(new URL(path, root), "utf8"); const route = readFileSync(new URL("src/app/api/admin/epay-settings/route.ts", root), "utf8"); const management = readFileSync(new URL("src/components/admin/payment-management.tsx", root), "utf8"); const createRoute = readFileSync(new URL("src/app/api/payment/epay/create/route.ts", root), "utf8"); @@ -184,9 +185,13 @@ test("支付开关贯通迁移、公共套餐 API、创建订单和会员页", ( assert.ok(createRoute.indexOf("availability.enabled") < createRoute.indexOf("await readEpayConfig()")); assert.ok(createRoute.indexOf("availability.enabled") < createRoute.indexOf("billing_products")); assert.match(management, /在对话页开放支付/); - assert.match(billingSource, /fetch\("\/api\/payment\/packages"/); - assert.match(billingSource, /payload\?\..*enabled === true/); - assert.match(billingSource, /setPaymentEnabled\(true\)/); + // 原值: billing hook 直接 fetch /api/payment/packages 并读取 payload.enabled。 + // 新值: 请求与 60 秒模块级缓存封装在 payment-packages-cache.ts,hook 通过 fetchPaymentPackages 接入;enabled 仍由返回 payload 驱动。 + // 原因: BUG-1022;复用同会话套餐结果,减少重复网络请求且不改变支付开关行为。 + assert.match(billingSource, /fetchPaymentPackages/); + assert.match(readFileSync(new URL("src/lib/payment-packages-cache.ts", root), "utf8"), /fetch\("\/api\/payment\/packages"/); + assert.match(readFileSync(new URL("src/lib/payment-packages-cache.ts", root), "utf8"), /enabled: record\.enabled === true/); + assert.match(billingHook, /setPaymentEnabled\(next\.enabled\)/); assert.match(billingSource, /setPaymentEnabled\(false\)[\s\S]*setPaymentPackages\(\[\]\)[\s\S]*setPaymentOrder\(null\)[\s\S]*setPaymentError\(""\)/); assert.doesNotMatch(page, /paymentEnabled|paymentPackages|paymentOrder|payingPackageId/); }); diff --git a/frontend/tests/membership-navigation-contract.test.ts b/frontend/tests/membership-navigation-contract.test.ts index e4fe6c52..9dee56f1 100644 --- a/frontend/tests/membership-navigation-contract.test.ts +++ b/frontend/tests/membership-navigation-contract.test.ts @@ -11,14 +11,23 @@ function countMatches(source: string, pattern: RegExp) { return source.match(pattern)?.length ?? 0; } -test("account and packages are requested concurrently on mount", () => { - assert.match(hookSource, /await Promise\.allSettled\(\[fetchAccountData\(\), fetchPackages\(\)\]\);/); - assert.doesNotMatch(hookSource, /await fetchAccountData\(\);\s*await fetchPackages\(\)/); - assert.doesNotMatch(hookSource, /Promise\.all\(\[/); +test("packages load on mount without a redundant account request", () => { + // 原值: 挂载并发 fetchAccountData() 与 fetchPackages(),并锁定 Promise.allSettled。 + // 新值: 挂载只执行 fetchPackages(),账户余额来自首页 bootstrap;成功兑换/支付使用接口返回 credits 通过共享事件同步。 + // 原因: BUG-1022 / TASK-home-slow-network D5,删除无用的完整 /api/account 请求,避免重复数据库读取。 + assert.match(hookSource, /void fetchPackages\(\);/); + assert.doesNotMatch(hookSource, /Promise\.allSettled\(\[fetchAccountData\(\), fetchPackages\(\)\]\)/); + assert.doesNotMatch(hookSource, /fetchAccountData/); + assert.doesNotMatch(hookSource, /fetch\("\/api\/account"/); + assert.match(hookSource, /notifyBalanceChanged\(result\.credits\)/); + assert.match(hookSource, /notifyBalanceChanged\(payload\.credits\)/); }); -test("a failing bootstrap request cannot hide the other section", () => { - assert.match(hookSource, /setAccountError\(caught instanceof Error/); +test("a failing packages request cannot hide the account summary", () => { + // 原值: 账户与套餐并发加载,各自错误状态均需保留。 + // 新值: 账户来自首页 bootstrap;账单挂载只请求套餐,失败仅显示套餐错误,账户摘要仍可见。 + // 原因: BUG-1022 / T2 删除重复账户请求,避免为已不存在的请求保留错误契约。 + assert.doesNotMatch(hookSource, /setAccountError\(caught instanceof Error/); assert.match(hookSource, /setPackagesError\("套餐支付暂时不可用,请稍后重试"\)/); // 原值: 摘要在整页 hero,失败时显示「正在读取…」 // 新值: 弹窗打开时账户已在内存,摘要直接渲染;套餐区失败只出 packagesError @@ -41,7 +50,10 @@ test("membership and orders navigate through next/link instead of a document rel test("401 redirects stay hard navigations so no client state survives the sign-out", () => { assert.match(hookSource, /if \(response\.status === 401\) \{\s*window\.location\.assign\("\/login"\);/); - assert.equal(countMatches(hookSource, /window\.location\.assign\("\/login"\)/g), 4); + // 原值: 4 次认证硬跳转,包含已删除的挂载账户请求。 + // 新值: 3 次,订单、兑换、支付状态仍在 401 时硬跳转登录。 + // 原因: 删除重复 /api/account 请求后,认证行为保留在仍实际发起的请求上。 + assert.equal(countMatches(hookSource, /window\.location\.assign\("\/login"\)/g), 3); }); test("browser back and the external cashier keep their native behaviour", () => { @@ -53,10 +65,10 @@ test("browser back and the external cashier keep their native behaviour", () => }); test("the hard navigation inventory does not grow", () => { - // 原值: 会员页 4 次 window.location. + 订单页 1 次 - // 新值: hook 仅 4 次 assign("/login"),面板 0 次 - // 原因: 去掉 goBack 的 assign("/") 后硬跳转只剩认证 - assert.equal(countMatches(hookSource, /window\.location\./g), 4); + // 原值: 会员页 4 次 window.location. + 订单页 1 次。 + // 新值: hook 仅 3 次 assign("/login"),面板 0 次。 + // 原因: 删除重复账户请求后,仅订单、兑换、支付状态保留认证硬跳转。 + assert.equal(countMatches(hookSource, /window\.location\./g), 3); assert.equal(countMatches(panelSource, /window\.location\./g), 0); });