91c333ea4a
移除 platform-wechat 的 openssl 测试依赖 使用固定 RSA PEM fixture 保持签名与解析测试语义 记录 Windows MSVC 原生 OpenSSL 测试依赖陷阱
4710 lines
171 KiB
Rust
4710 lines
171 KiB
Rust
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(¬ify_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, ×tamp, &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, ×tamp, &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>(¬ify.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>(¬ify.plain_text)
|
||
.map_err(|error| {
|
||
WechatPayError::Deserialize(format!(
|
||
"微信支付 V3 退款通知解密资源解析失败:{error}"
|
||
))
|
||
})?;
|
||
if !WECHAT_PAY_REFUND_NOTIFY_EVENTS.contains(¬ify.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(¬ify.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(¬ify.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, ×tamp, &nonce, "")?;
|
||
let response = with_wechat_pay_json_headers(
|
||
self.client
|
||
.get(request_url)
|
||
.header("Authorization", authorization),
|
||
&self.platform_serial_no,
|
||
)
|
||
.send()
|
||
.await
|
||
.map_err(|error| WechatPayError::RequestFailed(format!("微信支付查单请求失败:{error}")))?;
|
||
let status = response.status();
|
||
let 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, ×tamp, &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, ×tamp, &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(
|
||
¬ify_url,
|
||
WECHAT_PAY_NOTIFY_URL_MAX_CHARS,
|
||
"微信支付退款 notify_url",
|
||
)?;
|
||
validate_notify_url(¬ify_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, ×tamp)
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|