fix(billing): remove redundant account bootstrap request
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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,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);
|
||||
}
|
||||
|
||||
@@ -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"\)/);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user