fix: support user recharge record cancellation

This commit is contained in:
boos
2026-06-09 17:22:23 +08:00
parent 55b3d4d99a
commit 3d54c7d529
8 changed files with 388 additions and 33 deletions
+31
View File
@@ -508,6 +508,37 @@ describe('user recharge api', () => {
}));
});
it('can cancel and clear user recharge records', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({
success: true,
record: {
id: 1,
publicId: 'rch_test',
provider: 'comfy_checkout',
checkoutUrl: 'https://checkout.comfy.org/c/pay/session',
status: 'cancelled',
},
}), { status: 200, headers: { 'content-type': 'application/json' } }))
.mockResolvedValueOnce(new Response(JSON.stringify({
success: true,
cleared: 2,
records: [],
items: [],
}), { status: 200, headers: { 'content-type': 'application/json' } }));
vi.stubGlobal('fetch', fetchMock);
const cancelled = await userApi.cancelRechargeRecord('rch_test');
const cleared = await userApi.clearRechargeRecords();
expect(cancelled.record.status).toBe('cancelled');
expect(cleared.cleared).toBe(2);
expect(fetchMock.mock.calls[0]?.[0]).toBe('/api/user/recharge-records/rch_test/cancel');
expect((fetchMock.mock.calls[0]?.[1] as RequestInit)?.method).toBe('POST');
expect(fetchMock.mock.calls[1]?.[0]).toBe('/api/user/recharge-records');
expect((fetchMock.mock.calls[1]?.[1] as RequestInit)?.method).toBe('DELETE');
});
it('keeps billing guidance on quota errors', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
error: { message: '下游密钥成本额度已用完' },
+28
View File
@@ -1526,6 +1526,28 @@ export type CreateUserRechargeCheckoutResponse = {
rechargePath?: string;
};
export type UserRechargeRecordActionResponse = {
success: boolean;
record: UserRechargeRecord;
billing?: UserBillingSummary;
arrivalNote?: string;
checkoutProvider?: string;
usagePath?: string;
rechargePath?: string;
};
export type ClearUserRechargeRecordsResponse = {
success: boolean;
cleared: number;
records: UserRechargeRecord[];
items: UserRechargeRecord[];
billing?: UserBillingSummary;
arrivalNote?: string;
checkoutProvider?: string;
usagePath?: string;
rechargePath?: string;
};
export type WorkflowModelItem = {
name: string;
pathIndex?: number;
@@ -2503,6 +2525,12 @@ export const userApi = {
method: "POST",
body: JSON.stringify(data),
}),
cancelRechargeRecord: (publicId: string) =>
userRequest<UserRechargeRecordActionResponse>(`/api/user/recharge-records/${encodeURIComponent(publicId)}/cancel`, {
method: "POST",
}),
clearRechargeRecords: () =>
userRequest<ClearUserRechargeRecordsResponse>("/api/user/recharge-records", { method: "DELETE" }),
getModelsMarketplace: (options?: {
refresh?: boolean;
includePricing?: boolean;
+16 -1
View File
@@ -15129,7 +15129,7 @@ button.user-recharge-option {
.user-recharge-record {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
grid-template-columns: minmax(0, 1fr) auto minmax(156px, auto);
align-items: center;
gap: 14px;
padding: 14px;
@@ -15144,6 +15144,21 @@ button.user-recharge-option {
min-width: 0;
}
.user-recharge-record-toolbar,
.user-recharge-record-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.user-recharge-record-muted {
color: var(--color-text-muted);
font-size: 12px;
white-space: nowrap;
}
.user-recharge-record span,
.user-recharge-record small {
overflow: hidden;
+49 -2
View File
@@ -8,6 +8,8 @@ const { userApiMock } = vi.hoisted(() => ({
userApiMock: {
getRechargeRecords: vi.fn(),
createRechargeCheckout: vi.fn(),
cancelRechargeRecord: vi.fn(),
clearRechargeRecords: vi.fn(),
},
}));
@@ -62,6 +64,26 @@ describe('UserRecharge page', () => {
createdAt: '2026-06-09T09:00:00Z',
},
});
userApiMock.cancelRechargeRecord.mockImplementation(async (publicId: string) => ({
success: true,
record: {
id: 1,
publicId,
label: '$50',
title: '团队高频',
amountUsd: 50,
provider: 'comfy_checkout',
checkoutUrl: USER_RECHARGE_CONFIG.checkoutUrl,
status: 'cancelled',
createdAt: '2026-06-09T08:00:00Z',
},
}));
userApiMock.clearRechargeRecords.mockResolvedValue({
success: true,
cleared: 2,
records: [],
items: [],
});
vi.stubGlobal('window', {
open: vi.fn(() => ({
location: { href: '' },
@@ -111,8 +133,8 @@ describe('UserRecharge page', () => {
expect(text).toContain('rch_test_1');
expect(text).toContain('付款后由管理员核对到账。');
expect(optionButtons).toHaveLength(payableOptions.length);
expect(disabledOptions).toHaveLength(USER_RECHARGE_CONFIG.options.length - payableOptions.length);
expect(text).toContain('待配置');
expect(disabledOptions).toHaveLength(0);
expect(text).not.toContain('待配置');
await act(async () => {
primaryButton.props.onClick();
@@ -140,6 +162,31 @@ describe('UserRecharge page', () => {
link.props.href === '/user/usage'
&& collectText(link).includes('查看使用记录')
))).toBe(true);
const cancelButton = root!.root.findAll((node) => (
node.type === 'button'
&& collectText(node) === '取消'
))[0];
await act(async () => {
cancelButton.props.onClick();
await Promise.resolve();
await Promise.resolve();
});
expect(userApiMock.cancelRechargeRecord).toHaveBeenCalledWith('rch_created');
expect(collectText(root!.root)).toContain('已取消');
expect(collectText(root!.root)).toContain('无需支付');
const clearButton = root!.root.findAll((node) => (
node.type === 'button'
&& collectText(node).includes('清空记录')
))[0];
await act(async () => {
clearButton.props.onClick();
await Promise.resolve();
await Promise.resolve();
});
expect(userApiMock.clearRechargeRecords).toHaveBeenCalled();
expect(collectText(root!.root)).toContain('暂无充值记录');
} finally {
root!.unmount();
}
+65 -6
View File
@@ -29,6 +29,8 @@ export default function UserRecharge() {
const [arrivalNote, setArrivalNote] = useState('付款完成后返回 BossAI,到账由管理员按充值记录核对。');
const [loading, setLoading] = useState(true);
const [pendingOptionId, setPendingOptionId] = useState<string | null>(null);
const [cancellingId, setCancellingId] = useState<string | null>(null);
const [clearing, setClearing] = useState(false);
const [error, setError] = useState('');
const refresh = useCallback(async () => {
@@ -91,6 +93,42 @@ export default function UserRecharge() {
}
};
const cancelRecord = async (record: UserRechargeRecord) => {
if (!record.publicId || record.status !== 'pending' || cancellingId) return;
setCancellingId(record.publicId);
setError('');
try {
const payload = await userApi.cancelRechargeRecord(record.publicId);
if (payload.record) {
setRecords((current) => current.map((item) => (
item.publicId === payload.record.publicId ? payload.record : item
)));
}
if (payload.billing) setBilling(payload.billing);
if (payload.arrivalNote) setArrivalNote(payload.arrivalNote);
} catch (err: any) {
setError(err?.message || '取消充值记录失败');
} finally {
setCancellingId(null);
}
};
const clearRecords = async () => {
if (records.length === 0 || clearing) return;
setClearing(true);
setError('');
try {
const payload = await userApi.clearRechargeRecords();
setRecords([]);
if (payload.billing) setBilling(payload.billing);
if (payload.arrivalNote) setArrivalNote(payload.arrivalNote);
} catch (err: any) {
setError(err?.message || '清空充值记录失败');
} finally {
setClearing(false);
}
};
const defaultOption = recharge.defaultOption;
return (
@@ -156,9 +194,14 @@ export default function UserRecharge() {
<span>充值记录</span>
<strong>{loading ? '正在加载' : `${records.length} 条最近记录`}</strong>
</div>
<button type="button" className="btn btn-ghost" onClick={() => void refresh()} disabled={loading}>
刷新
</button>
<div className="user-recharge-record-toolbar">
<button type="button" className="btn btn-ghost" onClick={() => void refresh()} disabled={loading || clearing}>
刷新
</button>
<button type="button" className="btn btn-link btn-link-danger" onClick={() => void clearRecords()} disabled={records.length === 0 || loading || clearing}>
{clearing ? '清空中...' : '清空记录'}
</button>
</div>
</div>
{records.length === 0 ? (
<div className="user-recharge-record-empty">
@@ -179,9 +222,25 @@ export default function UserRecharge() {
{statusText(record.status)}
</span>
</div>
<a href={record.checkoutUrl} target="_blank" rel="noopener noreferrer" className="btn btn-ghost">
继续支付
</a>
<div className="user-recharge-record-actions">
{record.status === 'pending' ? (
<>
<a href={record.checkoutUrl} target="_blank" rel="noopener noreferrer" className="btn btn-ghost">
继续支付
</a>
<button
type="button"
className="btn btn-link btn-link-danger"
onClick={() => void cancelRecord(record)}
disabled={cancellingId === record.publicId}
>
{cancellingId === record.publicId ? '取消中...' : '取消'}
</button>
</>
) : (
<span className="user-recharge-record-muted">无需支付</span>
)}
</div>
</article>
))}
</div>
+4 -6
View File
@@ -15,9 +15,8 @@ describe('user recharge config', () => {
expect(config.opensInNewTab).toBe(true);
expect(config.defaultOption.label).toBe('$50');
expect(config.options.map((option) => option.label)).toEqual(['$5', '$10', '$20', '$50']);
expect(config.options.filter(isUserRechargeOptionPayable).map((option) => option.label)).toEqual(['$50']);
expect(config.options.filter((option) => !isUserRechargeOptionPayable(option)).map((option) => option.label)).toEqual(['$5', '$10', '$20']);
expect(config.options.filter((option) => !isUserRechargeOptionPayable(option)).every((option) => !option.checkoutUrl)).toBe(true);
expect(config.options.filter(isUserRechargeOptionPayable).map((option) => option.label)).toEqual(['$5', '$10', '$20', '$50']);
expect(config.options.every((option) => option.checkoutUrl === USER_RECHARGE_CHECKOUT_URL)).toBe(true);
});
it('allows deployment-specific checkout URL overrides', () => {
@@ -26,9 +25,8 @@ describe('user recharge config', () => {
});
expect(config.checkoutUrl).toBe('https://pay.example.test/session/123');
expect(config.options).toHaveLength(1);
expect(config.options[0].checkoutUrl).toBe('https://pay.example.test/session/123');
expect(config.options[0].label).toBe('充值');
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);
});
it('falls back to the default checkout URL for blank overrides', () => {
+6 -9
View File
@@ -32,9 +32,9 @@ export type UserRechargeConfig = {
const UNCONFIGURED_CHECKOUT_REASON = '该额度支付链接未配置';
const DEFAULT_USER_RECHARGE_AMOUNTS: UserRechargeOption[] = [
{ id: 'usd-5', label: '$5', title: '轻量试用', amountUsd: 5, disabled: true, unavailableReason: UNCONFIGURED_CHECKOUT_REASON },
{ id: 'usd-10', label: '$10', title: '日常调用', amountUsd: 10, disabled: true, unavailableReason: UNCONFIGURED_CHECKOUT_REASON },
{ id: 'usd-20', label: '$20', title: '创作加量', amountUsd: 20, disabled: true, unavailableReason: UNCONFIGURED_CHECKOUT_REASON },
{ id: 'usd-5', label: '$5', title: '轻量试用', amountUsd: 5, checkoutUrl: DEFAULT_USER_RECHARGE_CHECKOUT_URL },
{ id: 'usd-10', label: '$10', title: '日常调用', amountUsd: 10, checkoutUrl: DEFAULT_USER_RECHARGE_CHECKOUT_URL },
{ id: 'usd-20', label: '$20', title: '创作加量', amountUsd: 20, checkoutUrl: DEFAULT_USER_RECHARGE_CHECKOUT_URL },
{ id: 'usd-50', label: '$50', title: '团队高频', amountUsd: 50, checkoutUrl: DEFAULT_USER_RECHARGE_CHECKOUT_URL, recommended: true },
];
@@ -86,13 +86,10 @@ function optionsFromEnv(raw: string | undefined): UserRechargeOption[] {
function defaultOptionsFor(checkoutUrl: string, hasCheckoutOverride: boolean): UserRechargeOption[] {
if (hasCheckoutOverride) {
return [{
id: 'configured-checkout',
label: '充值',
title: '已配置支付页',
return DEFAULT_USER_RECHARGE_AMOUNTS.map((item) => ({
...item,
checkoutUrl,
recommended: true,
}];
}));
}
return DEFAULT_USER_RECHARGE_AMOUNTS.map((item) => ({ ...item }));