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 79af8322..d4c8f436 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 @@ -85,17 +85,42 @@ } ``` -### 3.3 `POST /api/profile/recharge/wechat/notify` +### 3.3 `POST /api/profile/recharge/orders/{order_id}/wechat/confirm` + +需要 Bearer JWT。该接口用于小程序支付页返回 web-view 后的主动查单确认,不替代微信支付通知: + +1. 后端读取本地 `profile_recharge_order` 并校验订单归属、支付渠道和当前状态。 +2. 若订单已是 `paid`,直接返回订单与账户中心快照。 +3. 若订单仍是 `pending`,后端调用微信支付按商户订单号查单接口。 +4. 只有微信查单返回 `trade_state = "SUCCESS"` 时,才调用统一入账 procedure 把订单改为 `paid` 并写入钱包流水或会员状态。 +5. 如果微信查单仍不是 `SUCCESS`,接口返回当前 pending 订单与账户中心快照;前端只显示“支付已提交”,不提前发放泥点或会员。 + +响应结构: + +```json +{ + "order": { + "orderId": "rcg...", + "status": "paid" + }, + "center": { + "walletBalance": 120 + } +} +``` + +### 3.4 `POST /api/profile/recharge/wechat/notify` 微信支付通知地址,无需 Bearer JWT。行为: -1. 真实渠道使用微信支付平台公钥和 `Wechatpay-*` 请求头验签。 +1. 真实渠道使用微信支付平台公钥和 `Wechatpay-*` 请求头验签;验签必须使用原始 HTTP body bytes 构造 `timestamp\nnonce\nbody\n`,不能先把 body 转成字符串再重建。 2. 使用 `WECHAT_PAY_API_V3_KEY` 解密通知 `resource`。 3. 仅当 `trade_state = "SUCCESS"` 时确认订单支付。 4. 使用微信通知里的 `out_trade_no` 查本地 `profile_recharge_order.order_id`,把订单从 `pending` 改为 `paid`。 5. 将微信平台订单号写入 `provider_transaction_id`,用于对账、查单、退款和客服排障。 6. 在同一 SpacetimeDB procedure 内写入钱包流水或会员到期时间,确保重复通知幂等。 7. 验签、解密和业务确认通过后返回 HTTP `204 No Content`;不要返回 V2 XML。 +8. 微信支付公钥模式下,真实请求会携带 `Wechatpay-Serial: PUB_KEY_ID_...`,通知验签必须要求回调头 `Wechatpay-Serial` 与 `WECHAT_PAY_PLATFORM_SERIAL_NO` 对应;若不匹配应返回 `401` 并在日志里记录 reason。 关键环境变量: @@ -118,6 +143,9 @@ 3. 默认打开 `泥点充值`,可切换到 `会员卡充值`。 4. 点击套餐后调用下单接口,按钮进入处理中状态;小程序环境走 native 支付页拉起 `wx.requestPayment`,支付页返回后刷新 `profileDashboard`。 - 小程序 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` 才入账。 + - `cancel` 和 `fail` 只复位按钮、刷新账户中心并展示状态,不调用入账逻辑。 5. 弹窗内不写大段说明文案,只保留必要金额、泥点、会员权益和状态反馈。 6. 会员卡充值区以套餐卡片优先展示周期、价格和处理状态;移动端单列,桌面端三列,权益表允许横向滚动,避免小屏挤压。 diff --git a/miniprogram/pages/web-view/index.js b/miniprogram/pages/web-view/index.js index 5cbc3925..534128d3 100644 --- a/miniprogram/pages/web-view/index.js +++ b/miniprogram/pages/web-view/index.js @@ -9,6 +9,7 @@ const { const MINI_PROGRAM_CLIENT_TYPE = 'mini_program'; const MINI_PROGRAM_CLIENT_RUNTIME = 'wechat_mini_program'; const CLIENT_INSTANCE_STORAGE_KEY = 'genarrative:mini-program-client-instance-id'; +const PAY_RESULT_STORAGE_KEY = 'genarrative:wechat-pay-result'; function isConfiguredEntryUrl(value) { const trimmed = String(value || '').trim(); @@ -273,6 +274,20 @@ Page({ } }, + onShow() { + const result = wx.getStorageSync(PAY_RESULT_STORAGE_KEY); + if (!result || !this.data.webViewUrl) { + return; + } + + wx.removeStorageSync(PAY_RESULT_STORAGE_KEY); + this.setData({ + webViewUrl: appendHashParams(this.data.webViewUrl, { + wx_pay_result: result, + }), + }); + }, + async handleGetPhoneNumber(event) { if (!this.data.authResult || !this.data.authResult.token) { this.handleRetryLogin(); diff --git a/miniprogram/pages/wechat-pay/index.js b/miniprogram/pages/wechat-pay/index.js index ab0e0041..332849ca 100644 --- a/miniprogram/pages/wechat-pay/index.js +++ b/miniprogram/pages/wechat-pay/index.js @@ -30,18 +30,25 @@ function requestPayment(payParams) { }); } +const PAY_RESULT_STORAGE_KEY = 'genarrative:wechat-pay-result'; + function appendPayResult(url, requestId, status) { const value = `${requestId}:${status}`; const hashIndex = String(url || '').indexOf('#'); const baseUrl = hashIndex >= 0 ? String(url).slice(0, hashIndex) : String(url || ''); const rawHash = hashIndex >= 0 ? String(url).slice(hashIndex + 1) : ''; - const params = new URLSearchParams(rawHash); - params.set('wx_pay_result', value); - return `${baseUrl}#${params.toString()}`; + const nextHash = rawHash + .split('&') + .filter((part) => part && !part.startsWith('wx_pay_result=')) + .concat(`wx_pay_result=${encodeURIComponent(value)}`) + .join('&'); + return `${baseUrl}#${nextHash}`; } function notifyPreviousWebView(requestId, status) { + const result = `${requestId}:${status}`; + wx.setStorageSync(PAY_RESULT_STORAGE_KEY, result); const pages = getCurrentPages(); const previousPage = pages.length >= 2 ? pages[pages.length - 2] : null; if (previousPage && typeof previousPage.setData === 'function') { diff --git a/packages/shared/src/contracts/runtime.ts b/packages/shared/src/contracts/runtime.ts index eab877b8..97a82ada 100644 --- a/packages/shared/src/contracts/runtime.ts +++ b/packages/shared/src/contracts/runtime.ts @@ -158,6 +158,11 @@ export type CreateProfileRechargeOrderResponse = { wechatMiniProgramPayParams?: WechatMiniProgramPayParams | null; }; +export type ConfirmWechatProfileRechargeOrderResponse = { + order: ProfileRechargeOrder; + center: ProfileRechargeCenterResponse; +}; + export type ProfileFeedbackStatus = 'open'; export type ProfileFeedbackEvidenceItemInput = { diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 85c86f4a..0448a3f3 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -129,10 +129,11 @@ use crate::{ admin_list_profile_invite_codes, admin_list_profile_redeem_codes, admin_list_profile_task_configs, admin_upsert_profile_invite_code, admin_upsert_profile_redeem_code, admin_upsert_profile_task_config, - claim_profile_task_reward, 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, + claim_profile_task_reward, confirm_wechat_profile_recharge_order, + 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, }, runtime_save::{ delete_runtime_snapshot, get_runtime_snapshot, list_profile_save_archives, @@ -1409,6 +1410,12 @@ pub fn build_router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/profile/recharge/orders/{order_id}/wechat/confirm", + post(confirm_wechat_profile_recharge_order).route_layer( + middleware::from_fn_with_state(state.clone(), require_bearer_auth), + ), + ) .route( "/api/profile/recharge/wechat/notify", post(handle_wechat_pay_notify), diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index f58a829e..987a8cf1 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -10,12 +10,12 @@ use module_runtime::{ RuntimeProfileFeedbackEvidenceSnapshot, RuntimeProfileFeedbackSubmissionRecord, RuntimeProfileInviteCodeRecord, RuntimeProfileMembershipBenefitRecord, RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, - RuntimeProfileRechargeProductRecord, RuntimeProfileRedeemCodeMode, - RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord, - RuntimeProfileTaskCenterRecord, RuntimeProfileTaskClaimRecord, RuntimeProfileTaskConfigRecord, - RuntimeProfileTaskCycle, RuntimeProfileTaskItemRecord, RuntimeProfileTaskStatus, - RuntimeProfileWalletLedgerSourceType, RuntimeReferralInviteCenterRecord, - RuntimeTrackingScopeKind, + RuntimeProfileRechargeOrderStatus, RuntimeProfileRechargeProductRecord, + RuntimeProfileRedeemCodeMode, RuntimeProfileRedeemCodeRecord, + RuntimeProfileRewardCodeRedeemRecord, RuntimeProfileTaskCenterRecord, + RuntimeProfileTaskClaimRecord, RuntimeProfileTaskConfigRecord, RuntimeProfileTaskCycle, + RuntimeProfileTaskItemRecord, RuntimeProfileTaskStatus, RuntimeProfileWalletLedgerSourceType, + RuntimeReferralInviteCenterRecord, RuntimeTrackingScopeKind, }; use serde::Deserialize; use serde_json::{Value, json}; @@ -25,10 +25,10 @@ use shared_contracts::runtime::{ AdminDisableProfileTaskConfigRequest, AdminUpsertProfileInviteCodeRequest, AdminUpsertProfileRedeemCodeRequest, AdminUpsertProfileTaskConfigRequest, AnalyticsBucketMetricResponse, AnalyticsMetricQueryResponse, ClaimProfileTaskRewardResponse, - CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse, - PROFILE_FEEDBACK_STATUS_OPEN, PROFILE_TASK_CYCLE_DAILY, PROFILE_TASK_STATUS_CLAIMABLE, - PROFILE_TASK_STATUS_CLAIMED, PROFILE_TASK_STATUS_DISABLED, PROFILE_TASK_STATUS_INCOMPLETE, - PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME, + ConfirmWechatProfileRechargeOrderResponse, CreateProfileRechargeOrderRequest, + CreateProfileRechargeOrderResponse, PROFILE_FEEDBACK_STATUS_OPEN, PROFILE_TASK_CYCLE_DAILY, + PROFILE_TASK_STATUS_CLAIMABLE, PROFILE_TASK_STATUS_CLAIMED, PROFILE_TASK_STATUS_DISABLED, + PROFILE_TASK_STATUS_INCOMPLETE, PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME, PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND, PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_TASK_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD, @@ -63,7 +63,10 @@ use crate::{ http_error::AppError, request_context::RequestContext, state::AppState, - wechat_pay::{build_wechat_payment_request, current_unix_micros, map_wechat_pay_error}, + wechat_pay::{ + WechatPayNotifyOrder, build_wechat_payment_request, current_unix_micros, + map_wechat_pay_error, + }, }; pub async fn get_profile_dashboard( @@ -244,6 +247,106 @@ pub async fn create_profile_recharge_order( )) } +pub async fn confirm_wechat_profile_recharge_order( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Path(order_id): Path, +) -> Result, Response> { + let user_id = authenticated.claims().user_id().to_string(); + let (center, order) = state + .spacetime_client() + .get_profile_recharge_order(order_id.clone()) + .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 order.payment_channel != PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM { + return Err(runtime_profile_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("该充值订单不是微信小程序支付订单"), + )); + } + 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), + }, + )); + } + 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), + }, + )); + } + + let wechat_order = state + .wechat_pay_client() + .query_order_by_out_trade_no(&order.order_id) + .await + .map_err(|error| { + runtime_profile_error_response(&request_context, map_wechat_pay_error(error)) + })?; + if wechat_order.out_trade_no != order.order_id { + return Err(runtime_profile_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_GATEWAY) + .with_message("微信支付查单返回的商户订单号与本地订单不一致") + .with_details(json!({ "provider": "wechat_pay" })), + )); + } + 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), + }, + )); + } + + let paid_at_micros = paid_at_micros_from_wechat_order(&wechat_order); + let (center, order) = state + .spacetime_client() + .mark_profile_recharge_order_paid( + wechat_order.out_trade_no, + paid_at_micros, + wechat_order.transaction_id, + ) + .await + .map_err(|error| { + runtime_profile_error_response( + &request_context, + map_runtime_profile_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + ConfirmWechatProfileRechargeOrderResponse { + order: build_profile_recharge_order_response(order), + center: build_profile_recharge_center_response(center), + }, + )) +} + pub async fn submit_profile_feedback( State(state): State, Extension(request_context): Extension, @@ -801,6 +904,15 @@ async fn resolve_wechat_identity_for_payment( .with_message("当前账号缺少微信小程序身份,请在小程序内重新登录后再支付")) } +fn paid_at_micros_from_wechat_order(order: &WechatPayNotifyOrder) -> i64 { + order + .success_time + .as_deref() + .and_then(|value| parse_rfc3339(value).ok()) + .map(offset_datetime_to_unix_micros) + .unwrap_or_else(current_unix_micros) +} + fn build_profile_recharge_center_response( record: RuntimeProfileRechargeCenterRecord, ) -> ProfileRechargeCenterResponse { @@ -1260,6 +1372,7 @@ mod tests { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let response = app + .clone() .oneshot( Request::builder() .method("GET") @@ -1271,6 +1384,20 @@ mod tests { .expect("request should succeed"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + + let confirm_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/profile/recharge/orders/rcgtest001/wechat/confirm") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(confirm_response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] diff --git a/server-rs/crates/api-server/src/wechat_pay.rs b/server-rs/crates/api-server/src/wechat_pay.rs index e14532e5..63b15b8d 100644 --- a/server-rs/crates/api-server/src/wechat_pay.rs +++ b/server-rs/crates/api-server/src/wechat_pay.rs @@ -18,6 +18,7 @@ use shared_contracts::runtime::WechatMiniProgramPayParamsResponse; use shared_kernel::offset_datetime_to_unix_micros; use time::OffsetDateTime; use tracing::{info, warn}; +use url::Url; use crate::{http_error::AppError, state::AppState}; @@ -28,6 +29,8 @@ const WECHAT_PAY_PAY_SIGN_TYPE: &str = "RSA"; const WECHAT_PAY_ACCEPT_HEADER: &str = "application/json"; const WECHAT_PAY_CONTENT_TYPE_HEADER: &str = "application/json"; const WECHAT_PAY_USER_AGENT: &str = "Genarrative-WechatPay/1.0"; +const WECHAT_PAY_SERIAL_HEADER: &str = "Wechatpay-Serial"; +const WECHAT_PAY_SIGNATURE_TEST_PREFIX: &str = "WECHATPAY/SIGNTEST/"; const WECHAT_PAY_APP_ID_MAX_CHARS: usize = 32; const WECHAT_PAY_MCH_ID_MAX_CHARS: usize = 32; const WECHAT_PAY_DESCRIPTION_MAX_CHARS: usize = 127; @@ -54,6 +57,7 @@ pub struct RealWechatPayClient { api_v3_key: String, notify_url: String, jsapi_endpoint: String, + query_order_endpoint_base: String, } #[derive(Clone, Debug)] @@ -81,7 +85,7 @@ pub enum WechatPayError { Upstream(String), Deserialize(String), Crypto(String), - InvalidSignature, + InvalidSignature(String), } #[derive(Serialize)] @@ -137,6 +141,16 @@ struct WechatPayTransactionResource { success_time: Option, } +#[derive(Deserialize)] +struct WechatPayQueryOrderResponse { + out_trade_no: String, + #[serde(default)] + transaction_id: Option, + trade_state: String, + #[serde(default)] + success_time: Option, +} + impl WechatPayClient { pub fn from_config(config: &crate::config::AppConfig) -> Result { if !config.wechat_pay_enabled { @@ -208,6 +222,7 @@ impl WechatPayClient { &config.wechat_pay_jsapi_endpoint, "WECHAT_PAY_JSAPI_ENDPOINT", )?; + let query_order_endpoint_base = resolve_query_order_endpoint_base(&jsapi_endpoint)?; Ok(Self::Real(Arc::new(RealWechatPayClient { client: reqwest::Client::new(), @@ -220,6 +235,7 @@ impl WechatPayClient { api_v3_key, notify_url, jsapi_endpoint, + query_order_endpoint_base, }))) } @@ -245,6 +261,22 @@ impl WechatPayClient { Self::Real(client) => client.parse_notify(headers, body), } } + + pub async fn query_order_by_out_trade_no( + &self, + order_id: &str, + ) -> Result { + match self { + Self::Disabled => Err(WechatPayError::Disabled), + Self::Mock => Ok(WechatPayNotifyOrder { + out_trade_no: normalize_out_trade_no(order_id)?, + transaction_id: Some(format!("mock-{order_id}")), + trade_state: "SUCCESS".to_string(), + success_time: Some(OffsetDateTime::now_utc().to_string()), + }), + Self::Real(client) => client.query_order_by_out_trade_no(order_id).await, + } + } } impl RealWechatPayClient { @@ -283,6 +315,7 @@ impl RealWechatPayClient { self.client .post(&self.jsapi_endpoint) .header("Authorization", authorization), + &self.platform_serial_no, ) .body(body) .send() @@ -389,6 +422,58 @@ impl RealWechatPayClient { }) } + async fn query_order_by_out_trade_no( + &self, + order_id: &str, + ) -> Result { + let order_id = normalize_out_trade_no(order_id)?; + let path = format!( + "/v3/pay/transactions/out-trade-no/{}?mchid={}", + urlencoding::encode(&order_id), + urlencoding::encode(&self.mch_id), + ); + let request_url = format!( + "{}/{}?mchid={}", + self.query_order_endpoint_base.trim_end_matches('/'), + urlencoding::encode(&order_id), + urlencoding::encode(&self.mch_id), + ); + let timestamp = OffsetDateTime::now_utc().unix_timestamp().to_string(); + let nonce = create_nonce()?; + let authorization = self.build_authorization("GET", &path, ×tamp, &nonce, "")?; + let response = with_wechat_pay_json_headers( + self.client + .get(request_url) + .header("Authorization", authorization), + &self.platform_serial_no, + ) + .send() + .await + .map_err(|error| WechatPayError::RequestFailed(format!("微信支付查单请求失败:{error}")))?; + let status = response.status(); + let response_text = response.text().await.map_err(|error| { + WechatPayError::Deserialize(format!("微信支付查单响应读取失败:{error}")) + })?; + if !status.is_success() { + return Err(WechatPayError::Upstream(format!( + "微信支付查单失败:HTTP {status},{response_text}" + ))); + } + let payload = serde_json::from_str::(&response_text).map_err( + |error| WechatPayError::Deserialize(format!("微信支付查单响应解析失败:{error}")), + )?; + + Ok(WechatPayNotifyOrder { + out_trade_no: payload.out_trade_no, + transaction_id: payload + .transaction_id + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()), + trade_state: payload.trade_state, + success_time: payload.success_time, + }) + } + fn verify_notify_signature( &self, headers: &HeaderMap, @@ -399,25 +484,33 @@ impl RealWechatPayClient { let signature = read_required_header(headers, "Wechatpay-Signature")?; let serial = read_required_header(headers, "Wechatpay-Serial")?; if serial != self.platform_serial_no { - return Err(WechatPayError::InvalidSignature); + warn!( + received_serial = serial, + configured_serial = self.platform_serial_no.as_str(), + "微信支付通知平台公钥序列号不匹配" + ); + return Err(WechatPayError::InvalidSignature(format!( + "微信支付通知平台公钥序列号不匹配:received={serial}" + ))); + } + if signature.starts_with(WECHAT_PAY_SIGNATURE_TEST_PREFIX) { + warn!("收到微信支付签名探测通知"); + return Err(WechatPayError::InvalidSignature( + "微信支付签名探测通知".to_string(), + )); } - let message = format!( - "{}\n{}\n{}\n", - timestamp, - nonce, - String::from_utf8_lossy(body) - ); - let signature_bytes = BASE64_STANDARD - .decode(signature) - .map_err(|_| WechatPayError::InvalidSignature)?; + let message = build_notify_signature_message(timestamp.as_bytes(), nonce.as_bytes(), body); + let signature_bytes = BASE64_STANDARD.decode(signature).map_err(|_| { + WechatPayError::InvalidSignature("微信支付通知签名 base64 无效".to_string()) + })?; let public_key = signature::UnparsedPublicKey::new( &signature::RSA_PKCS1_2048_8192_SHA256, &self.platform_public_key_der, ); public_key - .verify(message.as_bytes(), &signature_bytes) - .map_err(|_| WechatPayError::InvalidSignature) + .verify(&message, &signature_bytes) + .map_err(|_| WechatPayError::InvalidSignature("微信支付通知签名验签失败".to_string())) } fn sign_message(&self, message: &str) -> Result { @@ -499,9 +592,11 @@ pub fn map_wechat_pay_error(error: WechatPayError) -> AppError { | WechatPayError::Crypto(message) => AppError::from_status(StatusCode::BAD_GATEWAY) .with_message(message) .with_details(json!({ "provider": "wechat_pay" })), - WechatPayError::InvalidSignature => AppError::from_status(StatusCode::UNAUTHORIZED) - .with_message("微信支付通知签名无效") - .with_details(json!({ "provider": "wechat_pay" })), + WechatPayError::InvalidSignature(message) => { + AppError::from_status(StatusCode::UNAUTHORIZED) + .with_message("微信支付通知签名无效") + .with_details(json!({ "provider": "wechat_pay", "reason": message })) + } } } @@ -533,7 +628,10 @@ fn map_wechat_pay_notify_error(error: WechatPayError) -> AppError { map_wechat_pay_error(error) } -fn with_wechat_pay_jsapi_headers(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { +fn with_wechat_pay_json_headers( + builder: reqwest::RequestBuilder, + platform_serial_no: &str, +) -> reqwest::RequestBuilder { builder .header(reqwest::header::ACCEPT, WECHAT_PAY_ACCEPT_HEADER) .header( @@ -541,6 +639,14 @@ fn with_wechat_pay_jsapi_headers(builder: reqwest::RequestBuilder) -> reqwest::R WECHAT_PAY_CONTENT_TYPE_HEADER, ) .header(reqwest::header::USER_AGENT, WECHAT_PAY_USER_AGENT) + .header(WECHAT_PAY_SERIAL_HEADER, platform_serial_no) +} + +fn with_wechat_pay_jsapi_headers( + builder: reqwest::RequestBuilder, + platform_serial_no: &str, +) -> reqwest::RequestBuilder { + with_wechat_pay_json_headers(builder, platform_serial_no) } fn build_mock_pay_params(order_id: &str) -> WechatMiniProgramPayParamsResponse { @@ -627,6 +733,23 @@ fn validate_notify_url(value: &str, key: &str) -> Result<(), WechatPayError> { Ok(()) } +fn resolve_query_order_endpoint_base(jsapi_endpoint: &str) -> Result { + let url = Url::parse(jsapi_endpoint) + .map_err(|_| WechatPayError::InvalidConfig("WECHAT_PAY_JSAPI_ENDPOINT 无效".to_string()))?; + let origin = url + .origin() + .ascii_serialization() + .trim_end_matches('/') + .to_string(); + Ok(format!("{origin}/v3/pay/transactions/out-trade-no")) +} + +fn normalize_out_trade_no(value: &str) -> Result { + let value = value.trim(); + validate_out_trade_no(value)?; + Ok(value.to_string()) +} + fn validate_jsapi_order_request( client: &RealWechatPayClient, request: &WechatMiniProgramOrderRequest, @@ -841,7 +964,18 @@ fn read_required_header<'a>( .and_then(|value| value.to_str().ok()) .map(str::trim) .filter(|value| !value.is_empty()) - .ok_or(WechatPayError::InvalidSignature) + .ok_or_else(|| WechatPayError::InvalidSignature(format!("微信支付通知缺少 {name} 请求头"))) +} + +fn build_notify_signature_message(timestamp: &[u8], nonce: &[u8], body: &[u8]) -> Vec { + let mut message = Vec::with_capacity(timestamp.len() + nonce.len() + body.len() + 3); + message.extend_from_slice(timestamp); + message.push(b'\n'); + message.extend_from_slice(nonce); + message.push(b'\n'); + message.extend_from_slice(body); + message.push(b'\n'); + message } fn hex_sha256(content: &[u8]) -> String { @@ -864,7 +998,7 @@ impl std::fmt::Display for WechatPayError { | Self::Upstream(message) | Self::Deserialize(message) | Self::Crypto(message) => formatter.write_str(message), - Self::InvalidSignature => formatter.write_str("微信支付通知签名无效"), + Self::InvalidSignature(message) => formatter.write_str(message), } } } @@ -951,6 +1085,7 @@ mod tests { "Authorization", "WECHATPAY2-SHA256-RSA2048 mchid=\"1900000001\"", ), + "PUB_KEY_ID_0119000000012026051400000000000001", ) .build() .expect("request should build"); @@ -974,6 +1109,23 @@ mod tests { .and_then(|value| value.to_str().ok()), Some(WECHAT_PAY_USER_AGENT) ); + assert_eq!( + headers + .get(WECHAT_PAY_SERIAL_HEADER) + .and_then(|value| value.to_str().ok()), + Some("PUB_KEY_ID_0119000000012026051400000000000001") + ); + } + + #[test] + fn notify_signature_message_preserves_raw_body_bytes() { + let body = b"{\"message\":\"hello\\r\\nworld\"}\r\n"; + let message = build_notify_signature_message(b"1778759600", b"nonce-1", body); + + assert_eq!( + message, + b"1778759600\nnonce-1\n{\"message\":\"hello\\r\\nworld\"}\r\n\n".to_vec() + ); } #[test] diff --git a/server-rs/crates/module-runtime/src/commands.rs b/server-rs/crates/module-runtime/src/commands.rs index 90236501..e3249e64 100644 --- a/server-rs/crates/module-runtime/src/commands.rs +++ b/server-rs/crates/module-runtime/src/commands.rs @@ -242,6 +242,14 @@ pub fn build_runtime_profile_recharge_center_get_input( Ok(RuntimeProfileRechargeCenterGetInput { user_id }) } +pub fn build_runtime_profile_recharge_order_get_input( + order_id: String, +) -> Result { + let order_id = + normalize_required_string(order_id).ok_or(RuntimeProfileFieldError::MissingOrderId)?; + Ok(RuntimeProfileRechargeOrderGetInput { order_id }) +} + pub fn build_runtime_profile_recharge_order_create_input( user_id: String, product_id: String, diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index 88f261c2..e327f28d 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -1060,6 +1060,12 @@ pub struct RuntimeProfileRechargeCenterGetInput { pub user_id: String, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRechargeOrderGetInput { + pub order_id: String, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeProfileRechargeOrderCreateInput { diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index cedb0cc0..e36ac817 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -268,6 +268,13 @@ pub struct CreateProfileRechargeOrderResponse { pub wechat_mini_program_pay_params: Option, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ConfirmWechatProfileRechargeOrderResponse { + pub order: ProfileRechargeOrderResponse, + pub center: ProfileRechargeCenterResponse, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ProfileFeedbackEvidenceItemRequest { diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index 8ff41ab3..e620e3d9 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -177,6 +177,7 @@ use module_runtime::{ build_runtime_profile_play_stats_get_input, build_runtime_profile_play_stats_record, build_runtime_profile_recharge_center_get_input, build_runtime_profile_recharge_center_record, build_runtime_profile_recharge_order_create_input, + build_runtime_profile_recharge_order_get_input, build_runtime_profile_redeem_code_admin_disable_input, build_runtime_profile_redeem_code_admin_list_input, build_runtime_profile_redeem_code_admin_upsert_input, build_runtime_profile_redeem_code_record, diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index a530b4c5..800bc384 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -163,6 +163,16 @@ impl From } } +impl From + for RuntimeProfileRechargeOrderGetInput +{ + fn from(input: module_runtime::RuntimeProfileRechargeOrderGetInput) -> Self { + Self { + order_id: input.order_id, + } + } +} + impl From for RuntimeProfileRechargeOrderCreateInput { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_profile_recharge_order_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_profile_recharge_order_and_return_procedure.rs new file mode 100644 index 00000000..f187bc6f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_profile_recharge_order_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_recharge_center_procedure_result_type::RuntimeProfileRechargeCenterProcedureResult; +use super::runtime_profile_recharge_order_get_input_type::RuntimeProfileRechargeOrderGetInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetProfileRechargeOrderAndReturnArgs { + pub input: RuntimeProfileRechargeOrderGetInput, +} + +impl __sdk::InModule for GetProfileRechargeOrderAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_profile_recharge_order_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_profile_recharge_order_and_return { + fn get_profile_recharge_order_and_return(&self, input: RuntimeProfileRechargeOrderGetInput) { + self.get_profile_recharge_order_and_return_then(input, |_, _| {}); + } + + fn get_profile_recharge_order_and_return_then( + &self, + input: RuntimeProfileRechargeOrderGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_profile_recharge_order_and_return for super::RemoteProcedures { + fn get_profile_recharge_order_and_return_then( + &self, + input: RuntimeProfileRechargeOrderGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeProfileRechargeCenterProcedureResult>( + "get_profile_recharge_order_and_return", + GetProfileRechargeOrderAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs index a4006654..9a56b47d 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs @@ -320,6 +320,7 @@ pub mod get_player_progression_or_default_procedure; pub mod get_profile_dashboard_procedure; pub mod get_profile_play_stats_procedure; pub mod get_profile_recharge_center_procedure; +pub mod get_profile_recharge_order_and_return_procedure; pub mod get_profile_referral_invite_center_procedure; pub mod get_profile_task_center_procedure; pub mod get_puzzle_agent_session_procedure; @@ -620,6 +621,7 @@ pub mod runtime_profile_recharge_center_get_input_type; pub mod runtime_profile_recharge_center_procedure_result_type; pub mod runtime_profile_recharge_center_snapshot_type; pub mod runtime_profile_recharge_order_create_input_type; +pub mod runtime_profile_recharge_order_get_input_type; pub mod runtime_profile_recharge_order_paid_input_type; pub mod runtime_profile_recharge_order_snapshot_type; pub mod runtime_profile_recharge_order_status_type; @@ -1135,6 +1137,7 @@ pub use get_player_progression_or_default_procedure::get_player_progression_or_d pub use get_profile_dashboard_procedure::get_profile_dashboard; pub use get_profile_play_stats_procedure::get_profile_play_stats; pub use get_profile_recharge_center_procedure::get_profile_recharge_center; +pub use get_profile_recharge_order_and_return_procedure::get_profile_recharge_order_and_return; pub use get_profile_referral_invite_center_procedure::get_profile_referral_invite_center; pub use get_profile_task_center_procedure::get_profile_task_center; pub use get_puzzle_agent_session_procedure::get_puzzle_agent_session; @@ -1435,6 +1438,7 @@ pub use runtime_profile_recharge_center_get_input_type::RuntimeProfileRechargeCe pub use runtime_profile_recharge_center_procedure_result_type::RuntimeProfileRechargeCenterProcedureResult; pub use runtime_profile_recharge_center_snapshot_type::RuntimeProfileRechargeCenterSnapshot; pub use runtime_profile_recharge_order_create_input_type::RuntimeProfileRechargeOrderCreateInput; +pub use runtime_profile_recharge_order_get_input_type::RuntimeProfileRechargeOrderGetInput; pub use runtime_profile_recharge_order_paid_input_type::RuntimeProfileRechargeOrderPaidInput; pub use runtime_profile_recharge_order_snapshot_type::RuntimeProfileRechargeOrderSnapshot; pub use runtime_profile_recharge_order_status_type::RuntimeProfileRechargeOrderStatus; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_get_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_get_input_type.rs new file mode 100644 index 00000000..c1241d4e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_get_input_type.rs @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileRechargeOrderGetInput { + pub order_id: String, +} + +impl __sdk::InModule for RuntimeProfileRechargeOrderGetInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/runtime.rs b/server-rs/crates/spacetime-client/src/runtime.rs index 076aef6c..5e86b21f 100644 --- a/server-rs/crates/spacetime-client/src/runtime.rs +++ b/server-rs/crates/spacetime-client/src/runtime.rs @@ -268,6 +268,33 @@ impl SpacetimeClient { .await } + pub async fn get_profile_recharge_order( + &self, + order_id: String, + ) -> Result< + ( + RuntimeProfileRechargeCenterRecord, + RuntimeProfileRechargeOrderRecord, + ), + SpacetimeClientError, + > { + let procedure_input = build_runtime_profile_recharge_order_get_input(order_id) + .map_err(SpacetimeClientError::validation_failed)? + .into(); + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .get_profile_recharge_order_and_return_then(procedure_input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_runtime_profile_recharge_order_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + pub async fn mark_profile_recharge_order_paid( &self, order_id: String, diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs index f23e0969..656e023b 100644 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -771,6 +771,27 @@ pub fn get_profile_recharge_center( } } +#[spacetimedb::procedure] +pub fn get_profile_recharge_order_and_return( + ctx: &mut ProcedureContext, + input: RuntimeProfileRechargeOrderGetInput, +) -> RuntimeProfileRechargeCenterProcedureResult { + match ctx.try_with_tx(|tx| get_profile_recharge_order_snapshot(tx, input.clone())) { + Ok((record, order)) => RuntimeProfileRechargeCenterProcedureResult { + ok: true, + record: Some(record), + order: Some(order), + error_message: None, + }, + Err(message) => RuntimeProfileRechargeCenterProcedureResult { + ok: false, + record: None, + order: None, + error_message: Some(message), + }, + } +} + #[spacetimedb::procedure] pub fn create_profile_recharge_order_and_return( ctx: &mut ProcedureContext, @@ -2122,6 +2143,31 @@ fn create_profile_recharge_order_record( )) } +fn get_profile_recharge_order_snapshot( + ctx: &ReducerContext, + input: RuntimeProfileRechargeOrderGetInput, +) -> Result< + ( + RuntimeProfileRechargeCenterSnapshot, + RuntimeProfileRechargeOrderSnapshot, + ), + String, +> { + let validated_input = build_runtime_profile_recharge_order_get_input(input.order_id) + .map_err(|error| error.to_string())?; + let order = ctx + .db + .profile_recharge_order() + .order_id() + .find(&validated_input.order_id) + .ok_or_else(|| "profile_recharge_order 不存在".to_string())?; + + Ok(( + build_profile_recharge_center_snapshot(ctx, &order.user_id), + build_profile_recharge_order_snapshot_from_row(&order), + )) +} + fn mark_profile_recharge_order_paid_record( ctx: &ReducerContext, input: RuntimeProfileRechargeOrderPaidInput, diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index 1617b4a2..657eb51d 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -17,6 +17,7 @@ import type { PublicUserSummary, } from '../../../packages/shared/src/contracts/auth'; import type { + ConfirmWechatProfileRechargeOrderResponse, CreateProfileRechargeOrderResponse, ProfileReferralInviteCenterResponse, ProfileTaskCenterResponse, @@ -39,6 +40,7 @@ const { mockBuildReferralCenter, mockBuildTaskCenter, mockClaimRpgProfileTaskReward, + mockConfirmWechatRpgProfileRechargeOrder, mockCreateRpgProfileRechargeOrder, mockGetRpgProfileReferralInviteCenter, mockGetRpgProfileRechargeCenter, @@ -219,6 +221,65 @@ const { }, }), ), + mockConfirmWechatRpgProfileRechargeOrder: vi.fn( + async (): Promise => ({ + order: { + orderId: 'order-wechat-1', + productId: 'points_60', + productTitle: '60泥点', + kind: 'points', + amountCents: 600, + status: 'paid', + paymentChannel: 'wechat_mp', + paidAt: '2026-04-25T10:01:00Z', + providerTransactionId: 'wx-transaction-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: [ + { + productId: 'points_60', + title: '60泥点', + priceCents: 600, + kind: 'points', + pointsAmount: 60, + bonusPoints: 0, + durationDays: 0, + badgeLabel: '', + description: '60泥点', + tier: 'normal', + }, + ], + membershipProducts: [], + benefits: [], + latestOrder: { + orderId: 'order-wechat-1', + productId: 'points_60', + productTitle: '60泥点', + kind: 'points', + amountCents: 600, + status: 'paid', + paymentChannel: 'wechat_mp', + providerTransactionId: 'wx-transaction-1', + createdAt: '2026-04-25T10:00:00Z', + paidAt: '2026-04-25T10:01:00Z', + pointsDelta: 120, + membershipExpiresAt: null, + }, + hasPointsRecharged: true, + }, + }), + ), mockRedeemRpgProfileReferralInviteCode: vi.fn(async () => ({ center: buildReferralCenter({ invitedUsers: [], @@ -303,6 +364,8 @@ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({ redeemRpgProfileReferralInviteCode: mockRedeemRpgProfileReferralInviteCode, getRpgProfileRechargeCenter: mockGetRpgProfileRechargeCenter, createRpgProfileRechargeOrder: mockCreateRpgProfileRechargeOrder, + confirmWechatRpgProfileRechargeOrder: + mockConfirmWechatRpgProfileRechargeOrder, })); vi.mock('../ResolvedAssetImage', () => ({ @@ -975,11 +1038,8 @@ 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(); window.history.replaceState(null, '', '/?clientRuntime=wechat_mini_program'); - const navigateTo = vi.fn((options: { url: string }) => { - const url = new URL(`https://mini.test${options.url}`); - const requestId = url.searchParams.get('requestId'); - window.location.hash = `wx_pay_result=${requestId}:success`; - window.dispatchEvent(new HashChangeEvent('hashchange')); + const navigateTo = vi.fn((options: { url: string; success?: () => void }) => { + options.success?.(); }); window.wx = { miniProgram: { @@ -1040,23 +1100,32 @@ test('profile recharge modal posts requestPayment params in mini program web-vie }); expect(navigateTo).toHaveBeenCalledWith({ url: expect.stringContaining('/pages/wechat-pay/index?'), + success: expect.any(Function), fail: expect.any(Function), }); const navigateUrl = navigateTo.mock.calls[0]?.[0].url ?? ''; + const requestId = new URL(`https://mini.test${navigateUrl}`).searchParams.get( + 'requestId', + ); + expect(requestId).toBeTruthy(); + act(() => { + window.location.hash = `wx_pay_result=${requestId}:success`; + window.dispatchEvent(new HashChangeEvent('hashchange')); + }); expect(navigateUrl).toContain('order-wechat-1'); expect(decodeURIComponent(navigateUrl)).toContain('prepay_id=wx-prepay'); - expect(await screen.findByText('支付已提交')).toBeTruthy(); + expect(await screen.findByText('已到账')).toBeTruthy(); + expect(mockConfirmWechatRpgProfileRechargeOrder).toHaveBeenCalledWith( + 'order-wechat-1', + ); }); 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'); window.wx = undefined; - const navigateTo = vi.fn((options: { url: string }) => { - const url = new URL(`https://mini.test${options.url}`); - const requestId = url.searchParams.get('requestId'); - window.location.hash = `wx_pay_result=${requestId}:success`; - window.dispatchEvent(new HashChangeEvent('hashchange')); + const navigateTo = vi.fn((options: { url: string; success?: () => void }) => { + options.success?.(); }); mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({ order: { @@ -1120,10 +1189,110 @@ test('profile recharge modal loads wechat js sdk before mini program payment bri await waitFor(() => { expect(navigateTo).toHaveBeenCalledWith({ url: expect.stringContaining('/pages/wechat-pay/index?'), + success: expect.any(Function), fail: expect.any(Function), }); }); - expect(await screen.findByText('支付已提交')).toBeTruthy(); + const navigateUrl = navigateTo.mock.calls[0]?.[0].url ?? ''; + const requestId = new URL(`https://mini.test${navigateUrl}`).searchParams.get( + 'requestId', + ); + expect(requestId).toBeTruthy(); + act(() => { + window.location.hash = `wx_pay_result=${requestId}:success`; + window.dispatchEvent(new HashChangeEvent('hashchange')); + }); + expect(await screen.findByText('已到账')).toBeTruthy(); +}); + +test('profile recharge modal releases submitting state after cancelled wechat pay result', 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-cancel-1', + 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-cancel', + signType: 'RSA', + paySign: 'signature', + }, + }); + + renderProfileView(); + const shortcutRegion = screen.getByRole('region', { name: '常用功能' }); + await user.click( + within(shortcutRegion).getByRole('button', { name: /充值/u }), + ); + const buyButton = await screen.findByRole('button', { name: /60泥点/u }); + await user.click(buyButton); + + await waitFor(() => { + expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith( + 'points_60', + 'wechat_mp', + ); + }); + expect( + within(buyButton).getByText('处理中', { selector: 'span' }), + ).toBeTruthy(); + + const requestUrl = navigateTo.mock.calls[0]?.[0].url ?? ''; + const requestId = new URL(`https://mini.test${requestUrl}`).searchParams.get( + 'requestId', + ); + expect(requestId).toBeTruthy(); + act(() => { + window.location.hash = `wx_pay_result=${requestId}:cancel`; + window.dispatchEvent(new HashChangeEvent('hashchange')); + }); + + expect(await screen.findByText('支付已取消')).toBeTruthy(); + await waitFor(() => { + expect( + within(screen.getByRole('button', { name: /60泥点/u })).getByText( + '购买', + { selector: 'span' }, + ), + ).toBeTruthy(); + }); + expect(mockConfirmWechatRpgProfileRechargeOrder).not.toHaveBeenCalled(); }); test('profile daily task shortcut opens task center and claims reward', async () => { diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 62b1b746..4ac8b0f2 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -70,6 +70,7 @@ import { import { copyTextToClipboard } from '../../services/clipboard'; import { claimRpgProfileTaskReward, + confirmWechatRpgProfileRechargeOrder, createRpgProfileRechargeOrder, getRpgProfileReferralInviteCenter, getRpgProfileRechargeCenter, @@ -216,6 +217,11 @@ const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js'; type ProfilePopupPanel = 'invite' | 'redeem' | 'community'; type RechargeTab = 'points' | 'membership'; type WechatMiniProgramPaymentStatus = 'success' | 'fail' | 'cancel'; +type WechatPayResult = { + requestId: string; + orderId: string | null; + status: WechatMiniProgramPaymentStatus; +}; type DiscoverChannel = | 'recommend' | 'today' @@ -2342,6 +2348,37 @@ function clearWechatPayResultHash() { window.history.replaceState(null, '', nextUrl); } +function readWechatPayResultFromHash(): WechatPayResult | null { + if (typeof window === 'undefined') { + return null; + } + + const result = new URLSearchParams( + window.location.hash.replace(/^#/, ''), + ).get('wx_pay_result'); + if (!result) { + return null; + } + + const [requestId = '', rawStatus = ''] = result.split(':'); + const orderId = requestId + .replace(/^wechat_pay_/, '') + .replace(/_\d+$/, '') + .trim(); + const status = + rawStatus === 'success' + ? 'success' + : rawStatus === 'cancel' + ? 'cancel' + : 'fail'; + + return { + requestId, + orderId: orderId || null, + status, + }; +} + function loadWechatJsSdk() { if (typeof window === 'undefined') { return Promise.reject(new Error('请在微信小程序内完成支付')); @@ -2385,7 +2422,7 @@ function loadWechatJsSdk() { async function requestWechatMiniProgramPayment( payload: WechatMiniProgramPayParams | null | undefined, orderId: string, -) { +): Promise { if (!payload) { return Promise.reject(new Error('请在微信小程序内完成支付')); } @@ -2396,35 +2433,20 @@ async function requestWechatMiniProgramPayment( } const navigateTo = miniProgram.navigateTo; - return new Promise((resolve) => { - const requestId = `wechat_pay_${orderId}_${Date.now()}`; - const handleHashChange = () => { - const params = new URLSearchParams( - window.location.hash.replace(/^#/, ''), - ); - const result = params.get('wx_pay_result') ?? ''; - const [resultRequestId, status] = result.split(':'); - if (resultRequestId !== requestId) { - return; - } - - window.removeEventListener('hashchange', handleHashChange); - resolve( - status === 'success' - ? 'success' - : status === 'cancel' - ? 'cancel' - : 'fail', - ); - }; - - window.addEventListener('hashchange', handleHashChange); + const requestId = `wechat_pay_${orderId}_${Date.now()}`; + return new Promise((resolve, reject) => { navigateTo({ url: `/pages/wechat-pay/index?requestId=${encodeURIComponent(requestId)}&orderId=${encodeURIComponent(orderId)}&payParams=${encodeURIComponent(JSON.stringify(payload))}`, + success() { + resolve(); + }, fail(error) { - window.removeEventListener('hashchange', handleHashChange); console.error('[wechat-pay] navigateTo failed', error); - resolve('fail'); + reject( + error instanceof Error + ? error + : new Error('请在微信小程序内完成支付'), + ); }, }); }); @@ -3368,6 +3390,7 @@ export function RpgEntryHomeView({ useState(null); const profileCopyResetTimerRef = useRef(null); const avatarFileInputRef = useRef(null); + const pendingWechatRechargeOrderIdRef = useRef(null); const [isNicknameModalOpen, setIsNicknameModalOpen] = useState(false); const [nicknameInput, setNicknameInput] = useState(''); const [nicknameError, setNicknameError] = useState(null); @@ -3823,6 +3846,55 @@ export function RpgEntryHomeView({ }) .finally(() => setIsLoadingRechargeCenter(false)); }; + const refreshRechargeState = useCallback( + () => { + loadRechargeCenter(); + setSubmittingRechargeProductId(null); + pendingWechatRechargeOrderIdRef.current = null; + }, + [loadRechargeCenter], + ); + const handleWechatPayResult = useCallback(() => { + const payResult = readWechatPayResultFromHash(); + if (!payResult) { + return; + } + + if ( + pendingWechatRechargeOrderIdRef.current && + payResult.orderId && + payResult.orderId !== pendingWechatRechargeOrderIdRef.current + ) { + return; + } + + if (payResult.status === 'success') { + setRechargeSuccess('支付已提交'); + if (payResult.orderId) { + void confirmWechatRpgProfileRechargeOrder(payResult.orderId) + .then((response) => { + setRechargeCenter(response.center); + setRechargeSuccess( + response.order.status === 'paid' ? '已到账' : '支付已提交', + ); + setSubmittingRechargeProductId(null); + pendingWechatRechargeOrderIdRef.current = null; + }) + .catch(() => refreshRechargeState()); + } else { + refreshRechargeState(); + } + void onRechargeSuccess?.(); + } else if (payResult.status === 'cancel') { + setRechargeSuccess('支付已取消'); + refreshRechargeState(); + } else { + setRechargeError('微信支付未完成'); + refreshRechargeState(); + } + + clearWechatPayResultHash(); + }, [onRechargeSuccess, refreshRechargeState]); const openRechargeModal = () => { if (!authUi?.user) { authUi?.openLoginModal(); @@ -3847,63 +3919,44 @@ export function RpgEntryHomeView({ void createRpgProfileRechargeOrder(product.productId, paymentChannel) .then(async (response) => { if (paymentChannel === WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL) { - const status = await requestWechatMiniProgramPayment( + pendingWechatRechargeOrderIdRef.current = response.order.orderId; + await requestWechatMiniProgramPayment( response.wechatMiniProgramPayParams, response.order.orderId, ); - if (status === 'cancel') { - setRechargeCenter(response.center); - setRechargeSuccess('支付已取消'); - return; - } - if (status !== 'success') { - throw new Error('微信支付未完成'); - } - setRechargeSuccess('支付已提交'); - loadRechargeCenter(); + setRechargeCenter(response.center); + return; } else { setRechargeCenter(response.center); setRechargeSuccess('已到账'); + pendingWechatRechargeOrderIdRef.current = null; + setSubmittingRechargeProductId(null); } void onRechargeSuccess?.(); }) .catch((error: unknown) => { + pendingWechatRechargeOrderIdRef.current = null; setRechargeError(error instanceof Error ? error.message : '充值失败'); - }) - .finally(() => setSubmittingRechargeProductId(null)); + setSubmittingRechargeProductId(null); + }); }; useEffect(() => { - if (!isRechargeOpen) { - return undefined; - } - - const handleWechatPayResult = () => { - const result = new URLSearchParams( - window.location.hash.replace(/^#/, ''), - ).get('wx_pay_result'); - if (!result) { - return; - } - const [, status] = result.split(':'); - if (status === 'success') { - setRechargeSuccess('支付已提交'); - loadRechargeCenter(); - void onRechargeSuccess?.(); - clearWechatPayResultHash(); - } else if (status === 'cancel') { - setRechargeSuccess('支付已取消'); - clearWechatPayResultHash(); - } else { - setRechargeError('微信支付未完成'); - clearWechatPayResultHash(); - } + const handleResume = () => { + handleWechatPayResult(); }; - window.addEventListener('hashchange', handleWechatPayResult); - handleWechatPayResult(); - return () => - window.removeEventListener('hashchange', handleWechatPayResult); - }, [isRechargeOpen, onRechargeSuccess]); + window.addEventListener('hashchange', handleResume); + window.addEventListener('focus', handleResume); + window.addEventListener('pageshow', handleResume); + document.addEventListener('visibilitychange', handleResume); + handleResume(); + return () => { + window.removeEventListener('hashchange', handleResume); + window.removeEventListener('focus', handleResume); + window.removeEventListener('pageshow', handleResume); + document.removeEventListener('visibilitychange', handleResume); + }; + }, [handleWechatPayResult]); const loadTaskCenter = () => { setTaskCenterError(null); setIsLoadingTaskCenter(true); diff --git a/src/services/rpg-entry/rpgProfileClient.ts b/src/services/rpg-entry/rpgProfileClient.ts index d56702fb..a0b9a479 100644 --- a/src/services/rpg-entry/rpgProfileClient.ts +++ b/src/services/rpg-entry/rpgProfileClient.ts @@ -1,4 +1,5 @@ import type { + ConfirmWechatProfileRechargeOrderResponse, CreateProfileRechargeOrderResponse, ClaimProfileTaskRewardResponse, PlatformBrowseHistoryBatchSyncRequest, @@ -105,6 +106,18 @@ export function createRpgProfileRechargeOrder( ); } +export function confirmWechatRpgProfileRechargeOrder( + orderId: string, + options: RuntimeRequestOptions = {}, +) { + return requestRpgRuntimeJson( + `/profile/recharge/orders/${encodeURIComponent(orderId)}/wechat/confirm`, + { method: 'POST' }, + '确认微信支付订单失败', + options, + ); +} + export function submitRpgProfileFeedback( payload: SubmitProfileFeedbackRequest, options: RuntimeRequestOptions = {}, @@ -305,6 +318,7 @@ export const rpgProfileClient = { getWalletLedger: getRpgProfileWalletLedger, getRechargeCenter: getRpgProfileRechargeCenter, createRechargeOrder: createRpgProfileRechargeOrder, + confirmWechatRechargeOrder: confirmWechatRpgProfileRechargeOrder, submitFeedback: submitRpgProfileFeedback, getReferralInviteCenter: getRpgProfileReferralInviteCenter, redeemReferralInviteCode: redeemRpgProfileReferralInviteCode, diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index a419fc50..209a90be 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -9,6 +9,7 @@ interface Window { miniProgram?: { navigateTo?: (options: { url: string; + success?: (result?: unknown) => void; fail?: (error: { errMsg?: string }) => void; }) => void; postMessage?: (message: unknown) => void;