From ed8c93fb5d9ec11322e94ea3b4579e34f359700c Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 15 May 2026 01:19:34 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=88=B7=E6=96=B0=E5=BE=AE=E4=BF=A1?= =?UTF-8?q?=E6=94=AF=E4=BB=98=E5=88=B0=E8=B4=A6=E6=B3=A5=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...OUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md | 1 + .../RpgEntryHomeView.recharge.test.tsx | 148 +++++++++++++++++- src/components/rpg-entry/RpgEntryHomeView.tsx | 37 ++++- 3 files changed, 182 insertions(+), 4 deletions(-) diff --git a/docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md b/docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md index d2f52ad2..7d16625d 100644 --- a/docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md +++ b/docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md @@ -145,6 +145,7 @@ - 小程序 web-view 内的 H5 只负责加载微信 JS-SDK 并通过 `wx.miniProgram.navigateTo` 跳转到 `/pages/wechat-pay/index`;实际支付必须在小程序 native 页调用 `wx.requestPayment`,不要切换为 H5 支付产品。 - native 支付页通过 `wx_pay_result=:success|cancel|fail` 回填 web-view;H5 在 `hashchange`、`focus`、`pageshow` 和 `visibilitychange` 中都会尝试消费该结果,避免小程序返回 web-view 时没有触发单一事件导致状态不刷新。 - `success` 只表示微信客户端支付流程返回成功,前端随后调用 `POST /api/profile/recharge/orders/{order_id}/wechat/confirm` 由服务端查单确认;只有通知或服务端查单确认为 `SUCCESS` 才入账。 + - 小程序返回后,前端会对确认接口做短轮询,覆盖微信通知/查单结果与 web-view 恢复之间的秒级时间差;只有确认响应里的订单状态变成 `paid` 后,才触发父级 `profileDashboard` 刷新,确保“我的”页泥点卡片读取到最新余额。 - `cancel` 和 `fail` 只复位按钮、刷新账户中心并通过全局支付结果模态展示,不调用入账逻辑。 5. 支付结果使用页面级全局模态展示,不写回商品卡片或账户充值弹窗内部;充值弹窗只负责套餐选择、加载失败和下单失败。 6. 弹窗内不写大段说明文案,只保留必要金额、泥点、会员权益和操作状态。 diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index f2898a05..7a45adee 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -1040,6 +1040,7 @@ test('profile recharge modal buys points through mock channel outside mini progr test('profile recharge modal posts requestPayment params in mini program web-view', 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?.(); @@ -1088,7 +1089,7 @@ test('profile recharge modal posts requestPayment params in mini program web-vie }, }); - renderProfileView(); + renderProfileView(onRechargeSuccess); const shortcutRegion = screen.getByRole('region', { name: '常用功能' }); await user.click( within(shortcutRegion).getByRole('button', { name: /充值/u }), @@ -1124,6 +1125,151 @@ test('profile recharge modal posts requestPayment params in mini program web-vie expect(mockConfirmWechatRpgProfileRechargeOrder).toHaveBeenCalledWith( 'order-wechat-1', ); + expect(onRechargeSuccess).toHaveBeenCalledTimes(1); +}); + +test('profile recharge modal waits for paid confirmation before refreshing dashboard', 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-pending-then-paid', + productId: 'points_60', + productTitle: '60泥点', + kind: 'points', + amountCents: 600, + status: 'pending' as const, + paymentChannel: 'wechat_mp', + 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: { + timeStamp: '1777110165', + nonceStr: 'nonce', + package: 'prepay_id=wx-prepay', + signType: 'RSA', + paySign: 'signature', + }, + }); + mockConfirmWechatRpgProfileRechargeOrder + .mockResolvedValueOnce({ + order: { + orderId: 'order-wechat-pending-then-paid', + productId: 'points_60', + productTitle: '60泥点', + kind: 'points', + amountCents: 600, + status: 'pending' as const, + paymentChannel: 'wechat_mp', + 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-pending-then-paid', + productId: 'points_60', + productTitle: '60泥点', + kind: 'points', + amountCents: 600, + status: 'paid' as const, + paymentChannel: 'wechat_mp', + paidAt: '2026-04-25T10:01:00Z', + providerTransactionId: 'wx-transaction-2', + 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 })); + + const navigateUrl = navigateTo.mock.calls[0]?.[0].url ?? ''; + const requestId = new URL(`https://mini.test${navigateUrl}`).searchParams.get( + 'requestId', + ); + expect(requestId).toBeTruthy(); + await act(async () => { + window.location.hash = `wx_pay_result=${requestId}:success`; + window.dispatchEvent(new HashChangeEvent('hashchange')); + }); + + expect(mockConfirmWechatRpgProfileRechargeOrder).toHaveBeenCalledTimes(1); + expect(onRechargeSuccess).not.toHaveBeenCalled(); + + await waitFor(() => { + expect(mockConfirmWechatRpgProfileRechargeOrder).toHaveBeenCalledTimes(2); + }); + + expect( + await screen.findByRole('dialog', { name: '支付成功' }), + ).toBeTruthy(); + expect(onRechargeSuccess).toHaveBeenCalledTimes(1); }); test('profile recharge modal loads wechat js sdk before mini program payment bridge', async () => { diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index fe63d38f..004aa685 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -48,6 +48,7 @@ import type { PublicUserSummary } from '../../../packages/shared/src/contracts/a import type { CustomWorldLibraryEntry, PlatformBrowseHistoryEntry, + ConfirmWechatProfileRechargeOrderResponse, ProfileDashboardCardKey, ProfileDashboardSummary, ProfilePlayedWorkSummary, @@ -216,6 +217,7 @@ const RECOMMEND_ENTRY_COMMIT_ANIMATION_MS = 180; const RECOMMEND_ENTRY_DRAG_LIMIT_PX = 160; const WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL = 'wechat_mp'; const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js'; +const WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS = [800, 1600, 3000] as const; type ProfilePopupPanel = 'invite' | 'redeem' | 'community'; type RechargeTab = 'points' | 'membership'; @@ -2461,6 +2463,32 @@ async function requestWechatMiniProgramPayment( }); } +function waitWechatPayConfirmDelay(delayMs: number) { + return new Promise((resolve) => { + window.setTimeout(resolve, delayMs); + }); +} + +async function confirmWechatRechargeOrderUntilSettled( + orderId: string, +): Promise { + let latestResponse = await confirmWechatRpgProfileRechargeOrder(orderId); + if (latestResponse.order.status === 'paid') { + return latestResponse; + } + + for (const delayMs of WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS) { + await waitWechatPayConfirmDelay(delayMs); + + latestResponse = await confirmWechatRpgProfileRechargeOrder(orderId); + if (latestResponse.order.status === 'paid') { + return latestResponse; + } + } + + return latestResponse; +} + function RechargeProductCard({ product, submittingProductId, @@ -3934,11 +3962,12 @@ export function RpgEntryHomeView({ message: '正在确认到账状态,请稍后查看余额或会员状态。', }); if (payResult.orderId) { - void confirmWechatRpgProfileRechargeOrder(payResult.orderId) + void confirmWechatRechargeOrderUntilSettled(payResult.orderId) .then((response) => { + const isPaid = response.order.status === 'paid'; setRechargeCenter(response.center); setRechargePaymentResult( - response.order.status === 'paid' + isPaid ? { kind: 'success', title: '支付成功', @@ -3950,6 +3979,9 @@ export function RpgEntryHomeView({ message: '正在等待微信支付确认,请稍后查看账户状态。', }, ); + if (isPaid) { + void onRechargeSuccess?.(); + } setSubmittingRechargeProductId(null); pendingWechatRechargeOrderIdRef.current = null; }) @@ -3964,7 +3996,6 @@ export function RpgEntryHomeView({ } else { refreshRechargeState(); } - void onRechargeSuccess?.(); } else if (payResult.status === 'cancel') { setRechargePaymentResult({ kind: 'cancel',