feat: 接入微信H5与Native充值支付

This commit is contained in:
2026-05-15 06:40:40 +08:00
parent 73424f958a
commit 5b70ec6af7
18 changed files with 1890 additions and 122 deletions

View File

@@ -37,6 +37,8 @@ import {
} from './rpgEntryWorldPresentation';
const {
mockQrCodeToDataUrl,
mockRedirectToPaymentUrl,
mockBuildReferralCenter,
mockBuildTaskCenter,
mockClaimRpgProfileTaskReward,
@@ -48,6 +50,8 @@ const {
mockGetRpgProfileWalletLedger,
mockRedeemRpgProfileReferralInviteCode,
} = vi.hoisted(() => {
const qrCodeToDataUrl = vi.fn(async () => 'data:image/png;base64,QR');
const redirectToPaymentUrl = vi.fn();
const buildReferralCenter = (
overrides: Partial<ProfileReferralInviteCenterResponse> = {},
): ProfileReferralInviteCenterResponse => ({
@@ -119,6 +123,8 @@ const {
});
return {
mockQrCodeToDataUrl: qrCodeToDataUrl,
mockRedirectToPaymentUrl: redirectToPaymentUrl,
mockBuildReferralCenter: buildReferralCenter,
mockBuildTaskCenter: buildTaskCenter,
mockGetRpgProfileReferralInviteCenter: vi.fn(async () =>
@@ -343,6 +349,16 @@ vi.mock('../../services/authService', () => ({
updateAuthProfile: mockUpdateAuthProfile,
}));
vi.mock('qrcode', () => ({
default: {
toDataURL: mockQrCodeToDataUrl,
},
}));
vi.mock('../../services/payment/paymentRedirect', () => ({
redirectToPaymentUrl: mockRedirectToPaymentUrl,
}));
mockUpdateAuthProfile.mockResolvedValue({
id: 'user-1',
publicUserCode: '100001',
@@ -584,19 +600,32 @@ function buildBabyObjectMatchEntry(
}
function mockDesktopLayout() {
Object.defineProperty(navigator, 'userAgent', {
configurable: true,
value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
});
Object.defineProperty(navigator, 'maxTouchPoints', {
configurable: true,
value: 0,
});
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: vi.fn().mockImplementation(() => ({
matches: true,
media: '(min-width: 1024px)',
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
value: vi.fn().mockImplementation((query: string) => {
const normalizedQuery = query.replace(/\s/g, '');
return {
matches:
normalizedQuery.includes('min-width:1024px') ||
normalizedQuery.includes('min-width:1024'),
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
};
}),
});
}
@@ -924,7 +953,9 @@ afterEach(() => {
vi.unstubAllGlobals();
window.wx = undefined;
document
.querySelectorAll('script[src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"]')
.querySelectorAll(
'script[src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"]',
)
.forEach((script) => script.remove());
mockGetRpgProfileReferralInviteCenter.mockResolvedValue(
mockBuildReferralCenter(),
@@ -975,6 +1006,8 @@ afterEach(() => {
wechatBound: false,
createdAt: new Date().toISOString(),
});
mockQrCodeToDataUrl.mockResolvedValue('data:image/png;base64,QR');
mockRedirectToPaymentUrl.mockReset();
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
@@ -1011,11 +1044,45 @@ test('opens wallet ledger modal from narrative coin card', async () => {
expect(screen.getByText('+30')).toBeTruthy();
});
test('profile recharge modal buys points through mock channel outside mini program', async () => {
test('profile recharge modal shows native qr code on desktop web by default', async () => {
const user = userEvent.setup();
const onRechargeSuccess = vi.fn();
mockDesktopLayout();
mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({
order: {
orderId: 'order-native-1',
productId: 'points_60',
productTitle: '60泥点',
kind: 'points',
amountCents: 600,
status: 'pending' as const,
paymentChannel: 'wechat_native',
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,
},
wechatNativePayment: {
codeUrl: 'weixin://pay.weixin.qq.com/bizpayurl/up?pr=native-test',
},
});
renderProfileView(onRechargeSuccess);
renderProfileView();
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
await user.click(
within(shortcutRegion).getByRole('button', { name: //u }),
@@ -1028,14 +1095,96 @@ test('profile recharge modal buys points through mock channel outside mini progr
await waitFor(() => {
expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith(
'points_60',
'mock',
'wechat_native',
);
});
expect(await screen.findByText('微信扫码支付')).toBeTruthy();
await waitFor(() => {
expect(screen.getByAltText('微信 Native 支付二维码')).toBeTruthy();
});
expect(mockQrCodeToDataUrl).toHaveBeenCalledWith(
'weixin://pay.weixin.qq.com/bizpayurl/up?pr=native-test',
expect.objectContaining({ width: 180 }),
);
expect(screen.queryByRole('dialog', { name: '支付成功' })).toBeNull();
});
test('profile recharge modal jumps to h5 payment on mobile web by default', async () => {
const user = userEvent.setup();
Object.defineProperty(navigator, 'userAgent', {
configurable: true,
value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Mobile',
});
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: vi.fn().mockImplementation(() => ({
matches: true,
media: '(max-width: 767px)',
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({
order: {
orderId: 'order-h5-1',
productId: 'points_60',
productTitle: '60泥点',
kind: 'points',
amountCents: 600,
status: 'pending' as const,
paymentChannel: 'wechat_h5',
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,
},
wechatH5Payment: {
h5Url:
'https://wx.tenpay.com/cgi-bin/mmpayweb-bin/checkmweb?prepay_id=wx-h5',
},
});
renderProfileView();
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
await user.click(
within(shortcutRegion).getByRole('button', { name: //u }),
);
await user.click(await screen.findByRole('button', { name: /60/u }));
await waitFor(() => {
expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith(
'points_60',
'wechat_h5',
);
});
expect(mockRedirectToPaymentUrl).toHaveBeenCalledWith(
'https://wx.tenpay.com/cgi-bin/mmpayweb-bin/checkmweb?prepay_id=wx-h5',
);
expect(
await screen.findByRole('dialog', { name: '支付成功' }),
await screen.findByRole('dialog', { name: '正在打开微信支付' }),
).toBeTruthy();
expect(screen.getByText('已到账,账户状态已刷新。')).toBeTruthy();
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
expect(screen.queryByRole('dialog', { name: '支付成功' })).toBeNull();
});
test('profile recharge modal posts requestPayment params in mini program web-view', async () => {
@@ -1118,9 +1267,12 @@ test('profile recharge modal posts requestPayment params in mini program web-vie
});
expect(navigateUrl).toContain('order-wechat-1');
expect(decodeURIComponent(navigateUrl)).toContain('prepay_id=wx-prepay');
expect(
await screen.findByRole('dialog', { name: '支付成功' }),
).toBeTruthy();
expect(await screen.findByRole('dialog', { name: '支付成功' })).toBeTruthy();
expect(mockCreateRpgProfileRechargeOrder).not.toHaveBeenCalledWith(
'points_60',
'mock',
);
expect(mockRedirectToPaymentUrl).not.toHaveBeenCalled();
expect(screen.getByText('已到账,账户状态已刷新。')).toBeTruthy();
expect(mockConfirmWechatRpgProfileRechargeOrder).toHaveBeenCalledWith(
'order-wechat-1',
@@ -1266,9 +1418,7 @@ test('profile recharge modal waits for paid confirmation before refreshing dashb
expect(mockConfirmWechatRpgProfileRechargeOrder).toHaveBeenCalledTimes(2);
});
expect(
await screen.findByRole('dialog', { name: '支付成功' }),
).toBeTruthy();
expect(await screen.findByRole('dialog', { name: '支付成功' })).toBeTruthy();
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
});
@@ -1354,9 +1504,7 @@ test('profile recharge modal loads wechat js sdk before mini program payment bri
window.location.hash = `wx_pay_result=${requestId}:success`;
window.dispatchEvent(new HashChangeEvent('hashchange'));
});
expect(
await screen.findByRole('dialog', { name: '支付成功' }),
).toBeTruthy();
expect(await screen.findByRole('dialog', { name: '支付成功' })).toBeTruthy();
});
test('profile recharge modal releases submitting state after cancelled wechat pay result', async () => {
@@ -1452,6 +1600,93 @@ test('profile recharge modal releases submitting state after cancelled wechat pa
expect(mockConfirmWechatRpgProfileRechargeOrder).not.toHaveBeenCalled();
});
test('profile native qr confirmation refreshes only after server reports paid', async () => {
const user = userEvent.setup();
const onRechargeSuccess = vi.fn();
mockDesktopLayout();
mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({
order: {
orderId: 'order-native-paid',
productId: 'points_60',
productTitle: '60泥点',
kind: 'points',
amountCents: 600,
status: 'pending' as const,
paymentChannel: 'wechat_native',
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,
},
wechatNativePayment: {
codeUrl: 'weixin://pay.weixin.qq.com/bizpayurl/up?pr=native-paid',
},
});
mockConfirmWechatRpgProfileRechargeOrder.mockResolvedValueOnce({
order: {
orderId: 'order-native-paid',
productId: 'points_60',
productTitle: '60泥点',
kind: 'points',
amountCents: 600,
status: 'paid' as const,
paymentChannel: 'wechat_native',
paidAt: '2026-04-25T10:01:00Z',
providerTransactionId: 'wx-native-1',
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);
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
await user.click(
within(shortcutRegion).getByRole('button', { name: //u }),
);
await user.click(await screen.findByRole('button', { name: /60/u }));
await user.click(await screen.findByRole('button', { name: '我已支付' }));
await waitFor(() => {
expect(mockConfirmWechatRpgProfileRechargeOrder).toHaveBeenCalledWith(
'order-native-paid',
);
});
expect(await screen.findByRole('dialog', { name: '支付成功' })).toBeTruthy();
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
});
test('profile daily task shortcut opens task center and claims reward', async () => {
const user = userEvent.setup();
const onRechargeSuccess = vi.fn();