fix wechat virtual payment coin flow

This commit is contained in:
kdletters
2026-05-30 16:42:25 +08:00
parent e941ac4539
commit aaaba77c3a
8 changed files with 496 additions and 13 deletions

View File

@@ -1594,6 +1594,211 @@ test('profile recharge modal releases submitting state and shows virtual payment
});
});
test('profile recharge modal eventually shows error text even when hashchange is not dispatched', async () => {
const user = userEvent.setup();
window.history.replaceState(null, '', '/?clientRuntime=wechat_mini_program');
const navigateTo = vi.fn((options: { url: string; success?: () => void }) => {
options.success?.();
});
window.wx = {
miniProgram: {
navigateTo,
},
};
mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({
order: {
orderId: 'order-wechat-delayed-fail',
productId: 'points_60',
productTitle: '60泥点',
kind: 'points',
amountCents: 600,
status: 'pending' as const,
paymentChannel: 'wechat_mp_virtual',
paidAt: null as string | null,
providerTransactionId: null,
createdAt: '2026-04-25T10:00:00Z',
pointsDelta: 0,
membershipExpiresAt: null,
},
center: {
walletBalance: 0,
membership: {
status: 'normal',
tier: 'normal',
startedAt: null,
expiresAt: null,
updatedAt: null,
},
pointProducts: [],
membershipProducts: [],
benefits: [],
latestOrder: null,
hasPointsRecharged: false,
},
wechatMiniProgramPayParams: {
mode: 'short_series_coin',
signData:
'{"offerId":"offer-1","buyQuantity":1,"env":1,"currencyType":"CNY","outTradeNo":"order-wechat-delayed-fail","attach":"mud_points_60"}',
paySig: 'sandbox-pay-sig',
signature: 'user-sig',
},
});
renderProfileView();
await openRechargeModal(user);
const buyButton = await screen.findByRole('button', { name: /60/u });
await user.click(buyButton);
const navigateUrl = navigateTo.mock.calls[0]?.[0].url ?? '';
const requestId = new URL(`https://mini.test${navigateUrl}`).searchParams.get(
'requestId',
);
expect(requestId).toBeTruthy();
window.location.hash = `wx_pay_result=${requestId}:fail:order-wechat-delayed-fail:${encodeURIComponent('{"errCode":-1,"errMsg":"requestVirtualPayment:fail delayed"}')}`;
expect(
await screen.findByRole('dialog', { name: '支付未完成' }),
).toBeTruthy();
expect(screen.getByText(/requestVirtualPayment:fail delayed/u)).toBeTruthy();
});
test('profile recharge modal keeps polling long enough for late success result', async () => {
const user = userEvent.setup();
const onRechargeSuccess = vi.fn();
window.history.replaceState(null, '', '/?clientRuntime=wechat_mini_program');
const navigateTo = vi.fn((options: { url: string; success?: () => void }) => {
options.success?.();
});
window.wx = {
miniProgram: {
navigateTo,
},
};
mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({
order: {
orderId: 'order-wechat-late-success',
productId: 'points_60',
productTitle: '60泥点',
kind: 'points',
amountCents: 600,
status: 'pending' as const,
paymentChannel: 'wechat_mp_virtual',
paidAt: null as string | null,
providerTransactionId: null,
createdAt: '2026-04-25T10:00:00Z',
pointsDelta: 0,
membershipExpiresAt: null,
},
center: {
walletBalance: 0,
membership: {
status: 'normal',
tier: 'normal',
startedAt: null,
expiresAt: null,
updatedAt: null,
},
pointProducts: [],
membershipProducts: [],
benefits: [],
latestOrder: null,
hasPointsRecharged: false,
},
wechatMiniProgramPayParams: {
mode: 'short_series_coin',
signData:
'{"offerId":"offer-1","buyQuantity":60,"env":0,"currencyType":"CNY","outTradeNo":"order-wechat-late-success","attach":"mud_points_60"}',
paySig: 'pay-sig',
signature: 'user-sig',
},
});
mockConfirmWechatRpgProfileRechargeOrder
.mockResolvedValueOnce({
order: {
orderId: 'order-wechat-late-success',
productId: 'points_60',
productTitle: '60泥点',
kind: 'points',
amountCents: 600,
status: 'pending' as const,
paymentChannel: 'wechat_mp_virtual',
paidAt: null,
providerTransactionId: null,
createdAt: '2026-04-25T10:00:00Z',
pointsDelta: 0,
membershipExpiresAt: null,
},
center: {
walletBalance: 0,
membership: {
status: 'normal',
tier: 'normal',
startedAt: null,
expiresAt: null,
updatedAt: null,
},
pointProducts: [],
membershipProducts: [],
benefits: [],
latestOrder: null,
hasPointsRecharged: false,
},
})
.mockResolvedValueOnce({
order: {
orderId: 'order-wechat-late-success',
productId: 'points_60',
productTitle: '60泥点',
kind: 'points',
amountCents: 600,
status: 'paid' as const,
paymentChannel: 'wechat_mp_virtual',
paidAt: '2026-04-25T10:01:00Z',
providerTransactionId: 'wx-transaction-late',
createdAt: '2026-04-25T10:00:00Z',
pointsDelta: 120,
membershipExpiresAt: null,
},
center: {
walletBalance: 120,
membership: {
status: 'normal',
tier: 'normal',
startedAt: null,
expiresAt: null,
updatedAt: null,
},
pointProducts: [],
membershipProducts: [],
benefits: [],
latestOrder: null,
hasPointsRecharged: true,
},
});
renderProfileView(onRechargeSuccess);
await openRechargeModal(user);
await user.click(await screen.findByRole('button', { name: /60/u }));
const navigateUrl = navigateTo.mock.calls[0]?.[0].url ?? '';
const requestId = new URL(`https://mini.test${navigateUrl}`).searchParams.get(
'requestId',
);
expect(requestId).toBeTruthy();
await new Promise((resolve) => window.setTimeout(resolve, 2600));
act(() => {
window.location.hash = `wx_pay_result=${requestId}:success:order-wechat-late-success`;
window.dispatchEvent(new HashChangeEvent('hashchange'));
});
await waitFor(() => {
expect(mockConfirmWechatRpgProfileRechargeOrder).toHaveBeenCalledTimes(2);
});
expect(await screen.findByRole('dialog', { name: '支付成功' })).toBeTruthy();
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
}, 12000);
test('profile recharge modal waits for paid confirmation before refreshing dashboard', async () => {
const user = userEvent.setup();
const onRechargeSuccess = vi.fn();

View File

@@ -337,6 +337,8 @@ type RechargePaymentResult = {
title: string;
message: string;
};
const WECHAT_PAY_RESULT_RECHECK_INTERVAL_MS = 250;
const WECHAT_PAY_RESULT_RECHECK_TIMEOUT_MS = 10000;
function getBarcodeDetectorConstructor(): BarcodeDetectorConstructorLike | null {
const maybeDetector = (globalThis as unknown as {
@@ -4588,7 +4590,7 @@ export function RpgEntryHomeView({
const handleWechatPayResult = useCallback(() => {
const payResult = readWechatPayResultFromHash();
if (!payResult) {
return;
return false;
}
if (
@@ -4596,7 +4598,7 @@ export function RpgEntryHomeView({
payResult.orderId &&
payResult.orderId !== pendingWechatRechargeOrderIdRef.current
) {
return;
return false;
}
setSubmittingRechargeProductId(null);
@@ -4661,6 +4663,7 @@ export function RpgEntryHomeView({
}
clearWechatPayResultHash();
return true;
}, [onRechargeSuccess, refreshRechargeState]);
const openRechargeModal = () => {
if (!authUi?.user) {
@@ -4823,6 +4826,39 @@ export function RpgEntryHomeView({
document.removeEventListener('visibilitychange', handleResume);
};
}, [handleWechatPayResult]);
useEffect(() => {
if (
rechargePaymentResult?.kind !== 'pending' ||
rechargePaymentResult.title !== '正在支付'
) {
return undefined;
}
const startedAt = Date.now();
let timer: number | null = null;
const pollPayResult = () => {
if (handleWechatPayResult()) {
return;
}
if (Date.now() - startedAt >= WECHAT_PAY_RESULT_RECHECK_TIMEOUT_MS) {
return;
}
timer = window.setTimeout(
pollPayResult,
WECHAT_PAY_RESULT_RECHECK_INTERVAL_MS,
);
};
timer = window.setTimeout(
pollPayResult,
WECHAT_PAY_RESULT_RECHECK_INTERVAL_MS,
);
return () => {
if (timer !== null) {
window.clearTimeout(timer);
}
};
}, [handleWechatPayResult, rechargePaymentResult?.kind, rechargePaymentResult?.title]);
const loadTaskCenter = useCallback(() => {
const requestId = ++taskCenterRequestIdRef.current;
setTaskCenterError(null);