补齐微信虚拟支付查单与补偿

接入微信官方虚拟支付查单并缓存稳定接口令牌
让用户确认与订单过期补偿统一核对支付类型、状态、金额和单号
增加单笔历史订单的 dry-run 与确认指纹补单工具
隔离运行时身份与敏感 openid 文件并补充配置契约和回归测试
This commit is contained in:
2026-07-13 13:12:44 +08:00
parent 06aa99e3eb
commit 2f69f2fda2
16 changed files with 1343 additions and 33 deletions
+1
View File
@@ -4547,6 +4547,7 @@ dependencies = [
"sha2",
"shared-contracts",
"time",
"tokio",
"tracing",
"url",
"urlencoding",
+18
View File
@@ -145,6 +145,7 @@ pub struct AppConfig {
pub wechat_mini_program_virtual_payment_offer_id: Option<String>,
pub wechat_mini_program_virtual_payment_app_key: Option<String>,
pub wechat_mini_program_virtual_payment_sandbox_app_key: Option<String>,
pub wechat_mini_program_virtual_payment_query_order_endpoint: String,
pub wechat_mini_program_message_token: Option<String>,
pub wechat_mini_program_message_encoding_aes_key: Option<String>,
pub wechat_mini_program_subscribe_message_enabled: bool,
@@ -400,6 +401,8 @@ impl Default for AppConfig {
wechat_mini_program_virtual_payment_offer_id: None,
wechat_mini_program_virtual_payment_app_key: None,
wechat_mini_program_virtual_payment_sandbox_app_key: None,
wechat_mini_program_virtual_payment_query_order_endpoint:
"https://api.weixin.qq.com/xpay/query_order".to_string(),
wechat_mini_program_message_token: None,
wechat_mini_program_message_encoding_aes_key: None,
wechat_mini_program_subscribe_message_enabled: true,
@@ -941,6 +944,11 @@ impl AppConfig {
read_first_non_empty_env(&["WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY"]);
config.wechat_mini_program_virtual_payment_sandbox_app_key =
read_first_non_empty_env(&["WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY"]);
if let Some(endpoint) =
read_first_non_empty_env(&["WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_QUERY_ORDER_ENDPOINT"])
{
config.wechat_mini_program_virtual_payment_query_order_endpoint = endpoint;
}
config.wechat_mini_program_message_token =
read_first_non_empty_env(&["WECHAT_MINIPROGRAM_MESSAGE_TOKEN"]);
config.wechat_mini_program_message_encoding_aes_key =
@@ -2083,6 +2091,7 @@ mod tests {
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID");
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY");
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY");
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_QUERY_ORDER_ENDPOINT");
std::env::remove_var("WECHAT_MINIPROGRAM_MESSAGE_TOKEN");
std::env::remove_var("WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY");
std::env::remove_var("WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED");
@@ -2110,6 +2119,10 @@ mod tests {
"WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY",
"sandbox-app-key-001",
);
std::env::set_var(
"WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_QUERY_ORDER_ENDPOINT",
"http://127.0.0.1:18080/xpay/query_order",
);
std::env::set_var("WECHAT_MINIPROGRAM_MESSAGE_TOKEN", "message-token-001");
std::env::set_var(
"WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY",
@@ -2181,6 +2194,10 @@ mod tests {
);
assert_eq!(config.wechat_mini_program_subscribe_message_state, "trial");
assert_eq!(config.wechat_mini_program_virtual_payment_env, 1);
assert_eq!(
config.wechat_mini_program_virtual_payment_query_order_endpoint,
"http://127.0.0.1:18080/xpay/query_order"
);
unsafe {
std::env::remove_var("WECHAT_PAY_ENABLED");
@@ -2195,6 +2212,7 @@ mod tests {
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID");
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY");
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY");
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_QUERY_ORDER_ENDPOINT");
std::env::remove_var("WECHAT_MINIPROGRAM_MESSAGE_TOKEN");
std::env::remove_var("WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY");
std::env::remove_var("WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED");
@@ -4,12 +4,20 @@ use module_runtime::{
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL,
RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeOrderStatus,
};
use platform_wechat::WechatError;
use platform_wechat::pay::{WechatPayError, WechatPayNotifyOrder};
use shared_kernel::{offset_datetime_to_unix_micros, parse_rfc3339};
use tokio::time::sleep;
use tracing::{debug, info, warn};
use crate::{state::AppState, wechat::pay::current_unix_micros};
use crate::{
state::AppState,
wechat::pay::{
build_wechat_virtual_payment_query_order_request, current_unix_micros,
is_wechat_virtual_payment_order_paid, paid_at_micros_from_wechat_virtual_payment_order,
validate_wechat_virtual_payment_order,
},
};
const PROFILE_RECHARGE_EXPIRATION_LISTENER_RECONNECT_DELAY: Duration = Duration::from_secs(5);
const PROFILE_RECHARGE_EXPIRATION_CATCH_UP_LIMIT: u32 = 100;
@@ -184,16 +192,7 @@ async fn process_expired_profile_recharge_order_once(
}
if order.payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL {
mark_profile_recharge_expiration_checked(
state,
&order.order_id,
Some(current_unix_micros()),
Some("VIRTUAL_UNQUERYABLE".to_string()),
Some("wechat_mp_virtual has no v3 transaction query".to_string()),
)
.await?;
state.publish_profile_recharge_order_update(order.order_id.clone());
return Ok(());
return process_expired_virtual_payment_order(state, order).await;
}
let wechat_order = match state
@@ -260,6 +259,65 @@ async fn process_expired_profile_recharge_order_once(
}
}
async fn process_expired_virtual_payment_order(
state: &AppState,
order: &RuntimeProfileRechargeOrderRecord,
) -> Result<(), ExpirationCompensationError> {
let identity = state
.wechat_auth_service()
.get_identity_by_user_id(&order.user_id)
.map_err(|error| {
ExpirationCompensationError::Runtime(format!(
"failed to read WeChat identity for virtual payment query: {error}"
))
})?
.ok_or_else(|| {
ExpirationCompensationError::Runtime(
"virtual payment query requires the user's WeChat identity".to_string(),
)
})?;
let query_request = build_wechat_virtual_payment_query_order_request(
&state.config,
identity.provider_uid,
order.order_id.clone(),
)?;
let wechat_order = state
.wechat_client()
.query_virtual_payment_order(query_request)
.await?;
validate_wechat_virtual_payment_order(&order.order_id, order.amount_cents, &wechat_order)?;
if is_wechat_virtual_payment_order_paid(wechat_order.status) {
let paid_at_micros = paid_at_micros_from_wechat_virtual_payment_order(&wechat_order);
state
.spacetime_client()
.mark_profile_recharge_order_paid(
order.order_id.clone(),
paid_at_micros,
wechat_order.wxpay_order_id.or(wechat_order.wx_order_id),
)
.await?;
state.publish_profile_recharge_order_update(order.order_id.clone());
info!(
order_id = order.order_id.as_str(),
virtual_payment_status = wechat_order.status,
"expired virtual payment recharge order compensated as paid"
);
return Ok(());
}
mark_profile_recharge_expiration_checked(
state,
&order.order_id,
Some(current_unix_micros()),
Some(format!("VIRTUAL_STATUS_{}", wechat_order.status)),
None,
)
.await?;
state.publish_profile_recharge_order_update(order.order_id.clone());
Ok(())
}
async fn mark_expired_profile_recharge_order_paid(
state: &AppState,
order: &RuntimeProfileRechargeOrderRecord,
@@ -306,19 +364,9 @@ async fn mark_profile_recharge_expiration_checked(
Ok(())
}
async fn record_profile_recharge_expiration_error(
state: &AppState,
order_id: &str,
error: String,
) {
if let Err(mark_error) = mark_profile_recharge_expiration_checked(
state,
order_id,
None,
None,
Some(error),
)
.await
async fn record_profile_recharge_expiration_error(state: &AppState, order_id: &str, error: String) {
if let Err(mark_error) =
mark_profile_recharge_expiration_checked(state, order_id, None, None, Some(error)).await
{
warn!(
order_id,
@@ -330,11 +378,18 @@ async fn record_profile_recharge_expiration_error(
#[derive(Debug)]
enum ExpirationCompensationError {
Wechat(WechatError),
WechatPay(WechatPayError),
Spacetime(spacetime_client::SpacetimeClientError),
Runtime(String),
}
impl From<WechatError> for ExpirationCompensationError {
fn from(error: WechatError) -> Self {
Self::Wechat(error)
}
}
impl From<WechatPayError> for ExpirationCompensationError {
fn from(error: WechatPayError) -> Self {
Self::WechatPay(error)
@@ -350,6 +405,7 @@ impl From<spacetime_client::SpacetimeClientError> for ExpirationCompensationErro
impl std::fmt::Display for ExpirationCompensationError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Wechat(error) => write!(formatter, "wechat error: {error}"),
Self::WechatPay(error) => write!(formatter, "wechat pay error: {error}"),
Self::Spacetime(error) => write!(formatter, "spacetime error: {error}"),
Self::Runtime(message) => formatter.write_str(message),
@@ -89,11 +89,14 @@ use crate::{
api_response::json_success_body,
auth::AuthenticatedAccessToken,
http_error::AppError,
platform_errors::map_wechat_error,
request_context::RequestContext,
state::AppState,
wechat::pay::{
build_wechat_payment_request, build_wechat_web_payment_request, current_unix_micros,
map_wechat_pay_error,
build_wechat_payment_request, build_wechat_virtual_payment_query_order_request,
build_wechat_web_payment_request, current_unix_micros,
is_wechat_virtual_payment_order_paid, map_wechat_pay_error,
paid_at_micros_from_wechat_virtual_payment_order, validate_wechat_virtual_payment_order,
},
};
@@ -389,6 +392,52 @@ pub async fn confirm_wechat_profile_recharge_order(
));
}
if order.payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL {
let openid = resolve_wechat_identity_for_payment(&state, &order.user_id)
.await
.map_err(|error| runtime_profile_error_response(&request_context, error))?;
let query_request = build_wechat_virtual_payment_query_order_request(
&state.config,
openid,
order.order_id.clone(),
)
.map_err(|error| {
runtime_profile_error_response(&request_context, map_wechat_error(error))
})?;
let wechat_order = state
.wechat_client()
.query_virtual_payment_order(query_request)
.await
.map_err(|error| {
runtime_profile_error_response(&request_context, map_wechat_error(error))
})?;
validate_wechat_virtual_payment_order(&order.order_id, order.amount_cents, &wechat_order)
.map_err(|error| {
runtime_profile_error_response(&request_context, map_wechat_error(error))
})?;
if !is_wechat_virtual_payment_order_paid(wechat_order.status) {
return Ok(json_success_body(
Some(&request_context),
build_wechat_profile_recharge_order_confirmation(center, order),
));
}
let paid_at_micros = paid_at_micros_from_wechat_virtual_payment_order(&wechat_order);
let (center, order) = state
.spacetime_client()
.mark_profile_recharge_order_paid(
wechat_order.order_id,
paid_at_micros,
wechat_order.wxpay_order_id.or(wechat_order.wx_order_id),
)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
state.publish_profile_recharge_order_update(order.order_id.clone());
return Ok(json_success_body(
Some(&request_context),
build_wechat_profile_recharge_order_confirmation(center, order),
+3
View File
@@ -1883,6 +1883,9 @@ fn build_wechat_client(config: &AppConfig) -> WechatClient {
subscribe_message_endpoint: config
.wechat_mini_program_subscribe_message_endpoint
.clone(),
virtual_payment_query_order_endpoint: config
.wechat_mini_program_virtual_payment_query_order_endpoint
.clone(),
})
}
@@ -11,6 +11,9 @@ use platform_wechat::pay::{
parse_virtual_payment_notify, parse_wechat_mini_program_message_push_payload,
resolve_wechat_message_push_verify_response, verify_wechat_message_push_signature,
};
use platform_wechat::{
WechatError, WechatVirtualPaymentOrder, WechatVirtualPaymentQueryOrderRequest,
};
use serde::Serialize;
use serde_json::json;
use shared_kernel::offset_datetime_to_unix_micros;
@@ -248,6 +251,80 @@ pub fn build_wechat_pay_config(config: &AppConfig) -> WechatPayConfig {
}
}
pub fn build_wechat_virtual_payment_query_order_request(
config: &AppConfig,
openid: String,
order_id: String,
) -> Result<WechatVirtualPaymentQueryOrderRequest, WechatError> {
let app_key = match config.wechat_mini_program_virtual_payment_env {
0 => config
.wechat_mini_program_virtual_payment_app_key
.as_deref(),
1 => config
.wechat_mini_program_virtual_payment_sandbox_app_key
.as_deref(),
env => {
return Err(WechatError::InvalidConfig(format!(
"微信虚拟支付查单 env 只允许 0 或 1,当前为 {env}"
)));
}
}
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WechatError::InvalidConfig(match config.wechat_mini_program_virtual_payment_env {
1 => "微信虚拟支付沙箱 AppKey 未配置".to_string(),
_ => "微信虚拟支付 AppKey 未配置".to_string(),
})
})?;
Ok(WechatVirtualPaymentQueryOrderRequest {
openid,
order_id,
env: config.wechat_mini_program_virtual_payment_env,
app_key: app_key.to_string(),
})
}
pub fn validate_wechat_virtual_payment_order(
expected_order_id: &str,
expected_amount_cents: u64,
order: &WechatVirtualPaymentOrder,
) -> Result<(), WechatError> {
if order.order_id != expected_order_id {
return Err(WechatError::Upstream(
"微信虚拟支付查单返回的订单号与本地订单不一致".to_string(),
));
}
if !matches!(order.order_type, 0 | 7) {
return Err(WechatError::Upstream(
"微信虚拟支付查单返回的不是可入账支付单".to_string(),
));
}
if order.order_fee != expected_amount_cents {
return Err(WechatError::Upstream(
"微信虚拟支付查单返回的金额与本地订单不一致".to_string(),
));
}
if !(0..=10).contains(&order.status) {
return Err(WechatError::Upstream(
"微信虚拟支付查单返回了未知订单状态".to_string(),
));
}
Ok(())
}
pub fn is_wechat_virtual_payment_order_paid(status: i64) -> bool {
matches!(status, 2..=4)
}
pub fn paid_at_micros_from_wechat_virtual_payment_order(order: &WechatVirtualPaymentOrder) -> i64 {
order
.paid_time
.and_then(|seconds| seconds.checked_mul(1_000_000))
.unwrap_or_else(current_unix_micros)
}
pub fn map_wechat_pay_error(error: WechatPayError) -> AppError {
match error {
WechatPayError::Disabled => AppError::from_status(StatusCode::BAD_REQUEST)
@@ -345,6 +422,82 @@ fn build_wechat_message_push_verify_error_response(error: WechatPayError) -> Res
(StatusCode::BAD_REQUEST, message).into_response()
}
#[cfg(test)]
mod tests {
use super::{
build_wechat_virtual_payment_query_order_request, is_wechat_virtual_payment_order_paid,
validate_wechat_virtual_payment_order,
};
use crate::config::AppConfig;
use platform_wechat::WechatVirtualPaymentOrder;
#[test]
fn virtual_payment_query_uses_the_key_for_the_selected_environment() {
let config = AppConfig {
wechat_mini_program_virtual_payment_app_key: Some("production-key".to_string()),
wechat_mini_program_virtual_payment_sandbox_app_key: Some("sandbox-key".to_string()),
wechat_mini_program_virtual_payment_env: 1,
..AppConfig::default()
};
let request = build_wechat_virtual_payment_query_order_request(
&config,
"openid-001".to_string(),
"order-001".to_string(),
)
.expect("sandbox query request should build");
assert_eq!(request.app_key, "sandbox-key");
assert_eq!(request.env, 1);
}
#[test]
fn virtual_payment_query_only_treats_paid_and_delivery_states_as_paid() {
assert!(!is_wechat_virtual_payment_order_paid(0));
assert!(!is_wechat_virtual_payment_order_paid(1));
assert!(is_wechat_virtual_payment_order_paid(2));
assert!(is_wechat_virtual_payment_order_paid(3));
assert!(is_wechat_virtual_payment_order_paid(4));
assert!(!is_wechat_virtual_payment_order_paid(5));
assert!(!is_wechat_virtual_payment_order_paid(6));
}
#[test]
fn virtual_payment_query_rejects_a_mismatched_amount_before_crediting() {
let order = WechatVirtualPaymentOrder {
order_id: "order-001".to_string(),
status: 2,
order_fee: 601,
order_type: 0,
paid_time: Some(1_777_111_300),
wx_order_id: Some("wx-order-001".to_string()),
wxpay_order_id: Some("wxpay-order-001".to_string()),
};
assert!(validate_wechat_virtual_payment_order("order-001", 600, &order).is_err());
}
#[test]
fn virtual_payment_query_accepts_ios_payment_and_rejects_refunds() {
let mut order = WechatVirtualPaymentOrder {
order_id: "order-001".to_string(),
status: 2,
order_fee: 600,
order_type: 7,
paid_time: Some(1_777_111_300),
wx_order_id: Some("wx-order-001".to_string()),
wxpay_order_id: None,
};
validate_wechat_virtual_payment_order("order-001", 600, &order)
.expect("iOS payment order should be eligible for confirmation");
for refund_type in [1, 8] {
order.order_type = refund_type;
assert!(validate_wechat_virtual_payment_order("order-001", 600, &order).is_err());
}
}
}
fn build_virtual_payment_notify_error_response(
error: WechatPayError,
response_format: VirtualPaymentNotifyResponseFormat,
@@ -17,7 +17,11 @@ sha1 = { workspace = true }
sha2 = { workspace = true }
shared-contracts = { workspace = true }
time = { workspace = true, features = ["formatting"] }
tokio = { workspace = true, features = ["sync"] }
tracing = { workspace = true }
url = { workspace = true }
urlencoding = { workspace = true }
x509-parser = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
+3 -1
View File
@@ -7,5 +7,7 @@ pub use pay::{
};
pub use subscribe_message::{
DEFAULT_WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT, DEFAULT_WECHAT_SUBSCRIBE_MESSAGE_ENDPOINT,
WechatClient, WechatConfig, WechatError, WechatErrorKind, WechatSubscribeMessageRequest,
DEFAULT_WECHAT_VIRTUAL_PAYMENT_QUERY_ORDER_ENDPOINT, WechatClient, WechatConfig, WechatError,
WechatErrorKind, WechatSubscribeMessageRequest, WechatVirtualPaymentOrder,
WechatVirtualPaymentQueryOrderRequest,
};
@@ -1,8 +1,16 @@
use std::{collections::BTreeMap, error::Error, fmt};
use std::{
collections::BTreeMap,
error::Error,
fmt,
sync::Arc,
time::{Duration, Instant},
};
use reqwest::Client;
use ring::hmac;
use serde::{Deserialize, Serialize};
use serde_json::json;
use tokio::sync::Mutex;
use tracing::warn;
use url::Url;
@@ -10,6 +18,10 @@ pub const DEFAULT_WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT: &str =
"https://api.weixin.qq.com/cgi-bin/stable_token";
pub const DEFAULT_WECHAT_SUBSCRIBE_MESSAGE_ENDPOINT: &str =
"https://api.weixin.qq.com/cgi-bin/message/subscribe/send";
pub const DEFAULT_WECHAT_VIRTUAL_PAYMENT_QUERY_ORDER_ENDPOINT: &str =
"https://api.weixin.qq.com/xpay/query_order";
const WECHAT_VIRTUAL_PAYMENT_QUERY_ORDER_URI: &str = "/xpay/query_order";
const WECHAT_ACCESS_TOKEN_REFRESH_SAFETY_MARGIN: Duration = Duration::from_secs(5 * 60);
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WechatConfig {
@@ -17,12 +29,20 @@ pub struct WechatConfig {
pub app_secret: Option<String>,
pub stable_access_token_endpoint: String,
pub subscribe_message_endpoint: String,
pub virtual_payment_query_order_endpoint: String,
}
#[derive(Clone, Debug)]
pub struct WechatClient {
client: Client,
config: WechatConfig,
access_token_cache: Arc<Mutex<Option<WechatAccessTokenCacheEntry>>>,
}
#[derive(Clone, Debug)]
struct WechatAccessTokenCacheEntry {
access_token: String,
expires_at: Instant,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -35,6 +55,25 @@ pub struct WechatSubscribeMessageRequest {
pub data: BTreeMap<String, String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WechatVirtualPaymentQueryOrderRequest {
pub openid: String,
pub order_id: String,
pub env: u8,
pub app_key: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WechatVirtualPaymentOrder {
pub order_id: String,
pub status: i64,
pub order_fee: u64,
pub order_type: i64,
pub paid_time: Option<i64>,
pub wx_order_id: Option<String>,
pub wxpay_order_id: Option<String>,
}
#[derive(Debug, PartialEq, Eq)]
pub enum WechatError {
InvalidConfig(String),
@@ -54,6 +93,7 @@ pub enum WechatErrorKind {
#[derive(Debug, Deserialize)]
struct WechatStableAccessTokenResponse {
access_token: Option<String>,
expires_in: Option<u64>,
errcode: Option<i64>,
errmsg: Option<String>,
}
@@ -64,6 +104,34 @@ struct WechatSubscribeMessageResponse {
errmsg: Option<String>,
}
#[derive(Debug, Serialize)]
struct WechatVirtualPaymentQueryOrderBody<'a> {
openid: &'a str,
env: u8,
order_id: &'a str,
}
#[derive(Debug, Deserialize)]
struct WechatVirtualPaymentQueryOrderResponse {
errcode: i64,
errmsg: Option<String>,
order: Option<WechatVirtualPaymentQueryOrderPayload>,
}
#[derive(Debug, Deserialize)]
struct WechatVirtualPaymentQueryOrderPayload {
order_id: String,
status: i64,
order_fee: u64,
order_type: i64,
#[serde(default)]
paid_time: Option<i64>,
#[serde(default)]
wx_order_id: Option<String>,
#[serde(default)]
wxpay_order_id: Option<String>,
}
#[derive(Debug, Serialize)]
struct WechatTemplateDataValue {
value: String,
@@ -74,6 +142,7 @@ impl WechatClient {
Self {
client: Client::new(),
config,
access_token_cache: Arc::new(Mutex::new(None)),
}
}
@@ -146,11 +215,125 @@ impl WechatClient {
Ok(())
}
pub async fn query_virtual_payment_order(
&self,
request: WechatVirtualPaymentQueryOrderRequest,
) -> Result<WechatVirtualPaymentOrder, WechatError> {
let app_id = self
.config
.app_id
.as_deref()
.and_then(non_empty)
.ok_or_else(|| WechatError::InvalidConfig("微信小程序 AppID 未配置".to_string()))?;
let app_secret = self
.config
.app_secret
.as_deref()
.and_then(non_empty)
.ok_or_else(|| WechatError::InvalidConfig("微信小程序 AppSecret 未配置".to_string()))?;
let openid = non_empty(&request.openid)
.ok_or_else(|| WechatError::InvalidConfig("微信虚拟支付查单缺少 openid".to_string()))?;
let order_id = non_empty(&request.order_id).ok_or_else(|| {
WechatError::InvalidConfig("微信虚拟支付查单缺少 order_id".to_string())
})?;
let app_key = non_empty(&request.app_key)
.ok_or_else(|| WechatError::InvalidConfig("微信虚拟支付查单缺少 AppKey".to_string()))?;
if request.env > 1 {
return Err(WechatError::InvalidConfig(
"微信虚拟支付查单 env 只允许 0 或 1".to_string(),
));
}
let body = serde_json::to_string(&WechatVirtualPaymentQueryOrderBody {
openid,
env: request.env,
order_id,
})
.map_err(|error| {
WechatError::DeserializeFailed(format!("微信虚拟支付查单请求序列化失败:{error}"))
})?;
let pay_sig = calc_virtual_payment_pay_signature(
app_key,
WECHAT_VIRTUAL_PAYMENT_QUERY_ORDER_URI,
&body,
);
let access_token = self.request_access_token(app_id, app_secret).await?;
let mut url =
Url::parse(&self.config.virtual_payment_query_order_endpoint).map_err(|error| {
WechatError::InvalidConfig(format!("微信虚拟支付查单地址非法:{error}"))
})?;
url.query_pairs_mut()
.append_pair("access_token", &access_token)
.append_pair("pay_sig", &pay_sig);
let response = self
.client
.post(url.as_str())
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(body)
.send()
.await
.map_err(|_| {
warn!("微信虚拟支付查单请求失败");
WechatError::RequestFailed("微信虚拟支付查单请求失败".to_string())
})?;
let status = response.status();
let response_text = response.text().await.map_err(|error| {
warn!(error = %error, "微信虚拟支付查单响应读取失败");
WechatError::DeserializeFailed("微信虚拟支付查单响应读取失败".to_string())
})?;
if !status.is_success() {
return Err(WechatError::Upstream(format!(
"微信虚拟支付查单失败:HTTP {status}"
)));
}
let response =
serde_json::from_str::<WechatVirtualPaymentQueryOrderResponse>(&response_text)
.map_err(|error| {
warn!(error = %error, "微信虚拟支付查单响应解析失败");
WechatError::DeserializeFailed("微信虚拟支付查单响应非法".to_string())
})?;
if response.errcode != 0 {
return Err(WechatError::Upstream(format!(
"微信虚拟支付查单返回错误:{}",
response
.errmsg
.filter(|message| !message.trim().is_empty())
.unwrap_or_else(|| format!("errcode={}", response.errcode))
)));
}
let order = response
.order
.ok_or_else(|| WechatError::Upstream("微信虚拟支付查单响应缺少 order".to_string()))?;
if order.order_id != order_id {
return Err(WechatError::Upstream(
"微信虚拟支付查单返回的订单号与请求不一致".to_string(),
));
}
Ok(WechatVirtualPaymentOrder {
order_id: order.order_id,
status: order.status,
order_fee: order.order_fee,
order_type: order.order_type,
paid_time: order.paid_time.filter(|value| *value > 0),
wx_order_id: non_empty_owned_option(order.wx_order_id),
wxpay_order_id: non_empty_owned_option(order.wxpay_order_id),
})
}
async fn request_access_token(
&self,
app_id: &str,
app_secret: &str,
) -> Result<String, WechatError> {
let mut cache = self.access_token_cache.lock().await;
if let Some(entry) = cache.as_ref()
&& Instant::now() < entry.expires_at
{
return Ok(entry.access_token.clone());
}
let url = Url::parse(&self.config.stable_access_token_endpoint).map_err(|error| {
WechatError::InvalidConfig(format!("微信 stable_token 地址非法:{error}"))
})?;
@@ -185,10 +368,24 @@ impl WechatClient {
)));
}
payload
let expires_in = payload.expires_in.unwrap_or(7_200);
let access_token = payload
.access_token
.and_then(|value| non_empty_owned(value))
.ok_or_else(|| WechatError::Upstream("微信 stable_token 缺少 access_token".to_string()))
.ok_or_else(|| {
WechatError::Upstream("微信 stable_token 缺少 access_token".to_string())
})?;
let cache_lifetime = Duration::from_secs(expires_in)
.saturating_sub(WECHAT_ACCESS_TOKEN_REFRESH_SAFETY_MARGIN);
if !cache_lifetime.is_zero()
&& let Some(expires_at) = Instant::now().checked_add(cache_lifetime)
{
*cache = Some(WechatAccessTokenCacheEntry {
access_token: access_token.clone(),
expires_at,
});
}
Ok(access_token)
}
}
@@ -232,3 +429,148 @@ fn non_empty_owned(value: String) -> Option<String> {
Some(value)
}
}
fn non_empty_owned_option(value: Option<String>) -> Option<String> {
value.and_then(non_empty_owned)
}
fn calc_virtual_payment_pay_signature(app_key: &str, uri: &str, body: &str) -> String {
let key = hmac::Key::new(hmac::HMAC_SHA256, app_key.as_bytes());
hex::encode(hmac::sign(&key, format!("{uri}&{body}").as_bytes()).as_ref())
}
#[cfg(test)]
mod tests {
use std::{
io::{Read, Write},
net::TcpListener,
sync::mpsc,
thread,
};
use super::{
WECHAT_VIRTUAL_PAYMENT_QUERY_ORDER_URI, WechatClient, WechatConfig,
WechatVirtualPaymentQueryOrderBody, WechatVirtualPaymentQueryOrderRequest,
calc_virtual_payment_pay_signature,
};
#[test]
fn virtual_payment_pay_signature_matches_official_example() {
let body = r#"{"openid": "xxx", "user_ip": "127.0.0.1", "env": 0}"#;
assert_eq!(
calc_virtual_payment_pay_signature("12345", "/xpay/query_user_balance", body),
"c37809f27c6d7fd1837ad2500a04512b66b34fd793a39a385fade56dca89a4b5"
);
}
#[test]
fn virtual_payment_query_order_signs_the_exact_serialized_body() {
let body = serde_json::to_string(&WechatVirtualPaymentQueryOrderBody {
openid: "openid-001",
env: 0,
order_id: "order-001",
})
.expect("query body should serialize");
assert_eq!(
body,
r#"{"openid":"openid-001","env":0,"order_id":"order-001"}"#
);
assert_eq!(
calc_virtual_payment_pay_signature(
"app-key-001",
WECHAT_VIRTUAL_PAYMENT_QUERY_ORDER_URI,
&body,
),
"ca0540a55865df7b4dbd7400d4d7a6551fde4f66fff9f6290f40f23e32d33db8"
);
}
#[tokio::test]
async fn virtual_payment_query_order_uses_the_configured_mock_endpoint() {
let listener = TcpListener::bind("127.0.0.1:0").expect("mock listener should bind");
let address = listener.local_addr().expect("mock address should resolve");
let (request_sender, request_receiver) = mpsc::channel();
let server = thread::spawn(move || {
for response_body in [
r#"{"access_token":"access-token-001","expires_in":7200}"#,
r#"{"errcode":0,"errmsg":"ok","order":{"order_id":"order-001","status":2,"order_fee":600,"order_type":0,"paid_time":1777111300,"wx_order_id":"wx-order-001","wxpay_order_id":"wxpay-order-001"}}"#,
] {
let (mut stream, _) = listener.accept().expect("mock request should connect");
let request = read_http_request(&mut stream);
request_sender
.send(request)
.expect("mock request should be recorded");
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
response_body.len(),
response_body
);
stream
.write_all(response.as_bytes())
.expect("mock response should write");
}
});
let client = WechatClient::new(WechatConfig {
app_id: Some("wx-app-001".to_string()),
app_secret: Some("app-secret-001".to_string()),
stable_access_token_endpoint: format!("http://{address}/stable-token"),
subscribe_message_endpoint: format!("http://{address}/subscribe"),
virtual_payment_query_order_endpoint: format!("http://{address}/mock/query-order"),
});
let order = client
.query_virtual_payment_order(WechatVirtualPaymentQueryOrderRequest {
openid: "openid-001".to_string(),
order_id: "order-001".to_string(),
env: 0,
app_key: "app-key-001".to_string(),
})
.await
.expect("mock query should succeed");
assert_eq!(order.status, 2);
assert_eq!(order.order_fee, 600);
let token_request = request_receiver
.recv()
.expect("stable token request should be recorded");
let query_request = request_receiver
.recv()
.expect("query request should be recorded");
assert!(token_request.starts_with("POST /stable-token HTTP/1.1"));
assert!(query_request.starts_with("POST /mock/query-order?access_token="));
assert!(query_request.contains("&pay_sig="));
assert!(
query_request.ends_with(r#"{"openid":"openid-001","env":0,"order_id":"order-001"}"#)
);
server.join().expect("mock server should finish");
}
fn read_http_request(stream: &mut std::net::TcpStream) -> String {
let mut bytes = Vec::new();
let mut buffer = [0_u8; 4096];
loop {
let count = stream.read(&mut buffer).expect("mock request should read");
if count == 0 {
break;
}
bytes.extend_from_slice(&buffer[..count]);
if let Some(header_end) = bytes.windows(4).position(|window| window == b"\r\n\r\n") {
let headers = String::from_utf8_lossy(&bytes[..header_end + 4]);
let content_length = headers
.lines()
.find_map(|line| {
line.strip_prefix("content-length:")
.or_else(|| line.strip_prefix("Content-Length:"))
})
.and_then(|value| value.trim().parse::<usize>().ok())
.unwrap_or(0);
if bytes.len() >= header_end + 4 + content_length {
break;
}
}
}
String::from_utf8(bytes).expect("mock request should be UTF-8")
}
}