2345 lines
82 KiB
Rust
2345 lines
82 KiB
Rust
use std::{fs, path::Path, sync::Arc};
|
||
|
||
use aes::Aes256;
|
||
use axum::{
|
||
Json,
|
||
extract::{Query, State},
|
||
http::{HeaderMap, HeaderValue, StatusCode, header::CONTENT_TYPE},
|
||
response::{IntoResponse, Response},
|
||
};
|
||
use base64::{
|
||
Engine as _, alphabet,
|
||
engine::general_purpose::{GeneralPurpose, GeneralPurposeConfig, STANDARD as BASE64_STANDARD},
|
||
};
|
||
use bytes::Bytes;
|
||
use cbc::cipher::{BlockDecryptMut, KeyIvInit, block_padding::NoPadding};
|
||
use ring::{
|
||
aead,
|
||
rand::{SecureRandom, SystemRandom},
|
||
signature,
|
||
};
|
||
use serde::{Deserialize, Serialize};
|
||
use serde_json::{Value, json};
|
||
use sha1::Sha1;
|
||
use sha2::{Digest, Sha256};
|
||
use shared_contracts::runtime::{
|
||
WechatH5PaymentResponse, WechatMiniProgramPayParamsResponse, WechatNativePaymentResponse,
|
||
};
|
||
use shared_kernel::offset_datetime_to_unix_micros;
|
||
use std::convert::TryInto;
|
||
use time::OffsetDateTime;
|
||
use tracing::{info, warn};
|
||
use url::Url;
|
||
|
||
use crate::{http_error::AppError, state::AppState};
|
||
|
||
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_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_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_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),
|
||
);
|
||
|
||
#[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,
|
||
}
|
||
|
||
#[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 out_trade_no: String,
|
||
pub transaction_id: Option<String>,
|
||
pub trade_state: String,
|
||
pub success_time: Option<String>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
struct WechatVirtualPaymentNotifyOrder {
|
||
out_trade_no: String,
|
||
transaction_id: Option<String>,
|
||
paid_at_micros: Option<i64>,
|
||
event: String,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct WechatVirtualPaymentNotifyResponse {
|
||
#[serde(rename = "ErrCode")]
|
||
err_code: i32,
|
||
#[serde(rename = "ErrMsg")]
|
||
err_msg: String,
|
||
}
|
||
|
||
#[derive(Debug)]
|
||
pub enum WechatPayError {
|
||
Disabled,
|
||
InvalidConfig(String),
|
||
InvalidRequest(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,
|
||
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,
|
||
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,
|
||
notify_url: &'a str,
|
||
amount: WechatJsapiAmount,
|
||
scene_info: WechatNativeSceneInfo<'a>,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
struct WechatNativeSceneInfo<'a> {
|
||
payer_client_ip: &'a 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 WechatPayNotifyBody {
|
||
#[serde(default)]
|
||
resource: Option<WechatPayNotifyResource>,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct WechatPayNotifyResource {
|
||
ciphertext: String,
|
||
nonce: String,
|
||
#[serde(default)]
|
||
associated_data: Option<String>,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct WechatPayTransactionResource {
|
||
out_trade_no: String,
|
||
#[serde(default)]
|
||
transaction_id: Option<String>,
|
||
trade_state: String,
|
||
#[serde(default)]
|
||
success_time: Option<String>,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct WechatPayQueryOrderResponse {
|
||
out_trade_no: String,
|
||
#[serde(default)]
|
||
transaction_id: Option<String>,
|
||
trade_state: String,
|
||
#[serde(default)]
|
||
success_time: 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(crate) struct WechatMiniProgramMessagePushQuery {
|
||
signature: Option<String>,
|
||
timestamp: Option<String>,
|
||
nonce: Option<String>,
|
||
echostr: Option<String>,
|
||
msg_signature: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct WechatMiniProgramEncryptedMessage {
|
||
#[serde(rename = "ToUserName", alias = "to_user_name", default)]
|
||
to_user_name: Option<String>,
|
||
#[serde(rename = "Encrypt", alias = "encrypt")]
|
||
encrypt: String,
|
||
}
|
||
|
||
impl WechatPayClient {
|
||
pub fn from_config(config: &crate::config::AppConfig) -> Result<Self, WechatPayError> {
|
||
if !config.wechat_pay_enabled {
|
||
return Ok(Self::Disabled);
|
||
}
|
||
|
||
if config
|
||
.wechat_pay_provider
|
||
.trim()
|
||
.eq_ignore_ascii_case(WECHAT_PAY_PROVIDER_MOCK)
|
||
{
|
||
return Ok(Self::Mock);
|
||
}
|
||
|
||
if !config
|
||
.wechat_pay_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
|
||
.wechat_mini_program_app_id
|
||
.as_ref()
|
||
.or(config.wechat_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.wechat_pay_mch_id.as_deref(), "WECHAT_PAY_MCH_ID")?;
|
||
let merchant_serial_no = required_config(
|
||
config.wechat_pay_merchant_serial_no.as_deref(),
|
||
"WECHAT_PAY_MERCHANT_SERIAL_NO",
|
||
)?;
|
||
let private_key_pem = read_private_key_pem(
|
||
config.wechat_pay_private_key_pem.as_deref(),
|
||
config.wechat_pay_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.wechat_pay_platform_public_key_pem.as_deref(),
|
||
config.wechat_pay_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.wechat_pay_platform_serial_no.as_deref(),
|
||
"WECHAT_PAY_PLATFORM_SERIAL_NO",
|
||
)?;
|
||
let api_v3_key = required_config(
|
||
config.wechat_pay_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.wechat_pay_notify_url.as_deref(),
|
||
"WECHAT_PAY_NOTIFY_URL",
|
||
)?;
|
||
validate_notify_url(¬ify_url, "WECHAT_PAY_NOTIFY_URL")?;
|
||
let jsapi_endpoint = normalize_required_url(
|
||
&config.wechat_pay_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)?;
|
||
|
||
Ok(Self::Real(Arc::new(RealWechatPayClient {
|
||
client: reqwest::Client::new(),
|
||
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,
|
||
})))
|
||
}
|
||
|
||
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 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 {
|
||
out_trade_no: normalize_out_trade_no(order_id)?,
|
||
transaction_id: Some(format!("mock-{order_id}")),
|
||
trade_state: "SUCCESS".to_string(),
|
||
success_time: Some(OffsetDateTime::now_utc().to_string()),
|
||
}),
|
||
Self::Real(client) => client.query_order_by_out_trade_no(order_id).await,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl RealWechatPayClient {
|
||
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 body = serde_json::to_string(&WechatJsapiOrderRequest {
|
||
appid: &self.app_id,
|
||
mchid: &self.mch_id,
|
||
description: &request.description,
|
||
out_trade_no: &request.order_id,
|
||
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 body = serde_json::to_string(&WechatH5OrderRequest {
|
||
appid: &self.app_id,
|
||
mchid: &self.mch_id,
|
||
description: &request.description,
|
||
out_trade_no: &request.order_id,
|
||
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 body = serde_json::to_string(&WechatNativeOrderRequest {
|
||
appid: &self.app_id,
|
||
mchid: &self.mch_id,
|
||
description: &request.description,
|
||
out_trade_no: &request.order_id,
|
||
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 })
|
||
}
|
||
|
||
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 {
|
||
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> {
|
||
self.verify_notify_signature(headers, body)?;
|
||
let notify = serde_json::from_slice::<WechatPayNotifyBody>(body).map_err(|error| {
|
||
WechatPayError::Deserialize(format!("微信支付通知解析失败:{error}"))
|
||
})?;
|
||
let resource = notify.resource.ok_or_else(|| {
|
||
WechatPayError::InvalidRequest("微信支付通知缺少 resource".to_string())
|
||
})?;
|
||
let plain_text = decrypt_aes_256_gcm(
|
||
self.api_v3_key.as_bytes(),
|
||
resource.nonce.as_bytes(),
|
||
resource.associated_data.as_deref().unwrap_or("").as_bytes(),
|
||
resource.ciphertext.as_str(),
|
||
)?;
|
||
let transaction = serde_json::from_slice::<WechatPayTransactionResource>(&plain_text)
|
||
.map_err(|error| {
|
||
WechatPayError::Deserialize(format!("微信支付通知资源解析失败:{error}"))
|
||
})?;
|
||
|
||
Ok(WechatPayNotifyOrder {
|
||
out_trade_no: transaction.out_trade_no,
|
||
transaction_id: transaction
|
||
.transaction_id
|
||
.map(|value| value.trim().to_string())
|
||
.filter(|value| !value.is_empty()),
|
||
trade_state: transaction.trade_state,
|
||
success_time: transaction.success_time,
|
||
})
|
||
}
|
||
|
||
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 response_text = response.text().await.map_err(|error| {
|
||
WechatPayError::Deserialize(format!("微信支付查单响应读取失败:{error}"))
|
||
})?;
|
||
if !status.is_success() {
|
||
return Err(WechatPayError::Upstream(format!(
|
||
"微信支付查单失败:HTTP {status},{response_text}"
|
||
)));
|
||
}
|
||
let payload = serde_json::from_str::<WechatPayQueryOrderResponse>(&response_text).map_err(
|
||
|error| WechatPayError::Deserialize(format!("微信支付查单响应解析失败:{error}")),
|
||
)?;
|
||
|
||
Ok(WechatPayNotifyOrder {
|
||
out_trade_no: payload.out_trade_no,
|
||
transaction_id: payload
|
||
.transaction_id
|
||
.map(|value| value.trim().to_string())
|
||
.filter(|value| !value.is_empty()),
|
||
trade_state: payload.trade_state,
|
||
success_time: payload.success_time,
|
||
})
|
||
}
|
||
|
||
fn verify_notify_signature(
|
||
&self,
|
||
headers: &HeaderMap,
|
||
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 {
|
||
warn!(
|
||
received_serial = serial,
|
||
configured_serial = self.platform_serial_no.as_str(),
|
||
"微信支付通知平台公钥序列号不匹配"
|
||
);
|
||
return Err(WechatPayError::InvalidSignature(format!(
|
||
"微信支付通知平台公钥序列号不匹配:received={serial}"
|
||
)));
|
||
}
|
||
if signature.starts_with(WECHAT_PAY_SIGNATURE_TEST_PREFIX) {
|
||
warn!("收到微信支付签名探测通知");
|
||
return Err(WechatPayError::InvalidSignature(
|
||
"微信支付签名探测通知".to_string(),
|
||
));
|
||
}
|
||
|
||
let message = build_notify_signature_message(timestamp.as_bytes(), nonce.as_bytes(), body);
|
||
let signature_bytes = BASE64_STANDARD.decode(signature).map_err(|_| {
|
||
WechatPayError::InvalidSignature("微信支付通知签名 base64 无效".to_string())
|
||
})?;
|
||
let public_key = signature::UnparsedPublicKey::new(
|
||
&signature::RSA_PKCS1_2048_8192_SHA256,
|
||
&self.platform_public_key_der,
|
||
);
|
||
public_key
|
||
.verify(&message, &signature_bytes)
|
||
.map_err(|_| WechatPayError::InvalidSignature("微信支付通知签名验签失败".to_string()))
|
||
}
|
||
|
||
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))
|
||
}
|
||
}
|
||
|
||
pub async fn handle_wechat_pay_notify(
|
||
State(state): State<AppState>,
|
||
headers: HeaderMap,
|
||
body: Bytes,
|
||
) -> Result<StatusCode, AppError> {
|
||
let notify = state
|
||
.wechat_pay_client()
|
||
.parse_notify(&headers, &body)
|
||
.map_err(map_wechat_pay_notify_error)?;
|
||
if notify.trade_state != "SUCCESS" {
|
||
info!(
|
||
order_id = notify.out_trade_no.as_str(),
|
||
trade_state = notify.trade_state.as_str(),
|
||
"收到非成功微信支付通知"
|
||
);
|
||
return Ok(StatusCode::NO_CONTENT);
|
||
}
|
||
|
||
let paid_at_micros = notify
|
||
.success_time
|
||
.as_deref()
|
||
.and_then(|value| shared_kernel::parse_rfc3339(value).ok())
|
||
.map(offset_datetime_to_unix_micros)
|
||
.unwrap_or_else(current_unix_micros);
|
||
|
||
state
|
||
.spacetime_client()
|
||
.mark_profile_recharge_order_paid(
|
||
notify.out_trade_no.clone(),
|
||
paid_at_micros,
|
||
notify.transaction_id.clone(),
|
||
)
|
||
.await
|
||
.map_err(|error| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY)
|
||
.with_message(format!("确认微信支付订单失败:{error}"))
|
||
})?;
|
||
info!(
|
||
order_id = notify.out_trade_no.as_str(),
|
||
"微信支付通知已确认订单入账"
|
||
);
|
||
|
||
Ok(StatusCode::NO_CONTENT)
|
||
}
|
||
|
||
pub async fn handle_wechat_virtual_payment_message_push_verify(
|
||
State(state): State<AppState>,
|
||
Query(query): Query<WechatMiniProgramMessagePushQuery>,
|
||
) -> Response {
|
||
let token = match read_wechat_message_push_config(
|
||
state.config.wechat_mini_program_message_token.as_deref(),
|
||
"WECHAT_MINIPROGRAM_MESSAGE_TOKEN",
|
||
) {
|
||
Ok(token) => token,
|
||
Err(error) => return build_wechat_message_push_verify_error_response(error),
|
||
};
|
||
let aes_key = match read_wechat_message_push_config(
|
||
state
|
||
.config
|
||
.wechat_mini_program_message_encoding_aes_key
|
||
.as_deref(),
|
||
"WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY",
|
||
) {
|
||
Ok(value) => value,
|
||
Err(error) => return build_wechat_message_push_verify_error_response(error),
|
||
};
|
||
match resolve_wechat_message_push_verify_response(
|
||
token,
|
||
aes_key,
|
||
state
|
||
.config
|
||
.wechat_mini_program_app_id
|
||
.as_deref()
|
||
.or(state.config.wechat_app_id.as_deref()),
|
||
&query,
|
||
) {
|
||
Ok(plaintext) => (StatusCode::OK, plaintext).into_response(),
|
||
Err(error) => build_wechat_message_push_verify_error_response(error),
|
||
}
|
||
}
|
||
|
||
pub async fn handle_wechat_virtual_payment_notify(
|
||
State(state): State<AppState>,
|
||
headers: HeaderMap,
|
||
Query(query): Query<WechatMiniProgramMessagePushQuery>,
|
||
body: Bytes,
|
||
) -> Response {
|
||
let response_format = detect_virtual_payment_notify_response_format(&headers, &body);
|
||
let encrypted_payload = match parse_wechat_mini_program_message_push_payload(&body) {
|
||
Ok(payload) => payload,
|
||
Err(error) => return build_virtual_payment_notify_error_response(error, response_format),
|
||
};
|
||
let token = match read_wechat_message_push_config(
|
||
state.config.wechat_mini_program_message_token.as_deref(),
|
||
"WECHAT_MINIPROGRAM_MESSAGE_TOKEN",
|
||
) {
|
||
Ok(token) => token,
|
||
Err(error) => return build_virtual_payment_notify_error_response(error, response_format),
|
||
};
|
||
let aes_key = match read_wechat_message_push_config(
|
||
state
|
||
.config
|
||
.wechat_mini_program_message_encoding_aes_key
|
||
.as_deref(),
|
||
"WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY",
|
||
) {
|
||
Ok(value) => value,
|
||
Err(error) => return build_virtual_payment_notify_error_response(error, response_format),
|
||
};
|
||
let signature = query
|
||
.msg_signature
|
||
.as_deref()
|
||
.or(query.signature.as_deref())
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.unwrap_or("");
|
||
let timestamp = query.timestamp.as_deref().map(str::trim).unwrap_or("");
|
||
let nonce = query.nonce.as_deref().map(str::trim).unwrap_or("");
|
||
if signature.is_empty() || timestamp.is_empty() || nonce.is_empty() {
|
||
return build_virtual_payment_notify_error_response(
|
||
WechatPayError::InvalidRequest("微信消息推送加密参数不完整".to_string()),
|
||
response_format,
|
||
);
|
||
}
|
||
if !verify_wechat_message_push_signature(
|
||
token,
|
||
timestamp,
|
||
nonce,
|
||
encrypted_payload.encrypt.as_str(),
|
||
signature,
|
||
) {
|
||
return build_virtual_payment_notify_error_response(
|
||
WechatPayError::InvalidSignature("微信消息推送 msg_signature 无效".to_string()),
|
||
response_format,
|
||
);
|
||
}
|
||
let notify_body = match decrypt_wechat_message_push_ciphertext(
|
||
aes_key,
|
||
encrypted_payload.encrypt.as_str(),
|
||
state
|
||
.config
|
||
.wechat_mini_program_app_id
|
||
.as_deref()
|
||
.or(state.config.wechat_app_id.as_deref()),
|
||
) {
|
||
Ok(body) => body,
|
||
Err(error) => return build_virtual_payment_notify_error_response(error, response_format),
|
||
};
|
||
let notify = match parse_virtual_payment_notify(notify_body.as_bytes()) {
|
||
Ok(notify) => notify,
|
||
Err(error) => return build_virtual_payment_notify_error_response(error, response_format),
|
||
};
|
||
if notify.event != "xpay_goods_deliver_notify" && notify.event != "xpay_coin_pay_notify" {
|
||
info!(
|
||
event = notify.event.as_str(),
|
||
order_id = notify.out_trade_no.as_str(),
|
||
"收到非订单入账虚拟支付推送"
|
||
);
|
||
return build_virtual_payment_notify_success_response(response_format);
|
||
}
|
||
|
||
let paid_at_micros = notify.paid_at_micros.unwrap_or_else(current_unix_micros);
|
||
if state
|
||
.spacetime_client()
|
||
.mark_profile_recharge_order_paid(
|
||
notify.out_trade_no.clone(),
|
||
paid_at_micros,
|
||
notify.transaction_id.clone(),
|
||
)
|
||
.await
|
||
.is_err()
|
||
{
|
||
warn!(
|
||
order_id = notify.out_trade_no.as_str(),
|
||
"确认微信虚拟支付订单失败"
|
||
);
|
||
return build_virtual_payment_notify_error_response(
|
||
WechatPayError::Upstream("确认微信虚拟支付订单失败".to_string()),
|
||
response_format,
|
||
);
|
||
}
|
||
|
||
state.publish_profile_recharge_order_update(notify.out_trade_no.clone());
|
||
|
||
info!(
|
||
event = notify.event.as_str(),
|
||
order_id = notify.out_trade_no.as_str(),
|
||
"微信虚拟支付推送已确认订单入账"
|
||
);
|
||
|
||
build_virtual_payment_notify_success_response(response_format)
|
||
}
|
||
|
||
pub fn map_wechat_pay_error(error: WechatPayError) -> AppError {
|
||
match error {
|
||
WechatPayError::Disabled => AppError::from_status(StatusCode::BAD_REQUEST)
|
||
.with_message("微信支付暂未启用")
|
||
.with_details(json!({ "provider": "wechat_pay" })),
|
||
WechatPayError::InvalidConfig(message) => {
|
||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
|
||
.with_message(message)
|
||
.with_details(json!({ "provider": "wechat_pay" }))
|
||
}
|
||
WechatPayError::InvalidRequest(message) => AppError::from_status(StatusCode::BAD_REQUEST)
|
||
.with_message(message)
|
||
.with_details(json!({ "provider": "wechat_pay" })),
|
||
WechatPayError::RequestFailed(message)
|
||
| WechatPayError::Upstream(message)
|
||
| WechatPayError::Deserialize(message)
|
||
| WechatPayError::Crypto(message) => AppError::from_status(StatusCode::BAD_GATEWAY)
|
||
.with_message(message)
|
||
.with_details(json!({ "provider": "wechat_pay" })),
|
||
WechatPayError::InvalidSignature(message) => {
|
||
AppError::from_status(StatusCode::UNAUTHORIZED)
|
||
.with_message("微信支付通知签名无效")
|
||
.with_details(json!({ "provider": "wechat_pay", "reason": message }))
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn map_wechat_pay_init_error(error: WechatPayError) -> crate::state::AppStateInitError {
|
||
crate::state::AppStateInitError::WechatPay(error.to_string())
|
||
}
|
||
|
||
pub fn build_wechat_payment_request(
|
||
order_id: String,
|
||
product_title: String,
|
||
amount_cents: u64,
|
||
payer_openid: String,
|
||
) -> WechatMiniProgramOrderRequest {
|
||
WechatMiniProgramOrderRequest {
|
||
order_id,
|
||
description: format!("陶泥儿 - {product_title}"),
|
||
amount_cents,
|
||
payer_openid,
|
||
}
|
||
}
|
||
|
||
pub fn build_wechat_web_payment_request(
|
||
order_id: String,
|
||
product_title: String,
|
||
amount_cents: u64,
|
||
payer_client_ip: String,
|
||
) -> WechatWebOrderRequest {
|
||
WechatWebOrderRequest {
|
||
order_id,
|
||
description: format!("陶泥儿 - {product_title}"),
|
||
amount_cents,
|
||
payer_client_ip,
|
||
}
|
||
}
|
||
|
||
pub fn current_unix_micros() -> i64 {
|
||
let value = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
|
||
i64::try_from(value).unwrap_or(i64::MAX)
|
||
}
|
||
|
||
fn map_wechat_pay_notify_error(error: WechatPayError) -> AppError {
|
||
warn!(error = %error, "微信支付通知处理失败");
|
||
map_wechat_pay_error(error)
|
||
}
|
||
|
||
fn read_wechat_message_push_config<'a>(
|
||
value: Option<&'a str>,
|
||
key: &str,
|
||
) -> Result<&'a str, WechatPayError> {
|
||
value
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.ok_or_else(|| WechatPayError::InvalidConfig(format!("{key} 未配置")))
|
||
}
|
||
|
||
fn build_wechat_message_push_verify_error_response(error: WechatPayError) -> Response {
|
||
let message = match error {
|
||
WechatPayError::Disabled => "微信消息推送暂未启用".to_string(),
|
||
WechatPayError::InvalidConfig(message)
|
||
| WechatPayError::InvalidRequest(message)
|
||
| WechatPayError::RequestFailed(message)
|
||
| WechatPayError::Upstream(message)
|
||
| WechatPayError::Deserialize(message)
|
||
| WechatPayError::Crypto(message)
|
||
| WechatPayError::InvalidSignature(message) => message,
|
||
};
|
||
(StatusCode::BAD_REQUEST, message).into_response()
|
||
}
|
||
|
||
fn resolve_wechat_message_push_verify_response(
|
||
token: &str,
|
||
aes_key: &str,
|
||
expected_app_id: Option<&str>,
|
||
query: &WechatMiniProgramMessagePushQuery,
|
||
) -> Result<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())
|
||
}
|
||
|
||
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}"))
|
||
})
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
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 })
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
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)
|
||
.map(|paid_time| paid_time.saturating_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 build_virtual_payment_notify_error_response(
|
||
error: WechatPayError,
|
||
response_format: VirtualPaymentNotifyResponseFormat,
|
||
) -> Response {
|
||
warn!(error = %error, "微信虚拟支付通知处理失败");
|
||
let message = match error {
|
||
WechatPayError::Disabled => "微信虚拟支付暂未启用".to_string(),
|
||
WechatPayError::InvalidConfig(message)
|
||
| WechatPayError::InvalidRequest(message)
|
||
| WechatPayError::RequestFailed(message)
|
||
| WechatPayError::Upstream(message)
|
||
| WechatPayError::Deserialize(message)
|
||
| WechatPayError::Crypto(message)
|
||
| WechatPayError::InvalidSignature(message) => message,
|
||
};
|
||
build_virtual_payment_notify_response(response_format, 1, message)
|
||
}
|
||
|
||
fn build_virtual_payment_notify_success_response(
|
||
response_format: VirtualPaymentNotifyResponseFormat,
|
||
) -> Response {
|
||
build_virtual_payment_notify_response(response_format, 0, "success")
|
||
}
|
||
|
||
fn build_virtual_payment_notify_response(
|
||
response_format: VirtualPaymentNotifyResponseFormat,
|
||
err_code: i32,
|
||
err_msg: impl Into<String>,
|
||
) -> Response {
|
||
let err_msg = err_msg.into();
|
||
match response_format {
|
||
VirtualPaymentNotifyResponseFormat::Json => Json(
|
||
build_wechat_virtual_payment_notify_response(err_code, err_msg),
|
||
)
|
||
.into_response(),
|
||
VirtualPaymentNotifyResponseFormat::Xml => {
|
||
let body = format!(
|
||
"<xml><ErrCode>{err_code}</ErrCode><ErrMsg><![CDATA[{err_msg}]]></ErrMsg></xml>"
|
||
);
|
||
let mut response = (StatusCode::OK, body).into_response();
|
||
response.headers_mut().insert(
|
||
CONTENT_TYPE,
|
||
HeaderValue::from_static("application/xml; charset=utf-8"),
|
||
);
|
||
response
|
||
}
|
||
}
|
||
}
|
||
|
||
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 {
|
||
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_mock_native_payment(order_id: &str) -> WechatNativePaymentResponse {
|
||
WechatNativePaymentResponse {
|
||
code_url: format!(
|
||
"weixin://pay.weixin.qq.com/bizpayurl/up?pr=mock-{}",
|
||
hex_sha256(order_id.as_bytes())
|
||
),
|
||
}
|
||
}
|
||
|
||
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 {
|
||
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),
|
||
})
|
||
}
|
||
|
||
fn build_wechat_virtual_payment_notify_response(
|
||
err_code: i32,
|
||
err_msg: impl Into<String>,
|
||
) -> WechatVirtualPaymentNotifyResponse {
|
||
WechatVirtualPaymentNotifyResponse {
|
||
err_code,
|
||
err_msg: err_msg.into(),
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Copy)]
|
||
enum VirtualPaymentNotifyResponseFormat {
|
||
Json,
|
||
Xml,
|
||
}
|
||
|
||
fn detect_virtual_payment_notify_response_format(
|
||
headers: &HeaderMap,
|
||
body: &[u8],
|
||
) -> VirtualPaymentNotifyResponseFormat {
|
||
let content_type = headers
|
||
.get(CONTENT_TYPE)
|
||
.and_then(|value| value.to_str().ok())
|
||
.unwrap_or("")
|
||
.to_ascii_lowercase();
|
||
if content_type.contains("xml") {
|
||
return VirtualPaymentNotifyResponseFormat::Xml;
|
||
}
|
||
let body_trimmed = body
|
||
.iter()
|
||
.copied()
|
||
.skip_while(|byte| byte.is_ascii_whitespace())
|
||
.next();
|
||
match body_trimmed {
|
||
Some(b'<') => VirtualPaymentNotifyResponseFormat::Xml,
|
||
_ => VirtualPaymentNotifyResponseFormat::Json,
|
||
}
|
||
}
|
||
|
||
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 url = Url::parse(jsapi_endpoint)
|
||
.map_err(|_| WechatPayError::InvalidConfig("WECHAT_PAY_JSAPI_ENDPOINT 无效".to_string()))?;
|
||
let origin = url
|
||
.origin()
|
||
.ascii_serialization()
|
||
.trim_end_matches('/')
|
||
.to_string();
|
||
Ok(format!("{origin}/v3/pay/transactions/out-trade-no"))
|
||
}
|
||
|
||
fn resolve_wechat_pay_transaction_endpoint(
|
||
jsapi_endpoint: &str,
|
||
transaction_path: &str,
|
||
) -> Result<String, WechatPayError> {
|
||
let url = Url::parse(jsapi_endpoint)
|
||
.map_err(|_| WechatPayError::InvalidConfig("WECHAT_PAY_JSAPI_ENDPOINT 无效".to_string()))?;
|
||
let origin = url
|
||
.origin()
|
||
.ascii_serialization()
|
||
.trim_end_matches('/')
|
||
.to_string();
|
||
Ok(format!("{origin}{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 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(),
|
||
));
|
||
}
|
||
Ok(der)
|
||
}
|
||
|
||
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::RequestFailed(message)
|
||
| Self::Upstream(message)
|
||
| Self::Deserialize(message)
|
||
| Self::Crypto(message) => formatter.write_str(message),
|
||
Self::InvalidSignature(message) => formatter.write_str(message),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl std::error::Error for WechatPayError {}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use cbc::cipher::{BlockEncryptMut, block_padding::NoPadding};
|
||
|
||
#[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 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",
|
||
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["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",
|
||
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["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",
|
||
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!(
|
||
response.code_url.as_deref(),
|
||
Some("weixin://pay.weixin.qq.com/bizpayurl/up?pr=test")
|
||
);
|
||
}
|
||
|
||
#[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 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_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 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_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)
|
||
}
|
||
}
|