Files
Genarrative/server-rs/crates/platform-wechat/src/pay.rs
T
kdletters 91c333ea4a 修复 Windows 微信支付测试的 OpenSSL 构建依赖
移除 platform-wechat 的 openssl 测试依赖

使用固定 RSA PEM fixture 保持签名与解析测试语义

记录 Windows MSVC 原生 OpenSSL 测试依赖陷阱
2026-08-17 11:34:15 +08:00

4710 lines
171 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::{
collections::BTreeMap,
fs,
io::Read,
path::{Path, PathBuf},
sync::Arc,
time::Duration as StdDuration,
};
use aes::Aes256;
use base64::{
Engine as _, alphabet,
engine::general_purpose::{GeneralPurpose, GeneralPurposeConfig, STANDARD as BASE64_STANDARD},
};
use cbc::cipher::{BlockDecryptMut, KeyIvInit, block_padding::NoPadding};
use flate2::read::GzDecoder;
use reqwest::header::HeaderMap;
use ring::{
aead, hmac,
rand::{SecureRandom, SystemRandom},
signature,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha1::Sha1;
use sha2::{Digest, Sha256};
use shared_contracts::runtime::{
WechatH5PaymentResponse, WechatMiniProgramPayParamsResponse, WechatNativePaymentResponse,
};
use std::convert::TryInto;
use time::{Duration as TimeDuration, OffsetDateTime, format_description::well_known::Rfc3339};
use tracing::warn;
use url::Url;
use x509_parser::{
prelude::FromDer,
public_key::{PublicKey, RSAPublicKey},
x509::SubjectPublicKeyInfo,
};
const WECHAT_PAY_PROVIDER_MOCK: &str = "mock";
const WECHAT_PAY_PROVIDER_REAL: &str = "real";
const WECHAT_PAY_BODY_SIGNATURE_METHOD: &str = "WECHATPAY2-SHA256-RSA2048";
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_HTTP_CONNECT_TIMEOUT: StdDuration = StdDuration::from_secs(5);
const WECHAT_PAY_HTTP_REQUEST_TIMEOUT: StdDuration = StdDuration::from_secs(30);
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;
const WECHAT_PAY_OUT_TRADE_NO_MAX_CHARS: usize = 32;
const WECHAT_PAY_OUT_REFUND_NO_MAX_CHARS: usize = 64;
const WECHAT_PAY_REFUND_REASON_MAX_CHARS: usize = 80;
const WECHAT_PAY_NOTIFY_URL_MAX_CHARS: usize = 255;
const WECHAT_PAY_OPENID_MAX_CHARS: usize = 128;
const WECHAT_PAY_CLIENT_IP_MAX_CHARS: usize = 45;
const WECHAT_PAY_JSAPI_PATH: &str = "/v3/pay/transactions/jsapi";
const WECHAT_PAY_H5_PATH: &str = "/v3/pay/transactions/h5";
const WECHAT_PAY_NATIVE_PATH: &str = "/v3/pay/transactions/native";
const WECHAT_PAY_REFUND_PATH: &str = "/v3/refund/domestic/refunds";
const WECHAT_PAY_TRADE_BILL_PATH: &str = "/v3/bill/tradebill";
const WECHAT_PAY_BILL_DOWNLOAD_PATH: &str = "/v3/billdownload/file";
const WECHAT_PAY_ORDER_EXPIRE_SECONDS: i64 = 5 * 60;
const WECHAT_PAY_NOTIFY_RESOURCE_TYPE: &str = "encrypt-resource";
const WECHAT_PAY_NOTIFY_RESOURCE_ALGORITHM: &str = "AEAD_AES_256_GCM";
const WECHAT_PAY_REFUND_RESOURCE_ORIGINAL_TYPE: &str = "refund";
const WECHAT_PAY_NOTIFY_TIMESTAMP_TOLERANCE_SECONDS: i64 = 5 * 60;
const WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY_BYTES: usize = 43;
const WECHAT_MINIPROGRAM_MESSAGE_AES_KEY_BYTES: usize = 32;
const WECHAT_MINIPROGRAM_MESSAGE_RANDOM_BYTES: usize = 16;
const WECHAT_MINIPROGRAM_MESSAGE_LENGTH_BYTES: usize = 4;
const WECHAT_MINIPROGRAM_MESSAGE_AES_KEY_BASE64: GeneralPurpose = GeneralPurpose::new(
&alphabet::STANDARD,
GeneralPurposeConfig::new().with_decode_allow_trailing_bits(true),
);
const WECHAT_VIRTUAL_PAYMENT_DEBUG_MAX_SCHEMA_FIELDS: usize = 128;
const WECHAT_VIRTUAL_PAYMENT_DEBUG_MAX_DEPTH: usize = 12;
pub const WECHAT_VIRTUAL_PAYMENT_NOTIFY_EVENTS: [&str; 9] = [
"xpay_goods_deliver_notify",
"xpay_coin_pay_notify",
"xpay_refund_notify",
"xpay_complaint_notify",
"xpay_wxpay_callback_notify",
"xpay_subscribe_signing_result_notify",
"xpay_subscribe_pay_fail_notify",
"xpay_apple_subscribe_signing_result_notify",
"xpay_subscribe_ios_refund_query_notify",
];
pub const WECHAT_PAY_REFUND_NOTIFY_EVENTS: [&str; 3] =
["REFUND.SUCCESS", "REFUND.ABNORMAL", "REFUND.CLOSED"];
#[derive(Clone, Debug)]
pub struct WechatPayConfig {
pub enabled: bool,
pub provider: String,
pub app_id: Option<String>,
pub mch_id: Option<String>,
pub merchant_serial_no: Option<String>,
pub private_key_pem: Option<String>,
pub private_key_path: Option<PathBuf>,
pub platform_public_key_pem: Option<String>,
pub platform_public_key_path: Option<PathBuf>,
pub platform_serial_no: Option<String>,
pub api_v3_key: Option<String>,
pub notify_url: Option<String>,
pub jsapi_endpoint: String,
}
#[derive(Clone, Debug)]
pub enum WechatPayClient {
Disabled,
Mock,
Real(Arc<RealWechatPayClient>),
}
#[derive(Clone, Debug)]
pub struct RealWechatPayClient {
client: reqwest::Client,
app_id: String,
mch_id: String,
merchant_serial_no: String,
private_key: Arc<signature::RsaKeyPair>,
platform_public_key_der: Vec<u8>,
platform_serial_no: String,
api_v3_key: String,
notify_url: String,
jsapi_endpoint: String,
h5_endpoint: String,
native_endpoint: String,
query_order_endpoint_base: String,
refund_endpoint: String,
trade_bill_endpoint: String,
api_origin: String,
}
#[derive(Clone, Debug)]
pub struct WechatMiniProgramOrderRequest {
pub order_id: String,
pub description: String,
pub amount_cents: u64,
pub payer_openid: String,
}
#[derive(Clone, Debug)]
pub struct WechatWebOrderRequest {
pub order_id: String,
pub description: String,
pub amount_cents: u64,
pub payer_client_ip: String,
}
#[derive(Clone, Debug)]
pub struct WechatPayNotifyOrder {
pub app_id: Option<String>,
pub mch_id: Option<String>,
pub out_trade_no: String,
pub transaction_id: Option<String>,
pub trade_state: String,
pub success_time: Option<String>,
pub amount_total_cents: Option<u64>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WechatPayRefundRequest {
pub transaction_id: String,
pub out_trade_no: String,
pub out_refund_no: String,
pub reason: Option<String>,
pub notify_url: String,
pub refund_amount_cents: u64,
pub total_amount_cents: u64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WechatPayRefund {
pub mch_id: Option<String>,
pub transaction_id: String,
pub out_trade_no: String,
pub refund_id: String,
pub out_refund_no: String,
pub status: String,
pub success_time: Option<String>,
pub create_time: Option<String>,
pub amount_total_cents: u64,
pub amount_refund_cents: u64,
pub amount_payer_total_cents: u64,
pub amount_payer_refund_cents: u64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WechatPayRefundNotification {
pub notification_id: String,
pub create_time: String,
pub event_type: String,
pub refund: WechatPayRefund,
pub debug: WechatPayRefundNotifyDebugSummary,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WechatPayTradeBillDownload {
pub hash_type: String,
pub hash_value: String,
pub download_url: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WechatPayTradeBillRefundRow {
pub transaction_id: String,
pub out_trade_no: String,
pub refund_id: String,
pub out_refund_no: String,
pub accepted_time: Option<String>,
pub success_time: Option<String>,
pub refund_type: String,
pub bill_refund_status: String,
pub requested_refund_cents: u64,
pub refunded_cents: u64,
pub coupon_refund_cents: u64,
pub app_id: Option<String>,
pub mch_id: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct WechatPayRefundNotifyDebugText {
pub bytes: usize,
pub hmac_ref: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct WechatPayRefundNotifyDebugSummary {
pub event_type: String,
pub known_event: bool,
pub resource_type: String,
pub original_type: String,
pub algorithm: String,
pub create_time: String,
pub refund_status: String,
pub success_time: Option<String>,
pub amount_total_cents: u64,
pub amount_refund_cents: u64,
pub amount_payer_total_cents: u64,
pub amount_payer_refund_cents: u64,
pub payload_bytes: usize,
pub payload_fingerprint: String,
pub notification_ref: String,
pub merchant_ref: String,
pub transaction_ref: String,
pub order_ref: String,
pub refund_ref: String,
pub merchant_refund_ref: String,
pub user_received_account: Option<WechatPayRefundNotifyDebugText>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WechatVirtualPaymentNotifyOrder {
pub out_trade_no: String,
pub transaction_id: Option<String>,
pub paid_at_micros: Option<i64>,
pub event: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct WechatVirtualPaymentNotifyDebugText {
pub bytes: usize,
pub hmac_ref: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct WechatVirtualPaymentNotifyDebugSummary {
#[serde(skip)]
raw_event: String,
pub event: String,
pub event_ref: Option<String>,
pub known_event: bool,
pub payload_bytes: usize,
pub payload_fingerprint: String,
pub schema_fields: Vec<String>,
pub identifier_refs: BTreeMap<String, Vec<String>>,
pub safe_fields: BTreeMap<String, Value>,
pub sensitive_text_fields: BTreeMap<String, WechatVirtualPaymentNotifyDebugText>,
pub apple_subscription_info: bool,
pub subscription_info: bool,
}
impl WechatVirtualPaymentNotifyDebugSummary {
pub fn raw_event(&self) -> &str {
self.raw_event.as_str()
}
pub fn primary_identifier_ref(&self, category: &str) -> Option<&str> {
self.identifier_refs
.get(category)
.and_then(|values| values.first())
.map(String::as_str)
}
}
#[derive(Debug)]
pub enum WechatPayError {
Disabled,
InvalidConfig(String),
InvalidRequest(String),
OrderNotExist(String),
RequestFailed(String),
Upstream(String),
Deserialize(String),
Crypto(String),
InvalidSignature(String),
}
#[derive(Serialize)]
struct WechatJsapiOrderRequest<'a> {
appid: &'a str,
mchid: &'a str,
description: &'a str,
out_trade_no: &'a str,
time_expire: &'a str,
notify_url: &'a str,
amount: WechatJsapiAmount,
payer: WechatJsapiPayer<'a>,
}
#[derive(Serialize)]
struct WechatJsapiAmount {
total: i64,
currency: &'static str,
}
#[derive(Serialize)]
struct WechatJsapiPayer<'a> {
openid: &'a str,
}
#[derive(Serialize)]
struct WechatH5OrderRequest<'a> {
appid: &'a str,
mchid: &'a str,
description: &'a str,
out_trade_no: &'a str,
time_expire: &'a str,
notify_url: &'a str,
amount: WechatJsapiAmount,
scene_info: WechatH5SceneInfo<'a>,
}
#[derive(Serialize)]
struct WechatH5SceneInfo<'a> {
payer_client_ip: &'a str,
h5_info: WechatH5Info,
}
#[derive(Serialize)]
struct WechatH5Info {
#[serde(rename = "type")]
kind: &'static str,
}
#[derive(Serialize)]
struct WechatNativeOrderRequest<'a> {
appid: &'a str,
mchid: &'a str,
description: &'a str,
out_trade_no: &'a str,
time_expire: &'a str,
notify_url: &'a str,
amount: WechatJsapiAmount,
scene_info: WechatNativeSceneInfo<'a>,
}
#[derive(Serialize)]
struct WechatNativeSceneInfo<'a> {
payer_client_ip: &'a str,
}
#[derive(Serialize)]
struct WechatCloseOrderRequest<'a> {
mchid: &'a str,
}
#[derive(Serialize)]
struct WechatCreateRefundRequest<'a> {
transaction_id: &'a str,
out_trade_no: &'a str,
out_refund_no: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
reason: Option<&'a str>,
notify_url: &'a str,
amount: WechatCreateRefundAmount,
}
#[derive(Serialize)]
struct WechatCreateRefundAmount {
refund: u64,
total: u64,
currency: &'static str,
}
#[derive(Deserialize)]
struct WechatJsapiOrderResponse {
prepay_id: Option<String>,
code: Option<String>,
message: Option<String>,
}
#[derive(Deserialize)]
struct WechatH5OrderResponse {
h5_url: Option<String>,
code: Option<String>,
message: Option<String>,
}
#[derive(Deserialize)]
struct WechatNativeOrderResponse {
code_url: Option<String>,
code: Option<String>,
message: Option<String>,
}
#[derive(Deserialize)]
struct WechatPayNotifyEnvelope {
id: String,
create_time: String,
resource_type: String,
event_type: String,
resource: WechatPayNotifyResource,
}
#[derive(Deserialize)]
struct WechatPayNotifyResource {
algorithm: String,
original_type: String,
ciphertext: String,
nonce: String,
#[serde(default)]
associated_data: Option<String>,
}
#[derive(Deserialize)]
struct WechatPayTransactionResource {
appid: String,
mchid: String,
out_trade_no: String,
#[serde(default)]
transaction_id: Option<String>,
trade_state: String,
#[serde(default)]
success_time: Option<String>,
amount: WechatPayTransactionAmount,
}
#[derive(Deserialize)]
struct WechatPayTransactionAmount {
total: u64,
}
struct WechatPayDecryptedNotify {
id: String,
create_time: String,
resource_type: String,
event_type: String,
algorithm: String,
original_type: String,
plain_text: Vec<u8>,
}
#[derive(Deserialize)]
struct WechatPayRefundResource {
mchid: String,
transaction_id: String,
out_trade_no: String,
refund_id: String,
out_refund_no: String,
refund_status: String,
#[serde(default)]
success_time: Option<String>,
#[serde(default)]
user_received_account: Option<String>,
amount: WechatPayRefundAmount,
}
#[derive(Deserialize)]
struct WechatPayRefundAmount {
total: u64,
refund: u64,
payer_total: u64,
payer_refund: u64,
}
#[derive(Deserialize)]
struct WechatPayQueryOrderResponse {
appid: String,
mchid: String,
out_trade_no: String,
#[serde(default)]
transaction_id: Option<String>,
trade_state: String,
#[serde(default)]
success_time: Option<String>,
amount: WechatPayTransactionAmount,
}
#[derive(Deserialize)]
struct WechatPayRefundResponse {
refund_id: String,
out_refund_no: String,
transaction_id: String,
out_trade_no: String,
status: String,
#[serde(default)]
success_time: Option<String>,
#[serde(default)]
create_time: Option<String>,
amount: WechatPayRefundResponseAmount,
}
#[derive(Deserialize)]
struct WechatPayRefundResponseAmount {
total: u64,
refund: u64,
payer_total: u64,
payer_refund: u64,
}
#[derive(Deserialize)]
struct WechatPayTradeBillResponse {
hash_type: String,
hash_value: String,
download_url: String,
}
#[derive(Deserialize)]
struct WechatPayErrorResponse {
#[serde(default)]
code: Option<String>,
#[serde(default)]
message: Option<String>,
}
#[derive(Deserialize)]
struct WechatVirtualPaymentNotifyBody {
#[serde(rename = "Event", alias = "event")]
event: String,
#[serde(rename = "OutTradeNo", alias = "out_trade_no", default)]
out_trade_no: Option<String>,
#[serde(rename = "MchOrderId", alias = "mch_order_id", default)]
mch_order_id: Option<String>,
#[serde(rename = "WeChatPayInfo", alias = "wechat_pay_info", default)]
wechat_pay_info: Option<WechatVirtualPaymentNotifyPayInfo>,
}
#[derive(Deserialize)]
struct WechatVirtualPaymentNotifyPayInfo {
#[serde(rename = "MchOrderNo", alias = "mch_order_no", default)]
mch_order_no: Option<String>,
#[serde(rename = "TransactionId", alias = "transaction_id", default)]
transaction_id: Option<String>,
#[serde(rename = "PaidTime", alias = "paid_time", default)]
paid_time: Option<i64>,
}
#[derive(Debug, Deserialize)]
pub struct WechatMiniProgramMessagePushQuery {
pub signature: Option<String>,
pub timestamp: Option<String>,
pub nonce: Option<String>,
pub echostr: Option<String>,
pub msg_signature: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct WechatMiniProgramEncryptedMessage {
#[serde(rename = "ToUserName", alias = "to_user_name", default)]
_to_user_name: Option<String>,
#[serde(rename = "Encrypt", alias = "encrypt")]
pub encrypt: String,
}
impl WechatPayClient {
pub fn from_config(config: &WechatPayConfig) -> Result<Self, WechatPayError> {
if !config.enabled {
return Ok(Self::Disabled);
}
if config
.provider
.trim()
.eq_ignore_ascii_case(WECHAT_PAY_PROVIDER_MOCK)
{
return Ok(Self::Mock);
}
if !config
.provider
.trim()
.eq_ignore_ascii_case(WECHAT_PAY_PROVIDER_REAL)
{
return Err(WechatPayError::InvalidConfig(
"WECHAT_PAY_PROVIDER 仅支持 mock 或 real".to_string(),
));
}
let app_id = config
.app_id
.as_ref()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.ok_or_else(|| WechatPayError::InvalidConfig("微信支付缺少小程序 AppID".to_string()))?
.to_string();
let mch_id = required_config(config.mch_id.as_deref(), "WECHAT_PAY_MCH_ID")?;
let merchant_serial_no = required_config(
config.merchant_serial_no.as_deref(),
"WECHAT_PAY_MERCHANT_SERIAL_NO",
)?;
let private_key_pem = read_private_key_pem(
config.private_key_pem.as_deref(),
config.private_key_path.as_deref(),
)?;
let private_key = Arc::new(parse_rsa_private_key(&private_key_pem)?);
let platform_public_key_pem = read_pem(
config.platform_public_key_pem.as_deref(),
config.platform_public_key_path.as_deref(),
"WECHAT_PAY_PLATFORM_PUBLIC_KEY_PEM 或 WECHAT_PAY_PLATFORM_PUBLIC_KEY_PATH 未配置",
"读取微信支付平台公钥失败",
)?;
let platform_public_key_der = parse_public_key_pem(&platform_public_key_pem)?;
let platform_serial_no = required_config(
config.platform_serial_no.as_deref(),
"WECHAT_PAY_PLATFORM_SERIAL_NO",
)?;
let api_v3_key = required_config(config.api_v3_key.as_deref(), "WECHAT_PAY_API_V3_KEY")?;
if api_v3_key.as_bytes().len() != 32 {
return Err(WechatPayError::InvalidConfig(
"WECHAT_PAY_API_V3_KEY 必须是 32 字节字符串".to_string(),
));
}
let notify_url = required_config(config.notify_url.as_deref(), "WECHAT_PAY_NOTIFY_URL")?;
validate_notify_url(&notify_url, "WECHAT_PAY_NOTIFY_URL")?;
let jsapi_endpoint =
normalize_required_url(&config.jsapi_endpoint, "WECHAT_PAY_JSAPI_ENDPOINT")?;
let h5_endpoint =
resolve_wechat_pay_transaction_endpoint(&jsapi_endpoint, WECHAT_PAY_H5_PATH)?;
let native_endpoint =
resolve_wechat_pay_transaction_endpoint(&jsapi_endpoint, WECHAT_PAY_NATIVE_PATH)?;
let query_order_endpoint_base = resolve_query_order_endpoint_base(&jsapi_endpoint)?;
let refund_endpoint =
resolve_wechat_pay_transaction_endpoint(&jsapi_endpoint, WECHAT_PAY_REFUND_PATH)?;
let trade_bill_endpoint =
resolve_wechat_pay_transaction_endpoint(&jsapi_endpoint, WECHAT_PAY_TRADE_BILL_PATH)?;
let api_origin = resolve_wechat_pay_api_origin(&jsapi_endpoint)?;
let client = reqwest::Client::builder()
.connect_timeout(WECHAT_PAY_HTTP_CONNECT_TIMEOUT)
.timeout(WECHAT_PAY_HTTP_REQUEST_TIMEOUT)
.build()
.map_err(|error| {
WechatPayError::InvalidConfig(format!("创建微信支付 HTTP client 失败:{error}"))
})?;
Ok(Self::Real(Arc::new(RealWechatPayClient {
client,
app_id,
mch_id,
merchant_serial_no,
private_key,
platform_public_key_der,
platform_serial_no,
api_v3_key,
notify_url,
jsapi_endpoint,
h5_endpoint,
native_endpoint,
query_order_endpoint_base,
refund_endpoint,
trade_bill_endpoint,
api_origin,
})))
}
pub async fn create_mini_program_order(
&self,
request: WechatMiniProgramOrderRequest,
) -> Result<WechatMiniProgramPayParamsResponse, WechatPayError> {
match self {
Self::Disabled => Err(WechatPayError::Disabled),
Self::Mock => Ok(build_mock_pay_params(&request.order_id)),
Self::Real(client) => client.create_mini_program_order(request).await,
}
}
pub async fn create_h5_order(
&self,
request: WechatWebOrderRequest,
) -> Result<WechatH5PaymentResponse, WechatPayError> {
match self {
Self::Disabled => Err(WechatPayError::Disabled),
Self::Mock => Ok(build_mock_h5_payment(&request.order_id)),
Self::Real(client) => client.create_h5_order(request).await,
}
}
pub async fn create_native_order(
&self,
request: WechatWebOrderRequest,
) -> Result<WechatNativePaymentResponse, WechatPayError> {
match self {
Self::Disabled => Err(WechatPayError::Disabled),
Self::Mock => Ok(build_mock_native_payment(&request.order_id)),
Self::Real(client) => client.create_native_order(request).await,
}
}
pub fn parse_notify(
&self,
headers: &HeaderMap,
body: &[u8],
) -> Result<WechatPayNotifyOrder, WechatPayError> {
match self {
Self::Disabled => Err(WechatPayError::Disabled),
Self::Mock => parse_mock_notify(body),
Self::Real(client) => client.parse_notify(headers, body),
}
}
pub fn parse_refund_notify_debug(
&self,
headers: &HeaderMap,
body: &[u8],
) -> Result<WechatPayRefundNotifyDebugSummary, WechatPayError> {
match self {
Self::Disabled => Err(WechatPayError::Disabled),
Self::Mock => Err(WechatPayError::InvalidConfig(
"微信支付 V3 退款通知诊断仅支持 real provider".to_string(),
)),
Self::Real(client) => client.parse_refund_notify_debug(headers, body),
}
}
pub fn parse_refund_notify(
&self,
headers: &HeaderMap,
body: &[u8],
) -> Result<WechatPayRefundNotification, WechatPayError> {
match self {
Self::Disabled => Err(WechatPayError::Disabled),
Self::Mock => Err(WechatPayError::InvalidConfig(
"微信支付 V3 退款通知仅支持 real provider".to_string(),
)),
Self::Real(client) => client.parse_refund_notify(headers, body),
}
}
pub fn refund_notify_request_ref(&self, body: &[u8]) -> Option<String> {
match self {
Self::Real(client) => Some(payment_debug_hmac_ref(
client.api_v3_key.as_bytes(),
"v3-refund-payload",
body,
)),
Self::Disabled | Self::Mock => None,
}
}
pub async fn query_order_by_out_trade_no(
&self,
order_id: &str,
) -> Result<WechatPayNotifyOrder, WechatPayError> {
match self {
Self::Disabled => Err(WechatPayError::Disabled),
Self::Mock => Ok(WechatPayNotifyOrder {
app_id: Some("wx-mock-app".to_string()),
mch_id: Some("1900000001".to_string()),
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()),
amount_total_cents: None,
}),
Self::Real(client) => client.query_order_by_out_trade_no(order_id).await,
}
}
pub async fn close_order_by_out_trade_no(&self, order_id: &str) -> Result<(), WechatPayError> {
match self {
Self::Disabled => Err(WechatPayError::Disabled),
Self::Mock => {
normalize_out_trade_no(order_id)?;
Ok(())
}
Self::Real(client) => client.close_order_by_out_trade_no(order_id).await,
}
}
pub fn supports_refund_reconciliation(&self) -> bool {
matches!(self, Self::Real(_))
}
pub async fn create_refund(
&self,
request: WechatPayRefundRequest,
) -> Result<WechatPayRefund, WechatPayError> {
match self {
Self::Disabled => Err(WechatPayError::Disabled),
Self::Mock => Ok(build_mock_refund(&request, "PROCESSING")),
Self::Real(client) => client.create_refund(request).await,
}
}
pub async fn query_refund_by_out_refund_no(
&self,
out_refund_no: &str,
) -> Result<WechatPayRefund, WechatPayError> {
match self {
Self::Disabled => Err(WechatPayError::Disabled),
Self::Mock => {
let out_refund_no = normalize_out_refund_no(out_refund_no)?;
Ok(WechatPayRefund {
mch_id: None,
transaction_id: format!("mock-transaction-{out_refund_no}"),
out_trade_no: format!("mock-order-{out_refund_no}"),
refund_id: format!("mock-refund-{out_refund_no}"),
out_refund_no,
status: "SUCCESS".to_string(),
success_time: Some(OffsetDateTime::now_utc().to_string()),
create_time: Some(OffsetDateTime::now_utc().to_string()),
amount_total_cents: 1,
amount_refund_cents: 1,
amount_payer_total_cents: 1,
amount_payer_refund_cents: 1,
})
}
Self::Real(client) => client.query_refund_by_out_refund_no(out_refund_no).await,
}
}
pub async fn request_refund_trade_bill(
&self,
bill_date: &str,
) -> Result<WechatPayTradeBillDownload, WechatPayError> {
match self {
Self::Disabled => Err(WechatPayError::Disabled),
Self::Mock => Err(WechatPayError::InvalidConfig(
"mock provider 不提供微信退款交易账单".to_string(),
)),
Self::Real(client) => client.request_refund_trade_bill(bill_date).await,
}
}
pub async fn download_refund_trade_bill(
&self,
download: &WechatPayTradeBillDownload,
) -> Result<Vec<WechatPayTradeBillRefundRow>, WechatPayError> {
match self {
Self::Disabled => Err(WechatPayError::Disabled),
Self::Mock => Err(WechatPayError::InvalidConfig(
"mock provider 不提供微信退款交易账单".to_string(),
)),
Self::Real(client) => client.download_refund_trade_bill(download).await,
}
}
}
impl RealWechatPayClient {
async fn create_mini_program_order(
&self,
request: WechatMiniProgramOrderRequest,
) -> Result<WechatMiniProgramPayParamsResponse, WechatPayError> {
validate_jsapi_order_request(self, &request)?;
let amount_total = i64::try_from(request.amount_cents)
.map_err(|_| WechatPayError::InvalidRequest("微信支付金额超出 i64 范围".to_string()))?;
let (_, expires_at_text) = build_wechat_pay_expire_time("wechat pay JSAPI time_expire")?;
let body = serde_json::to_string(&WechatJsapiOrderRequest {
appid: &self.app_id,
mchid: &self.mch_id,
description: &request.description,
out_trade_no: &request.order_id,
time_expire: &expires_at_text,
notify_url: &self.notify_url,
amount: WechatJsapiAmount {
total: amount_total,
currency: "CNY",
},
payer: WechatJsapiPayer {
openid: &request.payer_openid,
},
})
.map_err(|error| WechatPayError::Deserialize(format!("微信支付请求序列化失败:{error}")))?;
let timestamp = OffsetDateTime::now_utc().unix_timestamp().to_string();
let nonce = create_nonce()?;
let authorization =
self.build_authorization("POST", WECHAT_PAY_JSAPI_PATH, &timestamp, &nonce, &body)?;
let response = with_wechat_pay_jsapi_headers(
self.client
.post(&self.jsapi_endpoint)
.header("Authorization", authorization),
&self.platform_serial_no,
)
.body(body)
.send()
.await
.map_err(|error| {
WechatPayError::RequestFailed(format!("微信支付 JSAPI 下单请求失败:{error}"))
})?;
let status = response.status();
let response_text = response.text().await.map_err(|error| {
WechatPayError::Deserialize(format!("微信支付 JSAPI 下单响应读取失败:{error}"))
})?;
let payload =
serde_json::from_str::<WechatJsapiOrderResponse>(&response_text).map_err(|error| {
WechatPayError::Deserialize(format!("微信支付 JSAPI 下单响应解析失败:{error}"))
})?;
if !status.is_success() {
return Err(WechatPayError::Upstream(format!(
"微信支付 JSAPI 下单失败:{}",
payload
.message
.or(payload.code)
.unwrap_or_else(|| format!("HTTP {status}"))
)));
}
let prepay_id = payload
.prepay_id
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.ok_or_else(|| WechatPayError::Upstream("微信支付未返回 prepay_id".to_string()))?;
self.build_pay_params(&prepay_id)
}
async fn create_h5_order(
&self,
request: WechatWebOrderRequest,
) -> Result<WechatH5PaymentResponse, WechatPayError> {
validate_web_order_request(self, &request)?;
let amount_total = i64::try_from(request.amount_cents)
.map_err(|_| WechatPayError::InvalidRequest("微信支付金额超出 i64 范围".to_string()))?;
let (_, expires_at_text) = build_wechat_pay_expire_time("wechat pay H5 time_expire")?;
let body = serde_json::to_string(&WechatH5OrderRequest {
appid: &self.app_id,
mchid: &self.mch_id,
description: &request.description,
out_trade_no: &request.order_id,
time_expire: &expires_at_text,
notify_url: &self.notify_url,
amount: WechatJsapiAmount {
total: amount_total,
currency: "CNY",
},
scene_info: WechatH5SceneInfo {
payer_client_ip: &request.payer_client_ip,
h5_info: WechatH5Info { kind: "Wap" },
},
})
.map_err(|error| {
WechatPayError::Deserialize(format!("微信支付 H5 请求序列化失败:{error}"))
})?;
let response_text = self
.post_wechat_json(
&self.h5_endpoint,
WECHAT_PAY_H5_PATH,
body,
"微信支付 H5 下单请求失败",
)
.await?;
let payload =
serde_json::from_str::<WechatH5OrderResponse>(&response_text).map_err(|error| {
WechatPayError::Deserialize(format!("微信支付 H5 下单响应解析失败:{error}"))
})?;
let h5_url = payload
.h5_url
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WechatPayError::Upstream(
payload
.message
.or(payload.code)
.unwrap_or_else(|| "微信支付未返回 h5_url".to_string()),
)
})?;
Ok(WechatH5PaymentResponse { h5_url })
}
async fn create_native_order(
&self,
request: WechatWebOrderRequest,
) -> Result<WechatNativePaymentResponse, WechatPayError> {
validate_web_order_request(self, &request)?;
let amount_total = i64::try_from(request.amount_cents)
.map_err(|_| WechatPayError::InvalidRequest("微信支付金额超出 i64 范围".to_string()))?;
let (_, expires_at_text) = build_wechat_pay_expire_time("wechat pay Native time_expire")?;
let body = serde_json::to_string(&WechatNativeOrderRequest {
appid: &self.app_id,
mchid: &self.mch_id,
description: &request.description,
out_trade_no: &request.order_id,
time_expire: &expires_at_text,
notify_url: &self.notify_url,
amount: WechatJsapiAmount {
total: amount_total,
currency: "CNY",
},
scene_info: WechatNativeSceneInfo {
payer_client_ip: &request.payer_client_ip,
},
})
.map_err(|error| {
WechatPayError::Deserialize(format!("微信支付 Native 请求序列化失败:{error}"))
})?;
let response_text = self
.post_wechat_json(
&self.native_endpoint,
WECHAT_PAY_NATIVE_PATH,
body,
"微信支付 Native 下单请求失败",
)
.await?;
let payload =
serde_json::from_str::<WechatNativeOrderResponse>(&response_text).map_err(|error| {
WechatPayError::Deserialize(format!("微信支付 Native 下单响应解析失败:{error}"))
})?;
let code_url = payload
.code_url
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WechatPayError::Upstream(
payload
.message
.or(payload.code)
.unwrap_or_else(|| "微信支付未返回 code_url".to_string()),
)
})?;
Ok(WechatNativePaymentResponse {
code_url,
expires_at: expires_at_text,
})
}
async fn post_wechat_json(
&self,
endpoint: &str,
canonical_path: &str,
body: String,
request_error_prefix: &str,
) -> Result<String, WechatPayError> {
let timestamp = OffsetDateTime::now_utc().unix_timestamp().to_string();
let nonce = create_nonce()?;
let authorization =
self.build_authorization("POST", canonical_path, &timestamp, &nonce, &body)?;
let response = with_wechat_pay_json_headers(
self.client
.post(endpoint)
.header("Authorization", authorization),
&self.platform_serial_no,
)
.body(body)
.send()
.await
.map_err(|error| {
WechatPayError::RequestFailed(format!("{request_error_prefix}:{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}"
)));
}
Ok(response_text)
}
fn build_authorization(
&self,
method: &str,
canonical_url: &str,
timestamp: &str,
nonce: &str,
body: &str,
) -> Result<String, WechatPayError> {
let message = format!("{method}\n{canonical_url}\n{timestamp}\n{nonce}\n{body}\n");
let signature = self.sign_message(&message)?;
Ok(format!(
"{WECHAT_PAY_BODY_SIGNATURE_METHOD} mchid=\"{}\",nonce_str=\"{}\",timestamp=\"{}\",serial_no=\"{}\",signature=\"{}\"",
self.mch_id, nonce, timestamp, self.merchant_serial_no, signature
))
}
fn build_pay_params(
&self,
prepay_id: &str,
) -> Result<WechatMiniProgramPayParamsResponse, WechatPayError> {
let time_stamp = OffsetDateTime::now_utc().unix_timestamp().to_string();
let nonce_str = create_nonce()?;
let package = format!("prepay_id={prepay_id}");
let message = format!(
"{}\n{}\n{}\n{}\n",
self.app_id, time_stamp, nonce_str, package
);
let pay_sign = self.sign_message(&message)?;
Ok(WechatMiniProgramPayParamsResponse {
app_id: Some(self.app_id.clone()),
time_stamp,
nonce_str,
package,
sign_type: WECHAT_PAY_PAY_SIGN_TYPE.to_string(),
pay_sign,
})
}
fn parse_notify(
&self,
headers: &HeaderMap,
body: &[u8],
) -> Result<WechatPayNotifyOrder, WechatPayError> {
let notify = self.decrypt_notify_resource(headers, body)?;
if notify.event_type != "TRANSACTION.SUCCESS" || notify.original_type != "transaction" {
return Err(WechatPayError::InvalidRequest(
"微信支付成功通知事件或资源类型无效".to_string(),
));
}
let transaction =
serde_json::from_slice::<WechatPayTransactionResource>(&notify.plain_text).map_err(
|error| WechatPayError::Deserialize(format!("微信支付通知资源解析失败:{error}")),
)?;
if transaction.appid.trim() != self.app_id || transaction.mchid.trim() != self.mch_id {
return Err(WechatPayError::InvalidRequest(
"微信支付成功通知 AppID 或商户号不匹配".to_string(),
));
}
if transaction.trade_state.trim() != "SUCCESS" {
return Err(WechatPayError::InvalidRequest(
"微信支付成功通知事件与交易状态不一致".to_string(),
));
}
validate_out_trade_no(transaction.out_trade_no.trim())?;
if transaction.amount.total == 0 {
return Err(WechatPayError::InvalidRequest(
"微信支付成功通知金额无效".to_string(),
));
}
let transaction_id = transaction
.transaction_id
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WechatPayError::InvalidRequest("微信支付成功通知缺少微信支付订单号".to_string())
})?;
if transaction
.success_time
.as_deref()
.map(str::trim)
.unwrap_or_default()
.is_empty()
{
return Err(WechatPayError::InvalidRequest(
"微信支付成功通知缺少支付成功时间".to_string(),
));
}
Ok(WechatPayNotifyOrder {
app_id: Some(refund_identifier(&transaction.appid)),
mch_id: Some(refund_identifier(&transaction.mchid)),
out_trade_no: transaction.out_trade_no,
transaction_id: Some(transaction_id),
trade_state: transaction.trade_state,
success_time: transaction.success_time,
amount_total_cents: Some(transaction.amount.total),
})
}
fn parse_refund_notify_debug(
&self,
headers: &HeaderMap,
body: &[u8],
) -> Result<WechatPayRefundNotifyDebugSummary, WechatPayError> {
Ok(self.parse_refund_notify(headers, body)?.debug)
}
fn parse_refund_notify(
&self,
headers: &HeaderMap,
body: &[u8],
) -> Result<WechatPayRefundNotification, WechatPayError> {
let notify = self.decrypt_notify_resource(headers, body)?;
if notify.original_type != WECHAT_PAY_REFUND_RESOURCE_ORIGINAL_TYPE {
return Err(WechatPayError::InvalidRequest(
"微信支付 V3 退款通知 resource.original_type 无效".to_string(),
));
}
let refund = serde_json::from_slice::<WechatPayRefundResource>(&notify.plain_text)
.map_err(|error| {
WechatPayError::Deserialize(format!(
"微信支付 V3 退款通知解密资源解析失败:{error}"
))
})?;
if !WECHAT_PAY_REFUND_NOTIFY_EVENTS.contains(&notify.event_type.as_str()) {
return Err(WechatPayError::InvalidRequest(
"微信支付 V3 退款通知 event_type 无效".to_string(),
));
}
let expected_refund_status = notify
.event_type
.strip_prefix("REFUND.")
.unwrap_or_default();
if refund.refund_status.trim() != expected_refund_status {
return Err(WechatPayError::InvalidRequest(
"微信支付 V3 退款通知事件与退款状态不一致".to_string(),
));
}
if refund.mchid.trim() != self.mch_id {
return Err(WechatPayError::InvalidRequest(
"微信支付 V3 退款通知商户号不匹配".to_string(),
));
}
validate_refund_notify_identifier(&notify.id, "通知 ID")?;
validate_refund_notify_identifier(&refund.mchid, "商户号")?;
validate_refund_notify_identifier(&refund.transaction_id, "微信支付订单号")?;
validate_refund_notify_identifier(&refund.out_trade_no, "商户订单号")?;
validate_refund_notify_identifier(&refund.refund_id, "微信退款单号")?;
validate_refund_notify_identifier(&refund.out_refund_no, "商户退款单号")?;
validate_refund_notify_identifier(&refund.refund_status, "退款状态")?;
validate_wechat_refund_amounts(
refund.amount.total,
refund.amount.refund,
refund.amount.payer_total,
refund.amount.payer_refund,
"微信支付 V3 退款通知",
)?;
let notification_id = refund_identifier(&notify.id);
let mch_id = refund_identifier(&refund.mchid);
let transaction_id = refund_identifier(&refund.transaction_id);
let out_trade_no = refund_identifier(&refund.out_trade_no);
let refund_id = refund_identifier(&refund.refund_id);
let out_refund_no = refund_identifier(&refund.out_refund_no);
let refund_status = refund_identifier(&refund.refund_status);
let success_time = normalize_optional_refund_text(refund.success_time);
let user_received_account = normalize_optional_refund_text(refund.user_received_account);
let reference_key = self.api_v3_key.as_bytes();
let debug = WechatPayRefundNotifyDebugSummary {
known_event: true,
event_type: notify.event_type.clone(),
resource_type: notify.resource_type.clone(),
original_type: notify.original_type.clone(),
algorithm: notify.algorithm.clone(),
create_time: notify.create_time.clone(),
refund_status: refund_status.clone(),
success_time: success_time.clone(),
amount_total_cents: refund.amount.total,
amount_refund_cents: refund.amount.refund,
amount_payer_total_cents: refund.amount.payer_total,
amount_payer_refund_cents: refund.amount.payer_refund,
payload_bytes: body.len(),
payload_fingerprint: payment_debug_hmac_ref(reference_key, "v3-refund-payload", body),
notification_ref: payment_debug_hmac_ref(
reference_key,
"v3-refund-notification-id",
notification_id.as_bytes(),
),
merchant_ref: payment_debug_hmac_ref(
reference_key,
"v3-refund-mchid",
mch_id.as_bytes(),
),
transaction_ref: payment_debug_hmac_ref(
reference_key,
"v3-refund-transaction-id",
transaction_id.as_bytes(),
),
order_ref: payment_debug_hmac_ref(
reference_key,
"v3-refund-out-trade-no",
out_trade_no.as_bytes(),
),
refund_ref: payment_debug_hmac_ref(
reference_key,
"v3-refund-refund-id",
refund_id.as_bytes(),
),
merchant_refund_ref: payment_debug_hmac_ref(
reference_key,
"v3-refund-out-refund-no",
out_refund_no.as_bytes(),
),
user_received_account: user_received_account.clone().map(|value| {
WechatPayRefundNotifyDebugText {
bytes: value.len(),
hmac_ref: payment_debug_hmac_ref(
reference_key,
"v3-refund-user-received-account",
value.as_bytes(),
),
}
}),
};
Ok(WechatPayRefundNotification {
notification_id,
create_time: notify.create_time,
event_type: notify.event_type,
refund: WechatPayRefund {
mch_id: Some(mch_id),
transaction_id,
out_trade_no,
refund_id,
out_refund_no,
status: refund_status,
success_time,
create_time: None,
amount_total_cents: refund.amount.total,
amount_refund_cents: refund.amount.refund,
amount_payer_total_cents: refund.amount.payer_total,
amount_payer_refund_cents: refund.amount.payer_refund,
},
debug,
})
}
fn decrypt_notify_resource(
&self,
headers: &HeaderMap,
body: &[u8],
) -> Result<WechatPayDecryptedNotify, WechatPayError> {
self.verify_notify_signature(headers, body)?;
let notify = serde_json::from_slice::<WechatPayNotifyEnvelope>(body).map_err(|error| {
WechatPayError::Deserialize(format!("微信支付通知解析失败:{error}"))
})?;
if notify.resource_type != WECHAT_PAY_NOTIFY_RESOURCE_TYPE {
return Err(WechatPayError::InvalidRequest(
"微信支付通知 resource_type 无效".to_string(),
));
}
if notify.resource.algorithm != WECHAT_PAY_NOTIFY_RESOURCE_ALGORITHM {
return Err(WechatPayError::InvalidRequest(
"微信支付通知 resource.algorithm 无效".to_string(),
));
}
let plain_text = decrypt_aes_256_gcm(
self.api_v3_key.as_bytes(),
notify.resource.nonce.as_bytes(),
notify
.resource
.associated_data
.as_deref()
.unwrap_or("")
.as_bytes(),
notify.resource.ciphertext.as_str(),
)?;
Ok(WechatPayDecryptedNotify {
id: notify.id,
create_time: notify.create_time,
resource_type: notify.resource_type,
event_type: notify.event_type,
algorithm: notify.resource.algorithm,
original_type: notify.resource.original_type,
plain_text,
})
}
async fn query_order_by_out_trade_no(
&self,
order_id: &str,
) -> Result<WechatPayNotifyOrder, WechatPayError> {
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, &timestamp, &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 headers = response.headers().clone();
let response_body = response.bytes().await.map_err(|error| {
WechatPayError::Deserialize(format!("微信支付查单响应读取失败:{error}"))
})?;
if !status.is_success() {
if let Ok(payload) = serde_json::from_slice::<WechatPayErrorResponse>(&response_body)
&& payload.code.as_deref() == Some("ORDER_NOT_EXIST")
{
return Err(WechatPayError::OrderNotExist(
payload
.message
.filter(|message| !message.trim().is_empty())
.unwrap_or_else(|| "微信支付订单不存在".to_string()),
));
}
let message = parse_wechat_error_message(&response_body)
.unwrap_or_else(|| format!("HTTP {status}"));
return Err(WechatPayError::Upstream(format!(
"微信支付查单失败:{message}"
)));
}
self.verify_response_signature(&headers, &response_body)?;
let payload = serde_json::from_slice::<WechatPayQueryOrderResponse>(&response_body)
.map_err(|error| {
WechatPayError::Deserialize(format!("微信支付查单响应解析失败:{error}"))
})?;
if payload.appid.trim() != self.app_id || payload.mchid.trim() != self.mch_id {
return Err(WechatPayError::InvalidRequest(
"微信支付查单响应 AppID 或商户号不匹配".to_string(),
));
}
if payload.out_trade_no.trim() != order_id || payload.amount.total == 0 {
return Err(WechatPayError::InvalidRequest(
"微信支付查单响应订单号或金额无效".to_string(),
));
}
Ok(WechatPayNotifyOrder {
app_id: Some(refund_identifier(&payload.appid)),
mch_id: Some(refund_identifier(&payload.mchid)),
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,
amount_total_cents: Some(payload.amount.total),
})
}
async fn close_order_by_out_trade_no(&self, order_id: &str) -> Result<(), WechatPayError> {
let order_id = normalize_out_trade_no(order_id)?;
let encoded_order_id = urlencoding::encode(&order_id);
let path = format!("/v3/pay/transactions/out-trade-no/{encoded_order_id}/close");
let request_url = format!(
"{}/{encoded_order_id}/close",
self.query_order_endpoint_base.trim_end_matches('/')
);
let body = serde_json::to_string(&WechatCloseOrderRequest {
mchid: &self.mch_id,
})
.map_err(|error| {
WechatPayError::Deserialize(format!(
"wechat pay close request serialize failed: {error}"
))
})?;
self.post_wechat_json(&request_url, &path, body, "wechat pay close request failed")
.await
.map(|_| ())
}
async fn create_refund(
&self,
request: WechatPayRefundRequest,
) -> Result<WechatPayRefund, WechatPayError> {
let request = validate_refund_request(request)?;
let body = serde_json::to_string(&WechatCreateRefundRequest {
transaction_id: &request.transaction_id,
out_trade_no: &request.out_trade_no,
out_refund_no: &request.out_refund_no,
reason: request.reason.as_deref(),
notify_url: &request.notify_url,
amount: WechatCreateRefundAmount {
refund: request.refund_amount_cents,
total: request.total_amount_cents,
currency: "CNY",
},
})
.map_err(|error| {
WechatPayError::Deserialize(format!("微信支付退款申请序列化失败:{error}"))
})?;
let response = self
.send_signed_json_request(
reqwest::Method::POST,
&self.refund_endpoint,
WECHAT_PAY_REFUND_PATH,
body,
"微信支付退款申请",
)
.await?;
let refund = parse_refund_response(&response, "微信支付退款申请响应")?;
validate_created_refund_response(&refund, &request)?;
Ok(refund)
}
async fn query_refund_by_out_refund_no(
&self,
out_refund_no: &str,
) -> Result<WechatPayRefund, WechatPayError> {
let out_refund_no = normalize_out_refund_no(out_refund_no)?;
let encoded = urlencoding::encode(&out_refund_no);
let canonical_path = format!("{WECHAT_PAY_REFUND_PATH}/{encoded}");
let endpoint = format!("{}/{encoded}", self.refund_endpoint.trim_end_matches('/'));
let response = self
.send_signed_json_request(
reqwest::Method::GET,
&endpoint,
&canonical_path,
String::new(),
"微信支付退款查询",
)
.await?;
let refund = parse_refund_response(&response, "微信支付退款查询响应")?;
validate_queried_refund_response(&refund, &out_refund_no)?;
Ok(refund)
}
async fn request_refund_trade_bill(
&self,
bill_date: &str,
) -> Result<WechatPayTradeBillDownload, WechatPayError> {
let bill_date = normalize_bill_date(bill_date)?;
let query = format!("bill_date={bill_date}&bill_type=REFUND&tar_type=GZIP");
let canonical_path = format!("{WECHAT_PAY_TRADE_BILL_PATH}?{query}");
let endpoint = format!("{}?{query}", self.trade_bill_endpoint);
let response = self
.send_signed_json_request(
reqwest::Method::GET,
&endpoint,
&canonical_path,
String::new(),
"微信支付退款交易账单申请",
)
.await?;
let payload =
serde_json::from_slice::<WechatPayTradeBillResponse>(&response).map_err(|error| {
WechatPayError::Deserialize(format!(
"微信支付退款交易账单申请响应解析失败:{error}"
))
})?;
validate_trade_bill_download(payload)
}
async fn download_refund_trade_bill(
&self,
download: &WechatPayTradeBillDownload,
) -> Result<Vec<WechatPayTradeBillRefundRow>, WechatPayError> {
validate_trade_bill_download_contract(download)?;
let url = Url::parse(&download.download_url)
.map_err(|_| WechatPayError::InvalidRequest("微信支付账单下载地址无效".to_string()))?;
if url.origin().ascii_serialization().trim_end_matches('/') != self.api_origin
|| url.path() != WECHAT_PAY_BILL_DOWNLOAD_PATH
|| url.query().is_none()
{
return Err(WechatPayError::InvalidRequest(
"微信支付账单下载地址不属于当前官方 API 源".to_string(),
));
}
let canonical_path = match url.query() {
Some(query) => format!("{}?{query}", url.path()),
None => url.path().to_string(),
};
let timestamp = OffsetDateTime::now_utc().unix_timestamp().to_string();
let nonce = create_nonce()?;
let authorization =
self.build_authorization("GET", &canonical_path, &timestamp, &nonce, "")?;
let response = with_wechat_pay_json_headers(
self.client.get(url).header("Authorization", authorization),
&self.platform_serial_no,
)
.send()
.await
.map_err(|error| {
WechatPayError::RequestFailed(format!("微信支付退款交易账单下载失败:{error}"))
})?;
let status = response.status();
let compressed = response.bytes().await.map_err(|error| {
WechatPayError::Deserialize(format!("微信支付退款交易账单读取失败:{error}"))
})?;
if !status.is_success() {
return Err(WechatPayError::Upstream(format!(
"微信支付退款交易账单下载失败:HTTP {status}"
)));
}
let mut csv_bytes = Vec::new();
GzDecoder::new(compressed.as_ref())
.read_to_end(&mut csv_bytes)
.map_err(|error| {
WechatPayError::Deserialize(format!("微信支付退款交易账单 GZIP 解压失败:{error}"))
})?;
verify_trade_bill_hash(download, &csv_bytes)?;
let rows = parse_refund_trade_bill_csv(&csv_bytes)?;
for row in &rows {
if row
.mch_id
.as_deref()
.is_some_and(|value| value != self.mch_id)
|| row
.app_id
.as_deref()
.is_some_and(|value| value != self.app_id)
{
return Err(WechatPayError::InvalidRequest(
"微信支付退款交易账单 AppID 或商户号不匹配".to_string(),
));
}
}
Ok(rows)
}
async fn send_signed_json_request(
&self,
method: reqwest::Method,
endpoint: &str,
canonical_path: &str,
body: String,
operation: &str,
) -> Result<Vec<u8>, WechatPayError> {
let timestamp = OffsetDateTime::now_utc().unix_timestamp().to_string();
let nonce = create_nonce()?;
let authorization =
self.build_authorization(method.as_str(), canonical_path, &timestamp, &nonce, &body)?;
let mut builder = self
.client
.request(method, endpoint)
.header("Authorization", authorization);
if !body.is_empty() {
builder = builder.body(body);
}
let response = with_wechat_pay_json_headers(builder, &self.platform_serial_no)
.send()
.await
.map_err(|error| {
WechatPayError::RequestFailed(format!("{operation}请求失败:{error}"))
})?;
let status = response.status();
let headers = response.headers().clone();
let response_body = response.bytes().await.map_err(|error| {
WechatPayError::Deserialize(format!("{operation}响应读取失败:{error}"))
})?;
if !status.is_success() {
return Err(map_signed_json_error_response(
operation,
status,
&response_body,
));
}
self.verify_response_signature(&headers, &response_body)?;
Ok(response_body.to_vec())
}
fn verify_response_signature(
&self,
headers: &HeaderMap,
body: &[u8],
) -> Result<(), WechatPayError> {
let timestamp = read_required_header(headers, "Wechatpay-Timestamp")?;
let nonce = read_required_header(headers, "Wechatpay-Nonce")?;
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(
"微信支付响应平台公钥序列号不匹配".to_string(),
));
}
if signature.starts_with(WECHAT_PAY_SIGNATURE_TEST_PREFIX) {
return Err(WechatPayError::InvalidSignature(
"微信支付响应签名探测值无效".to_string(),
));
}
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())
})?;
verify_rsa_sha256_signature(&self.platform_public_key_der, &message, &signature_bytes)
.map_err(|_| WechatPayError::InvalidSignature("微信支付响应签名验签失败".to_string()))
}
fn verify_notify_signature(
&self,
headers: &HeaderMap,
body: &[u8],
) -> Result<(), WechatPayError> {
self.verify_notify_signature_at(headers, body, OffsetDateTime::now_utc().unix_timestamp())
}
fn verify_notify_signature_at(
&self,
headers: &HeaderMap,
body: &[u8],
now_unix_seconds: i64,
) -> Result<(), WechatPayError> {
let timestamp = read_required_header(headers, "Wechatpay-Timestamp")?;
let timestamp_seconds = timestamp.parse::<i64>().map_err(|_| {
WechatPayError::InvalidSignature("微信支付通知时间戳格式无效".to_string())
})?;
if now_unix_seconds.abs_diff(timestamp_seconds)
> WECHAT_PAY_NOTIFY_TIMESTAMP_TOLERANCE_SECONDS as u64
{
return Err(WechatPayError::InvalidSignature(
"微信支付通知时间戳超出允许窗口".to_string(),
));
}
let nonce = read_required_header(headers, "Wechatpay-Nonce")?;
let signature = read_required_header(headers, "Wechatpay-Signature")?;
let serial = read_required_header(headers, "Wechatpay-Serial")?;
if serial != self.platform_serial_no {
warn!("微信支付通知平台公钥序列号不匹配");
return Err(WechatPayError::InvalidSignature(
"微信支付通知平台公钥序列号不匹配".to_string(),
));
}
if signature.starts_with(WECHAT_PAY_SIGNATURE_TEST_PREFIX) {
warn!("收到微信支付签名探测通知");
return Err(WechatPayError::InvalidSignature(
"微信支付签名探测通知".to_string(),
));
}
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())
})?;
verify_rsa_sha256_signature(&self.platform_public_key_der, &message, &signature_bytes)
}
fn sign_message(&self, message: &str) -> Result<String, WechatPayError> {
let rng = SystemRandom::new();
let mut signature = vec![0_u8; self.private_key.public().modulus_len()];
self.private_key
.sign(
&signature::RSA_PKCS1_SHA256,
&rng,
message.as_bytes(),
&mut signature,
)
.map_err(|_| WechatPayError::Crypto("微信支付签名失败".to_string()))?;
Ok(BASE64_STANDARD.encode(signature))
}
}
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(
reqwest::header::CONTENT_TYPE,
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 {
let time_stamp = OffsetDateTime::now_utc().unix_timestamp().to_string();
let nonce_str = "mock-nonce".to_string();
let package = format!("prepay_id=mock-{order_id}");
let pay_sign = hex_sha256(format!("{time_stamp}\n{nonce_str}\n{package}\n").as_bytes());
WechatMiniProgramPayParamsResponse {
app_id: Some("wx-mock-app".to_string()),
time_stamp,
nonce_str,
package,
sign_type: WECHAT_PAY_PAY_SIGN_TYPE.to_string(),
pay_sign,
}
}
fn build_mock_h5_payment(order_id: &str) -> WechatH5PaymentResponse {
WechatH5PaymentResponse {
h5_url: format!(
"https://mock.wechat-pay.local/h5?out_trade_no={}",
urlencoding::encode(order_id)
),
}
}
fn build_wechat_pay_expire_time(context: &str) -> Result<(OffsetDateTime, String), WechatPayError> {
let expires_at =
OffsetDateTime::now_utc() + TimeDuration::seconds(WECHAT_PAY_ORDER_EXPIRE_SECONDS);
let expires_at_text = format_wechat_pay_rfc3339_seconds(expires_at, context)?;
Ok((expires_at, expires_at_text))
}
fn format_wechat_pay_rfc3339_seconds(
value: OffsetDateTime,
context: &str,
) -> Result<String, WechatPayError> {
let value = value.replace_nanosecond(0).map_err(|error| {
WechatPayError::InvalidRequest(format!("{context} 秒级时间规整失败:{error}"))
})?;
value
.format(&Rfc3339)
.map_err(|error| WechatPayError::InvalidRequest(format!("{context} 格式化失败:{error}")))
}
fn build_mock_native_payment(order_id: &str) -> WechatNativePaymentResponse {
let expires_at =
OffsetDateTime::now_utc() + TimeDuration::seconds(WECHAT_PAY_ORDER_EXPIRE_SECONDS);
WechatNativePaymentResponse {
code_url: format!(
"weixin://pay.weixin.qq.com/bizpayurl/up?pr=mock-{}",
hex_sha256(order_id.as_bytes())
),
expires_at: format_wechat_pay_rfc3339_seconds(expires_at, "mock 微信支付 Native 过期时间")
.unwrap_or_else(|_| expires_at.to_string()),
}
}
fn build_mock_refund(request: &WechatPayRefundRequest, status: &str) -> WechatPayRefund {
WechatPayRefund {
mch_id: None,
transaction_id: request.transaction_id.clone(),
out_trade_no: request.out_trade_no.clone(),
refund_id: format!("mock-{}", request.out_refund_no),
out_refund_no: request.out_refund_no.clone(),
status: status.to_string(),
success_time: (status == "SUCCESS").then(|| OffsetDateTime::now_utc().to_string()),
create_time: Some(OffsetDateTime::now_utc().to_string()),
amount_total_cents: request.total_amount_cents,
amount_refund_cents: request.refund_amount_cents,
amount_payer_total_cents: request.total_amount_cents,
amount_payer_refund_cents: request.refund_amount_cents,
}
}
fn parse_mock_notify(body: &[u8]) -> Result<WechatPayNotifyOrder, WechatPayError> {
let value = serde_json::from_slice::<Value>(body).map_err(|error| {
WechatPayError::Deserialize(format!("mock 微信支付通知解析失败:{error}"))
})?;
Ok(WechatPayNotifyOrder {
app_id: value
.get("appId")
.or_else(|| value.get("appid"))
.and_then(Value::as_str)
.map(ToOwned::to_owned),
mch_id: value
.get("mchId")
.or_else(|| value.get("mchid"))
.and_then(Value::as_str)
.map(ToOwned::to_owned),
out_trade_no: value
.get("outTradeNo")
.or_else(|| value.get("out_trade_no"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WechatPayError::InvalidRequest("mock 微信支付通知缺少 outTradeNo".to_string())
})?
.to_string(),
transaction_id: value
.get("transactionId")
.or_else(|| value.get("transaction_id"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
trade_state: value
.get("tradeState")
.or_else(|| value.get("trade_state"))
.and_then(Value::as_str)
.unwrap_or("SUCCESS")
.to_string(),
success_time: value
.get("successTime")
.or_else(|| value.get("success_time"))
.and_then(Value::as_str)
.map(ToOwned::to_owned),
amount_total_cents: value
.get("amountTotalCents")
.or_else(|| value.get("amount_total_cents"))
.and_then(Value::as_u64),
})
}
pub fn resolve_wechat_message_push_verify_response(
token: &str,
aes_key: &str,
expected_app_id: Option<&str>,
query: &WechatMiniProgramMessagePushQuery,
) -> Result<String, WechatPayError> {
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())
}
pub fn parse_wechat_mini_program_message_push_payload(
body: &[u8],
) -> Result<WechatMiniProgramEncryptedMessage, WechatPayError> {
serde_json::from_slice(body).map_err(|error| {
WechatPayError::Deserialize(format!("微信消息推送 JSON 解析失败:{error}"))
})
}
pub fn verify_wechat_message_push_signature(
token: &str,
timestamp: &str,
nonce: &str,
value: &str,
signature: &str,
) -> bool {
let mut parts = [token, timestamp, nonce, value];
parts.sort_unstable();
let mut hasher = Sha1::new();
hasher.update(parts.join("").as_bytes());
let expected = hex::encode(hasher.finalize());
expected.eq_ignore_ascii_case(signature)
}
pub fn decrypt_wechat_message_push_ciphertext(
encoding_aes_key: &str,
ciphertext: &str,
expected_app_id: Option<&str>,
) -> Result<String, WechatPayError> {
let key = decode_wechat_message_push_encoding_aes_key(encoding_aes_key)?;
let ciphertext = BASE64_STANDARD
.decode(ciphertext.as_bytes())
.map_err(|error| {
WechatPayError::Crypto(format!("微信消息推送密文 Base64 解码失败:{error}"))
})?;
let iv = &key[..WECHAT_MINIPROGRAM_MESSAGE_RANDOM_BYTES];
let cipher = cbc::Decryptor::<Aes256>::new_from_slices(&key, iv)
.map_err(|error| WechatPayError::Crypto(format!("微信消息推送 AES 初始化失败:{error}")))?;
let decrypted = cipher
.decrypt_padded_vec_mut::<NoPadding>(&ciphertext)
.map_err(|error| WechatPayError::Crypto(format!("微信消息推送密文解密失败:{error}")))?;
let plaintext = remove_wechat_message_push_pkcs7_padding(&decrypted)?;
let payload = parse_wechat_message_push_plaintext(&plaintext)?;
if let Some(app_id) = expected_app_id
.map(str::trim)
.filter(|value| !value.is_empty())
&& payload.app_id != app_id
{
return Err(WechatPayError::InvalidSignature(
"微信消息推送明文 appid 校验失败".to_string(),
));
}
Ok(payload.message)
}
fn decode_wechat_message_push_encoding_aes_key(
encoding_aes_key: &str,
) -> Result<Vec<u8>, WechatPayError> {
if encoding_aes_key.chars().count() != WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY_BYTES {
return Err(WechatPayError::InvalidConfig(format!(
"WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY 必须是 {WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY_BYTES} 位"
)));
}
let padded_key = format!("{encoding_aes_key}=");
let key = WECHAT_MINIPROGRAM_MESSAGE_AES_KEY_BASE64
.decode(padded_key.as_bytes())
.map_err(|error| {
WechatPayError::InvalidConfig(format!(
"WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY Base64 解析失败:{error}"
))
})?;
if key.len() != WECHAT_MINIPROGRAM_MESSAGE_AES_KEY_BYTES {
return Err(WechatPayError::InvalidConfig(
"WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY 解码后长度必须为 32 字节".to_string(),
));
}
Ok(key)
}
fn remove_wechat_message_push_pkcs7_padding(plaintext: &[u8]) -> Result<Vec<u8>, WechatPayError> {
let Some(&pad_len) = plaintext.last() else {
return Err(WechatPayError::Deserialize(
"微信消息推送明文为空".to_string(),
));
};
let pad_len = pad_len as usize;
if pad_len == 0 || pad_len > 32 || pad_len > plaintext.len() {
return Err(WechatPayError::Deserialize(
"微信消息推送 PKCS7 填充无效".to_string(),
));
}
if plaintext[plaintext.len() - pad_len..]
.iter()
.any(|byte| *byte as usize != pad_len)
{
return Err(WechatPayError::Deserialize(
"微信消息推送 PKCS7 填充校验失败".to_string(),
));
}
Ok(plaintext[..plaintext.len() - pad_len].to_vec())
}
struct WechatMessagePushPlaintext {
message: String,
app_id: String,
}
fn parse_wechat_message_push_plaintext(
plaintext: &[u8],
) -> Result<WechatMessagePushPlaintext, WechatPayError> {
if plaintext.len()
< WECHAT_MINIPROGRAM_MESSAGE_LENGTH_BYTES + WECHAT_MINIPROGRAM_MESSAGE_RANDOM_BYTES + 1
{
return Err(WechatPayError::Deserialize(
"微信消息推送明文长度不足".to_string(),
));
}
let len_offset = WECHAT_MINIPROGRAM_MESSAGE_RANDOM_BYTES;
let length_bytes: [u8; WECHAT_MINIPROGRAM_MESSAGE_LENGTH_BYTES] = plaintext
[len_offset..len_offset + WECHAT_MINIPROGRAM_MESSAGE_LENGTH_BYTES]
.try_into()
.map_err(|_| WechatPayError::Deserialize("微信消息推送长度字段解析失败".to_string()))?;
let message_len = u32::from_be_bytes(length_bytes) as usize;
let message_start = len_offset + WECHAT_MINIPROGRAM_MESSAGE_LENGTH_BYTES;
let message_end = message_start + message_len;
if plaintext.len() <= message_end {
return Err(WechatPayError::Deserialize(
"微信消息推送明文长度与内容不匹配".to_string(),
));
}
let app_id_start = message_end;
let message =
String::from_utf8(plaintext[message_start..message_end].to_vec()).map_err(|error| {
WechatPayError::Deserialize(format!("微信消息推送明文不是合法 UTF-8:{error}"))
})?;
let app_id =
String::from_utf8(plaintext[app_id_start..plaintext.len()].to_vec()).map_err(|error| {
WechatPayError::Deserialize(format!("微信消息推送 appid 不是合法 UTF-8:{error}"))
})?;
Ok(WechatMessagePushPlaintext { message, app_id })
}
pub fn parse_virtual_payment_notify(
body: &[u8],
) -> Result<WechatVirtualPaymentNotifyOrder, WechatPayError> {
if let Ok(notify) = serde_json::from_slice::<WechatVirtualPaymentNotifyBody>(body) {
return build_virtual_payment_notify_order(
notify.event,
notify.out_trade_no,
notify.mch_order_id,
notify.wechat_pay_info,
);
}
let text = std::str::from_utf8(body).map_err(|error| {
WechatPayError::Deserialize(format!("微信虚拟支付推送不是合法 UTF-8:{error}"))
})?;
let event = extract_virtual_payment_text_value(text, "Event")
.ok_or_else(|| WechatPayError::InvalidRequest("微信虚拟支付推送缺少 Event".to_string()))?;
let out_trade_no = extract_virtual_payment_text_value(text, "OutTradeNo");
let mch_order_id = extract_virtual_payment_text_value(text, "MchOrderId");
let wechat_pay_info = extract_virtual_payment_block(text, "WeChatPayInfo").map(|inner| {
WechatVirtualPaymentNotifyPayInfo {
mch_order_no: extract_virtual_payment_text_value(&inner, "MchOrderNo"),
transaction_id: extract_virtual_payment_text_value(&inner, "TransactionId"),
paid_time: extract_virtual_payment_text_value(&inner, "PaidTime")
.and_then(|value| value.parse::<i64>().ok()),
}
});
build_virtual_payment_notify_order(event, out_trade_no, mch_order_id, wechat_pay_info)
}
pub fn parse_virtual_payment_notify_event(body: &[u8]) -> Result<String, WechatPayError> {
if let Ok(value) = serde_json::from_slice::<Value>(body) {
return value
.get("Event")
.or_else(|| value.get("event"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.ok_or_else(|| {
WechatPayError::InvalidRequest("微信虚拟支付推送缺少 Event".to_string())
});
}
let text = std::str::from_utf8(body).map_err(|error| {
WechatPayError::Deserialize(format!("微信虚拟支付推送不是合法 UTF-8:{error}"))
})?;
extract_virtual_payment_text_value(text, "Event")
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.ok_or_else(|| WechatPayError::InvalidRequest("微信虚拟支付推送缺少 Event".to_string()))
}
pub fn build_virtual_payment_notify_debug_summary(
body: &[u8],
reference_key: &[u8],
) -> Result<WechatVirtualPaymentNotifyDebugSummary, WechatPayError> {
let raw_event = parse_virtual_payment_notify_event(body)?;
let known_event = WECHAT_VIRTUAL_PAYMENT_NOTIFY_EVENTS.contains(&raw_event.as_str());
let mut summary = WechatVirtualPaymentNotifyDebugSummary {
event: if known_event {
raw_event.clone()
} else {
"unknown".to_string()
},
event_ref: (!known_event)
.then(|| virtual_payment_debug_hmac_ref(reference_key, "event", raw_event.as_bytes())),
raw_event,
known_event,
payload_bytes: body.len(),
payload_fingerprint: virtual_payment_debug_hmac_ref(reference_key, "payload", body),
schema_fields: Vec::new(),
identifier_refs: BTreeMap::new(),
safe_fields: BTreeMap::new(),
sensitive_text_fields: BTreeMap::new(),
apple_subscription_info: false,
subscription_info: false,
};
if let Ok(value) = serde_json::from_slice::<Value>(body) {
collect_virtual_payment_json_debug_fields(&value, "", 0, reference_key, &mut summary);
} else {
let text = std::str::from_utf8(body).map_err(|error| {
WechatPayError::Deserialize(format!("微信虚拟支付推送不是合法 UTF-8:{error}"))
})?;
collect_virtual_payment_xml_debug_fields(text, reference_key, &mut summary);
}
summary.schema_fields.sort();
summary.schema_fields.dedup();
Ok(summary)
}
fn collect_virtual_payment_json_debug_fields(
value: &Value,
prefix: &str,
depth: usize,
reference_key: &[u8],
summary: &mut WechatVirtualPaymentNotifyDebugSummary,
) {
if depth >= WECHAT_VIRTUAL_PAYMENT_DEBUG_MAX_DEPTH {
return;
}
match value {
Value::Object(fields) => {
for (key, value) in fields {
let segment = virtual_payment_debug_schema_segment(key, reference_key);
if segment.is_empty() {
continue;
}
let path = if prefix.is_empty() {
segment
} else {
format!("{prefix}.{segment}")
};
push_virtual_payment_debug_schema_field(summary, path.as_str());
record_virtual_payment_debug_field(
summary,
path.as_str(),
key,
value,
reference_key,
);
collect_virtual_payment_json_debug_fields(
value,
path.as_str(),
depth + 1,
reference_key,
summary,
);
}
}
Value::Array(values) => {
let path = if prefix.is_empty() {
"[]".to_string()
} else {
format!("{prefix}[]")
};
push_virtual_payment_debug_schema_field(summary, path.as_str());
for value in values.iter().take(4) {
collect_virtual_payment_json_debug_fields(
value,
path.as_str(),
depth + 1,
reference_key,
summary,
);
}
}
_ => {}
}
}
fn collect_virtual_payment_xml_debug_fields(
text: &str,
reference_key: &[u8],
summary: &mut WechatVirtualPaymentNotifyDebugSummary,
) {
const DEBUG_XML_FIELDS: &[&str] = &[
"Event",
"CreateTime",
"Env",
"RetryTimes",
"OpenId",
"UserOpenid",
"OutTradeNo",
"MchOrderId",
"MchOrderNo",
"PayOrderId",
"pay_order_id",
"WxOrderId",
"WxpayOrderId",
"TransactionId",
"ChannelBill",
"channel_bill",
"OriginalTransactionId",
"WxRefundId",
"MchRefundId",
"WxpayRefundTransactionId",
"ComplaintId",
"RequestId",
"ContractId",
"ContractNo",
"OutContractCode",
"ContractWxAppid",
"MerchantCode",
"BusinessCode",
"ProductId",
"product_id",
"BundleId",
"bundleid",
"RefundFee",
"RetCode",
"Action",
"SubscribePeriodDays",
"PCount",
"ProvideStatus",
"EventType",
"Code",
"State",
"BusinessState",
"AutoRenewStatus",
"RenewalTime",
"RenewalDate",
"SigningTime",
"SignedTime",
"RefundTime",
"refund_time",
"OrderTime",
"order_time",
"BusinessTime",
"StartTime",
"RefundStartTimestamp",
"EndTime",
"RefundSuccTimestamp",
"PaidTime",
"OpenorcloseTime",
"ComplaintTime",
"OrigPrice",
"ActualPrice",
"TeamType",
"TeamAction",
"p_count",
"provide_status",
"ComplaintDetail",
"RefundRequestReason",
"refund_request_reason",
"RefundReason",
"Attach",
"Remark",
"RetMsg",
];
for key in DEBUG_XML_FIELDS {
let Some(value) = extract_virtual_payment_text_value(text, key) else {
continue;
};
let path = virtual_payment_debug_schema_segment(key, reference_key);
push_virtual_payment_debug_schema_field(summary, path.as_str());
record_virtual_payment_debug_field(
summary,
path.as_str(),
key,
&Value::String(value),
reference_key,
);
}
if extract_virtual_payment_block(text, "AppleSubscriptionInfo").is_some() {
summary.apple_subscription_info = true;
summary.subscription_info = true;
push_virtual_payment_debug_schema_field(summary, "applesubscriptioninfo");
}
}
fn record_virtual_payment_debug_field(
summary: &mut WechatVirtualPaymentNotifyDebugSummary,
path: &str,
key: &str,
value: &Value,
reference_key: &[u8],
) {
let normalized_key = normalize_virtual_payment_debug_key(key);
if normalized_key == "applesubscriptioninfo" && !value.is_null() {
summary.apple_subscription_info = true;
}
if is_virtual_payment_subscription_marker(normalized_key.as_str()) && !value.is_null() {
summary.subscription_info = true;
}
let Some(text) = virtual_payment_debug_scalar_text(value) else {
return;
};
if let Some(category) = virtual_payment_debug_identifier_category(normalized_key.as_str()) {
let reference = virtual_payment_debug_hmac_ref(reference_key, category, text.as_bytes());
let values = summary
.identifier_refs
.entry(category.to_string())
.or_default();
if !values.contains(&reference) {
values.push(reference);
}
return;
}
if is_virtual_payment_debug_sensitive_text(normalized_key.as_str()) {
summary.sensitive_text_fields.insert(
path.to_string(),
WechatVirtualPaymentNotifyDebugText {
bytes: text.len(),
hmac_ref: virtual_payment_debug_hmac_ref(
reference_key,
"sensitive-text",
text.as_bytes(),
),
},
);
return;
}
if is_virtual_payment_debug_safe_scalar(normalized_key.as_str()) {
summary.safe_fields.insert(
path.to_string(),
sanitize_virtual_payment_debug_scalar(value, reference_key, normalized_key.as_str()),
);
}
}
fn normalize_virtual_payment_debug_key(key: &str) -> String {
key.chars()
.filter(|character| character.is_ascii_alphanumeric())
.map(|character| character.to_ascii_lowercase())
.collect()
}
fn virtual_payment_debug_schema_segment(key: &str, reference_key: &[u8]) -> String {
let normalized = normalize_virtual_payment_debug_key(key);
if is_virtual_payment_debug_known_schema_key(normalized.as_str()) {
return normalized;
}
format!(
"unknown_{}",
virtual_payment_debug_hmac_hex(reference_key, "schema-key", key.as_bytes())
)
}
fn virtual_payment_debug_scalar_text(value: &Value) -> Option<String> {
match value {
Value::String(value) => Some(value.clone()),
Value::Number(value) => Some(value.to_string()),
Value::Bool(value) => Some(value.to_string()),
Value::Null | Value::Array(_) | Value::Object(_) => None,
}
}
fn sanitize_virtual_payment_debug_scalar(value: &Value, reference_key: &[u8], key: &str) -> Value {
match value {
Value::String(value) => value
.parse::<i64>()
.ok()
.map(serde_json::Number::from)
.map(Value::Number)
.unwrap_or_else(|| {
let mut fields = serde_json::Map::new();
fields.insert(
"chars".to_string(),
Value::Number(serde_json::Number::from(value.chars().count())),
);
fields.insert(
"hmac_ref".to_string(),
Value::String(virtual_payment_debug_hmac_ref(
reference_key,
key,
value.as_bytes(),
)),
);
Value::Object(fields)
}),
Value::Number(_) | Value::Bool(_) => value.clone(),
Value::Null | Value::Array(_) | Value::Object(_) => Value::Null,
}
}
fn virtual_payment_debug_identifier_category(key: &str) -> Option<&'static str> {
match key {
"openid" | "useropenid" => Some("user_ref"),
"outtradeno" | "mchorderid" | "mchorderno" | "payorderid" | "orderid" => Some("order_ref"),
"wxorderid"
| "wxpayorderid"
| "transactionid"
| "channelbill"
| "originaltransactionid" => Some("provider_order_ref"),
"wxrefundid" | "mchrefundid" | "wxpayrefundtransactionid" => Some("refund_ref"),
"contractid" | "contractno" | "contractcode" | "outcontractcode" => Some("contract_ref"),
"complaintid" => Some("complaint_ref"),
"requestid" => Some("request_ref"),
"mchid" | "merchantid" | "merchantcode" => Some("merchant_ref"),
"businesscode" => Some("business_ref"),
"appid" | "wxappid" | "contractwxappid" | "bundleid" => Some("app_ref"),
_ => None,
}
}
fn is_virtual_payment_subscription_marker(key: &str) -> bool {
matches!(
key,
"applesubscriptioninfo"
| "subscriptioninfo"
| "outcontractcode"
| "contractwxappid"
| "subscribeperioddays"
)
}
fn is_virtual_payment_debug_sensitive_text(key: &str) -> bool {
matches!(
key,
"complaintdetail"
| "refundrequestreason"
| "refundreason"
| "attach"
| "remark"
| "retmsg"
| "resultinfo"
| "evidence"
| "description"
| "goodsname"
| "merchantname"
| "businessname"
)
}
fn is_virtual_payment_debug_safe_scalar(key: &str) -> bool {
matches!(
key,
"createtime"
| "env"
| "retrytimes"
| "refundfee"
| "retcode"
| "action"
| "subscribeperioddays"
| "pcount"
| "providestatus"
| "eventtype"
| "code"
| "state"
| "businessstate"
| "autorenewstatus"
| "renewaltime"
| "renewaldate"
| "signingtime"
| "signedtime"
| "refundtime"
| "ordertime"
| "businesstime"
| "starttime"
| "refundstarttimestamp"
| "endtime"
| "refundsucctimestamp"
| "paidtime"
| "openorclosetime"
| "complainttime"
| "productid"
| "goodsprice"
| "origprice"
| "actualprice"
| "quantity"
| "buyquantity"
| "orderfee"
| "status"
| "refundstatus"
| "paystatus"
| "currency"
| "perioddays"
| "teamtype"
| "teamaction"
)
}
fn is_virtual_payment_debug_known_schema_key(key: &str) -> bool {
virtual_payment_debug_identifier_category(key).is_some()
|| is_virtual_payment_debug_sensitive_text(key)
|| is_virtual_payment_debug_safe_scalar(key)
|| is_virtual_payment_subscription_marker(key)
|| matches!(
key,
"event"
| "wechatpayinfo"
| "goodsinfo"
| "coininfo"
| "teaminfo"
| "refundinfo"
| "complaintinfo"
| "subscribeinfo"
| "payinfo"
)
}
fn validate_refund_notify_identifier(value: &str, field_name: &str) -> Result<(), WechatPayError> {
if value.trim().is_empty() {
return Err(WechatPayError::InvalidRequest(format!(
"微信支付 V3 退款通知{field_name}为空"
)));
}
Ok(())
}
fn refund_identifier(value: &str) -> String {
value.trim().to_string()
}
fn normalize_optional_refund_text(value: Option<String>) -> Option<String> {
value
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn payment_debug_hmac_ref(reference_key: &[u8], domain: &str, value: &[u8]) -> String {
format!(
"hmac-sha256:{}",
virtual_payment_debug_hmac_hex(reference_key, domain, value)
)
}
fn virtual_payment_debug_hmac_ref(reference_key: &[u8], domain: &str, value: &[u8]) -> String {
payment_debug_hmac_ref(reference_key, domain, value)
}
fn virtual_payment_debug_hmac_hex(reference_key: &[u8], domain: &str, value: &[u8]) -> String {
let key = hmac::Key::new(hmac::HMAC_SHA256, reference_key);
let mut payload = Vec::with_capacity(domain.len() + value.len() + 1);
payload.extend_from_slice(domain.as_bytes());
payload.push(0);
payload.extend_from_slice(value);
let tag = hmac::sign(&key, payload.as_slice());
hex::encode(&tag.as_ref()[..16])
}
fn push_virtual_payment_debug_schema_field(
summary: &mut WechatVirtualPaymentNotifyDebugSummary,
path: &str,
) {
if summary.schema_fields.len() >= WECHAT_VIRTUAL_PAYMENT_DEBUG_MAX_SCHEMA_FIELDS
|| summary.schema_fields.iter().any(|value| value == path)
{
return;
}
summary.schema_fields.push(path.to_string());
}
fn build_virtual_payment_notify_order(
event: String,
out_trade_no: Option<String>,
mch_order_id: Option<String>,
wechat_pay_info: Option<WechatVirtualPaymentNotifyPayInfo>,
) -> Result<WechatVirtualPaymentNotifyOrder, WechatPayError> {
let event = event.trim().to_string();
if event.is_empty() {
return Err(WechatPayError::InvalidRequest(
"微信虚拟支付推送缺少 Event".to_string(),
));
}
let out_trade_no = out_trade_no
.or(mch_order_id)
.or_else(|| {
wechat_pay_info
.as_ref()
.and_then(|info| info.mch_order_no.clone())
})
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WechatPayError::InvalidRequest("微信虚拟支付推送缺少 OutTradeNo".to_string())
})?;
let transaction_id = wechat_pay_info
.as_ref()
.and_then(|info| info.transaction_id.clone())
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let paid_at_micros = wechat_pay_info
.and_then(|info| info.paid_time)
.filter(|paid_time| *paid_time > 0)
.and_then(|paid_time| paid_time.checked_mul(1_000_000));
Ok(WechatVirtualPaymentNotifyOrder {
out_trade_no,
transaction_id,
paid_at_micros,
event,
})
}
fn extract_virtual_payment_text_value(text: &str, tag: &str) -> Option<String> {
let open = format!("<{tag}>");
let close = format!("</{tag}>");
let start = text.find(&open)? + open.len();
let end = text[start..].find(&close)? + start;
let raw = &text[start..end];
Some(trim_virtual_payment_text_value(raw))
}
fn extract_virtual_payment_block(text: &str, tag: &str) -> Option<String> {
let open = format!("<{tag}>");
let close = format!("</{tag}>");
let start = text.find(&open)? + open.len();
let end = text[start..].find(&close)? + start;
Some(text[start..end].to_string())
}
fn trim_virtual_payment_text_value(value: &str) -> String {
let trimmed = value.trim();
if let Some(inner) = trimmed
.strip_prefix("<![CDATA[")
.and_then(|value| value.strip_suffix("]]>"))
{
return inner.trim().to_string();
}
trimmed.to_string()
}
fn required_config(value: Option<&str>, key: &str) -> Result<String, WechatPayError> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.ok_or_else(|| WechatPayError::InvalidConfig(format!("{key} 未配置")))
}
fn normalize_required_url(value: &str, key: &str) -> Result<String, WechatPayError> {
let value = value.trim();
if value.starts_with("https://") {
return Ok(value.to_string());
}
Err(WechatPayError::InvalidConfig(format!(
"{key} 必须是 https 地址"
)))
}
fn validate_notify_url(value: &str, key: &str) -> Result<(), WechatPayError> {
if value.chars().count() > WECHAT_PAY_NOTIFY_URL_MAX_CHARS {
return Err(WechatPayError::InvalidConfig(format!(
"{key} 不能超过 {WECHAT_PAY_NOTIFY_URL_MAX_CHARS} 字符"
)));
}
if value.contains('?') || value.contains('#') {
return Err(WechatPayError::InvalidConfig(format!(
"{key} 不能包含 query 或 fragment"
)));
}
Ok(())
}
fn resolve_query_order_endpoint_base(jsapi_endpoint: &str) -> Result<String, WechatPayError> {
let origin = resolve_wechat_pay_api_origin(jsapi_endpoint)?;
Ok(format!("{origin}/v3/pay/transactions/out-trade-no"))
}
fn resolve_wechat_pay_api_origin(jsapi_endpoint: &str) -> Result<String, WechatPayError> {
let url = Url::parse(jsapi_endpoint)
.map_err(|_| WechatPayError::InvalidConfig("WECHAT_PAY_JSAPI_ENDPOINT 无效".to_string()))?;
Ok(url
.origin()
.ascii_serialization()
.trim_end_matches('/')
.to_string())
}
fn resolve_wechat_pay_transaction_endpoint(
jsapi_endpoint: &str,
transaction_path: &str,
) -> Result<String, WechatPayError> {
let origin = resolve_wechat_pay_api_origin(jsapi_endpoint)?;
Ok(format!("{origin}{transaction_path}"))
}
fn normalize_out_trade_no(value: &str) -> Result<String, WechatPayError> {
let value = value.trim();
validate_out_trade_no(value)?;
Ok(value.to_string())
}
fn normalize_out_refund_no(value: &str) -> Result<String, WechatPayError> {
let value = value.trim();
validate_non_empty_max_chars(
value,
WECHAT_PAY_OUT_REFUND_NO_MAX_CHARS,
"微信支付 out_refund_no",
)?;
if !value
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '|' | '*' | '@'))
{
return Err(WechatPayError::InvalidRequest(
"微信支付 out_refund_no 只能包含数字、大小写字母、_、-、|、*、@".to_string(),
));
}
Ok(value.to_string())
}
fn validate_refund_request(
request: WechatPayRefundRequest,
) -> Result<WechatPayRefundRequest, WechatPayError> {
let transaction_id = request.transaction_id.trim().to_string();
validate_non_empty_max_chars(&transaction_id, 64, "微信支付 transaction_id")?;
let out_trade_no = normalize_out_trade_no(&request.out_trade_no)?;
let out_refund_no = normalize_out_refund_no(&request.out_refund_no)?;
let reason = request
.reason
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
if let Some(reason) = reason.as_deref() {
validate_non_empty_max_chars(
reason,
WECHAT_PAY_REFUND_REASON_MAX_CHARS,
"微信支付退款原因",
)?;
}
let notify_url = request.notify_url.trim().to_string();
validate_non_empty_max_chars(
&notify_url,
WECHAT_PAY_NOTIFY_URL_MAX_CHARS,
"微信支付退款 notify_url",
)?;
validate_notify_url(&notify_url, "微信支付退款 notify_url")?;
if !notify_url.starts_with("https://") {
return Err(WechatPayError::InvalidRequest(
"微信支付退款 notify_url 必须是 https 地址".to_string(),
));
}
if request.refund_amount_cents == 0
|| request.total_amount_cents == 0
|| request.refund_amount_cents > request.total_amount_cents
{
return Err(WechatPayError::InvalidRequest(
"微信支付退款金额必须大于 0 且不能超过订单总额".to_string(),
));
}
Ok(WechatPayRefundRequest {
transaction_id,
out_trade_no,
out_refund_no,
reason,
notify_url,
refund_amount_cents: request.refund_amount_cents,
total_amount_cents: request.total_amount_cents,
})
}
fn parse_refund_response(body: &[u8], context: &str) -> Result<WechatPayRefund, WechatPayError> {
let payload = serde_json::from_slice::<WechatPayRefundResponse>(body)
.map_err(|error| WechatPayError::Deserialize(format!("{context}解析失败:{error}")))?;
for (value, field_name) in [
(&payload.transaction_id, "微信支付订单号"),
(&payload.out_trade_no, "商户订单号"),
(&payload.refund_id, "微信退款单号"),
(&payload.out_refund_no, "商户退款单号"),
(&payload.status, "退款状态"),
] {
validate_refund_notify_identifier(value, field_name)?;
}
let status = payload.status.trim().to_ascii_uppercase();
if !matches!(
status.as_str(),
"PROCESSING" | "SUCCESS" | "ABNORMAL" | "CLOSED"
) {
return Err(WechatPayError::InvalidRequest(format!(
"{context}包含未知退款状态"
)));
}
validate_wechat_refund_amounts(
payload.amount.total,
payload.amount.refund,
payload.amount.payer_total,
payload.amount.payer_refund,
context,
)?;
Ok(WechatPayRefund {
mch_id: None,
transaction_id: refund_identifier(&payload.transaction_id),
out_trade_no: refund_identifier(&payload.out_trade_no),
refund_id: refund_identifier(&payload.refund_id),
out_refund_no: normalize_out_refund_no(&payload.out_refund_no)?,
status,
success_time: normalize_optional_refund_text(payload.success_time),
create_time: normalize_optional_refund_text(payload.create_time),
amount_total_cents: payload.amount.total,
amount_refund_cents: payload.amount.refund,
amount_payer_total_cents: payload.amount.payer_total,
amount_payer_refund_cents: payload.amount.payer_refund,
})
}
fn validate_created_refund_response(
refund: &WechatPayRefund,
request: &WechatPayRefundRequest,
) -> Result<(), WechatPayError> {
if refund.out_refund_no != request.out_refund_no
|| refund.out_trade_no != request.out_trade_no
|| refund.transaction_id != request.transaction_id
|| refund.amount_total_cents != request.total_amount_cents
|| refund.amount_refund_cents != request.refund_amount_cents
{
return Err(WechatPayError::InvalidRequest(
"微信支付退款申请响应与本次请求不匹配".to_string(),
));
}
Ok(())
}
fn validate_queried_refund_response(
refund: &WechatPayRefund,
requested_out_refund_no: &str,
) -> Result<(), WechatPayError> {
if refund.out_refund_no != requested_out_refund_no {
return Err(WechatPayError::InvalidRequest(
"微信支付退款查询响应商户退款单号与请求不匹配".to_string(),
));
}
Ok(())
}
fn validate_wechat_refund_amounts(
total_cents: u64,
refund_cents: u64,
payer_total_cents: u64,
payer_refund_cents: u64,
context: &str,
) -> Result<(), WechatPayError> {
if total_cents == 0
|| refund_cents == 0
|| refund_cents > total_cents
|| payer_total_cents > total_cents
|| payer_refund_cents > payer_total_cents
|| payer_refund_cents > refund_cents
{
return Err(WechatPayError::InvalidRequest(format!(
"{context}金额契约无效"
)));
}
Ok(())
}
fn parse_wechat_error_message(body: &[u8]) -> Option<String> {
let payload = serde_json::from_slice::<WechatPayErrorResponse>(body).ok()?;
match (payload.code, payload.message) {
(Some(code), Some(message)) => Some(format!("{code}: {message}")),
(Some(code), None) => Some(code),
(None, Some(message)) => Some(message),
(None, None) => None,
}
}
fn map_signed_json_error_response(
operation: &str,
status: reqwest::StatusCode,
body: &[u8],
) -> WechatPayError {
if let Ok(payload) = serde_json::from_slice::<WechatPayErrorResponse>(body)
&& matches!(
payload.code.as_deref(),
Some("ORDER_NOT_EXIST" | "RESOURCE_NOT_EXISTS")
)
{
return WechatPayError::OrderNotExist(
payload
.message
.filter(|message| !message.trim().is_empty())
.unwrap_or_else(|| format!("{operation}对应订单不存在")),
);
}
let message = parse_wechat_error_message(body).unwrap_or_else(|| format!("HTTP {status}"));
WechatPayError::Upstream(format!("{operation}失败:{message}"))
}
fn normalize_bill_date(value: &str) -> Result<String, WechatPayError> {
let value = value.trim();
let bytes = value.as_bytes();
if bytes.len() != 10
|| bytes[4] != b'-'
|| bytes[7] != b'-'
|| bytes
.iter()
.enumerate()
.any(|(index, byte)| !matches!(index, 4 | 7) && !byte.is_ascii_digit())
{
return Err(WechatPayError::InvalidRequest(
"微信支付账单日期必须使用 YYYY-MM-DD".to_string(),
));
}
let year = value[0..4].parse::<i32>().unwrap_or_default();
let month = value[5..7].parse::<u8>().unwrap_or_default();
let day = value[8..10].parse::<u8>().unwrap_or_default();
let max_day = match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if year % 400 == 0 || (year % 4 == 0 && year % 100 != 0) => 29,
2 => 28,
_ => 0,
};
if year < 2000 || day == 0 || day > max_day {
return Err(WechatPayError::InvalidRequest(
"微信支付账单日期无效".to_string(),
));
}
Ok(value.to_string())
}
fn validate_trade_bill_download(
payload: WechatPayTradeBillResponse,
) -> Result<WechatPayTradeBillDownload, WechatPayError> {
let download = WechatPayTradeBillDownload {
hash_type: payload.hash_type.trim().to_ascii_uppercase(),
hash_value: payload.hash_value.trim().to_ascii_lowercase(),
download_url: payload.download_url.trim().to_string(),
};
validate_trade_bill_download_contract(&download)?;
Ok(download)
}
fn validate_trade_bill_download_contract(
download: &WechatPayTradeBillDownload,
) -> Result<(), WechatPayError> {
if download.hash_type != "SHA1"
|| download.hash_value.len() != 40
|| !download.hash_value.chars().all(|ch| ch.is_ascii_hexdigit())
|| download.download_url.is_empty()
{
return Err(WechatPayError::InvalidRequest(
"微信支付退款交易账单下载契约无效".to_string(),
));
}
Ok(())
}
fn verify_trade_bill_hash(
download: &WechatPayTradeBillDownload,
csv_bytes: &[u8],
) -> Result<(), WechatPayError> {
let actual = hex::encode(Sha1::digest(csv_bytes));
if !actual.eq_ignore_ascii_case(&download.hash_value) {
return Err(WechatPayError::InvalidSignature(
"微信支付退款交易账单 SHA1 校验失败".to_string(),
));
}
Ok(())
}
fn parse_refund_trade_bill_csv(
csv_bytes: &[u8],
) -> Result<Vec<WechatPayTradeBillRefundRow>, WechatPayError> {
let mut reader = csv::ReaderBuilder::new()
.flexible(true)
.from_reader(csv_bytes);
let headers = reader
.headers()
.map_err(|error| {
WechatPayError::Deserialize(format!("微信支付退款交易账单表头解析失败:{error}"))
})?
.iter()
.map(normalize_trade_bill_field)
.collect::<Vec<_>>();
let header_index = headers
.iter()
.enumerate()
.map(|(index, value)| (value.as_str(), index))
.collect::<BTreeMap<_, _>>();
let mut rows = Vec::new();
for record in reader.records() {
let record = record.map_err(|error| {
WechatPayError::Deserialize(format!("微信支付退款交易账单行解析失败:{error}"))
})?;
let trade_state = trade_bill_value(&record, &header_index, &["交易状态", "trade_state"])
.unwrap_or_default();
if !trade_state.eq_ignore_ascii_case("REFUND") {
continue;
}
let transaction_id =
required_trade_bill_value(&record, &header_index, &["微信订单号", "transaction_id"])?;
let out_trade_no =
required_trade_bill_value(&record, &header_index, &["商户订单号", "out_trade_no"])?;
let refund_id =
required_trade_bill_value(&record, &header_index, &["微信退款单号", "refund_id"])?;
let out_refund_no =
required_trade_bill_value(&record, &header_index, &["商户退款单号", "out_refund_no"])?;
rows.push(WechatPayTradeBillRefundRow {
transaction_id,
out_trade_no,
refund_id,
out_refund_no,
accepted_time: trade_bill_value(
&record,
&header_index,
&["退款申请时间", "交易时间", "refund_apply_time"],
),
success_time: trade_bill_value(
&record,
&header_index,
&["退款成功时间", "refund_success_time"],
),
refund_type: required_trade_bill_value(
&record,
&header_index,
&["退款类型", "refund_type"],
)?,
bill_refund_status: required_trade_bill_value(
&record,
&header_index,
&["退款状态", "refund_status"],
)?,
requested_refund_cents: parse_yuan_to_cents(&required_trade_bill_value(
&record,
&header_index,
&["申请退款金额", "requested_refund_amount"],
)?)?,
refunded_cents: parse_optional_trade_bill_amount(trade_bill_value(
&record,
&header_index,
&["退款金额", "refund_amount"],
))?,
coupon_refund_cents: parse_optional_trade_bill_amount(trade_bill_value(
&record,
&header_index,
&["充值券退款金额", "coupon_refund_amount"],
))?,
app_id: trade_bill_value(&record, &header_index, &["公众账号ID", "appid"]),
mch_id: trade_bill_value(&record, &header_index, &["商户号", "mchid"]),
});
}
Ok(rows)
}
fn normalize_trade_bill_field(value: &str) -> String {
value
.trim_start_matches('\u{feff}')
.trim()
.trim_start_matches('`')
.trim()
.to_string()
}
fn trade_bill_value(
record: &csv::StringRecord,
header_index: &BTreeMap<&str, usize>,
aliases: &[&str],
) -> Option<String> {
aliases.iter().find_map(|alias| {
let index = header_index.get(alias)?;
record
.get(*index)
.map(normalize_trade_bill_field)
.filter(|value| !value.is_empty())
})
}
fn required_trade_bill_value(
record: &csv::StringRecord,
header_index: &BTreeMap<&str, usize>,
aliases: &[&str],
) -> Result<String, WechatPayError> {
trade_bill_value(record, header_index, aliases).ok_or_else(|| {
WechatPayError::Deserialize(format!("微信支付退款交易账单缺少字段:{}", aliases[0]))
})
}
fn parse_optional_trade_bill_amount(value: Option<String>) -> Result<u64, WechatPayError> {
value
.as_deref()
.map(parse_yuan_to_cents)
.transpose()
.map(|value| value.unwrap_or_default())
}
fn parse_yuan_to_cents(value: &str) -> Result<u64, WechatPayError> {
let value = normalize_trade_bill_field(value)
.trim_start_matches('¥')
.trim_start_matches('+')
.to_string();
if value.is_empty() || value.starts_with('-') {
return Err(WechatPayError::Deserialize(
"微信支付退款交易账单金额无效".to_string(),
));
}
let mut parts = value.split('.');
let whole = parts.next().unwrap_or_default();
let fraction = parts.next();
if parts.next().is_some() || whole.is_empty() || !whole.chars().all(|ch| ch.is_ascii_digit()) {
return Err(WechatPayError::Deserialize(
"微信支付退款交易账单金额格式无效".to_string(),
));
}
let fraction_cents = match fraction {
None | Some("") => 0,
Some(value) if value.len() <= 2 && value.chars().all(|ch| ch.is_ascii_digit()) => {
let value = value.parse::<u64>().unwrap_or_default();
if fraction.unwrap_or_default().len() == 1 {
value * 10
} else {
value
}
}
_ => {
return Err(WechatPayError::Deserialize(
"微信支付退款交易账单金额精度超过分".to_string(),
));
}
};
whole
.parse::<u64>()
.ok()
.and_then(|whole| whole.checked_mul(100))
.and_then(|whole| whole.checked_add(fraction_cents))
.ok_or_else(|| WechatPayError::Deserialize("微信支付退款交易账单金额超出范围".to_string()))
}
fn validate_jsapi_order_request(
client: &RealWechatPayClient,
request: &WechatMiniProgramOrderRequest,
) -> Result<(), WechatPayError> {
validate_non_empty_max_chars(
&client.app_id,
WECHAT_PAY_APP_ID_MAX_CHARS,
"微信支付 appid",
)?;
if !client.app_id.starts_with("wx") {
return Err(WechatPayError::InvalidConfig(
"微信支付 appid 必须使用小程序 AppID".to_string(),
));
}
validate_non_empty_max_chars(
&client.mch_id,
WECHAT_PAY_MCH_ID_MAX_CHARS,
"微信支付 mchid",
)?;
if !client.mch_id.chars().all(|ch| ch.is_ascii_digit()) {
return Err(WechatPayError::InvalidConfig(
"微信支付 mchid 必须是数字字符串".to_string(),
));
}
validate_non_empty_max_chars(
&request.description,
WECHAT_PAY_DESCRIPTION_MAX_CHARS,
"微信支付商品描述",
)?;
validate_out_trade_no(&request.order_id)?;
if request.amount_cents == 0 {
return Err(WechatPayError::InvalidRequest(
"微信支付金额必须大于 0 分".to_string(),
));
}
validate_non_empty_max_chars(
&request.payer_openid,
WECHAT_PAY_OPENID_MAX_CHARS,
"微信支付 payer.openid",
)?;
Ok(())
}
fn validate_web_order_request(
client: &RealWechatPayClient,
request: &WechatWebOrderRequest,
) -> Result<(), WechatPayError> {
validate_non_empty_max_chars(
&client.app_id,
WECHAT_PAY_APP_ID_MAX_CHARS,
"微信支付 appid",
)?;
if !client.app_id.starts_with("wx") {
return Err(WechatPayError::InvalidConfig(
"微信支付 appid 必须使用已绑定的微信 AppID".to_string(),
));
}
validate_non_empty_max_chars(
&client.mch_id,
WECHAT_PAY_MCH_ID_MAX_CHARS,
"微信支付 mchid",
)?;
if !client.mch_id.chars().all(|ch| ch.is_ascii_digit()) {
return Err(WechatPayError::InvalidConfig(
"微信支付 mchid 必须是数字字符串".to_string(),
));
}
validate_non_empty_max_chars(
&request.description,
WECHAT_PAY_DESCRIPTION_MAX_CHARS,
"微信支付商品描述",
)?;
validate_out_trade_no(&request.order_id)?;
if request.amount_cents == 0 {
return Err(WechatPayError::InvalidRequest(
"微信支付金额必须大于 0 分".to_string(),
));
}
validate_non_empty_max_chars(
&request.payer_client_ip,
WECHAT_PAY_CLIENT_IP_MAX_CHARS,
"微信支付 payer_client_ip",
)?;
Ok(())
}
fn validate_non_empty_max_chars(
value: &str,
max_chars: usize,
field_name: &str,
) -> Result<(), WechatPayError> {
let value = value.trim();
if value.is_empty() {
return Err(WechatPayError::InvalidRequest(format!(
"{field_name} 不能为空"
)));
}
if value.chars().count() > max_chars {
return Err(WechatPayError::InvalidRequest(format!(
"{field_name} 不能超过 {max_chars} 字符"
)));
}
Ok(())
}
fn validate_out_trade_no(value: &str) -> Result<(), WechatPayError> {
validate_non_empty_max_chars(
value,
WECHAT_PAY_OUT_TRADE_NO_MAX_CHARS,
"微信支付 out_trade_no",
)?;
if value.chars().count() < 6 {
return Err(WechatPayError::InvalidRequest(
"微信支付 out_trade_no 不能少于 6 字符".to_string(),
));
}
if !value
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '|' | '*'))
{
return Err(WechatPayError::InvalidRequest(
"微信支付 out_trade_no 只能包含数字、大小写字母、_、-、|、*".to_string(),
));
}
Ok(())
}
fn read_private_key_pem(
inline_pem: Option<&str>,
path: Option<&Path>,
) -> Result<String, WechatPayError> {
read_pem(
inline_pem,
path,
"WECHAT_PAY_PRIVATE_KEY_PEM 或 WECHAT_PAY_PRIVATE_KEY_PATH 未配置",
"读取微信支付私钥失败",
)
}
fn read_pem(
inline_pem: Option<&str>,
path: Option<&Path>,
missing_message: &str,
read_error_prefix: &str,
) -> Result<String, WechatPayError> {
if let Some(value) = inline_pem.map(str::trim).filter(|value| !value.is_empty()) {
return Ok(value.replace("\\n", "\n"));
}
let Some(path) = path else {
return Err(WechatPayError::InvalidConfig(missing_message.to_string()));
};
fs::read_to_string(path).map_err(|error| {
WechatPayError::InvalidConfig(format!("{read_error_prefix}:{}:{error}", path.display()))
})
}
fn parse_rsa_private_key(pem: &str) -> Result<signature::RsaKeyPair, WechatPayError> {
let (label, der) = parse_single_pem_block(pem)?;
match label.as_str() {
"PRIVATE KEY" => signature::RsaKeyPair::from_pkcs8(&der),
"RSA PRIVATE KEY" => signature::RsaKeyPair::from_der(&der),
_ => {
return Err(WechatPayError::InvalidConfig(
"微信支付私钥必须是 PRIVATE KEY 或 RSA PRIVATE KEY PEM".to_string(),
));
}
}
.map_err(|error| WechatPayError::InvalidConfig(format!("微信支付私钥解析失败:{error}")))
}
fn parse_public_key_pem(pem: &str) -> Result<Vec<u8>, WechatPayError> {
let (label, der) = parse_single_pem_block(pem)?;
if label != "PUBLIC KEY" {
return Err(WechatPayError::InvalidConfig(
"微信支付平台公钥必须是 PUBLIC KEY PEM".to_string(),
));
}
let (remaining, subject_public_key_info) =
SubjectPublicKeyInfo::from_der(&der).map_err(|_| {
WechatPayError::InvalidConfig("微信支付平台 PUBLIC KEY SPKI 解析失败".to_string())
})?;
if !remaining.is_empty() || subject_public_key_info.subject_public_key.unused_bits != 0 {
return Err(WechatPayError::InvalidConfig(
"微信支付平台 PUBLIC KEY SPKI 格式非法".to_string(),
));
}
if !matches!(subject_public_key_info.parsed(), Ok(PublicKey::RSA(_))) {
return Err(WechatPayError::InvalidConfig(
"微信支付平台 PUBLIC KEY 必须使用 RSA 算法".to_string(),
));
}
let rsa_public_key_der = subject_public_key_info.subject_public_key.data.as_ref();
let (remaining, _) = RSAPublicKey::from_der(rsa_public_key_der).map_err(|_| {
WechatPayError::InvalidConfig("微信支付平台公钥不是有效的 RSA 公钥".to_string())
})?;
if !remaining.is_empty() {
return Err(WechatPayError::InvalidConfig(
"微信支付平台 RSA 公钥包含多余数据".to_string(),
));
}
Ok(rsa_public_key_der.to_vec())
}
fn verify_rsa_sha256_signature(
public_key_der: &[u8],
message: &[u8],
signature_bytes: &[u8],
) -> Result<(), WechatPayError> {
signature::UnparsedPublicKey::new(&signature::RSA_PKCS1_2048_8192_SHA256, public_key_der)
.verify(message, signature_bytes)
.map_err(|_| WechatPayError::InvalidSignature("微信支付通知签名验签失败".to_string()))
}
fn parse_single_pem_block(pem: &str) -> Result<(String, Vec<u8>), WechatPayError> {
let mut label: Option<String> = None;
let mut content = String::new();
for line in pem.lines().map(str::trim).filter(|line| !line.is_empty()) {
if let Some(raw_label) = line
.strip_prefix("-----BEGIN ")
.and_then(|value| value.strip_suffix("-----"))
{
label = Some(raw_label.trim().to_string());
continue;
}
if line.starts_with("-----END ") {
break;
}
if label.is_some() {
content.push_str(line);
}
}
let label = label
.ok_or_else(|| WechatPayError::InvalidConfig("微信支付 PEM 缺少 BEGIN 标记".to_string()))?;
let der = BASE64_STANDARD
.decode(content)
.map_err(|_| WechatPayError::InvalidConfig("微信支付 PEM base64 无效".to_string()))?;
if der.is_empty() {
return Err(WechatPayError::InvalidConfig(
"微信支付 PEM 内容为空".to_string(),
));
}
Ok((label, der))
}
fn create_nonce() -> Result<String, WechatPayError> {
let mut bytes = [0_u8; 16];
SystemRandom::new()
.fill(&mut bytes)
.map_err(|_| WechatPayError::Crypto("生成微信支付 nonce 失败".to_string()))?;
Ok(hex_encode(&bytes))
}
fn decrypt_aes_256_gcm(
key: &[u8],
nonce: &[u8],
associated_data: &[u8],
ciphertext_base64: &str,
) -> Result<Vec<u8>, WechatPayError> {
let mut ciphertext = BASE64_STANDARD
.decode(ciphertext_base64)
.map_err(|_| WechatPayError::Crypto("微信支付通知密文 base64 无效".to_string()))?;
if ciphertext.len() < aead::AES_256_GCM.tag_len() {
return Err(WechatPayError::Crypto(
"微信支付通知密文长度无效".to_string(),
));
}
let nonce = aead::Nonce::try_assume_unique_for_key(nonce)
.map_err(|_| WechatPayError::Crypto("微信支付通知 nonce 长度无效".to_string()))?;
let key = aead::UnboundKey::new(&aead::AES_256_GCM, key)
.map_err(|_| WechatPayError::Crypto("微信支付通知解密 key 无效".to_string()))?;
let plain_text = aead::LessSafeKey::new(key)
.open_in_place(
nonce,
aead::Aad::from(associated_data),
ciphertext.as_mut_slice(),
)
.map_err(|_| WechatPayError::Crypto("微信支付通知认证或解密失败".to_string()))?;
Ok(plain_text.to_vec())
}
fn read_required_header<'a>(
headers: &'a HeaderMap,
name: &'static str,
) -> Result<&'a str, WechatPayError> {
headers
.get(name)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| WechatPayError::InvalidSignature(format!("微信支付通知缺少 {name} 请求头")))
}
fn build_notify_signature_message(timestamp: &[u8], nonce: &[u8], body: &[u8]) -> Vec<u8> {
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 {
let mut hasher = Sha256::new();
hasher.update(content);
hex_encode(&hasher.finalize())
}
fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
impl std::fmt::Display for WechatPayError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Disabled => formatter.write_str("微信支付暂未启用"),
Self::InvalidConfig(message)
| Self::InvalidRequest(message)
| Self::OrderNotExist(message)
| Self::RequestFailed(message)
| Self::Upstream(message)
| Self::Deserialize(message)
| Self::Crypto(message) => formatter.write_str(message),
Self::InvalidSignature(message) => formatter.write_str(message),
}
}
}
impl WechatPayError {
pub fn diagnostic_code(&self) -> &'static str {
match self {
Self::Disabled => "disabled",
Self::InvalidConfig(_) => "invalid_config",
Self::InvalidRequest(_) => "invalid_request",
Self::OrderNotExist(_) => "order_not_exist",
Self::RequestFailed(_) => "request_failed",
Self::Upstream(_) => "upstream",
Self::Deserialize(_) => "deserialize",
Self::Crypto(_) => "crypto",
Self::InvalidSignature(_) => "invalid_signature",
}
}
}
impl std::error::Error for WechatPayError {}
#[cfg(test)]
mod tests {
use super::*;
use cbc::cipher::{BlockEncryptMut, block_padding::NoPadding};
use reqwest::header::HeaderValue;
use serde_json::json;
const TEST_RSA_PUBLIC_KEY_SPKI_PEM: &str = r#"-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0MGrqlP1eWB08o5BsF1y
cMjr6oicQQjO3veTfYRdBPJYmluoVAM8Q6bRvRuayqmhKt0/HeKbFwm4hbXqyvlE
yyNbG/OQpcq++bS+2FBlCrr5j/G+YpltOmuIZbo5vmvuWxjoxpf5zvdvTPtonahJ
VprIAzq8+NsTpgRbBI/GI1KaFEg12pk/vjCMKgVLTH76bYjhAXaIvYpYJaDxzTlC
VxGe8+KWe/e3brg2CP84sFgCw4JRBRkrRqoJKhWnaj1CbaMEkWmebP48tWa/SMpk
QDeqguoVrBMCVVV75GSCSaGlxT8XxPr03T+H1M+xnQZaqD+0EyN5CABmuMisP9ql
KwIDAQAB
-----END PUBLIC KEY-----"#;
const TEST_RSA_SIGNATURE_BASE64: &str = "cuwiNfJ/Ck0UiG1xZl6T1tyAtap91hAXrfpH4FPI+8nzrbnX9NHj5T5DiUdGeuR+Y1BQ+N+y4M4SOih4g2oArdmWeGgfoKE/N7O61fN3SaEGfhSqSedrtTc02j3Yvk/2HeBtsdcgaLG8xb/ZFGifVTHAeGaHpT4Yy1tmP6V+Kd6FUoMVJsXdyDBxRRzWqssIKfEIfBO0gCZ/j34Hqrt6KLYeBu6hMW77YCe0ShpZO4An3MxpjcYAlkeF8fuhWdQsPz4DcF00mtoJWYg2ncTb6OCsCc4YeUC2dKHWo6S7vxsnr9qwp2XvRnRuYp9kHN7oTOCfs851lYmTYJJfMvjlLg==";
const TEST_REFUND_API_V3_KEY: &str = "0123456789abcdef0123456789abcdef";
const TEST_REFUND_MCH_ID: &str = "1900000100";
const TEST_REFUND_PLATFORM_SERIAL: &str = "PUB_KEY_ID_REFUND_TEST";
const TEST_REFUND_PRIVATE_KEY_PEM: &str = r#"-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDIcJQj53zLpqWa
d03vLxZjn4zUKi7aSiGTH5K9zcVsksySnsBt4RrkFqXSQligYT50o44Az04cc+cl
vjpzt1andBQz3jefv5S4Pc2hbVsjTkv3nJtei2niuFpvRc3MYFxS4fK2Nh1t2BqV
aUeXz0uJ3juyTNsgWEbN4zkw8bKDZKxLFqOxisv3vNopCkKKNRiqlaeN++MT/4xP
l6vD8+KHNMC7X4vMUiVK7g1yDi3cQcn+JhjhtPvzCOXsBzibBdhzhfIcmFg0zd5f
WJxMSOWWnrXlRaxCTykkk4dMGZfFuwmyfhgEEw0Ctm/Y3d/sRbYa1RHfmTk4md5S
bzshwJ8vAgMBAAECggEAHSw5e3JNOQzDrJQxrR7C2PQXWKfdEttYQKQHRQXGdrN0
/+AILNXnBox/TPdoE8ujffFyEQ8bFMwPt/AUbNQh0hR0fkoY+XC2Ugx4ucaDTcOv
DdZmzJ9o5V0kmG5l8hmSRmPpYfjFVr/tupJMn71FhsboFF7YQ8BlacDpCYUcEMBb
2zuk2v9Vt9TUSjx6SzGtuF+u7LeenytH1Mxkx7B6RsyrSFQ2ro0RMV7vOy208/XF
3w8C5G8LQjegukCzxBq3DaHXFTYG1+l3KLUW5yherZPoLhY7NRbqje1LelCLWH7N
Ed3seElOV4DFoqX/abBOwaj+r6yqZRvTRaryIN7dDQKBgQDltM4QGIGH/x8ATv7l
rloByPfTanqktUodj6xURkf4R6HHJEWtbp3Fqc6QbTqrKgSj0OpEnUEslYt035m5
p8C3Faf9yQYb65iGQFQ/KW6Co5EPhrv0tJUL862GpgQtj6QH7W7CkrtKT1kGqXJY
sKbwP0DfNXk3CdbRpXb8QqIu7QKBgQDfYim1tLOhlGnVouW1FgrrBo/8QGwZYOzv
UWDkifhaL0jYM/x3dbENbKZuG2K+n1MzmbteFcghudFeDM4rBIqLTX+SGIX7cg/V
2vl6UQWFQ5SMFL9i5WRY8B02S9Mo1NYVJInUW1rGxZXSaHmvGLzOhwfSnjnhCwU5
KSxDp1ynCwKBgEZHSiBsjvzf1klO4fe9nzYamZAqbLmF6dYfhfcGxbA1ok5+T38f
b8/xAwz3DlMd+Otx2KsDfdQ5MMCiRLMjmveo/YvM/DyAmVrBxcTJVVFM3+3jKBBX
S56rgDTJCFA0OVhfkVqgcALtQeyi3SQ7b+fjQmLIRnzLWtkribrmXB6FAoGAQ8A9
EExnMvmVg+MtMlgmJZRO9V4Aq03XbtpKveYOuiOfP1u9FepPMdgjPj/2GgSusnbM
+kM2+Oun0ZOmsCtnbcqqh4ACmvnETcNlaTDLsNiHZFkilvcmEUd6RK6Is9ihubXk
S9ENXj4tK6zeQzoIgcc4myu8OEIkamrPbxGCq3MCgYAqrW1pZHK5bNP9WUO3X+K1
PIaVC2CNiH5uI2Ot6RYO2jkrKHZ7RQvu4uOwDqN9zP39eWr1NJ+bGlXgsmYSmhxg
Rylk83d6D6ErDxVXHpXOU7YNRaI3N5fKIoSRn3t2XafQ+cF5BQzq/VckAC8rJtCV
7zCsCmQI/oIjX7q6GGKCRg==
-----END PRIVATE KEY-----"#;
const TEST_REFUND_PUBLIC_KEY_PEM: &str = r#"-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyHCUI+d8y6almndN7y8W
Y5+M1Cou2kohkx+Svc3FbJLMkp7AbeEa5Bal0kJYoGE+dKOOAM9OHHPnJb46c7dW
p3QUM943n7+UuD3NoW1bI05L95ybXotp4rhab0XNzGBcUuHytjYdbdgalWlHl89L
id47skzbIFhGzeM5MPGyg2SsSxajsYrL97zaKQpCijUYqpWnjfvjE/+MT5erw/Pi
hzTAu1+LzFIlSu4Ncg4t3EHJ/iYY4bT78wjl7Ac4mwXYc4XyHJhYNM3eX1icTEjl
lp615UWsQk8pJJOHTBmXxbsJsn4YBBMNArZv2N3f7EW2GtUR35k5OJneUm87IcCf
LwIDAQAB
-----END PUBLIC KEY-----"#;
#[test]
fn mock_pay_params_use_request_payment_shape() {
let params = build_mock_pay_params("recharge:user:1:points_60");
assert!(!params.time_stamp.is_empty());
assert_eq!(params.sign_type, "RSA");
assert!(params.package.starts_with("prepay_id=mock-"));
assert!(!params.pay_sign.is_empty());
}
#[test]
fn signed_json_error_preserves_refund_not_found_for_reconciliation() {
let error = map_signed_json_error_response(
"微信支付退款查询",
reqwest::StatusCode::NOT_FOUND,
r#"{"code":"RESOURCE_NOT_EXISTS","message":"退款单不存在"}"#.as_bytes(),
);
assert!(matches!(
error,
WechatPayError::OrderNotExist(message) if message == "退款单不存在"
));
let error = map_signed_json_error_response(
"微信支付退款查询",
reqwest::StatusCode::BAD_GATEWAY,
r#"{"code":"SYSTEM_ERROR","message":"系统错误"}"#.as_bytes(),
);
assert!(matches!(
error,
WechatPayError::Upstream(message)
if message == "微信支付退款查询失败:SYSTEM_ERROR: 系统错误"
));
}
#[test]
fn out_refund_no_accepts_all_provider_documented_ascii_symbols() {
assert_eq!(
normalize_out_refund_no("refund_A-1|2*@3").expect("out_refund_no should be valid"),
"refund_A-1|2*@3"
);
}
#[test]
fn refund_response_must_correlate_with_the_signed_request() {
let request = WechatPayRefundRequest {
transaction_id: "transaction-1".to_string(),
out_trade_no: "order-1".to_string(),
out_refund_no: "refund-1".to_string(),
reason: None,
notify_url: "https://api.example.com/refund-notify".to_string(),
refund_amount_cents: 300,
total_amount_cents: 600,
};
let refund = build_mock_refund(&request, "PROCESSING");
assert!(validate_created_refund_response(&refund, &request).is_ok());
assert!(validate_queried_refund_response(&refund, "refund-1").is_ok());
let mut mismatched_order = refund.clone();
mismatched_order.out_trade_no = "order-2".to_string();
assert!(matches!(
validate_created_refund_response(&mismatched_order, &request),
Err(WechatPayError::InvalidRequest(_))
));
let mut mismatched_amount = refund.clone();
mismatched_amount.amount_refund_cents = 200;
assert!(matches!(
validate_created_refund_response(&mismatched_amount, &request),
Err(WechatPayError::InvalidRequest(_))
));
assert!(matches!(
validate_queried_refund_response(&refund, "refund-2"),
Err(WechatPayError::InvalidRequest(_))
));
}
#[test]
fn jsapi_order_request_uses_wechat_v3_snake_case_fields() {
let body = serde_json::to_value(WechatJsapiOrderRequest {
appid: "wx-test-app",
mchid: "1900000001",
description: "陶泥儿 - 60泥点",
out_trade_no: "rcgtest001",
time_expire: "2026-05-15T10:05:00Z",
notify_url: "https://api.example.com/api/profile/recharge/wechat/notify",
amount: WechatJsapiAmount {
total: 600,
currency: "CNY",
},
payer: WechatJsapiPayer {
openid: "openid-test",
},
})
.expect("JSAPI order request should serialize");
assert_eq!(body["out_trade_no"], "rcgtest001");
assert_eq!(body["time_expire"], "2026-05-15T10:05:00Z");
assert_eq!(
body["notify_url"],
"https://api.example.com/api/profile/recharge/wechat/notify"
);
assert!(body.get("outTradeNo").is_none());
assert!(body.get("notifyUrl").is_none());
}
#[test]
fn h5_order_request_uses_wechat_required_scene_info() {
let body = serde_json::to_value(WechatH5OrderRequest {
appid: "wx-test-app",
mchid: "1900000001",
description: "陶泥儿 - 60泥点",
out_trade_no: "rcgtest001",
time_expire: "2026-05-15T10:05:00Z",
notify_url: "https://api.example.com/api/profile/recharge/wechat/notify",
amount: WechatJsapiAmount {
total: 600,
currency: "CNY",
},
scene_info: WechatH5SceneInfo {
payer_client_ip: "203.0.113.10",
h5_info: WechatH5Info { kind: "Wap" },
},
})
.expect("H5 order request should serialize");
assert_eq!(body["scene_info"]["payer_client_ip"], "203.0.113.10");
assert_eq!(body["scene_info"]["h5_info"]["type"], "Wap");
assert_eq!(body["time_expire"], "2026-05-15T10:05:00Z");
assert_eq!(body["amount"]["currency"], "CNY");
assert!(body.get("sceneInfo").is_none());
assert!(body["scene_info"].get("payerClientIp").is_none());
}
#[test]
fn native_order_request_uses_code_url_response_shape() {
let body = serde_json::to_value(WechatNativeOrderRequest {
appid: "wx-test-app",
mchid: "1900000001",
description: "陶泥儿 - 60泥点",
out_trade_no: "rcgtest001",
time_expire: "2026-05-15T10:05:00Z",
notify_url: "https://api.example.com/api/profile/recharge/wechat/notify",
amount: WechatJsapiAmount {
total: 600,
currency: "CNY",
},
scene_info: WechatNativeSceneInfo {
payer_client_ip: "203.0.113.10",
},
})
.expect("Native order request should serialize");
let response = serde_json::from_value::<WechatNativeOrderResponse>(json!({
"code_url": "weixin://pay.weixin.qq.com/bizpayurl/up?pr=test"
}))
.expect("Native order response should deserialize");
assert_eq!(body["scene_info"]["payer_client_ip"], "203.0.113.10");
assert_eq!(body["time_expire"], "2026-05-15T10:05:00Z");
assert_eq!(
response.code_url.as_deref(),
Some("weixin://pay.weixin.qq.com/bizpayurl/up?pr=test")
);
}
#[test]
fn wechat_pay_expire_time_uses_rfc3339_without_fractional_seconds() {
let value = OffsetDateTime::from_unix_timestamp(1_768_398_476)
.expect("timestamp should be valid")
.replace_nanosecond(395_297_400)
.expect("nanosecond should be valid");
let formatted =
format_wechat_pay_rfc3339_seconds(value, "测试时间").expect("time should format");
assert_eq!(formatted, "2026-01-14T13:47:56Z");
assert!(!formatted.contains('.'));
}
#[test]
fn transaction_endpoints_reuse_configured_wechat_pay_origin() {
let h5_endpoint = resolve_wechat_pay_transaction_endpoint(
"https://pay-gateway.example.com/v3/pay/transactions/jsapi",
WECHAT_PAY_H5_PATH,
)
.expect("H5 endpoint should resolve");
let native_endpoint = resolve_wechat_pay_transaction_endpoint(
"https://pay-gateway.example.com/v3/pay/transactions/jsapi",
WECHAT_PAY_NATIVE_PATH,
)
.expect("Native endpoint should resolve");
assert_eq!(
h5_endpoint,
"https://pay-gateway.example.com/v3/pay/transactions/h5"
);
assert_eq!(
native_endpoint,
"https://pay-gateway.example.com/v3/pay/transactions/native"
);
}
#[test]
fn jsapi_order_request_rejects_provider_field_limit_violations() {
assert!(validate_out_trade_no("abc12").is_err());
assert!(validate_out_trade_no("abc123").is_ok());
assert!(validate_out_trade_no("abc123_-|*").is_ok());
assert!(validate_out_trade_no("abc123中文").is_err());
assert!(validate_out_trade_no("a".repeat(33).as_str()).is_err());
assert!(validate_notify_url("https://api.example.com/pay/notify", "notify").is_ok());
assert!(validate_notify_url("https://api.example.com/pay/notify?x=1", "notify").is_err());
assert!(validate_notify_url(&format!("https://{}", "a".repeat(248)), "notify").is_err());
validate_non_empty_max_chars("陶泥儿 - 60泥点", WECHAT_PAY_DESCRIPTION_MAX_CHARS, "描述")
.expect("short description should pass");
assert!(
validate_non_empty_max_chars(
&"泥".repeat(128),
WECHAT_PAY_DESCRIPTION_MAX_CHARS,
"描述"
)
.is_err()
);
validate_non_empty_max_chars("openid-test", WECHAT_PAY_OPENID_MAX_CHARS, "openid")
.expect("short openid should pass");
assert!(
validate_non_empty_max_chars(&"o".repeat(129), WECHAT_PAY_OPENID_MAX_CHARS, "openid")
.is_err()
);
validate_non_empty_max_chars(
"203.0.113.10",
WECHAT_PAY_CLIENT_IP_MAX_CHARS,
"payer_client_ip",
)
.expect("short client ip should pass");
assert!(
validate_non_empty_max_chars(
&"1".repeat(46),
WECHAT_PAY_CLIENT_IP_MAX_CHARS,
"payer_client_ip",
)
.is_err()
);
}
#[test]
fn jsapi_order_request_sets_wechat_required_http_headers() {
let request = with_wechat_pay_jsapi_headers(
reqwest::Client::new()
.post("https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi")
.header(
"Authorization",
"WECHATPAY2-SHA256-RSA2048 mchid=\"1900000001\"",
),
"PUB_KEY_ID_0119000000012026051400000000000001",
)
.build()
.expect("request should build");
let headers = request.headers();
assert_eq!(
headers
.get(reqwest::header::ACCEPT)
.and_then(|value| value.to_str().ok()),
Some(WECHAT_PAY_ACCEPT_HEADER)
);
assert_eq!(
headers
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok()),
Some(WECHAT_PAY_CONTENT_TYPE_HEADER)
);
assert_eq!(
headers
.get(reqwest::header::USER_AGENT)
.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]
fn spki_platform_public_key_verifies_real_rsa_sha256_signature() {
let public_key_der = parse_public_key_pem(TEST_RSA_PUBLIC_KEY_SPKI_PEM)
.expect("SPKI platform public key should parse as PKCS#1 RSA key");
let message = build_notify_signature_message(
b"1778759600",
b"nonce-real-signature",
br#"{"id":"notify-1","event_type":"TRANSACTION.SUCCESS"}"#,
);
let signature_bytes = BASE64_STANDARD
.decode(TEST_RSA_SIGNATURE_BASE64)
.expect("test RSA signature should decode");
verify_rsa_sha256_signature(&public_key_der, &message, &signature_bytes)
.expect("SPKI-derived PKCS#1 public key should verify notification signature");
let mut tampered_message = message;
tampered_message.push(b' ');
assert!(
verify_rsa_sha256_signature(&public_key_der, &tampered_message, &signature_bytes)
.is_err(),
"tampered notification must fail signature verification"
);
}
#[test]
fn v3_transaction_notify_still_uses_the_shared_verified_envelope() {
let client = build_refund_notify_test_client();
let (headers, body) = build_transaction_notify_test_request(&client);
let notify = client
.parse_notify(&headers, &body)
.expect("signed encrypted transaction callback should parse");
assert_eq!(notify.app_id.as_deref(), Some("wx-refund-test"));
assert_eq!(notify.mch_id.as_deref(), Some(TEST_REFUND_MCH_ID));
assert_eq!(notify.out_trade_no, "transaction-order-secret");
assert_eq!(
notify.transaction_id.as_deref(),
Some("transaction-id-secret")
);
assert_eq!(notify.trade_state, "SUCCESS");
assert_eq!(
notify.success_time.as_deref(),
Some("2026-07-13T10:34:57+08:00")
);
assert_eq!(notify.amount_total_cents, Some(600));
}
#[test]
fn v3_refund_notify_verifies_decrypts_and_summarizes_all_terminal_events() {
let client = build_refund_notify_test_client();
for (event_type, refund_status) in [
("REFUND.SUCCESS", "SUCCESS"),
("REFUND.ABNORMAL", "ABNORMAL"),
("REFUND.CLOSED", "CLOSED"),
] {
let (headers, body) =
build_refund_notify_test_request(&client, event_type, refund_status);
let summary = client
.parse_refund_notify_debug(&headers, &body)
.expect("signed encrypted refund callback should parse");
assert_eq!(summary.event_type, event_type);
assert_eq!(summary.refund_status, refund_status);
assert!(summary.known_event);
assert_eq!(summary.original_type, "refund");
assert_eq!(summary.algorithm, "AEAD_AES_256_GCM");
assert_eq!(summary.amount_total_cents, 999);
assert_eq!(summary.amount_refund_cents, 888);
assert_eq!(summary.amount_payer_total_cents, 999);
assert_eq!(summary.amount_payer_refund_cents, 888);
assert!(summary.payload_fingerprint.starts_with("hmac-sha256:"));
}
}
#[test]
fn v3_refund_notify_returns_verified_business_fact_alongside_safe_debug_summary() {
let client = build_refund_notify_test_client();
let (headers, body) =
build_refund_notify_test_request(&client, "REFUND.SUCCESS", "SUCCESS");
let notification = client
.parse_refund_notify(&headers, &body)
.expect("verified refund notification should parse");
assert_eq!(notification.notification_id, "EV-REFUND-SECRET");
assert_eq!(notification.event_type, "REFUND.SUCCESS");
assert_eq!(
notification.refund.mch_id.as_deref(),
Some(TEST_REFUND_MCH_ID)
);
assert_eq!(
notification.refund.transaction_id,
"transaction-secret-value"
);
assert_eq!(notification.refund.out_trade_no, "order-secret-value");
assert_eq!(notification.refund.refund_id, "refund-secret-value");
assert_eq!(
notification.refund.out_refund_no,
"merchant-refund-secret-value"
);
assert_eq!(notification.refund.status, "SUCCESS");
assert_eq!(notification.refund.amount_total_cents, 999);
assert_eq!(notification.refund.amount_refund_cents, 888);
assert_eq!(notification.debug.refund_status, "SUCCESS");
}
#[test]
fn v3_refund_amount_contract_rejects_payer_amounts_above_provider_totals() {
assert!(validate_wechat_refund_amounts(600, 300, 601, 300, "test").is_err());
assert!(validate_wechat_refund_amounts(600, 300, 600, 301, "test").is_err());
}
#[test]
fn v3_signed_response_verification_rejects_body_and_serial_tampering() {
let client = build_refund_notify_test_client();
let body = br#"{"status":"SUCCESS"}"#;
let headers = sign_refund_notify_test_headers(&client, body);
client
.verify_response_signature(&headers, body)
.expect("signed response should verify");
assert!(matches!(
client.verify_response_signature(&headers, br#"{"status":"CLOSED"}"#),
Err(WechatPayError::InvalidSignature(_))
));
let mut wrong_serial = headers;
wrong_serial.insert(
"Wechatpay-Serial",
HeaderValue::from_static("PUB_KEY_ID_WRONG"),
);
assert!(matches!(
client.verify_response_signature(&wrong_serial, body),
Err(WechatPayError::InvalidSignature(_))
));
}
#[test]
fn refund_trade_bill_csv_parses_platform_refund_with_decimal_cents() {
let csv = concat!(
"交易时间,公众账号ID,商户号,微信订单号,商户订单号,交易状态,微信退款单号,商户退款单号,退款申请时间,退款成功时间,申请退款金额,退款金额,充值券退款金额,退款类型,退款状态\n",
"`2026-07-13 18:17:20,`wx-test,`1900000001,`tx-1,`order-1,`REFUND,`refund-1,`merchant-refund-1,`2026-07-13 18:17:20,`2026-07-13 18:17:23,`6.00,`5.50,`0.50,`PLATFORM-ORIGINAL,`SUCCESS\n",
"总交易单数,1,,,,,,,,,,,,,\n"
);
let rows = parse_refund_trade_bill_csv(csv.as_bytes())
.expect("official-shaped refund bill should parse");
assert_eq!(rows.len(), 1);
let row = &rows[0];
assert_eq!(row.transaction_id, "tx-1");
assert_eq!(row.out_trade_no, "order-1");
assert_eq!(row.refund_id, "refund-1");
assert_eq!(row.out_refund_no, "merchant-refund-1");
assert_eq!(row.refund_type, "PLATFORM-ORIGINAL");
assert_eq!(row.bill_refund_status, "SUCCESS");
assert_eq!(row.requested_refund_cents, 600);
assert_eq!(row.refunded_cents, 550);
assert_eq!(row.coupon_refund_cents, 50);
}
#[test]
fn refund_trade_bill_amount_parser_is_fixed_point_and_rejects_extra_precision() {
assert_eq!(parse_yuan_to_cents("`0").expect("whole yuan"), 0);
assert_eq!(parse_yuan_to_cents("`6.0").expect("one decimal"), 600);
assert_eq!(parse_yuan_to_cents("`6.01").expect("two decimals"), 601);
assert!(parse_yuan_to_cents("6.001").is_err());
assert!(parse_yuan_to_cents("-1.00").is_err());
}
#[test]
fn refund_trade_bill_hash_checks_decompressed_csv_bytes() {
let csv = b"header\nvalue\n";
let download = WechatPayTradeBillDownload {
hash_type: "SHA1".to_string(),
hash_value: hex::encode(Sha1::digest(csv)),
download_url: "https://api.example.com/v3/billdownload/file?token=test".to_string(),
};
verify_trade_bill_hash(&download, csv).expect("matching hash should pass");
assert!(verify_trade_bill_hash(&download, b"tampered").is_err());
}
#[test]
fn v3_refund_notify_rejects_signature_and_ciphertext_tampering() {
let client = build_refund_notify_test_client();
let (mut headers, body) =
build_refund_notify_test_request(&client, "REFUND.SUCCESS", "SUCCESS");
headers.insert("Wechatpay-Signature", HeaderValue::from_static("AAAA"));
assert!(matches!(
client.parse_refund_notify_debug(&headers, &body),
Err(WechatPayError::InvalidSignature(_))
));
let mut envelope: Value =
serde_json::from_slice(&body).expect("refund envelope should parse for tampering");
let ciphertext = envelope["resource"]["ciphertext"]
.as_str()
.expect("ciphertext should be a string");
let replacement = if ciphertext.starts_with('A') {
"B"
} else {
"A"
};
envelope["resource"]["ciphertext"] =
Value::String(format!("{replacement}{}", &ciphertext[1..]));
let tampered_body =
serde_json::to_vec(&envelope).expect("tampered envelope should serialize");
let tampered_headers = sign_refund_notify_test_headers(&client, &tampered_body);
assert!(matches!(
client.parse_refund_notify_debug(&tampered_headers, &tampered_body),
Err(WechatPayError::Crypto(_))
));
}
#[test]
fn v3_refund_notify_rejects_replays_probes_and_inconsistent_status() {
let client = build_refund_notify_test_client();
let (headers, body) =
build_refund_notify_test_request(&client, "REFUND.SUCCESS", "ABNORMAL");
assert!(matches!(
client.parse_refund_notify_debug(&headers, &body),
Err(WechatPayError::InvalidRequest(_))
));
let (headers, body) = build_refund_notify_test_request(&client, "REFUND.FUTURE", "FUTURE");
assert!(matches!(
client.parse_refund_notify_debug(&headers, &body),
Err(WechatPayError::InvalidRequest(_))
));
let (mut headers, body) =
build_refund_notify_test_request(&client, "REFUND.SUCCESS", "SUCCESS");
headers.insert(
"Wechatpay-Nonce",
HeaderValue::from_static("tampered-header-nonce"),
);
assert!(matches!(
client.parse_refund_notify_debug(&headers, &body),
Err(WechatPayError::InvalidSignature(_))
));
let mut serial_headers = sign_refund_notify_test_headers(&client, &body);
serial_headers.insert(
"Wechatpay-Serial",
HeaderValue::from_static("PUB_KEY_ID_WRONG"),
);
assert!(matches!(
client.parse_refund_notify_debug(&serial_headers, &body),
Err(WechatPayError::InvalidSignature(_))
));
let mut probe_headers = sign_refund_notify_test_headers(&client, &body);
probe_headers.insert(
"Wechatpay-Signature",
HeaderValue::from_static("WECHATPAY/SIGNTEST/AAAA"),
);
assert!(matches!(
client.parse_refund_notify_debug(&probe_headers, &body),
Err(WechatPayError::InvalidSignature(_))
));
let expired_timestamp = (OffsetDateTime::now_utc().unix_timestamp() - 301).to_string();
let expired_headers =
sign_refund_notify_test_headers_at(&client, &body, &expired_timestamp);
assert!(matches!(
client.parse_refund_notify_debug(&expired_headers, &body),
Err(WechatPayError::InvalidSignature(_))
));
}
#[test]
fn v3_refund_notify_duplicate_is_stably_acknowledgeable_without_plaintext_leaks() {
let client = build_refund_notify_test_client();
let (headers, body) =
build_refund_notify_test_request(&client, "REFUND.SUCCESS", "SUCCESS");
let first = client
.parse_refund_notify_debug(&headers, &body)
.expect("first refund callback should parse");
let duplicate = client
.parse_refund_notify_debug(&headers, &body)
.expect("duplicate refund callback should parse");
assert_eq!(first, duplicate);
let serialized = serde_json::to_string(&first).expect("summary should serialize");
for plaintext in [
"EV-REFUND-SECRET",
TEST_REFUND_MCH_ID,
"transaction-secret-value",
"order-secret-value",
"refund-secret-value",
"merchant-refund-secret-value",
"招商银行信用卡0403",
] {
assert!(
!serialized.contains(plaintext),
"refund debug summary must not contain plaintext: {plaintext}"
);
}
assert_eq!(
first
.user_received_account
.as_ref()
.expect("received account fingerprint should exist")
.bytes,
"招商银行信用卡0403".len()
);
}
#[test]
fn parse_mock_notify_defaults_success_state() {
let notify =
parse_mock_notify(br#"{"outTradeNo":"order-1"}"#).expect("mock notify should parse");
assert_eq!(notify.out_trade_no, "order-1");
assert_eq!(notify.transaction_id, None);
assert_eq!(notify.trade_state, "SUCCESS");
}
#[test]
fn parse_virtual_payment_notify_supports_goods_event_json() {
let notify = parse_virtual_payment_notify(
br#"{"Event":"xpay_goods_deliver_notify","OutTradeNo":"order-1","WeChatPayInfo":{"TransactionId":"wx-1","PaidTime":1710000000}}"#,
)
.expect("virtual payment notify should parse");
assert_eq!(notify.event, "xpay_goods_deliver_notify");
assert_eq!(notify.out_trade_no, "order-1");
assert_eq!(notify.transaction_id.as_deref(), Some("wx-1"));
assert_eq!(notify.paid_at_micros, Some(1_710_000_000_000_000));
}
#[test]
fn parse_virtual_payment_notify_supports_coin_event_xml() {
let notify = parse_virtual_payment_notify(
br#"<xml><Event><![CDATA[xpay_coin_pay_notify]]></Event><OutTradeNo><![CDATA[order-2]]></OutTradeNo><WeChatPayInfo><TransactionId><![CDATA[wx-2]]></TransactionId><PaidTime>1710000001</PaidTime></WeChatPayInfo></xml>"#,
)
.expect("virtual payment xml notify should parse");
assert_eq!(notify.event, "xpay_coin_pay_notify");
assert_eq!(notify.out_trade_no, "order-2");
assert_eq!(notify.transaction_id.as_deref(), Some("wx-2"));
assert_eq!(notify.paid_at_micros, Some(1_710_000_001_000_000));
}
#[test]
fn parse_virtual_payment_notify_event_supports_notifications_without_order_id() {
assert_eq!(
parse_virtual_payment_notify_event(
br#"{"Event":"xpay_refund_notify","MchRefundId":"refund-1"}"#,
)
.expect("refund event should parse"),
"xpay_refund_notify"
);
assert_eq!(
parse_virtual_payment_notify_event(
br#"<xml><Event><![CDATA[xpay_subscribe_ios_refund_query_notify]]></Event></xml>"#,
)
.expect("iOS refund query event should parse"),
"xpay_subscribe_ios_refund_query_notify"
);
}
#[test]
fn virtual_payment_debug_summary_recognizes_all_official_events() {
for event in WECHAT_VIRTUAL_PAYMENT_NOTIFY_EVENTS {
let body = format!(r#"{{"Event":"{event}"}}"#);
let summary =
build_virtual_payment_notify_debug_summary(body.as_bytes(), b"message-token")
.expect("official event should build a debug summary");
assert_eq!(summary.event, event);
assert!(summary.known_event, "event should be recognized: {event}");
}
let summary = build_virtual_payment_notify_debug_summary(
br#"{"Event":"xpay_future_notify"}"#,
b"message-token",
)
.expect("unknown event should still build a debug summary");
assert!(!summary.known_event);
assert_eq!(summary.event, "unknown");
assert!(summary.event_ref.is_some());
assert!(
!serde_json::to_string(&summary)
.expect("unknown summary should serialize")
.contains("xpay_future_notify")
);
}
#[test]
fn virtual_payment_debug_summary_is_useful_without_logging_plaintext_secrets() {
let body = serde_json::to_vec(&json!({
"Event": "xpay_goods_deliver_notify",
"CreateTime": 1_777_111_300,
"Env": 0,
"OpenId": "openid-secret-value",
"OutTradeNo": "order-secret-value",
"WeChatPayInfo": {
"TransactionId": "transaction-secret-value",
"PaidTime": 1_777_111_301
},
"AppleSubscriptionInfo": {
"OriginalTransactionId": "apple-transaction-secret-value",
"ProductId": "vip_month",
"SubscribePeriodDays": 30,
"Attach": "private-attach-value"
},
"ComplaintDetail": "private-complaint-value",
"MerchantCode": "merchant-secret-value",
"BusinessCode": "business-secret-value",
"BusinessState": "state-secret-value",
"Code": "code-secret-value",
"user-secret-as-key": "not-recorded"
}))
.expect("debug fixture should serialize");
let summary = build_virtual_payment_notify_debug_summary(&body, b"message-token")
.expect("debug summary should parse");
let serialized = serde_json::to_string(&summary).expect("debug summary should serialize");
assert!(summary.apple_subscription_info);
assert!(summary.subscription_info);
assert!(summary.primary_identifier_ref("user_ref").is_some());
assert!(summary.primary_identifier_ref("order_ref").is_some());
assert!(
summary
.primary_identifier_ref("provider_order_ref")
.is_some()
);
assert_eq!(
summary.safe_fields.get("wechatpayinfo.paidtime"),
Some(&json!(1_777_111_301_i64))
);
assert_eq!(
summary.safe_fields["applesubscriptioninfo.productid"]["chars"],
json!(9)
);
assert!(
summary
.sensitive_text_fields
.contains_key("applesubscriptioninfo.attach")
);
assert!(summary.schema_fields.contains(&"openid".to_string()));
assert!(
summary
.schema_fields
.iter()
.any(|field| field.starts_with("unknown_"))
);
assert!(summary.payload_fingerprint.starts_with("hmac-sha256:"));
for secret in [
"openid-secret-value",
"order-secret-value",
"transaction-secret-value",
"apple-transaction-secret-value",
"private-attach-value",
"private-complaint-value",
"merchant-secret-value",
"business-secret-value",
"state-secret-value",
"code-secret-value",
"user-secret-as-key",
"vip_month",
] {
assert!(
!serialized.contains(secret),
"debug output must not contain plaintext secret: {secret}"
);
}
}
#[test]
fn virtual_payment_debug_summary_covers_ios_refund_xml_fields() {
let summary = build_virtual_payment_notify_debug_summary(
br#"<xml><Event><![CDATA[xpay_subscribe_ios_refund_query_notify]]></Event><pay_order_id><![CDATA[order-secret]]></pay_order_id><channel_bill><![CDATA[channel-secret]]></channel_bill><refund_time>1777111300</refund_time><p_count>1</p_count><refund_request_reason><![CDATA[private-reason]]></refund_request_reason></xml>"#,
b"message-token",
)
.expect("iOS refund query XML should build a debug summary");
let serialized = serde_json::to_string(&summary).expect("summary should serialize");
assert_eq!(summary.event, "xpay_subscribe_ios_refund_query_notify");
assert!(summary.primary_identifier_ref("order_ref").is_some());
assert!(
summary
.primary_identifier_ref("provider_order_ref")
.is_some()
);
assert_eq!(
summary.safe_fields.get("refundtime"),
Some(&json!(1_777_111_300_i64))
);
assert_eq!(summary.safe_fields.get("pcount"), Some(&json!(1_i64)));
assert!(
summary
.sensitive_text_fields
.contains_key("refundrequestreason")
);
for secret in ["order-secret", "channel-secret", "private-reason"] {
assert!(!serialized.contains(secret));
}
}
#[test]
fn parse_virtual_payment_notify_rejects_missing_order_no() {
let error = parse_virtual_payment_notify(br#"{"Event":"xpay_goods_deliver_notify"}"#)
.expect_err("missing order id should fail");
match error {
WechatPayError::InvalidRequest(message) => {
assert!(message.contains("OutTradeNo"));
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn parse_virtual_payment_notify_leaves_missing_paid_time_for_authoritative_query() {
for body in [
br#"{"Event":"xpay_goods_deliver_notify","OutTradeNo":"order-1"}"#.as_slice(),
br#"{"Event":"xpay_goods_deliver_notify","OutTradeNo":"order-1","WeChatPayInfo":{"PaidTime":0}}"#.as_slice(),
br#"{"Event":"xpay_goods_deliver_notify","OutTradeNo":"order-1","WeChatPayInfo":{"PaidTime":9223372036854775807}}"#.as_slice(),
] {
let notify = parse_virtual_payment_notify(body)
.expect("order id is sufficient before authoritative query");
assert_eq!(notify.paid_at_micros, None);
}
}
#[test]
fn decode_wechat_message_push_encoding_aes_key_allows_trailing_bits() {
let canonical_key = BASE64_STANDARD.encode([0u8; 32]);
let mut encoding_aes_key = canonical_key.trim_end_matches('=').to_string();
encoding_aes_key.replace_range(encoding_aes_key.len() - 1.., "B");
let decoded = decode_wechat_message_push_encoding_aes_key(&encoding_aes_key)
.expect("wechat aes key with trailing bits should decode");
assert_eq!(decoded, vec![0u8; WECHAT_MINIPROGRAM_MESSAGE_AES_KEY_BYTES]);
}
#[test]
fn wechat_message_push_signature_uses_sorted_sha1_parts() {
let token = "token-1";
let timestamp = "1710000000";
let nonce = "nonce-1";
let encrypt = "encrypted-payload";
let signature = build_wechat_message_push_test_signature(token, timestamp, nonce, encrypt);
assert!(verify_wechat_message_push_signature(
token, timestamp, nonce, encrypt, &signature
));
assert!(!verify_wechat_message_push_signature(
token,
timestamp,
nonce,
"tampered-payload",
&signature
));
}
#[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";
let message = r#"{"Event":"xpay_coin_pay_notify","OutTradeNo":"order-1"}"#;
let encoding_aes_key = build_wechat_message_push_test_encoding_aes_key();
let encrypted =
encrypt_wechat_message_push_test_ciphertext(&encoding_aes_key, message, app_id);
let decrypted =
decrypt_wechat_message_push_ciphertext(&encoding_aes_key, &encrypted, Some(app_id))
.expect("encrypted message should decrypt");
assert_eq!(decrypted, message);
}
#[test]
fn wechat_message_push_rejects_mismatched_app_id() {
let encoding_aes_key = build_wechat_message_push_test_encoding_aes_key();
let encrypted = encrypt_wechat_message_push_test_ciphertext(
&encoding_aes_key,
r#"{"Event":"xpay_coin_pay_notify","OutTradeNo":"order-1"}"#,
"wx-real-app",
);
let error =
decrypt_wechat_message_push_ciphertext(&encoding_aes_key, &encrypted, Some("wx-other"))
.expect_err("mismatched app id should fail");
match error {
WechatPayError::InvalidSignature(message) => {
assert!(message.contains("appid"));
}
other => panic!("unexpected error: {other:?}"),
}
}
fn build_refund_notify_test_client() -> RealWechatPayClient {
RealWechatPayClient {
client: reqwest::Client::new(),
app_id: "wx-refund-test".to_string(),
mch_id: TEST_REFUND_MCH_ID.to_string(),
merchant_serial_no: "merchant-refund-test".to_string(),
private_key: Arc::new(
parse_rsa_private_key(TEST_REFUND_PRIVATE_KEY_PEM)
.expect("refund test private key should parse"),
),
platform_public_key_der: parse_public_key_pem(TEST_REFUND_PUBLIC_KEY_PEM)
.expect("refund test public key should parse"),
platform_serial_no: TEST_REFUND_PLATFORM_SERIAL.to_string(),
api_v3_key: TEST_REFUND_API_V3_KEY.to_string(),
notify_url: "https://api.example.com/api/profile/recharge/wechat/notify".to_string(),
jsapi_endpoint: "https://api.example.com/v3/pay/transactions/jsapi".to_string(),
h5_endpoint: "https://api.example.com/v3/pay/transactions/h5".to_string(),
native_endpoint: "https://api.example.com/v3/pay/transactions/native".to_string(),
query_order_endpoint_base: "https://api.example.com/v3/pay/transactions/out-trade-no"
.to_string(),
refund_endpoint: "https://api.example.com/v3/refund/domestic/refunds".to_string(),
trade_bill_endpoint: "https://api.example.com/v3/bill/tradebill".to_string(),
api_origin: "https://api.example.com".to_string(),
}
}
fn build_transaction_notify_test_request(client: &RealWechatPayClient) -> (HeaderMap, Vec<u8>) {
let plain_text = serde_json::to_vec(&json!({
"appid": "wx-refund-test",
"mchid": TEST_REFUND_MCH_ID,
"out_trade_no": "transaction-order-secret",
"transaction_id": "transaction-id-secret",
"trade_state": "SUCCESS",
"success_time": "2026-07-13T10:34:57+08:00",
"amount": { "total": 600 }
}))
.expect("transaction resource should serialize");
let associated_data = "transaction-associated-data";
let nonce = b"transnonce12";
let ciphertext = encrypt_refund_notify_test_resource(
client.api_v3_key.as_bytes(),
nonce,
associated_data.as_bytes(),
&plain_text,
);
let body = serde_json::to_vec(&json!({
"id": "EV-TRANSACTION-SECRET",
"create_time": "2026-07-13T10:34:56+08:00",
"resource_type": "encrypt-resource",
"event_type": "TRANSACTION.SUCCESS",
"summary": "支付成功",
"resource": {
"algorithm": "AEAD_AES_256_GCM",
"original_type": "transaction",
"ciphertext": ciphertext,
"nonce": String::from_utf8_lossy(nonce),
"associated_data": associated_data
}
}))
.expect("transaction notification envelope should serialize");
let headers = sign_refund_notify_test_headers(client, &body);
(headers, body)
}
fn build_refund_notify_test_request(
client: &RealWechatPayClient,
event_type: &str,
refund_status: &str,
) -> (HeaderMap, Vec<u8>) {
let success_time = (refund_status == "SUCCESS").then_some("2026-07-13T10:34:57+08:00");
let plain_text = serde_json::to_vec(&json!({
"mchid": TEST_REFUND_MCH_ID,
"transaction_id": "transaction-secret-value",
"out_trade_no": "order-secret-value",
"refund_id": "refund-secret-value",
"out_refund_no": "merchant-refund-secret-value",
"refund_status": refund_status,
"success_time": success_time,
"user_received_account": "招商银行信用卡0403",
"amount": {
"total": 999,
"refund": 888,
"payer_total": 999,
"payer_refund": 888
}
}))
.expect("refund resource should serialize");
let associated_data = "refund-associated-data";
let nonce = b"refundnonce1";
let ciphertext = encrypt_refund_notify_test_resource(
client.api_v3_key.as_bytes(),
nonce,
associated_data.as_bytes(),
&plain_text,
);
let body = serde_json::to_vec(&json!({
"id": "EV-REFUND-SECRET",
"create_time": "2026-07-13T10:34:56+08:00",
"resource_type": "encrypt-resource",
"event_type": event_type,
"summary": "退款结果",
"resource": {
"algorithm": "AEAD_AES_256_GCM",
"original_type": "refund",
"ciphertext": ciphertext,
"nonce": String::from_utf8_lossy(nonce),
"associated_data": associated_data
}
}))
.expect("refund notification envelope should serialize");
let headers = sign_refund_notify_test_headers(client, &body);
(headers, body)
}
fn sign_refund_notify_test_headers(client: &RealWechatPayClient, body: &[u8]) -> HeaderMap {
let timestamp = OffsetDateTime::now_utc().unix_timestamp().to_string();
sign_refund_notify_test_headers_at(client, body, &timestamp)
}
fn sign_refund_notify_test_headers_at(
client: &RealWechatPayClient,
body: &[u8],
timestamp: &str,
) -> HeaderMap {
let nonce = "refund-header-nonce";
let message = build_notify_signature_message(timestamp.as_bytes(), nonce.as_bytes(), body);
let signature = client
.sign_message(
std::str::from_utf8(&message).expect("test signature message should be UTF-8"),
)
.expect("refund test callback should sign");
let mut headers = HeaderMap::new();
headers.insert(
"Wechatpay-Timestamp",
HeaderValue::from_str(timestamp).expect("timestamp header should be valid"),
);
headers.insert("Wechatpay-Nonce", HeaderValue::from_static(nonce));
headers.insert(
"Wechatpay-Signature",
HeaderValue::from_str(&signature).expect("signature header should be valid"),
);
headers.insert(
"Wechatpay-Serial",
HeaderValue::from_static(TEST_REFUND_PLATFORM_SERIAL),
);
headers
}
fn encrypt_refund_notify_test_resource(
key: &[u8],
nonce: &[u8],
associated_data: &[u8],
plain_text: &[u8],
) -> String {
let nonce = aead::Nonce::try_assume_unique_for_key(nonce)
.expect("refund test nonce should be valid");
let key = aead::UnboundKey::new(&aead::AES_256_GCM, key)
.expect("refund test key should be valid");
let key = aead::LessSafeKey::new(key);
let mut ciphertext = plain_text.to_vec();
key.seal_in_place_append_tag(nonce, aead::Aad::from(associated_data), &mut ciphertext)
.expect("refund test resource should encrypt");
BASE64_STANDARD.encode(ciphertext)
}
fn build_wechat_message_push_test_signature(
token: &str,
timestamp: &str,
nonce: &str,
value: &str,
) -> String {
let mut parts = [token, timestamp, nonce, value];
parts.sort_unstable();
let mut hasher = Sha1::new();
hasher.update(parts.join("").as_bytes());
hex::encode(hasher.finalize())
}
fn build_wechat_message_push_test_encoding_aes_key() -> String {
let raw_key = std::array::from_fn::<_, 32, _>(|index| index as u8);
BASE64_STANDARD
.encode(raw_key)
.trim_end_matches('=')
.to_string()
}
fn encrypt_wechat_message_push_test_ciphertext(
encoding_aes_key: &str,
message: &str,
app_id: &str,
) -> String {
let key = decode_wechat_message_push_encoding_aes_key(encoding_aes_key)
.expect("test aes key should decode");
let mut plaintext = Vec::new();
plaintext.extend_from_slice(b"0123456789abcdef");
plaintext.extend_from_slice(&(message.as_bytes().len() as u32).to_be_bytes());
plaintext.extend_from_slice(message.as_bytes());
plaintext.extend_from_slice(app_id.as_bytes());
let pad_len = 32 - (plaintext.len() % 32);
plaintext.extend(std::iter::repeat(pad_len as u8).take(pad_len));
let iv = &key[..WECHAT_MINIPROGRAM_MESSAGE_RANDOM_BYTES];
let cipher = cbc::Encryptor::<Aes256>::new_from_slices(&key, iv)
.expect("test aes cipher should init");
let encrypted = cipher.encrypt_padded_vec_mut::<NoPadding>(&plaintext);
BASE64_STANDARD.encode(encrypted)
}
}