From aaaba77c3a859664607395008e7614b1a31fd7db Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Sat, 30 May 2026 16:42:25 +0800 Subject: [PATCH] fix wechat virtual payment coin flow --- .hermes/shared-memory/decision-log.md | 2 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- ...【技术方案】微信虚拟支付接入-2026-05-26.md | 3 + miniprogram/pages/wechat-pay/index.shared.js | 5 + miniprogram/pages/wechat-pay/index.test.js | 11 +- .../crates/api-server/src/runtime_profile.rs | 241 +++++++++++++++++- .../RpgEntryHomeView.recharge.test.tsx | 205 +++++++++++++++ src/components/rpg-entry/RpgEntryHomeView.tsx | 40 ++- 8 files changed, 496 insertions(+), 13 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 5b820053..e9addcc5 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -19,7 +19,7 @@ ## 2026-05-26 微信小程序充值全面接入虚拟支付 - 背景:泥点和会员都属于小程序内由 Genarrative 控制的虚拟资产/权益,继续走普通小程序支付不符合微信虚拟支付接入口径。 -- 决策:小程序 WebView 内充值商品全部使用渠道 `wechat_mp_virtual` 并由 `miniprogram/pages/wechat-pay` 调用 `wx.requestVirtualPayment`;泥点使用 `short_series_coin`,会员使用 `short_series_goods`,会员 `signData` 必须带 `productId` 与 `goodsPrice`。后端保存微信小程序 `session_key`,仅用于生成 `signature`,不下发客户端。客户端 success 只作为支付页返回信号,最终到账仍由后端微信通知或查询确认后写订单。 +- 决策:小程序 WebView 内充值商品全部使用渠道 `wechat_mp_virtual` 并由 `miniprogram/pages/wechat-pay` 调用 `wx.requestVirtualPayment`;泥点属于代币(coin),使用 `short_series_coin`,`buyQuantity` 必须取当前充值中心商品快照里的 `points_amount`;会员和后台新增道具类商品使用 `short_series_goods`,`signData` 必须带 `productId` 与 `goodsPrice`。后端保存微信小程序 `session_key`,仅用于生成 `signature`,不下发客户端。客户端 success 只作为支付页返回信号,最终到账仍由后端微信通知或查询确认后写订单。 - 影响范围:`src/services/payment/paymentPlatform.ts`、`src/components/rpg-entry/RpgEntryHomeView.tsx`、`miniprogram/pages/wechat-pay/`、`server-rs/crates/api-server/src/runtime_profile.rs`、`server-rs/crates/shared-contracts/src/runtime.rs`、`packages/shared/src/contracts/runtime.ts`、微信登录态存储。 - 验证方式:泥点和会员商品在小程序运行态都请求 `wechat_mp_virtual`;小程序页能按 payload 调用 `wx.requestVirtualPayment` / `wx.requestPayment`;`cargo check -p api-server --manifest-path server-rs/Cargo.toml` 与支付相关前端测试通过。 - 关联文档:`docs/【技术方案】微信虚拟支付接入-2026-05-26.md`。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index d2c50272..42395cc2 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -47,7 +47,7 @@ npm run dev:api-server 开发态 `npm run dev` 与 `npm run dev:api-server` 会默认注入 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true`,因此密码登录在本地开发环境可直接注册未知手机号账号;生产环境仍按 `api-server` 配置默认关闭该开关。 -微信小程序虚拟支付使用 `WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID`、`WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY`、`WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY` 和 `WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV` 配置。泥点充值在小程序 WebView 内走 `wechat_mp_virtual` / `wx.requestVirtualPayment`,会员仍走普通 `wechat_mp` / `wx.requestPayment`。旧登录快照若缺 `session_key`,需要用户在小程序内重新登录后再支付;客户端成功回调不是最终到账,仍以后端通知或查询确认订单为准。详细口径见 `docs/【技术方案】微信虚拟支付接入-2026-05-26.md`。 +微信小程序虚拟支付使用 `WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID`、`WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY`、`WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY` 和 `WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV` 配置。小程序充值统一走 `wechat_mp_virtual` / `wx.requestVirtualPayment`:泥点属于代币(`coin`),`buyQuantity` 按当前充值商品快照里的 `points_amount` 传;会员和后台新增道具类商品走 `short_series_goods`,`productId` 对应微信后台道具 ID。旧登录快照若缺 `session_key`,需要用户在小程序内重新登录后再支付;客户端成功回调不是最终到账,仍以后端通知或查询确认订单为准。详细口径见 `docs/【技术方案】微信虚拟支付接入-2026-05-26.md`。 如果本地 `GET /api/creation-entry/config` 返回 `No such procedure`,或 `api-server` 日志出现 `no such table: puzzle_gallery_card_view` / `no such table: wooden_fish_gallery_card_view` 这类公开 view 缺失,通常是 `.env.local` 指向的 SpacetimeDB 库还没有发布当前 `spacetime-module`,或当前 CLI 身份无权发布该库。debug 构建的 `api-server` 会临时使用后端默认入口配置兜底,避免创作作品架整块消失;正式修复仍应切换到拥有目标库权限的 SpacetimeDB 身份后重新运行 `npm run dev` 完成发布,或用 gitignored 的 `spacetime.local.json` 指向可发布的本地库。 diff --git a/docs/【技术方案】微信虚拟支付接入-2026-05-26.md b/docs/【技术方案】微信虚拟支付接入-2026-05-26.md index c5fdf9ed..cf10599d 100644 --- a/docs/【技术方案】微信虚拟支付接入-2026-05-26.md +++ b/docs/【技术方案】微信虚拟支付接入-2026-05-26.md @@ -38,6 +38,7 @@ WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV=0 - `signData`:传给 `wx.requestVirtualPayment` 的订单数据。 - `paySig`:`HMAC-SHA256(appKey, "requestVirtualPayment&" + signData)` 的小写 hex。 - `signature`:`HMAC-SHA256(session_key, signData)` 的小写 hex。 +- 泥点属于微信虚拟支付代币(coin),`short_series_coin` 的 `buyQuantity` 必须使用当前泥点商品的 `points_amount`;例如 60 泥点商品应传 `buyQuantity: 60`。 - 会员直购 `signData` 额外包含 `productId` 和 `goodsPrice`;`goodsPrice` 使用后端商品配置价,和微信后台道具价格校验保持一致。 ## 验收命令 @@ -54,7 +55,9 @@ npm run check:encoding - 旧微信登录快照可能没有 `session_key`;小程序 WebView 会在普通进入时静默刷新一次微信登录态,刷新失败时仍允许匿名打开 WebView,但虚拟支付会继续由后端拦截并提示重新登录。 - 小程序充值商品全部映射到虚拟支付;泥点使用 `short_series_coin`,会员使用 `short_series_goods`。 +- `short_series_coin` 只用于代币购买,后端从本次下单返回的充值中心商品快照读取 `points_amount` 并写入 `buyQuantity`;不要把 coin 商品当成道具,也不要把 `buyQuantity` 固定为 1。 - 后台新增的会员类充值商品会直接把商品 `productId` 作为微信 `short_series_goods` 的道具 ID;例如微信后台道具 ID 为 `item01` 时,后台会员商品 `productId` 也应配置为 `item01`,且商品价格需要与微信后台道具价格一致。 - 小程序页必须保留普通支付与虚拟支付双分支,按 pay params 字段判断调用 `wx.requestPayment` 或 `wx.requestVirtualPayment`。 - 小程序支付承接页回传 `wx_pay_result` 时必须携带 `requestId:status:orderId[:error]`,并同时写入上一页 hash 与本地 storage;WebView `onShow` 会立即检查一次、延迟二次检查一次,且同名 hash 参数必须替换,避免支付状态停留在处理中或重复处理。 - 沙箱或基础库失败会把微信返回的 `errCode` / `errMsg` 透传到前端失败弹窗,便于区分微信后台道具、沙箱 AppKey、签名和基础库能力问题。 +- Web 侧在“正在支付”状态下会短时轮询 `wx_pay_result`,即使小程序 `web-view` 回写 hash 没触发浏览器 `hashchange`,也必须展示回写的微信错误内容。 diff --git a/miniprogram/pages/wechat-pay/index.shared.js b/miniprogram/pages/wechat-pay/index.shared.js index d35b3c7d..d25f527e 100644 --- a/miniprogram/pages/wechat-pay/index.shared.js +++ b/miniprogram/pages/wechat-pay/index.shared.js @@ -105,6 +105,10 @@ function requestOrdinaryPayment(payParams) { function requestVirtualPayment(payParams) { return new Promise((resolve) => { if (!canUseVirtualPayment() || typeof wx.requestVirtualPayment !== 'function') { + console.error('[wechat-pay] requestVirtualPayment unavailable', { + canUseVirtualPayment: canUseVirtualPayment(), + hasRequestVirtualPayment: typeof wx.requestVirtualPayment === 'function', + }); resolve({ status: 'fail', errorMessage: '当前微信基础库不支持 requestVirtualPayment', @@ -120,6 +124,7 @@ function requestVirtualPayment(payParams) { resolve({ status: 'success', errorMessage: '' }); }, fail(error) { + console.error('[wechat-pay] requestVirtualPayment failed', error); resolve({ status: resolvePayStatus(error), errorMessage: normalizePayError(error), diff --git a/miniprogram/pages/wechat-pay/index.test.js b/miniprogram/pages/wechat-pay/index.test.js index e4520aa5..1407c89a 100644 --- a/miniprogram/pages/wechat-pay/index.test.js +++ b/miniprogram/pages/wechat-pay/index.test.js @@ -11,6 +11,7 @@ const { describe('wechat-pay mini program payment bridge', () => { beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}); globalThis.wx = { requestPayment: vi.fn(), requestVirtualPayment: vi.fn(), @@ -100,8 +101,12 @@ describe('wechat-pay mini program payment bridge', () => { }); test('maps virtual payment cancel errCode to cancel result', async () => { + const payError = { + errCode: -2, + errMsg: 'requestVirtualPayment:fail cancel', + }; globalThis.wx.requestVirtualPayment.mockImplementationOnce((options) => { - options.fail?.({ errCode: -2, errMsg: 'requestVirtualPayment:fail cancel' }); + options.fail?.(payError); }); await expect( @@ -118,6 +123,10 @@ describe('wechat-pay mini program payment bridge', () => { errMsg: 'requestVirtualPayment:fail cancel', }), }); + expect(console.error).toHaveBeenCalledWith( + '[wechat-pay] requestVirtualPayment failed', + payError, + ); }); test('page notifies previous web-view after virtual payment', async () => { diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index 6e37e17c..52823013 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -256,7 +256,7 @@ pub async fn create_profile_recharge_order( .await .map_err(|error| runtime_profile_error_response(&request_context, error))?; Some( - build_wechat_virtual_pay_params(&state, &order, &openid) + build_wechat_virtual_pay_params(&state, ¢er, &order, &openid) .map(WechatMiniProgramPaymentParamsResponse::Virtual) .map_err(|error| runtime_profile_error_response(&request_context, error))?, ) @@ -1169,9 +1169,28 @@ async fn resolve_wechat_identity_for_payment( fn build_wechat_virtual_pay_params( state: &AppState, + center: &RuntimeProfileRechargeCenterRecord, order: &RuntimeProfileRechargeOrderRecord, openid: &str, ) -> Result { + let product = match order.kind { + RuntimeProfileRechargeProductKind::Points => center + .point_products + .iter() + .find(|item| item.product_id == order.product_id) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("当前充值商品不存在,请刷新后再试") + })?, + RuntimeProfileRechargeProductKind::Membership => center + .membership_products + .iter() + .find(|item| item.product_id == order.product_id) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("当前充值商品不存在,请刷新后再试") + })?, + }; let identity = state .wechat_auth_service() .get_identity_by_user_id(&order.user_id) @@ -1198,9 +1217,13 @@ fn build_wechat_virtual_pay_params( RuntimeProfileRechargeProductKind::Points => "short_series_coin", RuntimeProfileRechargeProductKind::Membership => "short_series_goods", }; + let buy_quantity = match product.kind { + RuntimeProfileRechargeProductKind::Points => product.points_amount, + RuntimeProfileRechargeProductKind::Membership => 1, + }; let mut sign_data = serde_json::json!({ "offerId": offer_id, - "buyQuantity": 1, + "buyQuantity": buy_quantity, "env": state.config.wechat_mini_program_virtual_payment_env, "currencyType": "CNY", "outTradeNo": order.order_id, @@ -1772,8 +1795,11 @@ mod tests { use module_auth::{ResolveWechatLoginInput, WechatIdentityProfile}; use module_runtime::{ PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL, + RuntimeProfileMembershipRecord, RuntimeProfileMembershipStatus, + RuntimeProfileMembershipTier, RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeOrderStatus, - RuntimeProfileRechargeProductKind, RuntimeProfileWalletLedgerSourceType, + RuntimeProfileRechargeProductKind, RuntimeProfileRechargeProductRecord, + RuntimeProfileWalletLedgerSourceType, }; use super::{ @@ -2284,7 +2310,39 @@ mod tests { membership_expires_at_micros: None, }; - let params = build_wechat_virtual_pay_params(&state, &order, "openid-user-00000001") + let center = RuntimeProfileRechargeCenterRecord { + user_id: user_id.clone(), + wallet_balance: 0, + membership: RuntimeProfileMembershipRecord { + user_id: user_id.clone(), + status: RuntimeProfileMembershipStatus::Normal, + tier: RuntimeProfileMembershipTier::Normal, + started_at: None, + started_at_micros: None, + expires_at: None, + expires_at_micros: None, + updated_at: None, + updated_at_micros: None, + }, + point_products: vec![], + membership_products: vec![RuntimeProfileRechargeProductRecord { + product_id: "member_month".to_string(), + title: "月卡".to_string(), + price_cents: 2800, + kind: RuntimeProfileRechargeProductKind::Membership, + points_amount: 0, + bonus_points: 0, + duration_days: 30, + badge_label: String::new(), + description: "30天会员".to_string(), + tier: RuntimeProfileMembershipTier::Month, + }], + benefits: vec![], + latest_order: None, + has_points_recharged: false, + }; + + let params = build_wechat_virtual_pay_params(&state, ¢er, &order, "openid-user-00000001") .expect("membership virtual pay params should build"); let sign_data: Value = serde_json::from_str(¶ms.sign_data).expect("sign data should be valid json"); @@ -2296,6 +2354,7 @@ mod tests { .expect("attach should decode"); assert_eq!(params.mode, "short_series_goods"); + assert_eq!(sign_data["buyQuantity"], 1); assert_eq!(sign_data["offerId"], "offer-1"); assert_eq!(sign_data["productId"], "member_month"); assert_eq!(sign_data["goodsPrice"], 2800); @@ -2305,6 +2364,106 @@ mod tests { assert!(!params.signature.is_empty()); } + #[tokio::test] + async fn wechat_virtual_pay_params_use_coin_quantity_for_points_products() { + let state = seed_authenticated_state_with_config(AppConfig { + wechat_mini_program_virtual_payment_offer_id: Some("offer-1".to_string()), + wechat_mini_program_virtual_payment_app_key: Some("app-key-1".to_string()), + wechat_mini_program_virtual_payment_env: 0, + ..fast_spacetime_timeout_config() + }) + .await; + let wechat_login = state + .wechat_auth_service() + .resolve_login(ResolveWechatLoginInput { + profile: WechatIdentityProfile { + provider_uid: "openid-user-points-60".to_string(), + provider_union_id: Some("union-user-points-60".to_string()), + display_name: Some("资料页用户".to_string()), + avatar_url: None, + session_key: Some("session-key-points-60".to_string()), + }, + }) + .await + .expect("wechat identity should seed"); + let user_id = wechat_login.user.id.clone(); + let order = RuntimeProfileRechargeOrderRecord { + order_id: "pointsorder60".to_string(), + user_id: user_id.clone(), + product_id: "points_60".to_string(), + product_title: "60泥点".to_string(), + kind: RuntimeProfileRechargeProductKind::Points, + amount_cents: 600, + status: RuntimeProfileRechargeOrderStatus::Pending, + payment_channel: PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL + .to_string(), + paid_at: None, + paid_at_micros: None, + provider_transaction_id: None, + created_at: "2026-05-30T10:00:00Z".to_string(), + created_at_micros: 1_780_000_000_000_000, + points_delta: 0, + membership_expires_at: None, + membership_expires_at_micros: None, + }; + + let center = RuntimeProfileRechargeCenterRecord { + user_id: user_id.clone(), + wallet_balance: 0, + membership: RuntimeProfileMembershipRecord { + user_id: user_id.clone(), + status: RuntimeProfileMembershipStatus::Normal, + tier: RuntimeProfileMembershipTier::Normal, + started_at: None, + started_at_micros: None, + expires_at: None, + expires_at_micros: None, + updated_at: None, + updated_at_micros: None, + }, + point_products: vec![RuntimeProfileRechargeProductRecord { + product_id: "points_60".to_string(), + title: "60泥点".to_string(), + price_cents: 600, + kind: RuntimeProfileRechargeProductKind::Points, + points_amount: 60, + bonus_points: 60, + duration_days: 0, + badge_label: "首充双倍".to_string(), + description: "60+60泥点".to_string(), + tier: RuntimeProfileMembershipTier::Normal, + }], + membership_products: vec![], + benefits: vec![], + latest_order: None, + has_points_recharged: true, + }; + + let params = build_wechat_virtual_pay_params( + &state, + ¢er, + &order, + "openid-user-points-60", + ) + .expect("points virtual pay params should build"); + let sign_data: Value = + serde_json::from_str(¶ms.sign_data).expect("sign data should be valid json"); + let attach: Value = serde_json::from_str( + sign_data["attach"] + .as_str() + .expect("attach should be string json"), + ) + .expect("attach should decode"); + + assert_eq!(params.mode, "short_series_coin"); + assert_eq!(sign_data["buyQuantity"], 60); + assert_eq!(sign_data["offerId"], "offer-1"); + assert_eq!(sign_data["outTradeNo"], "pointsorder60"); + assert_eq!(attach["paymentChannel"], "wechat_mp_virtual"); + assert!(!params.pay_sig.is_empty()); + assert!(!params.signature.is_empty()); + } + #[tokio::test] async fn wechat_virtual_pay_params_accept_admin_membership_product_ids() { let state = seed_authenticated_state_with_config(AppConfig { @@ -2327,9 +2486,10 @@ mod tests { }) .await .expect("wechat identity should seed"); + let user_id = wechat_login.user.id.clone(); let order = RuntimeProfileRechargeOrderRecord { order_id: "item01order01".to_string(), - user_id: wechat_login.user.id, + user_id: user_id.clone(), product_id: "item01".to_string(), product_title: "测试道具".to_string(), kind: RuntimeProfileRechargeProductKind::Membership, @@ -2347,7 +2507,39 @@ mod tests { membership_expires_at_micros: None, }; - let params = build_wechat_virtual_pay_params(&state, &order, "openid-user-item01") + let center = RuntimeProfileRechargeCenterRecord { + user_id: user_id.clone(), + wallet_balance: 0, + membership: RuntimeProfileMembershipRecord { + user_id: user_id.clone(), + status: RuntimeProfileMembershipStatus::Normal, + tier: RuntimeProfileMembershipTier::Normal, + started_at: None, + started_at_micros: None, + expires_at: None, + expires_at_micros: None, + updated_at: None, + updated_at_micros: None, + }, + point_products: vec![], + membership_products: vec![RuntimeProfileRechargeProductRecord { + product_id: "item01".to_string(), + title: "测试道具".to_string(), + price_cents: 100, + kind: RuntimeProfileRechargeProductKind::Membership, + points_amount: 0, + bonus_points: 0, + duration_days: 30, + badge_label: String::new(), + description: "30天会员".to_string(), + tier: RuntimeProfileMembershipTier::Month, + }], + benefits: vec![], + latest_order: None, + has_points_recharged: false, + }; + + let params = build_wechat_virtual_pay_params(&state, ¢er, &order, "openid-user-item01") .expect("custom membership virtual pay params should build"); let sign_data: Value = serde_json::from_str(¶ms.sign_data).expect("sign data should be valid json"); @@ -2404,9 +2596,10 @@ mod tests { }) .await .expect("wechat identity should seed"); + let user_id = wechat_login.user.id.clone(); let order = RuntimeProfileRechargeOrderRecord { order_id: "sandboxorder01".to_string(), - user_id: wechat_login.user.id, + user_id: user_id.clone(), product_id: "points_60".to_string(), product_title: "60泥点".to_string(), kind: RuntimeProfileRechargeProductKind::Points, @@ -2424,7 +2617,39 @@ mod tests { membership_expires_at_micros: None, }; - let error = build_wechat_virtual_pay_params(&state, &order, "openid-sandbox-1") + let center = RuntimeProfileRechargeCenterRecord { + user_id: user_id.clone(), + wallet_balance: 0, + membership: RuntimeProfileMembershipRecord { + user_id: user_id.clone(), + status: RuntimeProfileMembershipStatus::Normal, + tier: RuntimeProfileMembershipTier::Normal, + started_at: None, + started_at_micros: None, + expires_at: None, + expires_at_micros: None, + updated_at: None, + updated_at_micros: None, + }, + point_products: vec![RuntimeProfileRechargeProductRecord { + product_id: "points_60".to_string(), + title: "60泥点".to_string(), + price_cents: 600, + kind: RuntimeProfileRechargeProductKind::Points, + points_amount: 60, + bonus_points: 60, + duration_days: 0, + badge_label: "首充双倍".to_string(), + description: "60+60泥点".to_string(), + tier: RuntimeProfileMembershipTier::Normal, + }], + membership_products: vec![], + benefits: vec![], + latest_order: None, + has_points_recharged: false, + }; + + let error = build_wechat_virtual_pay_params(&state, ¢er, &order, "openid-sandbox-1") .expect_err("sandbox pay params should reject missing sandbox app key"); assert!( error.to_string().contains("沙箱 AppKey 未配置"), diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index 4fbd15a3..bd57b40d 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -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(); diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 8bb452ad..a83f5a12 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -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);