fix: 修复微信支付回跳刷新与查单确认
This commit is contained in:
@@ -18,6 +18,7 @@ use shared_contracts::runtime::WechatMiniProgramPayParamsResponse;
|
||||
use shared_kernel::offset_datetime_to_unix_micros;
|
||||
use time::OffsetDateTime;
|
||||
use tracing::{info, warn};
|
||||
use url::Url;
|
||||
|
||||
use crate::{http_error::AppError, state::AppState};
|
||||
|
||||
@@ -28,6 +29,8 @@ 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;
|
||||
@@ -54,6 +57,7 @@ pub struct RealWechatPayClient {
|
||||
api_v3_key: String,
|
||||
notify_url: String,
|
||||
jsapi_endpoint: String,
|
||||
query_order_endpoint_base: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -81,7 +85,7 @@ pub enum WechatPayError {
|
||||
Upstream(String),
|
||||
Deserialize(String),
|
||||
Crypto(String),
|
||||
InvalidSignature,
|
||||
InvalidSignature(String),
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -137,6 +141,16 @@ struct WechatPayTransactionResource {
|
||||
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>,
|
||||
}
|
||||
|
||||
impl WechatPayClient {
|
||||
pub fn from_config(config: &crate::config::AppConfig) -> Result<Self, WechatPayError> {
|
||||
if !config.wechat_pay_enabled {
|
||||
@@ -208,6 +222,7 @@ impl WechatPayClient {
|
||||
&config.wechat_pay_jsapi_endpoint,
|
||||
"WECHAT_PAY_JSAPI_ENDPOINT",
|
||||
)?;
|
||||
let query_order_endpoint_base = resolve_query_order_endpoint_base(&jsapi_endpoint)?;
|
||||
|
||||
Ok(Self::Real(Arc::new(RealWechatPayClient {
|
||||
client: reqwest::Client::new(),
|
||||
@@ -220,6 +235,7 @@ impl WechatPayClient {
|
||||
api_v3_key,
|
||||
notify_url,
|
||||
jsapi_endpoint,
|
||||
query_order_endpoint_base,
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -245,6 +261,22 @@ impl WechatPayClient {
|
||||
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 {
|
||||
@@ -283,6 +315,7 @@ impl RealWechatPayClient {
|
||||
self.client
|
||||
.post(&self.jsapi_endpoint)
|
||||
.header("Authorization", authorization),
|
||||
&self.platform_serial_no,
|
||||
)
|
||||
.body(body)
|
||||
.send()
|
||||
@@ -389,6 +422,58 @@ impl RealWechatPayClient {
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -399,25 +484,33 @@ impl RealWechatPayClient {
|
||||
let signature = read_required_header(headers, "Wechatpay-Signature")?;
|
||||
let serial = read_required_header(headers, "Wechatpay-Serial")?;
|
||||
if serial != self.platform_serial_no {
|
||||
return Err(WechatPayError::InvalidSignature);
|
||||
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 = format!(
|
||||
"{}\n{}\n{}\n",
|
||||
timestamp,
|
||||
nonce,
|
||||
String::from_utf8_lossy(body)
|
||||
);
|
||||
let signature_bytes = BASE64_STANDARD
|
||||
.decode(signature)
|
||||
.map_err(|_| WechatPayError::InvalidSignature)?;
|
||||
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.as_bytes(), &signature_bytes)
|
||||
.map_err(|_| WechatPayError::InvalidSignature)
|
||||
.verify(&message, &signature_bytes)
|
||||
.map_err(|_| WechatPayError::InvalidSignature("微信支付通知签名验签失败".to_string()))
|
||||
}
|
||||
|
||||
fn sign_message(&self, message: &str) -> Result<String, WechatPayError> {
|
||||
@@ -499,9 +592,11 @@ pub fn map_wechat_pay_error(error: WechatPayError) -> AppError {
|
||||
| WechatPayError::Crypto(message) => AppError::from_status(StatusCode::BAD_GATEWAY)
|
||||
.with_message(message)
|
||||
.with_details(json!({ "provider": "wechat_pay" })),
|
||||
WechatPayError::InvalidSignature => AppError::from_status(StatusCode::UNAUTHORIZED)
|
||||
.with_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 }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -533,7 +628,10 @@ fn map_wechat_pay_notify_error(error: WechatPayError) -> AppError {
|
||||
map_wechat_pay_error(error)
|
||||
}
|
||||
|
||||
fn with_wechat_pay_jsapi_headers(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
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(
|
||||
@@ -541,6 +639,14 @@ fn with_wechat_pay_jsapi_headers(builder: reqwest::RequestBuilder) -> reqwest::R
|
||||
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 {
|
||||
@@ -627,6 +733,23 @@ fn validate_notify_url(value: &str, key: &str) -> Result<(), WechatPayError> {
|
||||
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 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,
|
||||
@@ -841,7 +964,18 @@ fn read_required_header<'a>(
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or(WechatPayError::InvalidSignature)
|
||||
.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 {
|
||||
@@ -864,7 +998,7 @@ impl std::fmt::Display for WechatPayError {
|
||||
| Self::Upstream(message)
|
||||
| Self::Deserialize(message)
|
||||
| Self::Crypto(message) => formatter.write_str(message),
|
||||
Self::InvalidSignature => formatter.write_str("微信支付通知签名无效"),
|
||||
Self::InvalidSignature(message) => formatter.write_str(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -951,6 +1085,7 @@ mod tests {
|
||||
"Authorization",
|
||||
"WECHATPAY2-SHA256-RSA2048 mchid=\"1900000001\"",
|
||||
),
|
||||
"PUB_KEY_ID_0119000000012026051400000000000001",
|
||||
)
|
||||
.build()
|
||||
.expect("request should build");
|
||||
@@ -974,6 +1109,23 @@ mod tests {
|
||||
.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]
|
||||
|
||||
Reference in New Issue
Block a user