diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index c3fa7188b..0593e9507 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -464,7 +464,7 @@ pub async fn stream_wechat_profile_recharge_order_events( "order", &initial_response, )); - if order.status != RuntimeProfileRechargeOrderStatus::Pending { + if is_wechat_profile_recharge_order_terminal_for_confirmation(&order) { yield Ok::(wechat_profile_recharge_sse_json_event( "done", &WechatProfileRechargeOrderDoneEvent { @@ -499,7 +499,7 @@ pub async fn stream_wechat_profile_recharge_order_events( "order", &response, )); - if order.status != RuntimeProfileRechargeOrderStatus::Pending { + if is_wechat_profile_recharge_order_terminal_for_confirmation(&order) { yield Ok::(wechat_profile_recharge_sse_json_event( "done", &WechatProfileRechargeOrderDoneEvent { @@ -1273,6 +1273,22 @@ fn build_profile_recharge_order_status(status: RuntimeProfileRechargeOrderStatus .to_string() } +fn is_wechat_profile_recharge_order_terminal_for_confirmation( + order: &RuntimeProfileRechargeOrderRecord, +) -> bool { + if order.status == RuntimeProfileRechargeOrderStatus::Pending { + return false; + } + + if order.status == RuntimeProfileRechargeOrderStatus::Expired + && order.expiration_checked_at_micros.is_none() + { + return false; + } + + true +} + fn wechat_profile_recharge_sse_json_event(event_name: &str, payload: &T) -> Event where T: Serialize, @@ -2146,7 +2162,9 @@ mod tests { use super::{ build_wechat_virtual_pay_params, calc_wechat_virtual_payment_pay_signature_with_key, calc_wechat_virtual_payment_user_signature_with_key, - format_profile_wallet_ledger_source_type, normalize_admin_invite_code_metadata, + format_profile_wallet_ledger_source_type, + is_wechat_profile_recharge_order_terminal_for_confirmation, + normalize_admin_invite_code_metadata, }; use axum::{ @@ -3228,6 +3246,58 @@ mod tests { ); } + fn build_test_recharge_order( + status: RuntimeProfileRechargeOrderStatus, + expiration_checked_at_micros: Option, + ) -> RuntimeProfileRechargeOrderRecord { + RuntimeProfileRechargeOrderRecord { + order_id: "confirm-terminal-order".to_string(), + user_id: "user-terminal".to_string(), + product_id: "points_60".to_string(), + product_title: "60娉ョ偣".to_string(), + kind: RuntimeProfileRechargeProductKind::Points, + amount_cents: 600, + status, + payment_channel: "wechat_native".to_string(), + paid_at: None, + paid_at_micros: None, + provider_transaction_id: None, + created_at: "2026-04-25T10:00:00Z".to_string(), + created_at_micros: 1_777_111_200_000_000, + points_delta: 0, + membership_expires_at: None, + membership_expires_at_micros: None, + expired_at: None, + expired_at_micros: None, + expiration_checked_at: expiration_checked_at_micros + .map(|_| "2026-04-25T10:05:02Z".to_string()), + expiration_checked_at_micros, + expiration_provider_state: None, + expiration_last_error: None, + } + } + + #[test] + fn expired_recharge_order_waits_for_expiration_compensation_check() { + let order = build_test_recharge_order(RuntimeProfileRechargeOrderStatus::Expired, None); + + assert!(!is_wechat_profile_recharge_order_terminal_for_confirmation( + &order + )); + } + + #[test] + fn checked_expired_recharge_order_is_terminal_for_confirmation() { + let order = build_test_recharge_order( + RuntimeProfileRechargeOrderStatus::Expired, + Some(1_777_111_502_000_000), + ); + + assert!(is_wechat_profile_recharge_order_terminal_for_confirmation( + &order + )); + } + #[tokio::test] async fn wechat_virtual_payment_sandbox_requires_sandbox_app_key() { let state = seed_authenticated_state_with_config(AppConfig { diff --git a/src/components/platform-entry/usePlatformProfileCenterController.ts b/src/components/platform-entry/usePlatformProfileCenterController.ts index 2b5b1c167..c111cce29 100644 --- a/src/components/platform-entry/usePlatformProfileCenterController.ts +++ b/src/components/platform-entry/usePlatformProfileCenterController.ts @@ -206,11 +206,25 @@ function waitWechatPayConfirmDelay(delayMs: number) { }); } +function isWechatRechargeOrderTerminalForConfirmation( + order: Pick, +) { + if (order.status === 'pending') { + return false; + } + + if (order.status === 'expired' && !order.expirationCheckedAt) { + return false; + } + + return true; +} + async function confirmWechatRechargeOrderUntilSettled( orderId: string, ): Promise { let latestResponse = await confirmWechatRpgProfileRechargeOrder(orderId); - if (latestResponse.order.status !== 'pending') { + if (isWechatRechargeOrderTerminalForConfirmation(latestResponse.order)) { return latestResponse; } @@ -218,7 +232,7 @@ async function confirmWechatRechargeOrderUntilSettled( await waitWechatPayConfirmDelay(delayMs); latestResponse = await confirmWechatRpgProfileRechargeOrder(orderId); - if (latestResponse.order.status !== 'pending') { + if (isWechatRechargeOrderTerminalForConfirmation(latestResponse.order)) { return latestResponse; } } @@ -235,7 +249,7 @@ async function confirmWechatRechargeOrderQuickly( orderId: string, ): Promise { let latestResponse = await confirmWechatRpgProfileRechargeOrder(orderId); - if (latestResponse.order.status !== 'pending') { + if (isWechatRechargeOrderTerminalForConfirmation(latestResponse.order)) { return latestResponse; } @@ -243,7 +257,7 @@ async function confirmWechatRechargeOrderQuickly( await waitWechatPayConfirmDelay(delayMs); latestResponse = await confirmWechatRpgProfileRechargeOrder(orderId); - if (latestResponse.order.status !== 'pending') { + if (isWechatRechargeOrderTerminalForConfirmation(latestResponse.order)) { return latestResponse; } } @@ -252,7 +266,7 @@ async function confirmWechatRechargeOrderQuickly( } function buildRechargePaymentResultForOrder( - order: Pick, + order: Pick, ): RechargePaymentResult { switch (order.status) { case 'paid': @@ -262,6 +276,13 @@ function buildRechargePaymentResultForOrder( message: '已到账,账户状态已刷新。', }; case 'expired': + if (!order.expirationCheckedAt) { + return { + kind: 'pending', + title: '支付处理中', + message: '正在等待到账状态确认,请稍后查看余额或会员状态。', + }; + } return { kind: 'expired', title: '支付已过期', diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index 740ea3a5b..3547f9a88 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -2732,6 +2732,8 @@ test('profile native qr confirmation closes qr dialog when order is expired', as pointsDelta: 0, membershipExpiresAt: null, expiredAt: '2026-04-25T10:05:00Z', + expirationCheckedAt: '2026-04-25T10:05:02Z', + expirationProviderState: 'NOTPAY', }, center: { walletBalance: 0, @@ -2764,6 +2766,99 @@ test('profile native qr confirmation closes qr dialog when order is expired', as expect(onRechargeSuccess).not.toHaveBeenCalled(); }); +test('profile native qr confirmation keeps qr dialog while expired order is awaiting compensation', async () => { + const user = userEvent.setup(); + const onRechargeSuccess = vi.fn(); + mockDesktopLayout(); + mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({ + order: { + orderId: 'order-native-expired-unchecked', + productId: 'points_60', + productTitle: '60泥点', + kind: 'points', + amountCents: 600, + status: 'pending' as const, + paymentChannel: 'wechat_native', + createdAt: '2026-04-25T10:00:00Z', + paidAt: null, + providerTransactionId: null, + pointsDelta: 0, + membershipExpiresAt: null, + }, + center: { + walletBalance: 0, + membership: buildNormalMembership(), + pointProducts: [], + membershipProducts: [], + benefits: [], + latestOrder: null, + hasPointsRecharged: false, + }, + wechatNativePayment: { + codeUrl: 'weixin://pay.weixin.qq.com/bizpayurl/up?pr=native-expired-unchecked', + expiresAt: '2099-01-01T00:05:00Z', + }, + }); + mockConfirmWechatRpgProfileRechargeOrder.mockResolvedValue({ + order: { + orderId: 'order-native-expired-unchecked', + productId: 'points_60', + productTitle: '60泥点', + kind: 'points', + amountCents: 600, + status: 'expired' as const, + paymentChannel: 'wechat_native', + createdAt: '2026-04-25T10:00:00Z', + paidAt: null, + providerTransactionId: null, + pointsDelta: 0, + membershipExpiresAt: null, + expiredAt: '2026-04-25T10:05:00Z', + expirationCheckedAt: null, + }, + center: { + walletBalance: 0, + membership: buildNormalMembership(), + pointProducts: [], + membershipProducts: [], + benefits: [], + latestOrder: null, + hasPointsRecharged: false, + }, + }); + + 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).toHaveBeenCalledTimes(3); + }, + { timeout: 5000 }, + ); + expect( + await screen.findByText( + '暂未确认到账,请确认付款完成后再点一次。', + undefined, + { + timeout: 5000, + }, + ), + ).toBeTruthy(); + expect( + screen.getByRole('dialog', { + name: '微信扫码支付', + }), + ).toBeTruthy(); + expect(screen.queryByRole('dialog', { name: '支付已过期' })).toBeNull(); + expect(onRechargeSuccess).not.toHaveBeenCalled(); +}); + test('profile native qr confirmation keeps qr dialog when payment is still pending', async () => { const user = userEvent.setup(); const onRechargeSuccess = vi.fn(); diff --git a/src/services/rpg-entry/rpgProfileClient.test.ts b/src/services/rpg-entry/rpgProfileClient.test.ts index 1ea95c4e2..23c866cf3 100644 --- a/src/services/rpg-entry/rpgProfileClient.test.ts +++ b/src/services/rpg-entry/rpgProfileClient.test.ts @@ -398,7 +398,85 @@ describe('rpgProfileClient recharge order events', () => { expect(result.center.walletBalance).toBe(120); }); - it('treats an expired order event as a settled SSE result', async () => { + it('waits past unchecked expired order events until compensation publishes paid', async () => { + const pendingOrder = { + orderId: 'order-wechat-sse-expired-then-paid', + productId: 'points_60', + productTitle: '60娉ョ偣', + kind: 'points', + amountCents: 600, + status: 'pending', + paymentChannel: 'wechat_native', + paidAt: null, + providerTransactionId: null, + createdAt: '2026-04-25T10:00:00Z', + pointsDelta: 0, + membershipExpiresAt: null, + }; + const center = { + walletBalance: 0, + membership: { + status: 'normal', + tier: 'normal', + startedAt: null, + expiresAt: null, + updatedAt: null, + }, + pointProducts: [], + membershipProducts: [], + benefits: [], + latestOrder: null, + hasPointsRecharged: false, + }; + const uncheckedExpiredOrder = { + ...pendingOrder, + status: 'expired', + expiredAt: '2026-04-25T10:05:00Z', + expirationCheckedAt: null, + }; + const paidOrder = { + ...pendingOrder, + status: 'paid', + paidAt: '2026-04-25T10:05:03Z', + providerTransactionId: 'wx-expired-compensated-1', + pointsDelta: 120, + }; + fetchWithApiAuthMock.mockResolvedValueOnce( + createSseResponse( + [ + 'event: order', + `data: ${JSON.stringify({ order: pendingOrder, center })}`, + '', + 'event: order', + `data: ${JSON.stringify({ order: uncheckedExpiredOrder, center })}`, + '', + 'event: order', + `data: ${JSON.stringify({ + order: paidOrder, + center: { + ...center, + walletBalance: 120, + hasPointsRecharged: true, + }, + })}`, + '', + 'event: done', + 'data: {"orderId":"order-wechat-sse-expired-then-paid","status":"paid"}', + '', + '', + ].join('\n'), + ), + ); + + const result = await watchWechatRpgProfileRechargeOrder( + 'order-wechat-sse-expired-then-paid', + ); + + expect(result.order.status).toBe('paid'); + expect(result.center.walletBalance).toBe(120); + }); + + it('treats a checked expired order event as a settled SSE result', async () => { const pendingOrder = { orderId: 'order-wechat-sse-expired', productId: 'points_60', @@ -432,6 +510,8 @@ describe('rpgProfileClient recharge order events', () => { ...pendingOrder, status: 'expired', expiredAt: '2026-04-25T10:05:00Z', + expirationCheckedAt: '2026-04-25T10:05:02Z', + expirationProviderState: 'NOTPAY', }; fetchWithApiAuthMock.mockResolvedValueOnce( createSseResponse( diff --git a/src/services/rpg-entry/rpgProfileClient.ts b/src/services/rpg-entry/rpgProfileClient.ts index 53f3facad..3e221aad9 100644 --- a/src/services/rpg-entry/rpgProfileClient.ts +++ b/src/services/rpg-entry/rpgProfileClient.ts @@ -10,6 +10,7 @@ import type { PlatformBrowseHistoryWriteEntry, ProfileDashboardSummary, ProfilePlayStatsResponse, + ProfileRechargeOrder, ProfileRechargeCenterResponse, ProfileReferralInviteCenterResponse, ProfileSaveArchiveListResponse, @@ -212,6 +213,20 @@ function normalizeRechargeOrderSseEvent( return null; } +function isWechatRechargeOrderTerminalForConfirmation( + order: Pick, +) { + if (order.status === 'pending') { + return false; + } + + if (order.status === 'expired' && !order.expirationCheckedAt) { + return false; + } + + return true; +} + export async function watchWechatRpgProfileRechargeOrder( orderId: string, options: RuntimeRequestOptions = {}, @@ -259,7 +274,9 @@ export async function watchWechatRpgProfileRechargeOrder( if (normalized.type === 'order') { lastResponse = normalized.payload; - if (normalized.payload.order.status !== 'pending') { + if ( + isWechatRechargeOrderTerminalForConfirmation(normalized.payload.order) + ) { finalResponse = normalized.payload; return false; } @@ -267,7 +284,11 @@ export async function watchWechatRpgProfileRechargeOrder( } if (normalized.type === 'done') { - if (!finalResponse && lastResponse) { + if ( + !finalResponse && + lastResponse && + isWechatRechargeOrderTerminalForConfirmation(lastResponse.order) + ) { finalResponse = lastResponse; } return false;