diff --git a/docs/【技术方案】微信虚拟支付接入-2026-05-26.md b/docs/【技术方案】微信虚拟支付接入-2026-05-26.md index a886c557..b89854cc 100644 --- a/docs/【技术方案】微信虚拟支付接入-2026-05-26.md +++ b/docs/【技术方案】微信虚拟支付接入-2026-05-26.md @@ -43,8 +43,8 @@ WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV=0 - 泥点属于微信虚拟支付代币(coin),`short_series_coin` 的 `buyQuantity` 必须使用当前泥点商品的 `points_amount`;例如 60 泥点商品应传 `buyQuantity: 60`。 - 会员直购 `signData` 额外包含 `productId` 和 `goodsPrice`;`goodsPrice` 使用后端商品配置价,和微信后台道具价格校验保持一致。 - 微信小程序“开发者服务器接收消息推送”必须配置为安全模式,数据格式选 JSON,URL 统一指向 `/api/profile/recharge/wechat/virtual-notify`。 -- `WECHAT_MINIPROGRAM_MESSAGE_TOKEN` 和 `WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY` 由环境变量注入;后端会先校验 `signature/msg_signature`,再用 `EncodingAESKey` 解密 `Encrypt`,然后按虚拟支付事件入账。 -- 安全模式下,GET 验证会直接返回解密后的 `echostr`;POST 推送会先解密再解析 `xpay_goods_deliver_notify` / `xpay_coin_pay_notify`。 +- `WECHAT_MINIPROGRAM_MESSAGE_TOKEN` 和 `WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY` 由环境变量注入;GET URL 验证按官方规则用 `Token/timestamp/nonce` 校验 `signature` 并原样返回 `echostr`,POST 安全模式推送再校验 `msg_signature`、用 `EncodingAESKey` 解密 `Encrypt`,然后按虚拟支付事件入账。 +- 安全模式下,POST 推送会先解密再解析 `xpay_goods_deliver_notify` / `xpay_coin_pay_notify`;不要把 GET URL 验证里的 `echostr` 当密文解密。 ## 验收命令 diff --git a/server-rs/crates/api-server/src/wechat_pay.rs b/server-rs/crates/api-server/src/wechat_pay.rs index 4fe51525..010b3ec6 100644 --- a/server-rs/crates/api-server/src/wechat_pay.rs +++ b/server-rs/crates/api-server/src/wechat_pay.rs @@ -901,35 +901,15 @@ pub async fn handle_wechat_virtual_payment_message_push_verify( Ok(value) => value, Err(error) => return build_wechat_message_push_verify_error_response(error), }; - let signature = query - .msg_signature - .as_deref() - .or(query.signature.as_deref()) - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(""); - let timestamp = query.timestamp.as_deref().map(str::trim).unwrap_or(""); - let nonce = query.nonce.as_deref().map(str::trim).unwrap_or(""); - let echostr = query.echostr.as_deref().map(str::trim).unwrap_or(""); - if signature.is_empty() || timestamp.is_empty() || nonce.is_empty() || echostr.is_empty() { - return build_wechat_message_push_verify_error_response(WechatPayError::InvalidRequest( - "微信消息推送校验参数不完整".to_string(), - )); - } - if !verify_wechat_message_push_signature(token, timestamp, nonce, echostr, signature) { - return build_wechat_message_push_verify_error_response(WechatPayError::InvalidSignature( - "微信消息推送校验签名无效".to_string(), - )); - } - - match decrypt_wechat_message_push_ciphertext( + match resolve_wechat_message_push_verify_response( + token, aes_key, - echostr, state .config .wechat_mini_program_app_id .as_deref() .or(state.config.wechat_app_id.as_deref()), + &query, ) { Ok(plaintext) => (StatusCode::OK, plaintext).into_response(), Err(error) => build_wechat_message_push_verify_error_response(error), @@ -1139,6 +1119,50 @@ fn build_wechat_message_push_verify_error_response(error: WechatPayError) -> Res (StatusCode::BAD_REQUEST, message).into_response() } +fn resolve_wechat_message_push_verify_response( + token: &str, + aes_key: &str, + expected_app_id: Option<&str>, + query: &WechatMiniProgramMessagePushQuery, +) -> Result { + let timestamp = query.timestamp.as_deref().map(str::trim).unwrap_or(""); + let nonce = query.nonce.as_deref().map(str::trim).unwrap_or(""); + let echostr = query.echostr.as_deref().map(str::trim).unwrap_or(""); + if timestamp.is_empty() || nonce.is_empty() || echostr.is_empty() { + return Err(WechatPayError::InvalidRequest( + "微信消息推送校验参数不完整".to_string(), + )); + } + let msg_signature = query + .msg_signature + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + if let Some(signature) = msg_signature { + if !verify_wechat_message_push_signature(token, timestamp, nonce, echostr, signature) { + return Err(WechatPayError::InvalidSignature( + "微信消息推送 msg_signature 无效".to_string(), + )); + } + return decrypt_wechat_message_push_ciphertext(aes_key, echostr, expected_app_id); + } + + let signature = query + .signature + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + WechatPayError::InvalidRequest("微信消息推送校验参数不完整".to_string()) + })?; + if !verify_wechat_message_push_signature(token, timestamp, nonce, "", signature) { + return Err(WechatPayError::InvalidSignature( + "微信消息推送校验签名无效".to_string(), + )); + } + Ok(echostr.to_string()) +} + fn parse_wechat_mini_program_message_push_payload( body: &[u8], ) -> Result { @@ -2217,6 +2241,31 @@ mod tests { )); } + #[test] + fn wechat_message_push_plain_get_verify_returns_echostr() { + let token = "AAAAA"; + let timestamp = "1714036504"; + let nonce = "1514711492"; + let echostr = "4375120948345356249"; + let signature = "f464b24fc39322e44b38aa78f5edd27bd1441696"; + + let plaintext = resolve_wechat_message_push_verify_response( + token, + "unused-aes-key", + Some("wx-test-app"), + &WechatMiniProgramMessagePushQuery { + signature: Some(signature.to_string()), + timestamp: Some(timestamp.to_string()), + nonce: Some(nonce.to_string()), + echostr: Some(echostr.to_string()), + msg_signature: None, + }, + ) + .expect("plain url verification should return echostr"); + + assert_eq!(plaintext, echostr); + } + #[test] fn wechat_message_push_decrypts_safe_mode_ciphertext() { let app_id = "wx-test-app";