feat: add wechat mini program virtual payment

This commit is contained in:
kdletters
2026-05-31 22:44:22 +08:00
parent 78448d2a7b
commit 3db956ec81
24 changed files with 919 additions and 99 deletions

View File

@@ -1,11 +1,15 @@
use std::{fs, path::Path, sync::Arc};
use aes::Aes256;
use axum::{
extract::State,
http::{HeaderMap, StatusCode},
Json,
extract::{Query, State},
http::{HeaderMap, HeaderValue, StatusCode, header::CONTENT_TYPE},
response::{IntoResponse, Response},
};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use bytes::Bytes;
use cbc::cipher::{BlockDecryptMut, KeyIvInit, block_padding::NoPadding};
use ring::{
aead,
rand::{SecureRandom, SystemRandom},
@@ -13,11 +17,13 @@ use ring::{
};
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;
@@ -43,6 +49,10 @@ 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;
#[derive(Clone, Debug)]
pub enum WechatPayClient {
@@ -92,6 +102,22 @@ pub struct WechatPayNotifyOrder {
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,
@@ -220,6 +246,45 @@ struct WechatPayQueryOrderResponse {
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 {
@@ -806,6 +871,172 @@ pub async fn handle_wechat_pay_notify(
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),
};
let signature = query
.msg_signature
.as_deref()
.or(query.signature.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("");
let timestamp = query.timestamp.as_deref().map(str::trim).unwrap_or("");
let nonce = query.nonce.as_deref().map(str::trim).unwrap_or("");
let echostr = query.echostr.as_deref().map(str::trim).unwrap_or("");
if signature.is_empty() || timestamp.is_empty() || nonce.is_empty() || echostr.is_empty() {
return build_wechat_message_push_verify_error_response(WechatPayError::InvalidRequest(
"微信消息推送校验参数不完整".to_string(),
));
}
if !verify_wechat_message_push_signature(token, timestamp, nonce, echostr, signature) {
return build_wechat_message_push_verify_error_response(WechatPayError::InvalidSignature(
"微信消息推送校验签名无效".to_string(),
));
}
match decrypt_wechat_message_push_ciphertext(
aes_key,
echostr,
state
.config
.wechat_mini_program_app_id
.as_deref()
.or(state.config.wechat_app_id.as_deref()),
) {
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,
);
}
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)
@@ -875,6 +1106,320 @@ fn map_wechat_pay_notify_error(error: WechatPayError) -> AppError {
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 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 = BASE64_STANDARD
.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,
@@ -965,6 +1510,45 @@ fn parse_mock_notify(body: &[u8]) -> Result<WechatPayNotifyOrder, WechatPayError
})
}
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)
@@ -1330,6 +1914,7 @@ 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() {
@@ -1551,4 +2136,141 @@ mod tests {
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 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_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)
}
}