feat: switch mini program recharge to virtual payment

This commit is contained in:
kdletters
2026-05-26 22:32:16 +08:00
parent b388b124da
commit f36b90ebdb
22 changed files with 959 additions and 137 deletions

View File

@@ -236,7 +236,7 @@ const {
kind: 'points',
amountCents: 600,
status: 'paid',
paymentChannel: 'wechat_mp',
paymentChannel: 'wechat_mp_virtual',
paidAt: '2026-04-25T10:01:00Z',
providerTransactionId: 'wx-transaction-1',
createdAt: '2026-04-25T10:00:00Z',
@@ -275,7 +275,7 @@ const {
kind: 'points',
amountCents: 600,
status: 'paid',
paymentChannel: 'wechat_mp',
paymentChannel: 'wechat_mp_virtual',
providerTransactionId: 'wx-transaction-1',
createdAt: '2026-04-25T10:00:00Z',
paidAt: '2026-04-25T10:01:00Z',
@@ -1319,7 +1319,7 @@ test('profile recharge modal trusts per-product first bonus display after points
expect(screen.getByText('60+60泥点')).toBeTruthy();
});
test('profile recharge modal posts requestPayment params in mini program web-view', async () => {
test('profile recharge modal posts virtual payment params in mini program web-view', async () => {
const user = userEvent.setup();
const onRechargeSuccess = vi.fn();
window.history.replaceState(null, '', '/?clientRuntime=wechat_mini_program');
@@ -1339,7 +1339,7 @@ test('profile recharge modal posts requestPayment params in mini program web-vie
kind: 'points',
amountCents: 600,
status: 'pending' as const,
paymentChannel: 'wechat_mp',
paymentChannel: 'wechat_mp_virtual',
paidAt: null as string | null,
providerTransactionId: null,
createdAt: '2026-04-25T10:00:00Z',
@@ -1362,11 +1362,11 @@ test('profile recharge modal posts requestPayment params in mini program web-vie
hasPointsRecharged: false,
},
wechatMiniProgramPayParams: {
timeStamp: '1777110165',
nonceStr: 'nonce',
package: 'prepay_id=wx-prepay',
signType: 'RSA',
paySign: 'signature',
mode: 'short_series_coin',
signData:
'{"offerId":"offer-1","buyQuantity":1,"env":0,"currencyType":"CNY","outTradeNo":"order-wechat-1","attach":"mud_points_60"}',
paySig: 'pay-sig',
signature: 'user-sig',
},
});
@@ -1377,7 +1377,7 @@ test('profile recharge modal posts requestPayment params in mini program web-vie
await waitFor(() => {
expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith(
'points_60',
'wechat_mp',
'wechat_mp_virtual',
);
});
expect(navigateTo).toHaveBeenCalledWith({
@@ -1395,7 +1395,7 @@ test('profile recharge modal posts requestPayment params in mini program web-vie
window.dispatchEvent(new HashChangeEvent('hashchange'));
});
expect(navigateUrl).toContain('order-wechat-1');
expect(decodeURIComponent(navigateUrl)).toContain('prepay_id=wx-prepay');
expect(decodeURIComponent(navigateUrl)).toContain('short_series_coin');
expect(await screen.findByRole('dialog', { name: '支付成功' })).toBeTruthy();
expect(mockCreateRpgProfileRechargeOrder).not.toHaveBeenCalledWith(
'points_60',
@@ -1409,6 +1409,82 @@ test('profile recharge modal posts requestPayment params in mini program web-vie
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
});
test('profile recharge modal posts membership goods virtual payment params in mini program web-view', 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-member-virtual-1',
productId: 'member_month',
productTitle: '月卡',
kind: 'membership',
amountCents: 2800,
status: 'pending' as const,
paymentChannel: 'wechat_mp_virtual',
paidAt: null as string | null,
providerTransactionId: null,
createdAt: '2026-04-25T10:00:00Z',
pointsDelta: 0,
membershipExpiresAt: '2026-06-25T10:00:00Z',
},
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_goods',
signData:
'{"offerId":"offer-1","buyQuantity":1,"env":0,"currencyType":"CNY","productId":"member_month","goodsPrice":2800,"outTradeNo":"order-member-virtual-1","attach":"member_month"}',
paySig: 'pay-sig',
signature: 'user-sig',
},
});
renderProfileView();
await openRechargeModal(user);
await user.click(screen.getByRole('button', { name: '会员卡' }));
await user.click(await screen.findByRole('button', { name: //u }));
await waitFor(() => {
expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith(
'member_month',
'wechat_mp_virtual',
);
});
const navigateUrl = navigateTo.mock.calls[0]?.[0].url ?? '';
const requestId = new URL(`https://mini.test${navigateUrl}`).searchParams.get(
'requestId',
);
expect(requestId).toBeTruthy();
const payParams = JSON.parse(
new URL(`https://mini.test${navigateUrl}`).searchParams.get('payParams') ?? '{}',
);
const signData = JSON.parse(payParams.signData);
expect(payParams.mode).toBe('short_series_goods');
expect(signData.productId).toBe('member_month');
expect(signData.goodsPrice).toBe(2800);
expect(decodeURIComponent(navigateUrl)).toContain('"paySig":"pay-sig"');
});
test('profile recharge modal waits for paid confirmation before refreshing dashboard', async () => {
const user = userEvent.setup();
const onRechargeSuccess = vi.fn();
@@ -1429,7 +1505,7 @@ test('profile recharge modal waits for paid confirmation before refreshing dashb
kind: 'points',
amountCents: 600,
status: 'pending' as const,
paymentChannel: 'wechat_mp',
paymentChannel: 'wechat_mp_virtual',
paidAt: null as string | null,
providerTransactionId: null,
createdAt: '2026-04-25T10:00:00Z',
@@ -1452,11 +1528,11 @@ test('profile recharge modal waits for paid confirmation before refreshing dashb
hasPointsRecharged: false,
},
wechatMiniProgramPayParams: {
timeStamp: '1777110165',
nonceStr: 'nonce',
package: 'prepay_id=wx-prepay',
signType: 'RSA',
paySign: 'signature',
mode: 'short_series_coin',
signData:
'{"offerId":"offer-1","buyQuantity":1,"env":0,"currencyType":"CNY","outTradeNo":"order-wechat-pending-then-paid","attach":"mud_points_60"}',
paySig: 'pay-sig',
signature: 'user-sig',
},
});
mockConfirmWechatRpgProfileRechargeOrder
@@ -1468,7 +1544,7 @@ test('profile recharge modal waits for paid confirmation before refreshing dashb
kind: 'points',
amountCents: 600,
status: 'pending' as const,
paymentChannel: 'wechat_mp',
paymentChannel: 'wechat_mp_virtual',
paidAt: null,
providerTransactionId: null,
createdAt: '2026-04-25T10:00:00Z',
@@ -1499,7 +1575,7 @@ test('profile recharge modal waits for paid confirmation before refreshing dashb
kind: 'points',
amountCents: 600,
status: 'paid' as const,
paymentChannel: 'wechat_mp',
paymentChannel: 'wechat_mp_virtual',
paidAt: '2026-04-25T10:01:00Z',
providerTransactionId: 'wx-transaction-2',
createdAt: '2026-04-25T10:00:00Z',
@@ -1563,7 +1639,7 @@ test('profile recharge modal loads wechat js sdk before mini program payment bri
kind: 'points',
amountCents: 600,
status: 'pending' as const,
paymentChannel: 'wechat_mp',
paymentChannel: 'wechat_mp_virtual',
paidAt: null as string | null,
providerTransactionId: null,
createdAt: '2026-04-25T10:00:00Z',
@@ -1586,11 +1662,11 @@ test('profile recharge modal loads wechat js sdk before mini program payment bri
hasPointsRecharged: false,
},
wechatMiniProgramPayParams: {
timeStamp: '1777110165',
nonceStr: 'nonce',
package: 'prepay_id=wx-prepay',
signType: 'RSA',
paySign: 'signature',
mode: 'short_series_coin',
signData:
'{"offerId":"offer-1","buyQuantity":1,"env":0,"currencyType":"CNY","outTradeNo":"order-wechat-sdk-1","attach":"mud_points_60"}',
paySig: 'pay-sig',
signature: 'user-sig',
},
});
@@ -1649,7 +1725,7 @@ test('profile recharge modal releases submitting state after cancelled wechat pa
kind: 'points',
amountCents: 600,
status: 'pending' as const,
paymentChannel: 'wechat_mp',
paymentChannel: 'wechat_mp_virtual',
paidAt: null as string | null,
providerTransactionId: null,
createdAt: '2026-04-25T10:00:00Z',
@@ -1672,11 +1748,11 @@ test('profile recharge modal releases submitting state after cancelled wechat pa
hasPointsRecharged: false,
},
wechatMiniProgramPayParams: {
timeStamp: '1777110165',
nonceStr: 'nonce',
package: 'prepay_id=wx-prepay-cancel',
signType: 'RSA',
paySign: 'signature',
mode: 'short_series_coin',
signData:
'{"offerId":"offer-1","buyQuantity":1,"env":0,"currencyType":"CNY","outTradeNo":"order-wechat-cancel-1","attach":"mud_points_60"}',
paySig: 'pay-sig',
signature: 'user-sig',
},
});
@@ -1688,7 +1764,7 @@ test('profile recharge modal releases submitting state after cancelled wechat pa
await waitFor(() => {
expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith(
'points_60',
'wechat_mp',
'wechat_mp_virtual',
);
});
expect(

View File

@@ -76,6 +76,7 @@ import type {
ProfileWalletLedgerResponse,
RedeemProfileRewardCodeResponse,
WechatMiniProgramPayParams,
WechatMiniProgramVirtualPayParams,
WechatNativePayment,
} from '../../../packages/shared/src/contracts/runtime';
import { isMatch3DDemoProfileId } from '../../data/match3dDemoGalleryCard';
@@ -89,10 +90,10 @@ import {
} from '../../services/authService';
import { copyTextToClipboard } from '../../services/clipboard';
import {
resolveProfileRechargePaymentChannel,
resolveProfileRechargeProductPaymentChannel,
shouldShowRechargeEntry,
WECHAT_H5_PAYMENT_CHANNEL,
WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL,
WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL,
WECHAT_NATIVE_PAYMENT_CHANNEL,
} from '../../services/payment/paymentPlatform';
import { redirectToPaymentUrl } from '../../services/payment/paymentRedirect';
@@ -2740,7 +2741,11 @@ function loadWechatJsSdk() {
}
async function requestWechatMiniProgramPayment(
payload: WechatMiniProgramPayParams | null | undefined,
payload:
| WechatMiniProgramPayParams
| WechatMiniProgramVirtualPayParams
| null
| undefined,
orderId: string,
): Promise<void> {
if (!payload) {
@@ -4664,14 +4669,17 @@ export function RpgEntryHomeView({
return;
}
const paymentChannel = resolveProfileRechargePaymentChannel();
const paymentChannel = resolveProfileRechargeProductPaymentChannel(
{ kind: product.kind },
{},
);
setSubmittingRechargeProductId(product.productId);
setRechargeError(null);
setRechargePaymentResult(null);
setNativeWechatPayment(null);
void createRpgProfileRechargeOrder(product.productId, paymentChannel)
.then(async (response) => {
if (paymentChannel === WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL) {
if (paymentChannel === WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL) {
pendingWechatRechargeOrderIdRef.current = response.order.orderId;
await requestWechatMiniProgramPayment(
response.wechatMiniProgramPayParams,

View File

@@ -2,20 +2,45 @@ import { describe, expect, test } from 'vitest';
import {
resolveProfileRechargePaymentChannel,
resolveProfileRechargeProductPaymentChannel,
shouldShowRechargeEntry,
WECHAT_H5_PAYMENT_CHANNEL,
WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL,
WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL,
WECHAT_NATIVE_PAYMENT_CHANNEL,
} from './paymentPlatform';
describe('resolveProfileRechargePaymentChannel', () => {
test('小程序运行态选择 wechat_mp', () => {
test('小程序运行态基础通道选择 wechat_mp_virtual', () => {
expect(
resolveProfileRechargePaymentChannel({
location: { search: '?clientRuntime=wechat_mini_program' },
navigator: { userAgent: 'Mozilla/5.0 (iPhone)' },
}),
).toBe(WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL);
).toBe(WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL);
});
test('点数商品在小程序运行态选择 wechat_mp_virtual', () => {
expect(
resolveProfileRechargeProductPaymentChannel(
{ kind: 'points' },
{
location: { search: '?clientRuntime=wechat_mini_program' },
navigator: { userAgent: 'Mozilla/5.0 (iPhone)' },
},
),
).toBe(WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL);
});
test('会员商品在小程序运行态也选择 wechat_mp_virtual', () => {
expect(
resolveProfileRechargeProductPaymentChannel(
{ kind: 'membership' },
{
location: { search: '?clientRuntime=wechat_mini_program' },
navigator: { userAgent: 'Mozilla/5.0 (iPhone)' },
},
),
).toBe(WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL);
});
test('移动网页选择 wechat_h5', () => {

View File

@@ -1,13 +1,19 @@
export const WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL = 'wechat_mp';
export const WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL = 'wechat_mp_virtual';
export const WECHAT_H5_PAYMENT_CHANNEL = 'wechat_h5';
export const WECHAT_NATIVE_PAYMENT_CHANNEL = 'wechat_native';
export const MOCK_PAYMENT_CHANNEL = 'mock';
export type ProfileRechargeWechatPaymentChannel =
| typeof WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL
| typeof WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL
| typeof WECHAT_H5_PAYMENT_CHANNEL
| typeof WECHAT_NATIVE_PAYMENT_CHANNEL;
export type ProfileRechargeProductPaymentMode = {
kind: 'points' | 'membership';
};
type PaymentPlatformNavigator = Pick<Navigator, 'userAgent' | 'maxTouchPoints'>;
export type PaymentPlatformContext = {
@@ -45,7 +51,7 @@ export function resolveProfileRechargePaymentChannel(
: null);
if (isWechatMiniProgramRuntime(location)) {
return WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL;
return WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL;
}
if (isMobileWebRuntime(navigatorLike, matchMedia)) {
@@ -55,6 +61,13 @@ export function resolveProfileRechargePaymentChannel(
return WECHAT_NATIVE_PAYMENT_CHANNEL;
}
export function resolveProfileRechargeProductPaymentChannel(
_product: ProfileRechargeProductPaymentMode,
context: PaymentPlatformContext = {},
): ProfileRechargeWechatPaymentChannel {
return resolveProfileRechargePaymentChannel(context);
}
export function isManualMockPaymentChannel(paymentChannel: string) {
return paymentChannel.trim() === MOCK_PAYMENT_CHANNEL;
}