fix(recharge): per-amount distinct checkout URLs

- 前端 /0/0/0 各自携带独立 checkoutUrl
- 后端按金额解析独立 Checkout URL 配置
- 同时校验 optionId 和 amountUsd 防止金额错配
- 移除旧单一固定 checkout URL 兜底
This commit is contained in:
boos
2026-06-10 19:54:20 +08:00
parent 3d54c7d529
commit df97633300
4 changed files with 106 additions and 55 deletions
+45 -8
View File
@@ -4,7 +4,8 @@ import { MemoryRouter } from 'react-router-dom';
import { USER_RECHARGE_CONFIG, isUserRechargeOptionPayable } from '../userRecharge.js'; import { USER_RECHARGE_CONFIG, isUserRechargeOptionPayable } from '../userRecharge.js';
import UserRecharge from './UserRecharge.js'; import UserRecharge from './UserRecharge.js';
const { userApiMock } = vi.hoisted(() => ({ const { TEST_CHECKOUT_URL, userApiMock } = vi.hoisted(() => ({
TEST_CHECKOUT_URL: 'https://checkout.comfy.org/c/pay/session_50',
userApiMock: { userApiMock: {
getRechargeRecords: vi.fn(), getRechargeRecords: vi.fn(),
createRechargeCheckout: vi.fn(), createRechargeCheckout: vi.fn(),
@@ -33,7 +34,7 @@ describe('UserRecharge page', () => {
title: '团队高频', title: '团队高频',
amountUsd: 50, amountUsd: 50,
provider: 'comfy_checkout', provider: 'comfy_checkout',
checkoutUrl: USER_RECHARGE_CONFIG.checkoutUrl, checkoutUrl: TEST_CHECKOUT_URL,
status: 'pending', status: 'pending',
createdAt: '2026-06-09T08:00:00Z', createdAt: '2026-06-09T08:00:00Z',
}], }],
@@ -51,7 +52,7 @@ describe('UserRecharge page', () => {
}); });
userApiMock.createRechargeCheckout.mockResolvedValue({ userApiMock.createRechargeCheckout.mockResolvedValue({
success: true, success: true,
checkoutUrl: USER_RECHARGE_CONFIG.checkoutUrl, checkoutUrl: TEST_CHECKOUT_URL,
record: { record: {
id: 2, id: 2,
publicId: 'rch_created', publicId: 'rch_created',
@@ -59,7 +60,7 @@ describe('UserRecharge page', () => {
title: '团队高频', title: '团队高频',
amountUsd: 50, amountUsd: 50,
provider: 'comfy_checkout', provider: 'comfy_checkout',
checkoutUrl: USER_RECHARGE_CONFIG.checkoutUrl, checkoutUrl: TEST_CHECKOUT_URL,
status: 'pending', status: 'pending',
createdAt: '2026-06-09T09:00:00Z', createdAt: '2026-06-09T09:00:00Z',
}, },
@@ -73,7 +74,7 @@ describe('UserRecharge page', () => {
title: '团队高频', title: '团队高频',
amountUsd: 50, amountUsd: 50,
provider: 'comfy_checkout', provider: 'comfy_checkout',
checkoutUrl: USER_RECHARGE_CONFIG.checkoutUrl, checkoutUrl: TEST_CHECKOUT_URL,
status: 'cancelled', status: 'cancelled',
createdAt: '2026-06-09T08:00:00Z', createdAt: '2026-06-09T08:00:00Z',
}, },
@@ -86,6 +87,7 @@ describe('UserRecharge page', () => {
}); });
vi.stubGlobal('window', { vi.stubGlobal('window', {
open: vi.fn(() => ({ open: vi.fn(() => ({
opener: {},
location: { href: '' }, location: { href: '' },
close: vi.fn(), close: vi.fn(),
})), })),
@@ -133,8 +135,8 @@ describe('UserRecharge page', () => {
expect(text).toContain('rch_test_1'); expect(text).toContain('rch_test_1');
expect(text).toContain('付款后由管理员核对到账。'); expect(text).toContain('付款后由管理员核对到账。');
expect(optionButtons).toHaveLength(payableOptions.length); expect(optionButtons).toHaveLength(payableOptions.length);
expect(disabledOptions).toHaveLength(0);
expect(text).not.toContain('待配置'); expect(text).not.toContain('待配置');
expect(disabledOptions).toHaveLength(0);
await act(async () => { await act(async () => {
primaryButton.props.onClick(); primaryButton.props.onClick();
@@ -146,8 +148,9 @@ describe('UserRecharge page', () => {
optionId: USER_RECHARGE_CONFIG.defaultOption.id, optionId: USER_RECHARGE_CONFIG.defaultOption.id,
amountUsd: USER_RECHARGE_CONFIG.defaultOption.amountUsd, amountUsd: USER_RECHARGE_CONFIG.defaultOption.amountUsd,
}); });
expect((window.open as any)).toHaveBeenCalledWith('about:blank', '_blank', 'noopener,noreferrer'); expect((window.open as any)).toHaveBeenCalledWith('about:blank', '_blank');
expect((window.open as any).mock.results[0].value.location.href).toBe(USER_RECHARGE_CONFIG.checkoutUrl); expect((window.open as any).mock.results[0].value.location.href).toBe(TEST_CHECKOUT_URL);
expect((window.open as any).mock.results[0].value.opener).toBeNull();
expect((window.location.assign as any)).not.toHaveBeenCalled(); expect((window.location.assign as any)).not.toHaveBeenCalled();
const externalCheckoutLinks = links.filter((link) => ( const externalCheckoutLinks = links.filter((link) => (
String(link.props.href || '').startsWith('https://checkout.comfy.org/c/pay/') String(link.props.href || '').startsWith('https://checkout.comfy.org/c/pay/')
@@ -191,4 +194,38 @@ describe('UserRecharge page', () => {
root!.unmount(); root!.unmount();
} }
}); });
it('falls back to current-tab navigation when the checkout popup is blocked', async () => {
(window.open as any).mockReturnValueOnce(null);
let root: ReturnType<typeof create>;
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/user/recharge']}>
<UserRecharge />
</MemoryRouter>,
);
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
try {
const primaryButton = root!.root.find((node) => (
node.type === 'button'
&& collectText(node).includes('创建记录并支付')
));
await act(async () => {
primaryButton.props.onClick();
await Promise.resolve();
await Promise.resolve();
});
expect((window.open as any)).toHaveBeenCalledWith('about:blank', '_blank');
expect(window.location.assign).toHaveBeenCalledWith(TEST_CHECKOUT_URL);
} finally {
root!.unmount();
}
});
}); });
+22 -6
View File
@@ -22,6 +22,26 @@ function statusText(status: string) {
return '待确认'; return '待确认';
} }
function reserveCheckoutWindow(): Window | null {
const checkoutWindow = window.open('about:blank', '_blank');
if (checkoutWindow) {
try {
checkoutWindow.opener = null;
} catch {
// Ignore browsers that do not allow mutating the opener reference.
}
}
return checkoutWindow;
}
function navigateCheckoutWindow(checkoutWindow: Window | null, checkoutUrl: string) {
if (checkoutWindow) {
checkoutWindow.location.href = checkoutUrl;
return;
}
window.location.assign(checkoutUrl);
}
export default function UserRecharge() { export default function UserRecharge() {
const recharge = USER_RECHARGE_CONFIG; const recharge = USER_RECHARGE_CONFIG;
const [records, setRecords] = useState<UserRechargeRecord[]>([]); const [records, setRecords] = useState<UserRechargeRecord[]>([]);
@@ -61,7 +81,7 @@ export default function UserRecharge() {
if (pendingOptionId) return; if (pendingOptionId) return;
setPendingOptionId(option.id); setPendingOptionId(option.id);
setError(''); setError('');
const checkoutWindow = window.open('about:blank', '_blank', 'noopener,noreferrer'); const checkoutWindow = reserveCheckoutWindow();
try { try {
const payload = await userApi.createRechargeCheckout({ const payload = await userApi.createRechargeCheckout({
optionId: option.id, optionId: option.id,
@@ -76,11 +96,7 @@ export default function UserRecharge() {
if (!checkoutUrl) { if (!checkoutUrl) {
throw new Error('支付链接未返回,请稍后重试。'); throw new Error('支付链接未返回,请稍后重试。');
} }
if (checkoutWindow) { navigateCheckoutWindow(checkoutWindow, checkoutUrl);
checkoutWindow.location.href = checkoutUrl;
} else {
window.location.assign(checkoutUrl);
}
} catch (err: any) { } catch (err: any) {
try { try {
checkoutWindow?.close(); checkoutWindow?.close();
+24 -12
View File
@@ -1,40 +1,46 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
USER_RECHARGE_CHECKOUT_URL,
isUserRechargeOptionPayable, isUserRechargeOptionPayable,
resolveUserRechargeConfig, resolveUserRechargeConfig,
} from './userRecharge.js'; } from './userRecharge.js';
describe('user recharge config', () => { describe('user recharge config', () => {
it('uses the Comfy checkout link by default', () => { it('uses amount-specific recharge options by default without a fixed checkout url', () => {
const config = resolveUserRechargeConfig({}); const config = resolveUserRechargeConfig({});
expect(config.checkoutUrl).toBe(USER_RECHARGE_CHECKOUT_URL); expect(config.checkoutUrl).toBe('');
expect(config.checkoutUrl).toContain('https://checkout.comfy.org/c/pay/');
expect(config.providerLabel).toBe('Comfy Checkout'); expect(config.providerLabel).toBe('Comfy Checkout');
expect(config.opensInNewTab).toBe(true); expect(config.opensInNewTab).toBe(true);
expect(config.defaultOption.id).toBe('usd-50');
expect(config.defaultOption.label).toBe('$50'); expect(config.defaultOption.label).toBe('$50');
expect(config.defaultOption.amountUsd).toBe(50);
expect(config.options.map((option) => option.label)).toEqual(['$5', '$10', '$20', '$50']); expect(config.options.map((option) => option.label)).toEqual(['$5', '$10', '$20', '$50']);
expect(config.options.filter(isUserRechargeOptionPayable).map((option) => option.label)).toEqual(['$5', '$10', '$20', '$50']); expect(config.options.filter(isUserRechargeOptionPayable).map((option) => option.label)).toEqual([
expect(config.options.every((option) => option.checkoutUrl === USER_RECHARGE_CHECKOUT_URL)).toBe(true); '$5',
'$10',
'$20',
'$50',
]);
expect(config.options.every((option) => !option.disabled && !option.checkoutUrl)).toBe(true);
}); });
it('allows deployment-specific checkout URL overrides', () => { it('does not let a single checkout URL override price every amount', () => {
const config = resolveUserRechargeConfig({ const config = resolveUserRechargeConfig({
VITE_USER_RECHARGE_CHECKOUT_URL: ' https://pay.example.test/session/123 ', VITE_USER_RECHARGE_CHECKOUT_URL: ' https://pay.example.test/session/123 ',
}); });
expect(config.checkoutUrl).toBe('https://pay.example.test/session/123'); expect(config.checkoutUrl).toBe('');
expect(config.defaultOption.id).toBe('usd-50');
expect(config.options.map((option) => option.label)).toEqual(['$5', '$10', '$20', '$50']); expect(config.options.map((option) => option.label)).toEqual(['$5', '$10', '$20', '$50']);
expect(config.options.every((option) => option.checkoutUrl === 'https://pay.example.test/session/123')).toBe(true); expect(config.options.every((option) => !option.checkoutUrl)).toBe(true);
}); });
it('falls back to the default checkout URL for blank overrides', () => { it('ignores blank single checkout URL overrides', () => {
const config = resolveUserRechargeConfig({ const config = resolveUserRechargeConfig({
VITE_USER_RECHARGE_CHECKOUT_URL: ' ', VITE_USER_RECHARGE_CHECKOUT_URL: ' ',
}); });
expect(config.checkoutUrl).toBe(USER_RECHARGE_CHECKOUT_URL); expect(config.checkoutUrl).toBe('');
}); });
it('supports deployment-specific checkout links per recharge amount', () => { it('supports deployment-specific checkout links per recharge amount', () => {
@@ -81,11 +87,17 @@ describe('user recharge config', () => {
expect(config.defaultOption.label).toBe('$50'); expect(config.defaultOption.label).toBe('$50');
}); });
it('ignores invalid option JSON and keeps default amounts', () => { it('ignores invalid option JSON and keeps amount-specific default behavior', () => {
const config = resolveUserRechargeConfig({ const config = resolveUserRechargeConfig({
VITE_USER_RECHARGE_OPTIONS: 'not-json', VITE_USER_RECHARGE_OPTIONS: 'not-json',
}); });
expect(config.options.map((option) => option.label)).toEqual(['$5', '$10', '$20', '$50']); expect(config.options.map((option) => option.label)).toEqual(['$5', '$10', '$20', '$50']);
expect(config.options.filter(isUserRechargeOptionPayable).map((option) => option.label)).toEqual([
'$5',
'$10',
'$20',
'$50',
]);
}); });
}); });
+15 -29
View File
@@ -1,5 +1,3 @@
const DEFAULT_USER_RECHARGE_CHECKOUT_URL = 'https://checkout.comfy.org/c/pay/cs_live_b1r35y0Hs4XhUUkxJRjr9EnD96jFyWdocmfVBEn0RpOzFeKDMLDaqNEFho#fid1d2BpamRhQ2prcSc%2FJ0xrcWB3JyknZ2p3YWB3VnF8aWAnPydhYGNkcGlxJyknYnBkZmRoamlgU2R3bGRrcSc%2FJ2Zqa3F3amknKSdkdWxOYHwnPyd1blppbHNgWjA0SDRhSU9Bd1dTfFFUaEB%2FdXBvRF9qXE1KRHZpS2BBTWBBQT1nb1NuM2M2YGthVmFWY0QwSW1pb09Hd0xfPGZ0ZFc2fTNcQW1HdUROSF18UElWbmY0YkJkNTUwYlNxU2czRCcpJ2N3amhWYHdzYHcnP3F3cGApJ2dkZm5id2pwa2FGamlqdyc%2FJyY3NDQ8NzInKSdpZHxqcHFRfHVgJz8naHBpcWxabHFgaCcpJ2BrZGdpYFVpZGZgbWppYWB3dic%2FcXdwYHgl';
type UserRechargeEnv = { type UserRechargeEnv = {
VITE_USER_RECHARGE_CHECKOUT_URL?: string; VITE_USER_RECHARGE_CHECKOUT_URL?: string;
VITE_USER_RECHARGE_OPTIONS?: string; VITE_USER_RECHARGE_OPTIONS?: string;
@@ -17,7 +15,6 @@ export type UserRechargeOption = {
}; };
export type UserRechargeCheckoutOption = UserRechargeOption & { export type UserRechargeCheckoutOption = UserRechargeOption & {
checkoutUrl: string;
disabled?: false; disabled?: false;
}; };
@@ -31,19 +28,20 @@ export type UserRechargeConfig = {
const UNCONFIGURED_CHECKOUT_REASON = '该额度支付链接未配置'; const UNCONFIGURED_CHECKOUT_REASON = '该额度支付链接未配置';
const DEFAULT_USER_RECHARGE_AMOUNTS: UserRechargeOption[] = [ const DEFAULT_USER_RECHARGE_AMOUNTS: UserRechargeCheckoutOption[] = [
{ id: 'usd-5', label: '$5', title: '轻量试用', amountUsd: 5, checkoutUrl: DEFAULT_USER_RECHARGE_CHECKOUT_URL }, { id: 'usd-5', label: '$5', title: '轻量试用', amountUsd: 5 },
{ id: 'usd-10', label: '$10', title: '日常调用', amountUsd: 10, checkoutUrl: DEFAULT_USER_RECHARGE_CHECKOUT_URL }, { id: 'usd-10', label: '$10', title: '日常调用', amountUsd: 10 },
{ id: 'usd-20', label: '$20', title: '创作加量', amountUsd: 20, checkoutUrl: DEFAULT_USER_RECHARGE_CHECKOUT_URL }, { id: 'usd-20', label: '$20', title: '创作加量', amountUsd: 20 },
{ id: 'usd-50', label: '$50', title: '团队高频', amountUsd: 50, checkoutUrl: DEFAULT_USER_RECHARGE_CHECKOUT_URL, recommended: true }, { id: 'usd-50', label: '$50', title: '团队高频', amountUsd: 50, recommended: true },
]; ];
function checkoutUrlOverrideFromEnv(env: UserRechargeEnv) { function hasValidAmount(option: UserRechargeOption) {
return (env.VITE_USER_RECHARGE_CHECKOUT_URL || '').trim(); return typeof option.amountUsd === 'number' && Number.isFinite(option.amountUsd) && option.amountUsd > 0;
} }
export function isUserRechargeOptionPayable(option: UserRechargeOption): option is UserRechargeCheckoutOption { export function isUserRechargeOptionPayable(option: UserRechargeOption): option is UserRechargeCheckoutOption {
return Boolean(option.checkoutUrl && !option.disabled); const checkoutUrl = String(option.checkoutUrl || '').trim();
return !option.disabled && (Boolean(checkoutUrl) || hasValidAmount(option));
} }
function normalizeRechargeOption(value: any, index: number): UserRechargeOption | null { function normalizeRechargeOption(value: any, index: number): UserRechargeOption | null {
@@ -84,36 +82,24 @@ function optionsFromEnv(raw: string | undefined): UserRechargeOption[] {
} }
} }
function defaultOptionsFor(checkoutUrl: string, hasCheckoutOverride: boolean): UserRechargeOption[] { function defaultOptions(): UserRechargeCheckoutOption[] {
if (hasCheckoutOverride) {
return DEFAULT_USER_RECHARGE_AMOUNTS.map((item) => ({
...item,
checkoutUrl,
}));
}
return DEFAULT_USER_RECHARGE_AMOUNTS.map((item) => ({ ...item })); return DEFAULT_USER_RECHARGE_AMOUNTS.map((item) => ({ ...item }));
} }
export function resolveUserRechargeConfig(env: UserRechargeEnv = import.meta.env as UserRechargeEnv): UserRechargeConfig { export function resolveUserRechargeConfig(env: UserRechargeEnv = import.meta.env as UserRechargeEnv): UserRechargeConfig {
const checkoutUrlOverride = checkoutUrlOverrideFromEnv(env);
const fallbackCheckoutUrl = checkoutUrlOverride || DEFAULT_USER_RECHARGE_CHECKOUT_URL;
const customOptions = optionsFromEnv(env.VITE_USER_RECHARGE_OPTIONS); const customOptions = optionsFromEnv(env.VITE_USER_RECHARGE_OPTIONS);
const options = customOptions.length > 0 const options = customOptions.length > 0
? customOptions ? customOptions
: defaultOptionsFor(fallbackCheckoutUrl, Boolean(checkoutUrlOverride)); : defaultOptions();
const payableOptions = options.filter(isUserRechargeOptionPayable); const payableOptions = options.filter(isUserRechargeOptionPayable);
const fallbackOptions = defaultOptions();
const defaultOption = payableOptions.find((item) => item.recommended) const defaultOption = payableOptions.find((item) => item.recommended)
|| payableOptions[0] || payableOptions[0]
|| { || fallbackOptions.find((item) => item.recommended)
id: 'default-checkout', || fallbackOptions[0]!;
label: '充值',
title: '打开支付页',
checkoutUrl: fallbackCheckoutUrl,
};
return { return {
checkoutUrl: defaultOption.checkoutUrl, checkoutUrl: defaultOption.checkoutUrl || '',
defaultOption, defaultOption,
options, options,
providerLabel: 'Comfy Checkout', providerLabel: 'Comfy Checkout',