diff --git a/docs/【技术方案】微信虚拟支付接入-2026-05-26.md b/docs/【技术方案】微信虚拟支付接入-2026-05-26.md index b89854cc..27d98f26 100644 --- a/docs/【技术方案】微信虚拟支付接入-2026-05-26.md +++ b/docs/【技术方案】微信虚拟支付接入-2026-05-26.md @@ -8,7 +8,7 @@ - 会员商品在微信小程序 WebView 内同样走 `wechat_mp_virtual`,由小程序页调用 `wx.requestVirtualPayment` 的 `short_series_goods` 模式,并在 `signData` 内带 `productId` 与 `goodsPrice`。 - H5 与桌面微信环境仍分别走 `wechat_h5` / `wechat_native`,不进入虚拟支付链路。 - `session_key` 只保存在后端认证仓储内,用于计算虚拟支付用户态签名,不下发给前端。 -- 客户端支付成功回调只代表已拉起支付并返回成功;最终到账仍以后端虚拟支付消息推送写入订单为准,普通微信支付订单则继续走微信支付 V3 notify / query。 +- 客户端支付成功回调只代表已拉起支付并返回成功;最终到账仍以后端虚拟支付消息推送写入订单为准,普通微信支付订单则继续走微信支付 V3 notify / query。虚拟支付订单的确认接口只读取本地订单真相,不再用普通微信支付 V3 查单。 - 小程序 WebView 默认进入时会静默调用 `wx.login` 刷新后端微信登录态,避免历史登录用户只有前端 JWT、后端缺少 `session_key` 时无法生成虚拟支付签名。 ## 关键文件 @@ -18,6 +18,7 @@ - 小程序支付承接页:`miniprogram/pages/wechat-pay/index.shared.js` - API 契约:`packages/shared/src/contracts/runtime.ts`、`server-rs/crates/shared-contracts/src/runtime.rs` - 后端下单与签名:`server-rs/crates/api-server/src/runtime_profile.rs` +- WebView 回流确认:`GET /api/profile/recharge/orders/{orderId}/wechat/events`、`POST /api/profile/recharge/orders/{orderId}/wechat/confirm` - 微信登录态保存:`server-rs/crates/platform-auth/src/lib.rs`、`server-rs/crates/module-auth/src/lib.rs` ## 后端配置 @@ -66,4 +67,6 @@ npm run check:encoding - 小程序支付承接页回传 `wx_pay_result` 时必须携带 `requestId:status:orderId[:error]`,并同时写入上一页 hash 与本地 storage;WebView `onShow` 会立即检查一次、延迟二次检查一次,且同名 hash 参数必须替换,避免支付状态停留在处理中或重复处理。 - 微信虚拟支付消息推送使用独立后端入口 `/api/profile/recharge/wechat/virtual-notify`,按 `xpay_goods_deliver_notify` 和 `xpay_coin_pay_notify` 推进充值订单入账;回包需按入站格式返回 `ErrCode=0` / `ErrMsg=success`(JSON 入站回 JSON,XML 入站回 XML),错误时带具体 `ErrMsg` 便于微信侧重试与排障。 - 沙箱或基础库失败会把微信返回的 `errCode` / `errMsg` 透传到前端失败弹窗,便于区分微信后台道具、沙箱 AppKey、签名和基础库能力问题。 -- Web 侧在“正在支付”状态下会短时轮询 `wx_pay_result`,即使小程序 `web-view` 回写 hash 没触发浏览器 `hashchange`,也必须展示回写的微信错误内容。 +- Web 侧在拉起虚拟支付后会短时轮询 `wx_pay_result`,即使小程序 `web-view` 回写 hash 没触发浏览器 `hashchange`,也必须展示回写的微信错误内容。 +- WebView 返回但没有拿到 `wx_pay_result` 时,前端必须主动调用订单确认接口,并接入 `/api/profile/recharge/orders/{orderId}/wechat/events` 的 SSE 事件流作为服务端推送兜底;后端收到虚拟支付消息推送并入账后会发布订单更新,SSE 先推当前订单快照,再在订单结束时推 `done`。 +- WebView 返回后,在订单状态拉取或 SSE 等待期间展示不可关闭遮罩“正在确认支付”,阻止用户离开或继续操作;只有确认到最终订单状态后才展示一次最终结果弹窗,不能先弹“正在支付/支付已提交”再二次弹成功。 diff --git a/packages/shared/src/contracts/runtime.ts b/packages/shared/src/contracts/runtime.ts index 8158b592..b0d5c74c 100644 --- a/packages/shared/src/contracts/runtime.ts +++ b/packages/shared/src/contracts/runtime.ts @@ -183,6 +183,15 @@ export type ConfirmWechatProfileRechargeOrderResponse = { center: ProfileRechargeCenterResponse; }; +export type WechatProfileRechargeOrderDoneEvent = { + orderId: string; + status: ProfileRechargeOrderStatus; +}; + +export type WechatProfileRechargeOrderErrorEvent = { + message: string; +}; + export type ProfileFeedbackStatus = 'open'; export type ProfileFeedbackEvidenceItemInput = { diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index 956be5e1..402efac2 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -321,9 +321,8 @@ fn validate_admin_creation_entry_config( let unified_creation_spec_json = unified_creation_spec .as_ref() .map(|spec| { - encode_unified_creation_spec_response(spec).map_err(|error| { - AppError::from_status(StatusCode::BAD_REQUEST).with_message(error) - }) + encode_unified_creation_spec_response(spec) + .map_err(|error| AppError::from_status(StatusCode::BAD_REQUEST).with_message(error)) }) .transpose()?; Ok(module_runtime::CreationEntryTypeAdminUpsertInput { diff --git a/server-rs/crates/api-server/src/modules/profile.rs b/server-rs/crates/api-server/src/modules/profile.rs index 6651e1a8..8875caf2 100644 --- a/server-rs/crates/api-server/src/modules/profile.rs +++ b/server-rs/crates/api-server/src/modules/profile.rs @@ -14,7 +14,8 @@ use crate::{ create_profile_recharge_order, get_profile_analytics_metric, get_profile_dashboard, get_profile_play_stats, get_profile_recharge_center, get_profile_referral_invite_center, get_profile_task_center, get_profile_wallet_ledger, redeem_profile_referral_invite_code, - redeem_profile_reward_code, submit_profile_feedback, + redeem_profile_reward_code, stream_wechat_profile_recharge_order_events, + submit_profile_feedback, }, runtime_save::{list_profile_save_archives, resume_profile_save_archive}, state::AppState, @@ -73,6 +74,12 @@ pub fn router(state: AppState) -> Router { middleware::from_fn_with_state(state.clone(), require_bearer_auth), ), ) + .route( + "/api/profile/recharge/orders/{order_id}/wechat/events", + get(stream_wechat_profile_recharge_order_events).route_layer( + middleware::from_fn_with_state(state.clone(), require_bearer_auth), + ), + ) .route( "/api/profile/feedback", post(submit_profile_feedback) diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index a9e010bb..c02efa28 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -2,7 +2,10 @@ use axum::{ Json, extract::{Extension, Path, Query, State}, http::{HeaderMap, StatusCode}, - response::Response, + response::{ + IntoResponse, Response, + sse::{Event, Sse}, + }, }; use hmac::{Hmac, Mac}; use module_runtime::{ @@ -23,7 +26,7 @@ use module_runtime::{ RuntimeProfileWalletLedgerSourceType, RuntimeReferralInviteCenterRecord, RuntimeTrackingScopeKind, }; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use sha2::Sha256; use shared_contracts::runtime::{ @@ -63,10 +66,12 @@ use shared_contracts::runtime::{ RedeemProfileRewardCodeRequest, RedeemProfileRewardCodeResponse, SubmitProfileFeedbackRequest, SubmitProfileFeedbackResponse, TRACKING_SCOPE_KIND_MODULE, TRACKING_SCOPE_KIND_SITE, TRACKING_SCOPE_KIND_USER, TRACKING_SCOPE_KIND_WORK, WechatMiniProgramPaymentParamsResponse, - WechatMiniProgramVirtualPayParamsResponse, + WechatMiniProgramVirtualPayParamsResponse, WechatProfileRechargeOrderDoneEvent, + WechatProfileRechargeOrderErrorEvent, }; use shared_kernel::{offset_datetime_to_unix_micros, parse_rfc3339}; use spacetime_client::SpacetimeClientError; +use std::{convert::Infallible, time::Duration}; use time::OffsetDateTime; use crate::{ @@ -347,19 +352,19 @@ pub async fn confirm_wechat_profile_recharge_order( if order.status == RuntimeProfileRechargeOrderStatus::Paid { return Ok(json_success_body( Some(&request_context), - ConfirmWechatProfileRechargeOrderResponse { - order: build_profile_recharge_order_response(order), - center: build_profile_recharge_center_response(center), - }, + build_wechat_profile_recharge_order_confirmation(center, order), )); } if order.status != RuntimeProfileRechargeOrderStatus::Pending { return Ok(json_success_body( Some(&request_context), - ConfirmWechatProfileRechargeOrderResponse { - order: build_profile_recharge_order_response(order), - center: build_profile_recharge_center_response(center), - }, + build_wechat_profile_recharge_order_confirmation(center, order), + )); + } + if order.payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL { + return Ok(json_success_body( + Some(&request_context), + build_wechat_profile_recharge_order_confirmation(center, order), )); } @@ -381,10 +386,7 @@ pub async fn confirm_wechat_profile_recharge_order( if wechat_order.trade_state != "SUCCESS" { return Ok(json_success_body( Some(&request_context), - ConfirmWechatProfileRechargeOrderResponse { - order: build_profile_recharge_order_response(order), - center: build_profile_recharge_center_response(center), - }, + build_wechat_profile_recharge_order_confirmation(center, order), )); } @@ -406,13 +408,94 @@ pub async fn confirm_wechat_profile_recharge_order( Ok(json_success_body( Some(&request_context), - ConfirmWechatProfileRechargeOrderResponse { - order: build_profile_recharge_order_response(order), - center: build_profile_recharge_center_response(center), - }, + build_wechat_profile_recharge_order_confirmation(center, order), )) } +pub async fn stream_wechat_profile_recharge_order_events( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Path(order_id): Path, +) -> Result { + let user_id = authenticated.claims().user_id().to_string(); + let (center, order) = load_user_wechat_profile_recharge_order( + &state, + &request_context, + user_id.clone(), + order_id.clone(), + ) + .await?; + let stream_state = state.clone(); + let stream_context = request_context.clone(); + let stream = async_stream::stream! { + let initial_response = build_wechat_profile_recharge_order_confirmation(center, order.clone()); + yield Ok::(wechat_profile_recharge_sse_json_event( + "order", + &initial_response, + )); + if order.status != RuntimeProfileRechargeOrderStatus::Pending { + yield Ok::(wechat_profile_recharge_sse_json_event( + "done", + &WechatProfileRechargeOrderDoneEvent { + order_id: order.order_id.clone(), + status: build_profile_recharge_order_status(order.status), + }, + )); + return; + } + + let mut updates = stream_state.subscribe_profile_recharge_order_updates(); + let mut poll_interval = tokio::time::interval(Duration::from_millis(1200)); + for _ in 0..25 { + tokio::select! { + maybe_order_id = updates.recv() => { + if !matches!(maybe_order_id, Ok(ref value) if value == &order_id) { + continue; + } + } + _ = poll_interval.tick() => {} + } + + match load_user_wechat_profile_recharge_order( + &stream_state, + &stream_context, + user_id.clone(), + order_id.clone(), + ).await { + Ok((center, order)) => { + let response = build_wechat_profile_recharge_order_confirmation(center, order.clone()); + yield Ok::(wechat_profile_recharge_sse_json_event( + "order", + &response, + )); + if order.status != RuntimeProfileRechargeOrderStatus::Pending { + yield Ok::(wechat_profile_recharge_sse_json_event( + "done", + &WechatProfileRechargeOrderDoneEvent { + order_id: order.order_id.clone(), + status: build_profile_recharge_order_status(order.status), + }, + )); + return; + } + } + Err(_) => { + yield Ok::(wechat_profile_recharge_sse_json_event( + "error", + &WechatProfileRechargeOrderErrorEvent { + message: "读取充值订单状态失败".to_string(), + }, + )); + return; + } + } + } + }; + + Ok(Sse::new(stream).into_response()) +} + pub async fn submit_profile_feedback( State(state): State, Extension(request_context): Extension, @@ -1029,6 +1112,81 @@ fn runtime_profile_error_response(request_context: &RequestContext, error: AppEr error.into_response_with_context(Some(request_context)) } +async fn load_user_wechat_profile_recharge_order( + state: &AppState, + request_context: &RequestContext, + user_id: String, + order_id: String, +) -> Result< + ( + RuntimeProfileRechargeCenterRecord, + RuntimeProfileRechargeOrderRecord, + ), + Response, +> { + let (center, order) = state + .spacetime_client() + .get_profile_recharge_order(order_id) + .await + .map_err(|error| { + runtime_profile_error_response(request_context, map_runtime_profile_client_error(error)) + })?; + + if order.user_id != user_id { + return Err(runtime_profile_error_response( + request_context, + AppError::from_status(StatusCode::NOT_FOUND).with_message("充值订单不存在"), + )); + } + if !is_wechat_recharge_payment_channel(&order.payment_channel) { + return Err(runtime_profile_error_response( + request_context, + AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("该充值订单不是微信支付订单"), + )); + } + + Ok((center, order)) +} + +fn build_wechat_profile_recharge_order_confirmation( + center: RuntimeProfileRechargeCenterRecord, + order: RuntimeProfileRechargeOrderRecord, +) -> ConfirmWechatProfileRechargeOrderResponse { + ConfirmWechatProfileRechargeOrderResponse { + order: build_profile_recharge_order_response(order), + center: build_profile_recharge_center_response(center), + } +} + +fn build_profile_recharge_order_status(status: RuntimeProfileRechargeOrderStatus) -> String { + match status { + RuntimeProfileRechargeOrderStatus::Pending => "pending", + RuntimeProfileRechargeOrderStatus::Paid => "paid", + RuntimeProfileRechargeOrderStatus::Failed => "failed", + RuntimeProfileRechargeOrderStatus::Closed => "closed", + RuntimeProfileRechargeOrderStatus::Refunded => "refunded", + } + .to_string() +} + +fn wechat_profile_recharge_sse_json_event(event_name: &str, payload: &T) -> Event +where + T: Serialize, +{ + Event::default() + .event(event_name) + .json_data(payload) + .unwrap_or_else(|_| { + Event::default() + .event("error") + .json_data(&WechatProfileRechargeOrderErrorEvent { + message: "充值订单状态事件序列化失败".to_string(), + }) + .unwrap_or_else(|_| Event::default().event("error").data("{}")) + }) +} + fn normalize_recharge_payment_channel(raw: Option) -> Result { raw.map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 8d70b13e..c0c61378 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -27,7 +27,7 @@ use shared_contracts::creation_entry_config::CreationEntryConfigResponse; use shared_contracts::creative_agent::CreativeAgentSessionSnapshot; use spacetime_client::{SpacetimeClient, SpacetimeClientConfig, SpacetimeClientError}; use time::OffsetDateTime; -use tokio::sync::Semaphore; +use tokio::sync::{Semaphore, broadcast}; use tracing::{info, warn}; use crate::config::AppConfig; @@ -257,6 +257,7 @@ pub struct AppStateInner { // Phase 1 任务 E 的 creative session facade 暂存在 api-server。 // creative_agent_* 表由任务 D 收口后,这里只保留读写 facade。 creative_agent_sessions: Arc>>, + profile_recharge_order_updates: broadcast::Sender, #[cfg(test)] // 测试环境允许在未启动 SpacetimeDB 时,用内存快照兜底当前 runtime story 回归链。 test_runtime_snapshot_store: Arc>>, @@ -394,6 +395,7 @@ impl AppState { let llm_client = build_llm_client(&config)?; let creative_agent_gpt5_client = build_creative_agent_gpt5_client(&config)?; let http_request_permit_pools = HttpRequestPermitPools::from_config(&config); + let (profile_recharge_order_updates, _) = broadcast::channel(128); Ok(Self(Arc::new(AppStateInner { config, @@ -423,6 +425,7 @@ impl AppState { creative_agent_gpt5_client, creative_agent_executor: Arc::new(MockLangChainRustAgentExecutor), creative_agent_sessions: Arc::new(Mutex::new(HashMap::new())), + profile_recharge_order_updates, #[cfg(test)] test_runtime_snapshot_store: Arc::new(Mutex::new(HashMap::new())), }))) @@ -710,6 +713,16 @@ impl AppState { self.creative_agent_executor.clone() } + pub fn subscribe_profile_recharge_order_updates( + &self, + ) -> tokio::sync::broadcast::Receiver { + self.profile_recharge_order_updates.subscribe() + } + + pub fn publish_profile_recharge_order_update(&self, order_id: impl Into) { + let _ = self.profile_recharge_order_updates.send(order_id.into()); + } + pub fn get_creative_agent_session( &self, session_id: &str, diff --git a/server-rs/crates/api-server/src/wechat_pay.rs b/server-rs/crates/api-server/src/wechat_pay.rs index 010b3ec6..eba24beb 100644 --- a/server-rs/crates/api-server/src/wechat_pay.rs +++ b/server-rs/crates/api-server/src/wechat_pay.rs @@ -9,9 +9,7 @@ use axum::{ }; use base64::{ Engine as _, alphabet, - engine::general_purpose::{ - GeneralPurpose, GeneralPurposeConfig, STANDARD as BASE64_STANDARD, - }, + engine::general_purpose::{GeneralPurpose, GeneralPurposeConfig, STANDARD as BASE64_STANDARD}, }; use bytes::Bytes; use cbc::cipher::{BlockDecryptMut, KeyIvInit, block_padding::NoPadding}; @@ -1017,6 +1015,8 @@ pub async fn handle_wechat_virtual_payment_notify( ); } + state.publish_profile_recharge_order_update(notify.out_trade_no.clone()); + info!( event = notify.event.as_str(), order_id = notify.out_trade_no.as_str(), @@ -1152,9 +1152,7 @@ fn resolve_wechat_message_push_verify_response( .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) - .ok_or_else(|| { - WechatPayError::InvalidRequest("微信消息推送校验参数不完整".to_string()) - })?; + .ok_or_else(|| WechatPayError::InvalidRequest("微信消息推送校验参数不完整".to_string()))?; if !verify_wechat_message_push_signature(token, timestamp, nonce, "", signature) { return Err(WechatPayError::InvalidSignature( "微信消息推送校验签名无效".to_string(), diff --git a/server-rs/crates/shared-contracts/src/creation_entry_config.rs b/server-rs/crates/shared-contracts/src/creation_entry_config.rs index 832570ed..b297f2ed 100644 --- a/server-rs/crates/shared-contracts/src/creation_entry_config.rs +++ b/server-rs/crates/shared-contracts/src/creation_entry_config.rs @@ -262,8 +262,18 @@ mod tests { ); let jump_hop = build_phase1_unified_creation_spec("jump-hop").expect("jump-hop spec"); - assert!(jump_hop.fields.iter().any(|field| field.id == "stylePreset")); - assert!(jump_hop.fields.iter().any(|field| field.id == "endMoodPrompt")); + assert!( + jump_hop + .fields + .iter() + .any(|field| field.id == "stylePreset") + ); + assert!( + jump_hop + .fields + .iter() + .any(|field| field.id == "endMoodPrompt") + ); let wooden_fish = build_phase1_unified_creation_spec("wooden-fish").expect("wooden-fish spec"); diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index bc7d3dcb..294df1bf 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -313,6 +313,19 @@ pub struct ConfirmWechatProfileRechargeOrderResponse { pub center: ProfileRechargeCenterResponse, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct WechatProfileRechargeOrderDoneEvent { + pub order_id: String, + pub status: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct WechatProfileRechargeOrderErrorEvent { + pub message: String, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ProfileFeedbackEvidenceItemRequest { diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index d9ef46eb..f0e2a7eb 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -49,6 +49,7 @@ const { mockGetRpgProfileTasks, mockGetRpgProfileWalletLedger, mockRedeemRpgProfileReferralInviteCode, + mockWatchWechatRpgProfileRechargeOrder, } = vi.hoisted(() => { const qrCodeToDataUrl = vi.fn(async () => 'data:image/png;base64,QR'); const redirectToPaymentUrl = vi.fn(); @@ -313,6 +314,7 @@ const { }, ], })), + mockWatchWechatRpgProfileRechargeOrder: vi.fn(async () => null), }; }); @@ -379,6 +381,7 @@ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({ createRpgProfileRechargeOrder: mockCreateRpgProfileRechargeOrder, confirmWechatRpgProfileRechargeOrder: mockConfirmWechatRpgProfileRechargeOrder, + watchWechatRpgProfileRechargeOrder: mockWatchWechatRpgProfileRechargeOrder, })); vi.mock('../ResolvedAssetImage', () => ({ @@ -1407,7 +1410,7 @@ test('profile recharge modal posts virtual payment params in mini program web-vi 'requestId', ); expect(requestId).toBeTruthy(); - expect(screen.getByRole('dialog', { name: '正在支付' })).toBeTruthy(); + expect(screen.queryByRole('dialog', { name: '正在支付' })).toBeNull(); act(() => { window.location.hash = `wx_pay_result=${requestId}:success:order-wechat-1`; window.dispatchEvent(new HashChangeEvent('hashchange')); @@ -1928,6 +1931,234 @@ test('profile recharge modal waits for paid confirmation before refreshing dashb expect(onRechargeSuccess).toHaveBeenCalledTimes(1); }); +test('profile recharge modal confirms virtual payment after returning without hash 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-no-hash-paid', + productId: 'points_60', + productTitle: '60泥点', + kind: 'points', + amountCents: 600, + status: 'pending' as const, + paymentChannel: 'wechat_mp_virtual', + createdAt: '2026-04-25T10:00:00Z', + paidAt: null as string | null, + providerTransactionId: null, + 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-no-hash-paid","attach":"mud_points_60"}', + paySig: 'pay-sig', + signature: 'user-sig', + }, + }); + mockConfirmWechatRpgProfileRechargeOrder + .mockResolvedValueOnce({ + order: { + orderId: 'order-wechat-no-hash-paid', + productId: 'points_60', + productTitle: '60泥点', + kind: 'points', + amountCents: 600, + status: 'pending' as const, + paymentChannel: 'wechat_mp_virtual', + createdAt: '2026-04-25T10:00:00Z', + paidAt: null, + providerTransactionId: null, + 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-no-hash-paid', + productId: 'points_60', + productTitle: '60泥点', + kind: 'points', + amountCents: 600, + status: 'paid' as const, + paymentChannel: 'wechat_mp_virtual', + createdAt: '2026-04-25T10:00:00Z', + paidAt: '2026-04-25T10:01:00Z', + providerTransactionId: 'wx-transaction-no-hash', + 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 })); + + act(() => { + window.dispatchEvent(new PageTransitionEvent('pageshow')); + }); + expect(screen.getByRole('dialog', { name: '正在确认支付' })).toBeTruthy(); + await waitFor(() => { + expect(mockConfirmWechatRpgProfileRechargeOrder).toHaveBeenCalledWith( + 'order-wechat-no-hash-paid', + ); + }); + expect(await screen.findByRole('dialog', { name: '支付成功' })).toBeTruthy(); + expect(onRechargeSuccess).toHaveBeenCalledTimes(1); +}); + +test('profile recharge modal blocks tab navigation while virtual payment confirmation is pending', 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-confirm-mask', + productId: 'points_60', + productTitle: '60泥点', + kind: 'points', + amountCents: 600, + status: 'pending' as const, + paymentChannel: 'wechat_mp_virtual', + createdAt: '2026-04-25T10:00:00Z', + paidAt: null as string | null, + providerTransactionId: null, + 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-confirm-mask","attach":"mud_points_60"}', + paySig: 'pay-sig', + signature: 'user-sig', + }, + }); + mockConfirmWechatRpgProfileRechargeOrder.mockResolvedValueOnce({ + order: { + orderId: 'order-wechat-confirm-mask', + productId: 'points_60', + productTitle: '60泥点', + kind: 'points', + amountCents: 600, + status: 'pending' as const, + paymentChannel: 'wechat_mp_virtual', + createdAt: '2026-04-25T10:00:00Z', + paidAt: null, + providerTransactionId: null, + 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, + }, + }); + mockWatchWechatRpgProfileRechargeOrder.mockReturnValueOnce(new Promise(() => undefined)); + + renderProfileView(); + await openRechargeModal(user); + await user.click(await screen.findByRole('button', { name: /60泥点/u })); + + act(() => { + window.dispatchEvent(new PageTransitionEvent('pageshow')); + }); + + expect(screen.getByRole('dialog', { name: '正在确认支付' })).toBeTruthy(); + expect( + (screen.getByRole('button', { name: '创作' }) as HTMLButtonElement) + .disabled, + ).toBe(true); +}); + test('profile recharge modal loads wechat js sdk before mini program payment bridge', async () => { const user = userEvent.setup(); window.history.replaceState(null, '', '/?clientRuntime=wechat_mini_program'); diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 51e2db1e..47b0e4b8 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -16,6 +16,7 @@ import { Heart, LogIn, MessageCircle, + Loader2, Palette, Pencil, Plus, @@ -104,6 +105,7 @@ import { getRpgProfileWalletLedger, redeemRpgProfileReferralInviteCode, redeemRpgProfileRewardCode, + watchWechatRpgProfileRechargeOrder, } from '../../services/rpg-entry/rpgProfileClient'; import type { CustomWorldProfile } from '../../types'; import { useAuthUi } from '../auth/AuthUiContext'; @@ -337,6 +339,9 @@ type RechargePaymentResult = { title: string; message: string; }; +type WechatRechargeOrderConfirmationState = { + orderId: string; +}; const WECHAT_PAY_RESULT_RECHECK_INTERVAL_MS = 250; const WECHAT_PAY_RESULT_RECHECK_TIMEOUT_MS = 10000; @@ -1204,6 +1209,7 @@ function PlatformTabButton({ onClick, emphasized = false, showDot = false, + disabled = false, }: { active: boolean; label: string; @@ -1211,6 +1217,7 @@ function PlatformTabButton({ onClick: () => void; emphasized?: boolean; showDot?: boolean; + disabled?: boolean; }) { const ariaLabel = showDot ? `${label},有新草稿` : label; @@ -1218,8 +1225,9 @@ function PlatformTabButton({ + + + ) : isAuthenticated && activeTab === 'create' ? ( + ) : !isAuthenticated ? ( - - ) : isAuthenticated && activeTab === 'create' ? ( - - ) : !isAuthenticated ? ( - - ) : null} + ) : null} + + ) : null} + +
+ {tabPanels}
- ) : null} -
- {tabPanels} -
- -
-
- {visibleTabs.map((tab) => ( - { - if (activeTab === 'home' && tab === 'home') { - selectNextRecommendEntry(); - return; +
+
+ {visibleTabs.map((tab) => ( + { + if (isRechargePaymentConfirmationPending) { + return; + } + if (activeTab === 'home' && tab === 'home') { + selectNextRecommendEntry(); + return; + } - onTabChange(tab); - }} - /> - ))} + onTabChange(tab); + }} + /> + ))} +
-
- {profilePopupPanel === 'saveArchives' ? ( + {profilePopupPanel === 'saveArchives' ? ( {profileEditModals}
+ {rechargePaymentConfirmationMask} + ); } return ( -
-
-
-
-
- - -
+ <> +
+
+
+
+
+ + +
-
- {isAuthenticated && activeTab === 'create' ? ( +
+ {isAuthenticated && activeTab === 'create' ? ( + + ) : null} - ) : null} - +
-
-
- +
+ -
- {tabPanels} +
+ {tabPanels} +
@@ -7227,7 +7369,8 @@ export function RpgEntryHomeView({ onClose={() => setActiveLegalDocumentId(null)} /> {profileEditModals} -
+ {rechargePaymentConfirmationMask} + ); } diff --git a/src/services/rpg-entry/rpgProfileClient.test.ts b/src/services/rpg-entry/rpgProfileClient.test.ts index 19f96353..68d292b2 100644 --- a/src/services/rpg-entry/rpgProfileClient.test.ts +++ b/src/services/rpg-entry/rpgProfileClient.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -const { requestJsonMock } = vi.hoisted(() => ({ +const { fetchWithApiAuthMock, requestJsonMock } = vi.hoisted(() => ({ + fetchWithApiAuthMock: vi.fn(), requestJsonMock: vi.fn(), })); @@ -12,6 +13,7 @@ import { submitRpgProfileFeedback, syncRpgProfileBrowseHistory, upsertRpgProfileBrowseHistory, + watchWechatRpgProfileRechargeOrder, } from './rpgProfileClient'; vi.mock('../apiClient', () => ({ @@ -21,9 +23,30 @@ vi.mock('../apiClient', () => ({ notifyAuthStateChange: false, clearAuthOnUnauthorized: false, }, + fetchWithApiAuth: fetchWithApiAuthMock, requestJson: requestJsonMock, })); +function createSseResponse(bodyText: string) { + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(bodyText)); + controller.close(); + }, + }), + { + headers: { + 'Content-Type': 'text/event-stream; charset=utf-8', + }, + }, + ); +} + +beforeEach(() => { + fetchWithApiAuthMock.mockReset(); +}); + describe('rpgProfileClient browse history routes', () => { beforeEach(() => { requestJsonMock.mockReset(); @@ -231,3 +254,86 @@ describe('rpgProfileClient feedback routes', () => { }); }); }); + +describe('rpgProfileClient recharge order events', () => { + beforeEach(() => { + fetchWithApiAuthMock.mockReset(); + }); + + it('waits for a non-pending order event before completing the SSE watch', async () => { + const pendingOrder = { + orderId: 'order-wechat-sse-1', + productId: 'points_60', + productTitle: '60泥点', + kind: 'points', + amountCents: 600, + status: 'pending', + paymentChannel: 'wechat_mp_virtual', + 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 paidOrder = { + ...pendingOrder, + status: 'paid', + paidAt: '2026-04-25T10:01:00Z', + providerTransactionId: 'wx-sse-1', + pointsDelta: 120, + }; + fetchWithApiAuthMock.mockResolvedValueOnce( + createSseResponse( + [ + 'event: order', + `data: ${JSON.stringify({ order: pendingOrder, center })}`, + '', + 'event: order', + `data: ${JSON.stringify({ + order: paidOrder, + center: { + ...center, + walletBalance: 120, + hasPointsRecharged: true, + }, + })}`, + '', + 'event: done', + 'data: {"orderId":"order-wechat-sse-1","status":"paid"}', + '', + '', + ].join('\n'), + ), + ); + + const result = await watchWechatRpgProfileRechargeOrder( + 'order-wechat-sse-1', + ); + + expect(fetchWithApiAuthMock).toHaveBeenCalledWith( + '/api/profile/recharge/orders/order-wechat-sse-1/wechat/events', + expect.objectContaining({ + method: 'GET', + headers: { Accept: 'text/event-stream' }, + }), + expect.any(Object), + ); + expect(result.order.status).toBe('paid'); + expect(result.center.walletBalance).toBe(120); + }); +}); diff --git a/src/services/rpg-entry/rpgProfileClient.ts b/src/services/rpg-entry/rpgProfileClient.ts index f5942c80..0751d4db 100644 --- a/src/services/rpg-entry/rpgProfileClient.ts +++ b/src/services/rpg-entry/rpgProfileClient.ts @@ -19,8 +19,10 @@ import type { SubmitProfileFeedbackRequest, SubmitProfileFeedbackResponse, } from '../../../packages/shared/src/contracts/runtime'; +import { appendApiErrorRequestId, parseApiErrorMessage } from '../../../packages/shared/src/http'; import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; +import { fetchWithApiAuth } from '../apiClient'; import { RUNTIME_BACKGROUND_AUTH_OPTIONS, requestRpgRuntimeJson, @@ -116,6 +118,235 @@ export function confirmWechatRpgProfileRechargeOrder( ); } +type RechargeOrderSseEvent = + | { + type: 'order'; + payload: ConfirmWechatProfileRechargeOrderResponse; + } + | { + type: 'done'; + payload: { orderId: string; status: string }; + } + | { + type: 'error'; + payload: { message: string }; + }; + +function findSseEventBoundary(buffer: string) { + const lfBoundary = buffer.indexOf('\n\n'); + const crlfBoundary = buffer.indexOf('\r\n\r\n'); + + if (lfBoundary === -1 && crlfBoundary === -1) { + return null; + } + + if (lfBoundary === -1) { + return { + index: crlfBoundary, + length: 4, + }; + } + + if (crlfBoundary === -1 || lfBoundary < crlfBoundary) { + return { + index: lfBoundary, + length: 2, + }; + } + + return { + index: crlfBoundary, + length: 4, + }; +} + +function parseSseEventBlock(eventBlock: string) { + let eventName = 'message'; + const dataLines: string[] = []; + + for (const rawLine of eventBlock.split(/\r?\n/u)) { + const line = rawLine.trim(); + + if (line.startsWith('event:')) { + eventName = line.slice(6).trim() || 'message'; + continue; + } + + if (line.startsWith('data:')) { + dataLines.push(line.slice(5).trim()); + } + } + + return { + eventName, + data: dataLines.join('\n'), + }; +} + +function parseJsonObject(data: string) { + try { + return JSON.parse(data) as Record; + } catch { + return null; + } +} + +function normalizeRechargeOrderSseEvent( + eventName: string, + parsed: Record, +): RechargeOrderSseEvent | null { + if (eventName === 'order' && parsed.order && parsed.center) { + return { + type: 'order', + payload: parsed as ConfirmWechatProfileRechargeOrderResponse, + }; + } + + if (eventName === 'done') { + const orderId = + typeof parsed.orderId === 'string' ? parsed.orderId.trim() : ''; + const status = typeof parsed.status === 'string' ? parsed.status.trim() : ''; + if (orderId && status) { + return { + type: 'done', + payload: { orderId, status }, + }; + } + } + + if (eventName === 'error') { + const message = + typeof parsed.message === 'string' && parsed.message.trim() + ? parsed.message.trim() + : ''; + return { + type: 'error', + payload: { message }, + }; + } + + return null; +} + +export async function watchWechatRpgProfileRechargeOrder( + orderId: string, + options: RuntimeRequestOptions = {}, +): Promise { + const response = await fetchWithApiAuth( + `/api/profile/recharge/orders/${encodeURIComponent(orderId)}/wechat/events`, + { + method: 'GET', + headers: { + Accept: 'text/event-stream', + }, + signal: options.signal, + }, + { + skipRefresh: options.skipRefresh, + skipAuth: options.skipAuth, + authImpact: options.authImpact, + notifyAuthStateChange: options.notifyAuthStateChange, + clearAuthOnUnauthorized: options.clearAuthOnUnauthorized, + }, + ); + + if (!response.ok) { + const responseText = await response.text(); + throw new Error( + appendApiErrorRequestId( + parseApiErrorMessage(responseText, '订阅充值订单状态失败'), + response.headers.get('x-request-id'), + ), + ); + } + + if (!response.body) { + throw new Error('streaming response body is unavailable'); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder('utf-8'); + let buffer = ''; + let finalResponse: ConfirmWechatProfileRechargeOrderResponse | null = null; + let lastResponse: ConfirmWechatProfileRechargeOrderResponse | null = null; + let streamDone = false; + + const consumeBuffer = () => { + for (;;) { + const boundary = findSseEventBoundary(buffer); + if (!boundary) { + break; + } + + const eventBlock = buffer.slice(0, boundary.index); + buffer = buffer.slice(boundary.index + boundary.length); + const { eventName, data } = parseSseEventBlock(eventBlock); + + if (!data) { + continue; + } + + const parsed = parseJsonObject(data); + if (!parsed) { + continue; + } + const normalized = normalizeRechargeOrderSseEvent(eventName, parsed); + if (!normalized) { + continue; + } + + if (normalized.type === 'order') { + lastResponse = normalized.payload; + if (normalized.payload.order.status !== 'pending') { + finalResponse = normalized.payload; + } + continue; + } + + if (normalized.type === 'done') { + streamDone = true; + if (!finalResponse && lastResponse) { + finalResponse = lastResponse; + } + continue; + } + + throw new Error(normalized.payload.message || '订阅充值订单状态失败'); + } + }; + + for (;;) { + const { done, value } = await reader.read(); + if (done) { + break; + } + + buffer += decoder.decode(value, { stream: true }); + consumeBuffer(); + if (finalResponse) { + break; + } + if (streamDone) { + break; + } + } + + buffer += decoder.decode(); + consumeBuffer(); + + if (!finalResponse) { + if (lastResponse) { + finalResponse = lastResponse; + } + } + + if (!finalResponse) { + throw new Error('充值订单状态流返回不完整'); + } + + return finalResponse; +} + export function submitRpgProfileFeedback( payload: SubmitProfileFeedbackRequest, options: RuntimeRequestOptions = {},