fix(billing): remove redundant account bootstrap request

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 0e4dd0f475
commit 25e32fe6fd
5 changed files with 63 additions and 47 deletions
+5 -23
View File
@@ -109,24 +109,6 @@ export function useBillingPanel(input: {
? activeSubscription.productCode
: null;
const fetchAccountData = useCallback(async (): Promise<BillingAccount | null> => {
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),
+3 -1
View File
@@ -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);
}
+24 -9
View File
@@ -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"\)/);
});
+8 -3
View File
@@ -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/);
});
@@ -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);
});