将 api-server 微信 HTTP/BFF 适配统一迁移到 wechat 目录。 将微信支付和虚拟支付消息协议细节下沉到 platform-wechat。 拆分 platform-wechat 的订阅消息与支付模块并补齐依赖。 修正微信相关测试的用户 ID 夹具并同步后端架构文档。
424 lines
15 KiB
Rust
424 lines
15 KiB
Rust
use axum::{
|
|
Json,
|
|
extract::{Query, State},
|
|
http::{HeaderMap, HeaderValue, StatusCode, header::CONTENT_TYPE},
|
|
response::{IntoResponse, Response},
|
|
};
|
|
use bytes::Bytes;
|
|
use platform_wechat::pay::{
|
|
WechatMiniProgramMessagePushQuery, WechatMiniProgramOrderRequest, WechatPayConfig,
|
|
WechatPayError, WechatWebOrderRequest, decrypt_wechat_message_push_ciphertext,
|
|
parse_virtual_payment_notify, parse_wechat_mini_program_message_push_payload,
|
|
resolve_wechat_message_push_verify_response, verify_wechat_message_push_signature,
|
|
};
|
|
use serde::Serialize;
|
|
use serde_json::json;
|
|
use shared_kernel::offset_datetime_to_unix_micros;
|
|
use time::OffsetDateTime;
|
|
use tracing::{info, warn};
|
|
|
|
use crate::{config::AppConfig, http_error::AppError, state::AppState};
|
|
|
|
#[derive(Clone, Copy)]
|
|
enum VirtualPaymentNotifyResponseFormat {
|
|
Json,
|
|
Xml,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct ApiWechatVirtualPaymentNotifyResponse {
|
|
#[serde(rename = "ErrCode")]
|
|
err_code: i32,
|
|
#[serde(rename = "ErrMsg")]
|
|
err_msg: String,
|
|
}
|
|
|
|
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 build_wechat_pay_config(config: &AppConfig) -> WechatPayConfig {
|
|
WechatPayConfig {
|
|
enabled: config.wechat_pay_enabled,
|
|
provider: config.wechat_pay_provider.clone(),
|
|
app_id: config
|
|
.wechat_mini_program_app_id
|
|
.clone()
|
|
.or_else(|| config.wechat_app_id.clone()),
|
|
mch_id: config.wechat_pay_mch_id.clone(),
|
|
merchant_serial_no: config.wechat_pay_merchant_serial_no.clone(),
|
|
private_key_pem: config.wechat_pay_private_key_pem.clone(),
|
|
private_key_path: config.wechat_pay_private_key_path.clone(),
|
|
platform_public_key_pem: config.wechat_pay_platform_public_key_pem.clone(),
|
|
platform_public_key_path: config.wechat_pay_platform_public_key_path.clone(),
|
|
platform_serial_no: config.wechat_pay_platform_serial_no.clone(),
|
|
api_v3_key: config.wechat_pay_api_v3_key.clone(),
|
|
notify_url: config.wechat_pay_notify_url.clone(),
|
|
jsapi_endpoint: config.wechat_pay_jsapi_endpoint.clone(),
|
|
}
|
|
}
|
|
|
|
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 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 build_wechat_virtual_payment_notify_response(
|
|
err_code: i32,
|
|
err_msg: impl Into<String>,
|
|
) -> ApiWechatVirtualPaymentNotifyResponse {
|
|
ApiWechatVirtualPaymentNotifyResponse {
|
|
err_code,
|
|
err_msg: err_msg.into(),
|
|
}
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|