8862887baa
新增充值订单查询、通用用户详情、钱包冻结与部分退款操作 接入微信V3退款申请、回调、主动查单、交易账单对账与安全诊断 新增退款占用、权益追回、异常欠账和消费限制事务 补齐Native支付SSE自动收口及退款编号与部分退款校验 同步SpacetimeDB schema、生成绑定、测试与项目文档
1483 lines
56 KiB
Rust
1483 lines
56 KiB
Rust
use std::time::{Duration, Instant};
|
|
|
|
use axum::{
|
|
Json,
|
|
extract::{Query, State},
|
|
http::{HeaderMap, HeaderValue, StatusCode, header::CONTENT_TYPE},
|
|
response::{IntoResponse, Response},
|
|
};
|
|
use bytes::Bytes;
|
|
use module_runtime::{
|
|
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5, PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_JSAPI,
|
|
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM,
|
|
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL,
|
|
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE, RuntimeProfileRechargeOrderStatus,
|
|
RuntimeProfileRechargeRefundObservationInput, RuntimeProfileRechargeRefundObservationSource,
|
|
RuntimeProfileRechargeRefundStatus, build_runtime_profile_recharge_refund_observation_input,
|
|
};
|
|
use platform_wechat::pay::{
|
|
WechatMiniProgramMessagePushQuery, WechatMiniProgramOrderRequest, WechatPayConfig,
|
|
WechatPayError, WechatPayRefund, WechatPayRefundNotification,
|
|
WechatVirtualPaymentNotifyDebugSummary, WechatVirtualPaymentNotifyOrder, WechatWebOrderRequest,
|
|
build_virtual_payment_notify_debug_summary, 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 platform_wechat::{
|
|
WechatError, WechatVirtualPaymentNotifyProvideGoodsRequest, WechatVirtualPaymentOrder,
|
|
WechatVirtualPaymentQueryOrderRequest,
|
|
};
|
|
use serde::Serialize;
|
|
use serde_json::json;
|
|
use sha2::{Digest, Sha256};
|
|
use shared_kernel::offset_datetime_to_unix_micros;
|
|
use spacetime_client::SpacetimeClientError;
|
|
use time::OffsetDateTime;
|
|
use tracing::{debug, info, warn};
|
|
|
|
use crate::{config::AppConfig, http_error::AppError, state::AppState};
|
|
|
|
#[derive(Clone, Copy)]
|
|
enum VirtualPaymentNotifyResponseFormat {
|
|
Json,
|
|
Xml,
|
|
}
|
|
|
|
impl VirtualPaymentNotifyResponseFormat {
|
|
fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::Json => "json",
|
|
Self::Xml => "xml",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct ApiWechatVirtualPaymentNotifyResponse {
|
|
#[serde(rename = "ErrCode")]
|
|
err_code: i32,
|
|
#[serde(rename = "ErrMsg")]
|
|
err_msg: String,
|
|
}
|
|
|
|
const WECHAT_VIRTUAL_PAYMENT_IOS_REFUND_QUERY_EVENT: &str =
|
|
"xpay_subscribe_ios_refund_query_notify";
|
|
const WECHAT_VIRTUAL_PAYMENT_NOTIFY_CONFIRM_TIMEOUT: Duration = Duration::from_millis(2_500);
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
enum VirtualPaymentNotifyHandling {
|
|
CreditOrder,
|
|
DebugAcknowledge,
|
|
DebugRetry,
|
|
}
|
|
|
|
enum VirtualPaymentNotifyCreditOutcome {
|
|
IgnoredNoRechargeOrder,
|
|
AlreadyPaid { order_id: String },
|
|
Credited { order_id: String },
|
|
}
|
|
|
|
impl VirtualPaymentNotifyHandling {
|
|
fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::CreditOrder => "credit_order",
|
|
Self::DebugAcknowledge => "debug_acknowledge",
|
|
Self::DebugRetry => "debug_retry",
|
|
}
|
|
}
|
|
}
|
|
|
|
struct VirtualPaymentNotifyDebugContext {
|
|
request_ref: String,
|
|
response_format: VirtualPaymentNotifyResponseFormat,
|
|
started_at: Instant,
|
|
}
|
|
|
|
impl VirtualPaymentNotifyDebugContext {
|
|
fn new(response_format: VirtualPaymentNotifyResponseFormat, body: &[u8]) -> Self {
|
|
let digest = Sha256::digest(body);
|
|
Self {
|
|
request_ref: format!("sha256:{}", hex::encode(&digest[..16])),
|
|
response_format,
|
|
started_at: Instant::now(),
|
|
}
|
|
}
|
|
|
|
fn log_received(&self, body_bytes: usize) {
|
|
debug!(
|
|
request_ref = self.request_ref.as_str(),
|
|
response_format = self.response_format.as_str(),
|
|
encrypted_request_bytes = body_bytes,
|
|
"收到微信虚拟支付通知"
|
|
);
|
|
}
|
|
|
|
fn log_signature_verified(&self, ciphertext_bytes: usize) {
|
|
debug!(
|
|
request_ref = self.request_ref.as_str(),
|
|
signature_verified = true,
|
|
ciphertext_bytes,
|
|
"微信虚拟支付通知验签成功"
|
|
);
|
|
}
|
|
|
|
fn log_summary(&self, summary: &WechatVirtualPaymentNotifyDebugSummary) {
|
|
debug!(
|
|
request_ref = self.request_ref.as_str(),
|
|
notification_fingerprint = summary.payload_fingerprint.as_str(),
|
|
event = summary.event.as_str(),
|
|
event_ref = summary.event_ref.as_deref().unwrap_or("not_applicable"),
|
|
known_event = summary.known_event,
|
|
response_format = self.response_format.as_str(),
|
|
payload_bytes = summary.payload_bytes,
|
|
payload_schema_keys = ?summary.schema_fields,
|
|
identifier_refs = ?summary.identifier_refs,
|
|
safe_fields = ?summary.safe_fields,
|
|
sensitive_text_fingerprints = ?summary.sensitive_text_fields,
|
|
apple_subscription_info = summary.apple_subscription_info,
|
|
subscription_info = summary.subscription_info,
|
|
signature_verified = true,
|
|
decrypt_succeeded = true,
|
|
"微信虚拟支付通知诊断摘要"
|
|
);
|
|
}
|
|
|
|
fn success(
|
|
&self,
|
|
summary: &WechatVirtualPaymentNotifyDebugSummary,
|
|
handling: VirtualPaymentNotifyHandling,
|
|
) -> Response {
|
|
debug!(
|
|
request_ref = self.request_ref.as_str(),
|
|
notification_fingerprint = summary.payload_fingerprint.as_str(),
|
|
event = summary.event.as_str(),
|
|
handling = handling.as_str(),
|
|
response_err_code = 0,
|
|
handler_latency_ms = self.started_at.elapsed().as_millis() as u64,
|
|
"微信虚拟支付通知处理完成"
|
|
);
|
|
build_virtual_payment_notify_success_response(self.response_format)
|
|
}
|
|
|
|
fn error(
|
|
&self,
|
|
error: WechatPayError,
|
|
stage: &'static str,
|
|
summary: Option<&WechatVirtualPaymentNotifyDebugSummary>,
|
|
) -> Response {
|
|
let event = summary
|
|
.map(|value| value.event.as_str())
|
|
.unwrap_or("unparsed");
|
|
let notification_fingerprint = summary
|
|
.map(|value| value.payload_fingerprint.as_str())
|
|
.unwrap_or("unavailable");
|
|
warn!(
|
|
request_ref = self.request_ref.as_str(),
|
|
notification_fingerprint,
|
|
event,
|
|
stage,
|
|
error = %error,
|
|
response_err_code = 1,
|
|
handler_latency_ms = self.started_at.elapsed().as_millis() as u64,
|
|
"微信虚拟支付通知处理失败"
|
|
);
|
|
build_virtual_payment_notify_error_response(error, self.response_format)
|
|
}
|
|
}
|
|
|
|
fn classify_virtual_payment_notify(
|
|
summary: &WechatVirtualPaymentNotifyDebugSummary,
|
|
) -> VirtualPaymentNotifyHandling {
|
|
match summary.raw_event() {
|
|
WECHAT_VIRTUAL_PAYMENT_IOS_REFUND_QUERY_EVENT => VirtualPaymentNotifyHandling::DebugRetry,
|
|
"xpay_goods_deliver_notify" | "xpay_coin_pay_notify" if summary.subscription_info => {
|
|
VirtualPaymentNotifyHandling::DebugAcknowledge
|
|
}
|
|
"xpay_goods_deliver_notify" | "xpay_coin_pay_notify" => {
|
|
VirtualPaymentNotifyHandling::CreditOrder
|
|
}
|
|
_ if summary.known_event => VirtualPaymentNotifyHandling::DebugAcknowledge,
|
|
_ => VirtualPaymentNotifyHandling::DebugRetry,
|
|
}
|
|
}
|
|
|
|
async fn confirm_virtual_payment_recharge_order(
|
|
state: &AppState,
|
|
notify: &WechatVirtualPaymentNotifyOrder,
|
|
) -> Result<VirtualPaymentNotifyCreditOutcome, WechatPayError> {
|
|
let (_, order) = match state
|
|
.spacetime_client()
|
|
.get_profile_recharge_order(notify.out_trade_no.clone())
|
|
.await
|
|
{
|
|
Ok(result) => result,
|
|
Err(error) if is_profile_recharge_order_not_found(&error) => {
|
|
return Ok(VirtualPaymentNotifyCreditOutcome::IgnoredNoRechargeOrder);
|
|
}
|
|
Err(error) => {
|
|
return Err(WechatPayError::Upstream(format!(
|
|
"读取本地充值订单失败:{error}"
|
|
)));
|
|
}
|
|
};
|
|
if order.payment_channel != PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL {
|
|
return Err(WechatPayError::InvalidRequest(
|
|
"微信虚拟支付通知对应的本地订单渠道不匹配".to_string(),
|
|
));
|
|
}
|
|
if order.status == RuntimeProfileRechargeOrderStatus::Paid {
|
|
return Ok(VirtualPaymentNotifyCreditOutcome::AlreadyPaid {
|
|
order_id: order.order_id,
|
|
});
|
|
}
|
|
if !matches!(
|
|
order.status,
|
|
RuntimeProfileRechargeOrderStatus::Pending | RuntimeProfileRechargeOrderStatus::Expired
|
|
) {
|
|
return Err(WechatPayError::InvalidRequest(
|
|
"微信虚拟支付通知对应的本地订单状态不允许确认支付".to_string(),
|
|
));
|
|
}
|
|
|
|
let identity = state
|
|
.wechat_auth_service()
|
|
.get_identity_by_user_id(&order.user_id)
|
|
.map_err(|error| WechatPayError::Upstream(format!("读取微信身份失败:{error}")))?
|
|
.ok_or_else(|| {
|
|
WechatPayError::InvalidRequest("微信虚拟支付通知对应的本地订单缺少微信身份".to_string())
|
|
})?;
|
|
let query_request = build_wechat_virtual_payment_query_order_request(
|
|
&state.config,
|
|
identity.provider_uid,
|
|
order.order_id.clone(),
|
|
)
|
|
.map_err(|error| {
|
|
WechatPayError::InvalidConfig(format!("构造微信虚拟支付查单请求失败:{error}"))
|
|
})?;
|
|
let wechat_order = state
|
|
.wechat_client()
|
|
.query_virtual_payment_order(query_request)
|
|
.await
|
|
.map_err(|error| WechatPayError::Upstream(format!("微信虚拟支付查单失败:{error}")))?;
|
|
validate_wechat_virtual_payment_order(&order.order_id, order.amount_cents, &wechat_order)
|
|
.map_err(|error| {
|
|
WechatPayError::Upstream(format!("微信虚拟支付查单契约校验失败:{error}"))
|
|
})?;
|
|
if !is_wechat_virtual_payment_order_paid(wechat_order.status) {
|
|
return Err(WechatPayError::Upstream(format!(
|
|
"微信虚拟支付查单尚未确认支付,status={}",
|
|
wechat_order.status
|
|
)));
|
|
}
|
|
let paid_at_micros =
|
|
paid_at_micros_from_wechat_virtual_payment_order(&wechat_order).map_err(|error| {
|
|
WechatPayError::Upstream(format!("微信虚拟支付查单缺少权威支付时间:{error}"))
|
|
})?;
|
|
let order_id = order.order_id;
|
|
state
|
|
.spacetime_client()
|
|
.mark_profile_recharge_order_paid(
|
|
order_id.clone(),
|
|
paid_at_micros,
|
|
wechat_order.wxpay_order_id.or(wechat_order.wx_order_id),
|
|
)
|
|
.await
|
|
.map_err(|error| WechatPayError::Upstream(format!("确认微信虚拟支付订单失败:{error}")))?;
|
|
|
|
Ok(VirtualPaymentNotifyCreditOutcome::Credited { order_id })
|
|
}
|
|
|
|
fn is_profile_recharge_order_not_found(error: &SpacetimeClientError) -> bool {
|
|
matches!(
|
|
error,
|
|
SpacetimeClientError::Procedure(message)
|
|
if matches!(
|
|
message.trim(),
|
|
"profile_recharge_order missing" | "profile_recharge_order 不存在"
|
|
)
|
|
)
|
|
}
|
|
|
|
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()
|
|
.ok_or_else(|| {
|
|
AppError::from_status(StatusCode::BAD_REQUEST)
|
|
.with_message("微信支付成功通知缺少 success_time")
|
|
})
|
|
.and_then(|value| {
|
|
shared_kernel::parse_rfc3339(value).map_err(|error| {
|
|
AppError::from_status(StatusCode::BAD_REQUEST)
|
|
.with_message(format!("微信支付成功通知 success_time 无效:{error}"))
|
|
})
|
|
})
|
|
.map(offset_datetime_to_unix_micros)?;
|
|
let transaction_id = notify.transaction_id.clone().ok_or_else(|| {
|
|
AppError::from_status(StatusCode::BAD_REQUEST)
|
|
.with_message("微信支付成功通知缺少 transaction_id")
|
|
})?;
|
|
let amount_total_cents = notify.amount_total_cents.ok_or_else(|| {
|
|
AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信支付成功通知缺少金额")
|
|
})?;
|
|
let (_, order) = state
|
|
.spacetime_client()
|
|
.get_profile_recharge_order(notify.out_trade_no.clone())
|
|
.await
|
|
.map_err(|error| {
|
|
AppError::from_status(StatusCode::BAD_GATEWAY)
|
|
.with_message(format!("读取微信支付本地订单失败:{error}"))
|
|
})?;
|
|
validate_wechat_pay_notify_order(&order, amount_total_cents, &transaction_id)?;
|
|
|
|
state
|
|
.spacetime_client()
|
|
.mark_profile_recharge_order_paid(
|
|
notify.out_trade_no.clone(),
|
|
paid_at_micros,
|
|
Some(transaction_id),
|
|
)
|
|
.await
|
|
.map_err(|error| {
|
|
AppError::from_status(StatusCode::BAD_GATEWAY)
|
|
.with_message(format!("确认微信支付订单失败:{error}"))
|
|
})?;
|
|
state.publish_profile_recharge_order_update(notify.out_trade_no.clone());
|
|
info!(
|
|
order_id = notify.out_trade_no.as_str(),
|
|
"微信支付通知已确认订单入账"
|
|
);
|
|
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
fn validate_wechat_pay_notify_order(
|
|
order: &module_runtime::RuntimeProfileRechargeOrderRecord,
|
|
amount_total_cents: u64,
|
|
transaction_id: &str,
|
|
) -> Result<(), AppError> {
|
|
if !matches!(
|
|
order.payment_channel.as_str(),
|
|
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM
|
|
| PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_JSAPI
|
|
| PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5
|
|
| PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE
|
|
) {
|
|
return Err(AppError::from_status(StatusCode::BAD_REQUEST)
|
|
.with_message("微信支付成功通知对应订单不是普通 V3 渠道"));
|
|
}
|
|
if order.amount_cents != amount_total_cents {
|
|
return Err(AppError::from_status(StatusCode::BAD_REQUEST)
|
|
.with_message("微信支付成功通知金额与本地订单不一致"));
|
|
}
|
|
if let Some(existing) = order.provider_transaction_id.as_deref()
|
|
&& existing != transaction_id
|
|
{
|
|
return Err(AppError::from_status(StatusCode::BAD_REQUEST)
|
|
.with_message("微信支付成功通知 transaction_id 与已结算订单不一致"));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn handle_wechat_pay_refund_notify(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
body: Bytes,
|
|
) -> Response {
|
|
let request_ref = state
|
|
.wechat_pay_client()
|
|
.refund_notify_request_ref(&body)
|
|
.unwrap_or_else(|| "unavailable".to_string());
|
|
debug!(
|
|
request_ref = request_ref.as_str(),
|
|
payload_bytes = body.len(),
|
|
"收到微信支付 V3 退款结果通知,开始验签解密"
|
|
);
|
|
let notification = match state
|
|
.wechat_pay_client()
|
|
.parse_refund_notify(&headers, &body)
|
|
{
|
|
Ok(notification) => notification,
|
|
Err(error) => {
|
|
warn!(
|
|
request_ref = request_ref.as_str(),
|
|
stage = wechat_pay_refund_notify_error_stage(&error),
|
|
error_code = error.diagnostic_code(),
|
|
failure_reason = wechat_pay_refund_notify_failure_reason(&error),
|
|
payload_bytes = body.len(),
|
|
"微信支付 V3 退款结果通知处理失败"
|
|
);
|
|
return build_wechat_pay_refund_notify_error_response(error);
|
|
}
|
|
};
|
|
let summary = ¬ification.debug;
|
|
let received_account_bytes = summary
|
|
.user_received_account
|
|
.as_ref()
|
|
.map(|value| value.bytes);
|
|
let received_account_ref = summary
|
|
.user_received_account
|
|
.as_ref()
|
|
.map(|value| value.hmac_ref.as_str());
|
|
debug!(
|
|
request_ref = request_ref.as_str(),
|
|
event_type = summary.event_type.as_str(),
|
|
known_event = summary.known_event,
|
|
resource_type = summary.resource_type.as_str(),
|
|
original_type = summary.original_type.as_str(),
|
|
algorithm = summary.algorithm.as_str(),
|
|
create_time = summary.create_time.as_str(),
|
|
refund_status = summary.refund_status.as_str(),
|
|
success_time = ?summary.success_time.as_deref(),
|
|
amount_total_cents = summary.amount_total_cents,
|
|
amount_refund_cents = summary.amount_refund_cents,
|
|
amount_payer_total_cents = summary.amount_payer_total_cents,
|
|
amount_payer_refund_cents = summary.amount_payer_refund_cents,
|
|
payload_bytes = summary.payload_bytes,
|
|
payload_fingerprint = summary.payload_fingerprint.as_str(),
|
|
notification_ref = summary.notification_ref.as_str(),
|
|
merchant_ref = summary.merchant_ref.as_str(),
|
|
transaction_ref = summary.transaction_ref.as_str(),
|
|
order_ref = summary.order_ref.as_str(),
|
|
refund_ref = summary.refund_ref.as_str(),
|
|
merchant_refund_ref = summary.merchant_refund_ref.as_str(),
|
|
user_received_account_bytes = ?received_account_bytes,
|
|
user_received_account_ref = ?received_account_ref,
|
|
signature_verified = true,
|
|
decrypt_succeeded = true,
|
|
business_mutation = true,
|
|
"微信支付 V3 退款结果通知诊断摘要"
|
|
);
|
|
|
|
let observation = match build_wechat_pay_refund_notification_observation(¬ification) {
|
|
Ok(observation) => observation,
|
|
Err(error) => {
|
|
warn!(
|
|
request_ref = request_ref.as_str(),
|
|
stage = "observation_validation",
|
|
error_code = error.diagnostic_code(),
|
|
failure_reason = wechat_pay_refund_notify_failure_reason(&error),
|
|
"微信支付 V3 退款结果通知无法构造持久化事实"
|
|
);
|
|
return build_wechat_pay_refund_notify_error_response(error);
|
|
}
|
|
};
|
|
let (record, settlement, duplicate, resolution_code) = match state
|
|
.spacetime_client()
|
|
.record_profile_recharge_refund_observation(observation)
|
|
.await
|
|
{
|
|
Ok(result) => result,
|
|
Err(error) => {
|
|
warn!(
|
|
request_ref = request_ref.as_str(),
|
|
stage = "refund_persistence",
|
|
error = %error,
|
|
"微信支付 V3 退款结果通知持久化失败"
|
|
);
|
|
return build_wechat_pay_refund_notify_error_response(WechatPayError::Upstream(
|
|
format!("持久化微信支付退款事实失败:{error}"),
|
|
));
|
|
}
|
|
};
|
|
state.publish_profile_recharge_order_update(record.order_id.clone());
|
|
let wallet_frozen = settlement
|
|
.as_ref()
|
|
.map(|value| value.wallet_frozen)
|
|
.unwrap_or(false);
|
|
if matches!(
|
|
resolution_code.as_str(),
|
|
"immutable_conflict" | "provider_refund_id_conflict" | "status_conflict"
|
|
) {
|
|
warn!(
|
|
request_ref = request_ref.as_str(),
|
|
notification_fingerprint = summary.payload_fingerprint.as_str(),
|
|
duplicate,
|
|
resolution_code = resolution_code.as_str(),
|
|
wallet_frozen,
|
|
"微信支付 V3 退款结果通知已持久化为冲突观察"
|
|
);
|
|
} else {
|
|
info!(
|
|
request_ref = request_ref.as_str(),
|
|
notification_fingerprint = summary.payload_fingerprint.as_str(),
|
|
duplicate,
|
|
resolution_code = resolution_code.as_str(),
|
|
wallet_frozen,
|
|
"微信支付 V3 退款结果通知已持久化"
|
|
);
|
|
}
|
|
StatusCode::NO_CONTENT.into_response()
|
|
}
|
|
|
|
fn build_wechat_pay_refund_notification_observation(
|
|
notification: &WechatPayRefundNotification,
|
|
) -> Result<RuntimeProfileRechargeRefundObservationInput, WechatPayError> {
|
|
let observed_at_micros = parse_wechat_pay_refund_time(
|
|
Some(notification.create_time.as_str()),
|
|
"微信支付退款通知 create_time",
|
|
)?
|
|
.ok_or_else(|| {
|
|
WechatPayError::InvalidRequest("微信支付退款通知缺少 create_time".to_string())
|
|
})?;
|
|
build_wechat_pay_refund_observation(
|
|
notification.notification_id.clone(),
|
|
RuntimeProfileRechargeRefundObservationSource::Callback,
|
|
Some(notification.debug.notification_ref.clone()),
|
|
notification.debug.payload_fingerprint.clone(),
|
|
¬ification.refund,
|
|
observed_at_micros,
|
|
)
|
|
}
|
|
|
|
pub(crate) fn build_wechat_pay_refund_observation(
|
|
observation_id: String,
|
|
source: RuntimeProfileRechargeRefundObservationSource,
|
|
notification_ref: Option<String>,
|
|
payload_fingerprint: String,
|
|
refund: &WechatPayRefund,
|
|
observed_at_micros: i64,
|
|
) -> Result<RuntimeProfileRechargeRefundObservationInput, WechatPayError> {
|
|
let provider_status = match refund.status.trim().to_ascii_uppercase().as_str() {
|
|
"PROCESSING" => RuntimeProfileRechargeRefundStatus::Processing,
|
|
"SUCCESS" => RuntimeProfileRechargeRefundStatus::Success,
|
|
"ABNORMAL" => RuntimeProfileRechargeRefundStatus::Abnormal,
|
|
"CLOSED" => RuntimeProfileRechargeRefundStatus::Closed,
|
|
_ => {
|
|
return Err(WechatPayError::InvalidRequest(
|
|
"微信支付退款包含未知状态".to_string(),
|
|
));
|
|
}
|
|
};
|
|
let success_at_micros =
|
|
parse_wechat_pay_refund_time(refund.success_time.as_deref(), "微信支付退款 success_time")?;
|
|
build_runtime_profile_recharge_refund_observation_input(
|
|
observation_id,
|
|
source,
|
|
notification_ref,
|
|
payload_fingerprint,
|
|
refund.out_refund_no.clone(),
|
|
refund.refund_id.clone(),
|
|
refund.out_trade_no.clone(),
|
|
refund.transaction_id.clone(),
|
|
provider_status,
|
|
refund.amount_total_cents,
|
|
refund.amount_refund_cents,
|
|
refund.amount_payer_total_cents,
|
|
refund.amount_payer_refund_cents,
|
|
success_at_micros,
|
|
observed_at_micros,
|
|
)
|
|
.map_err(WechatPayError::InvalidRequest)
|
|
}
|
|
|
|
fn parse_wechat_pay_refund_time(
|
|
value: Option<&str>,
|
|
field_name: &str,
|
|
) -> Result<Option<i64>, WechatPayError> {
|
|
value
|
|
.map(|value| {
|
|
shared_kernel::parse_rfc3339(value)
|
|
.map(offset_datetime_to_unix_micros)
|
|
.map_err(|error| {
|
|
WechatPayError::InvalidRequest(format!("{field_name} 格式无效:{error}"))
|
|
})
|
|
})
|
|
.transpose()
|
|
}
|
|
|
|
fn wechat_pay_refund_notify_error_stage(error: &WechatPayError) -> &'static str {
|
|
match error {
|
|
WechatPayError::InvalidSignature(_) => "signature_verification",
|
|
WechatPayError::Crypto(_) => "payload_decryption",
|
|
WechatPayError::Deserialize(_) => "payload_parse",
|
|
WechatPayError::InvalidRequest(_) => "contract_validation",
|
|
WechatPayError::Disabled | WechatPayError::InvalidConfig(_) => "provider_config",
|
|
WechatPayError::OrderNotExist(_)
|
|
| WechatPayError::RequestFailed(_)
|
|
| WechatPayError::Upstream(_) => "unexpected_upstream",
|
|
}
|
|
}
|
|
|
|
fn wechat_pay_refund_notify_failure_reason(error: &WechatPayError) -> &'static str {
|
|
match error {
|
|
WechatPayError::InvalidSignature(message) if message.contains("Wechatpay-Timestamp") => {
|
|
"missing_timestamp"
|
|
}
|
|
WechatPayError::InvalidSignature(message) if message.contains("Wechatpay-Nonce") => {
|
|
"missing_nonce"
|
|
}
|
|
WechatPayError::InvalidSignature(message) if message.contains("Wechatpay-Signature") => {
|
|
"missing_signature"
|
|
}
|
|
WechatPayError::InvalidSignature(message) if message.contains("Wechatpay-Serial") => {
|
|
"missing_platform_serial"
|
|
}
|
|
WechatPayError::InvalidSignature(message) if message.contains("时间戳格式") => {
|
|
"invalid_timestamp"
|
|
}
|
|
WechatPayError::InvalidSignature(message) if message.contains("时间戳超出") => {
|
|
"timestamp_out_of_window"
|
|
}
|
|
WechatPayError::InvalidSignature(message) if message.contains("序列号不匹配") => {
|
|
"platform_serial_mismatch"
|
|
}
|
|
WechatPayError::InvalidSignature(message) if message.contains("签名探测") => {
|
|
"signature_probe"
|
|
}
|
|
WechatPayError::InvalidSignature(message) if message.contains("base64") => {
|
|
"signature_encoding_invalid"
|
|
}
|
|
WechatPayError::InvalidSignature(_) => "signature_invalid",
|
|
WechatPayError::Crypto(message) if message.contains("base64") => {
|
|
"ciphertext_encoding_invalid"
|
|
}
|
|
WechatPayError::Crypto(message) if message.contains("nonce") => "resource_nonce_invalid",
|
|
WechatPayError::Crypto(_) => "ciphertext_authentication_failed",
|
|
WechatPayError::Deserialize(message) if message.contains("解密资源") => {
|
|
"decrypted_resource_schema_invalid"
|
|
}
|
|
WechatPayError::Deserialize(_) => "notification_envelope_schema_invalid",
|
|
WechatPayError::InvalidRequest(message) if message.contains("event_type") => {
|
|
"event_type_invalid"
|
|
}
|
|
WechatPayError::InvalidRequest(message) if message.contains("退款状态不一致") => {
|
|
"event_status_mismatch"
|
|
}
|
|
WechatPayError::InvalidRequest(message) if message.contains("商户号不匹配") => {
|
|
"merchant_mismatch"
|
|
}
|
|
WechatPayError::InvalidRequest(message) if message.contains("resource_type") => {
|
|
"resource_type_invalid"
|
|
}
|
|
WechatPayError::InvalidRequest(message) if message.contains("algorithm") => {
|
|
"resource_algorithm_invalid"
|
|
}
|
|
WechatPayError::InvalidRequest(message) if message.contains("original_type") => {
|
|
"resource_original_type_invalid"
|
|
}
|
|
WechatPayError::InvalidRequest(_) => "contract_invalid",
|
|
WechatPayError::Disabled => "provider_disabled",
|
|
WechatPayError::InvalidConfig(_) => "provider_config_invalid",
|
|
WechatPayError::OrderNotExist(_) => "unexpected_order_lookup",
|
|
WechatPayError::RequestFailed(_) => "unexpected_request_failure",
|
|
WechatPayError::Upstream(_) => "unexpected_upstream_failure",
|
|
}
|
|
}
|
|
|
|
fn build_wechat_pay_refund_notify_error_response(error: WechatPayError) -> Response {
|
|
let status = match error {
|
|
WechatPayError::InvalidSignature(_)
|
|
| WechatPayError::InvalidRequest(_)
|
|
| WechatPayError::Deserialize(_)
|
|
| WechatPayError::Crypto(_) => StatusCode::BAD_REQUEST,
|
|
WechatPayError::Disabled | WechatPayError::InvalidConfig(_) => {
|
|
StatusCode::SERVICE_UNAVAILABLE
|
|
}
|
|
WechatPayError::OrderNotExist(_)
|
|
| WechatPayError::RequestFailed(_)
|
|
| WechatPayError::Upstream(_) => StatusCode::BAD_GATEWAY,
|
|
};
|
|
(
|
|
status,
|
|
Json(json!({
|
|
"code": "FAIL",
|
|
"message": "失败"
|
|
})),
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
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 debug_context = VirtualPaymentNotifyDebugContext::new(response_format, &body);
|
|
debug_context.log_received(body.len());
|
|
let encrypted_payload = match parse_wechat_mini_program_message_push_payload(&body) {
|
|
Ok(payload) => payload,
|
|
Err(error) => return debug_context.error(error, "encrypted_envelope_parse", None),
|
|
};
|
|
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 debug_context.error(error, "message_token_config", None),
|
|
};
|
|
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 debug_context.error(error, "message_aes_key_config", None),
|
|
};
|
|
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 debug_context.error(
|
|
WechatPayError::InvalidRequest("微信消息推送加密参数不完整".to_string()),
|
|
"signature_parameters",
|
|
None,
|
|
);
|
|
}
|
|
if !verify_wechat_message_push_signature(
|
|
token,
|
|
timestamp,
|
|
nonce,
|
|
encrypted_payload.encrypt.as_str(),
|
|
signature,
|
|
) {
|
|
return debug_context.error(
|
|
WechatPayError::InvalidSignature("微信消息推送 msg_signature 无效".to_string()),
|
|
"signature_verification",
|
|
None,
|
|
);
|
|
}
|
|
debug_context.log_signature_verified(encrypted_payload.encrypt.len());
|
|
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 debug_context.error(error, "payload_decryption", None),
|
|
};
|
|
let summary = match build_virtual_payment_notify_debug_summary(
|
|
notify_body.as_bytes(),
|
|
token.as_bytes(),
|
|
) {
|
|
Ok(summary) => summary,
|
|
Err(error) => return debug_context.error(error, "payload_summary", None),
|
|
};
|
|
debug_context.log_summary(&summary);
|
|
let handling = classify_virtual_payment_notify(&summary);
|
|
match handling {
|
|
VirtualPaymentNotifyHandling::DebugAcknowledge => {
|
|
info!(
|
|
event = summary.event.as_str(),
|
|
notification_fingerprint = summary.payload_fingerprint.as_str(),
|
|
apple_subscription_info = summary.apple_subscription_info,
|
|
subscription_info = summary.subscription_info,
|
|
"微信虚拟支付通知已进入诊断处理,不变更订单或用户权益"
|
|
);
|
|
return debug_context.success(&summary, handling);
|
|
}
|
|
VirtualPaymentNotifyHandling::DebugRetry => {
|
|
let (stage, message) =
|
|
if summary.raw_event() == WECHAT_VIRTUAL_PAYMENT_IOS_REFUND_QUERY_EVENT {
|
|
(
|
|
"ios_refund_decision_unconfigured",
|
|
"iOS退款问询尚未配置基于真实履约数据的自动决策",
|
|
)
|
|
} else {
|
|
("unknown_event", "微信虚拟支付通知事件尚未配置处理策略")
|
|
};
|
|
return debug_context.error(
|
|
WechatPayError::InvalidRequest(message.to_string()),
|
|
stage,
|
|
Some(&summary),
|
|
);
|
|
}
|
|
VirtualPaymentNotifyHandling::CreditOrder => {}
|
|
}
|
|
let notify = match parse_virtual_payment_notify(notify_body.as_bytes()) {
|
|
Ok(notify) => notify,
|
|
Err(error) => return debug_context.error(error, "order_payload_parse", Some(&summary)),
|
|
};
|
|
let outcome = match tokio::time::timeout(
|
|
WECHAT_VIRTUAL_PAYMENT_NOTIFY_CONFIRM_TIMEOUT,
|
|
confirm_virtual_payment_recharge_order(&state, ¬ify),
|
|
)
|
|
.await
|
|
{
|
|
Ok(Ok(outcome)) => outcome,
|
|
Ok(Err(error)) => {
|
|
return debug_context.error(error, "authoritative_order_confirmation", Some(&summary));
|
|
}
|
|
Err(_) => {
|
|
return debug_context.error(
|
|
WechatPayError::Upstream("微信虚拟支付通知权威查单确认超出 2.5 秒预算".to_string()),
|
|
"authoritative_order_confirmation_timeout",
|
|
Some(&summary),
|
|
);
|
|
}
|
|
};
|
|
|
|
match outcome {
|
|
VirtualPaymentNotifyCreditOutcome::IgnoredNoRechargeOrder => {
|
|
info!(
|
|
event = notify.event.as_str(),
|
|
order_ref = summary
|
|
.primary_identifier_ref("order_ref")
|
|
.unwrap_or("unavailable"),
|
|
"微信虚拟支付通知没有对应的本地充值订单,已按非充值通知记录"
|
|
);
|
|
debug_context.success(&summary, VirtualPaymentNotifyHandling::DebugAcknowledge)
|
|
}
|
|
VirtualPaymentNotifyCreditOutcome::AlreadyPaid { order_id } => {
|
|
state.publish_profile_recharge_order_update(order_id);
|
|
info!(
|
|
event = notify.event.as_str(),
|
|
order_ref = summary
|
|
.primary_identifier_ref("order_ref")
|
|
.unwrap_or("unavailable"),
|
|
"微信虚拟支付通知对应的本地充值订单已入账"
|
|
);
|
|
debug_context.success(&summary, handling)
|
|
}
|
|
VirtualPaymentNotifyCreditOutcome::Credited { order_id } => {
|
|
state.publish_profile_recharge_order_update(order_id);
|
|
info!(
|
|
event = notify.event.as_str(),
|
|
order_ref = summary
|
|
.primary_identifier_ref("order_ref")
|
|
.unwrap_or("unavailable"),
|
|
"微信虚拟支付推送已通过官方查单确认订单入账"
|
|
);
|
|
debug_context.success(&summary, handling)
|
|
}
|
|
}
|
|
}
|
|
|
|
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 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 build_wechat_virtual_payment_notify_provide_goods_request(
|
|
config: &AppConfig,
|
|
order_id: String,
|
|
) -> Result<WechatVirtualPaymentNotifyProvideGoodsRequest, WechatError> {
|
|
if config.wechat_mini_program_virtual_payment_env > 1 {
|
|
return Err(WechatError::InvalidConfig(format!(
|
|
"微信虚拟支付发货确认 env 只允许 0 或 1,当前为 {}",
|
|
config.wechat_mini_program_virtual_payment_env
|
|
)));
|
|
}
|
|
Ok(WechatVirtualPaymentNotifyProvideGoodsRequest {
|
|
order_id,
|
|
env: config.wechat_mini_program_virtual_payment_env,
|
|
})
|
|
}
|
|
|
|
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,
|
|
) -> Result<i64, WechatError> {
|
|
let paid_time = order
|
|
.paid_time
|
|
.filter(|value| *value > 0)
|
|
.ok_or_else(|| WechatError::Upstream("微信已支付虚拟订单缺少合法 paid_time".to_string()))?;
|
|
paid_time.checked_mul(1_000_000).ok_or_else(|| {
|
|
WechatError::Upstream("微信已支付虚拟订单 paid_time 超出安全范围".to_string())
|
|
})
|
|
}
|
|
|
|
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::OrderNotExist(message) => AppError::from_status(StatusCode::NOT_FOUND)
|
|
.with_message(message)
|
|
.with_details(json!({ "provider": "wechat_pay", "code": "ORDER_NOT_EXIST" })),
|
|
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::OrderNotExist(message)
|
|
| WechatPayError::RequestFailed(message)
|
|
| WechatPayError::Upstream(message)
|
|
| WechatPayError::Deserialize(message)
|
|
| WechatPayError::Crypto(message)
|
|
| WechatPayError::InvalidSignature(message) => message,
|
|
};
|
|
(StatusCode::BAD_REQUEST, message).into_response()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{
|
|
VirtualPaymentNotifyHandling, build_wechat_pay_refund_notify_error_response,
|
|
build_wechat_pay_refund_observation, build_wechat_virtual_payment_notify_response,
|
|
build_wechat_virtual_payment_query_order_request, classify_virtual_payment_notify,
|
|
is_profile_recharge_order_not_found, is_wechat_virtual_payment_order_paid,
|
|
paid_at_micros_from_wechat_virtual_payment_order, validate_wechat_pay_notify_order,
|
|
validate_wechat_virtual_payment_order,
|
|
};
|
|
use crate::config::AppConfig;
|
|
use module_runtime::{
|
|
RuntimeProfileRechargeOrderSnapshot, RuntimeProfileRechargeOrderStatus,
|
|
RuntimeProfileRechargeProductKind, RuntimeProfileRechargeRefundObservationSource,
|
|
build_runtime_profile_recharge_order_record,
|
|
};
|
|
use platform_wechat::{
|
|
WechatVirtualPaymentOrder,
|
|
pay::{WechatPayError, WechatPayRefund, build_virtual_payment_notify_debug_summary},
|
|
};
|
|
use spacetime_client::SpacetimeClientError;
|
|
|
|
#[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_only_acks_the_exact_missing_recharge_order_error() {
|
|
assert!(is_profile_recharge_order_not_found(
|
|
&SpacetimeClientError::Procedure("profile_recharge_order missing".to_string())
|
|
));
|
|
assert!(is_profile_recharge_order_not_found(
|
|
&SpacetimeClientError::Procedure("profile_recharge_order 不存在".to_string())
|
|
));
|
|
assert!(!is_profile_recharge_order_not_found(
|
|
&SpacetimeClientError::Procedure("SpacetimeDB 连接失败".to_string())
|
|
));
|
|
assert!(!is_profile_recharge_order_not_found(
|
|
&SpacetimeClientError::ConnectDropped
|
|
));
|
|
}
|
|
|
|
#[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_requires_a_stable_provider_paid_time() {
|
|
let mut order = WechatVirtualPaymentOrder {
|
|
order_id: "order-001".to_string(),
|
|
status: 2,
|
|
order_fee: 600,
|
|
order_type: 0,
|
|
paid_time: Some(1_777_111_300),
|
|
wx_order_id: None,
|
|
wxpay_order_id: None,
|
|
};
|
|
assert_eq!(
|
|
paid_at_micros_from_wechat_virtual_payment_order(&order)
|
|
.expect("paid_time should convert"),
|
|
1_777_111_300_000_000
|
|
);
|
|
|
|
order.paid_time = None;
|
|
assert!(paid_at_micros_from_wechat_virtual_payment_order(&order).is_err());
|
|
order.paid_time = Some(i64::MAX);
|
|
assert!(paid_at_micros_from_wechat_virtual_payment_order(&order).is_err());
|
|
}
|
|
|
|
#[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());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn virtual_payment_debug_routing_does_not_credit_refunds_or_subscriptions() {
|
|
for (body, expected) in [
|
|
(
|
|
r#"{"Event":"xpay_goods_deliver_notify","OutTradeNo":"order-1"}"#,
|
|
VirtualPaymentNotifyHandling::CreditOrder,
|
|
),
|
|
(
|
|
r#"{"Event":"xpay_goods_deliver_notify","AppleSubscriptionInfo":{"ProductId":"vip_month"}}"#,
|
|
VirtualPaymentNotifyHandling::DebugAcknowledge,
|
|
),
|
|
(
|
|
r#"{"Event":"xpay_goods_deliver_notify","OutContractCode":"contract-1","ContractWxAppid":"wx-app-1"}"#,
|
|
VirtualPaymentNotifyHandling::DebugAcknowledge,
|
|
),
|
|
(
|
|
r#"{"Event":"xpay_refund_notify","MchRefundId":"refund-1"}"#,
|
|
VirtualPaymentNotifyHandling::DebugAcknowledge,
|
|
),
|
|
(
|
|
r#"{"Event":"xpay_subscribe_ios_refund_query_notify","PayOrderId":"order-1"}"#,
|
|
VirtualPaymentNotifyHandling::DebugRetry,
|
|
),
|
|
(
|
|
r#"{"Event":"xpay_subscribe_ios_refund_query_notify","AppleSubscriptionInfo":{"ProductId":"vip_month"}}"#,
|
|
VirtualPaymentNotifyHandling::DebugRetry,
|
|
),
|
|
(
|
|
r#"{"Event":"xpay_future_notify"}"#,
|
|
VirtualPaymentNotifyHandling::DebugRetry,
|
|
),
|
|
] {
|
|
let summary =
|
|
build_virtual_payment_notify_debug_summary(body.as_bytes(), b"message-token")
|
|
.expect("debug routing fixture should parse");
|
|
assert_eq!(classify_virtual_payment_notify(&summary), expected);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn virtual_payment_ios_refund_query_has_no_fake_decision_response() {
|
|
let payload = serde_json::to_value(build_wechat_virtual_payment_notify_response(
|
|
1,
|
|
"iOS退款问询尚未配置基于真实履约数据的自动决策".to_string(),
|
|
))
|
|
.expect("iOS refund query retry response should serialize");
|
|
|
|
assert_eq!(payload["ErrCode"], 1);
|
|
assert!(payload.get("IosRefundQueryResponse").is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn v3_refund_notify_failure_uses_the_wechat_fail_response_contract() {
|
|
let response = build_wechat_pay_refund_notify_error_response(
|
|
WechatPayError::InvalidSignature("secret header value".to_string()),
|
|
);
|
|
assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST);
|
|
let body = axum::body::to_bytes(response.into_body(), 1_024)
|
|
.await
|
|
.expect("refund failure response body should read");
|
|
let payload: serde_json::Value =
|
|
serde_json::from_slice(&body).expect("refund failure response should be JSON");
|
|
|
|
assert_eq!(
|
|
payload,
|
|
serde_json::json!({
|
|
"code": "FAIL",
|
|
"message": "失败"
|
|
})
|
|
);
|
|
assert!(!String::from_utf8_lossy(&body).contains("secret header value"));
|
|
}
|
|
|
|
#[test]
|
|
fn v3_refund_observation_preserves_verified_provider_money_and_status() {
|
|
let refund = WechatPayRefund {
|
|
mch_id: Some("1900000001".to_string()),
|
|
transaction_id: "tx-1".to_string(),
|
|
out_trade_no: "order-1".to_string(),
|
|
refund_id: "refund-1".to_string(),
|
|
out_refund_no: "merchant-refund-1".to_string(),
|
|
status: "SUCCESS".to_string(),
|
|
success_time: Some("2026-07-13T18:17:23+08:00".to_string()),
|
|
create_time: Some("2026-07-13T18:17:20+08:00".to_string()),
|
|
amount_total_cents: 600,
|
|
amount_refund_cents: 600,
|
|
amount_payer_total_cents: 600,
|
|
amount_payer_refund_cents: 600,
|
|
};
|
|
let observation = build_wechat_pay_refund_observation(
|
|
"callback:event-1".to_string(),
|
|
RuntimeProfileRechargeRefundObservationSource::Callback,
|
|
Some("hmac:event-1".to_string()),
|
|
"hmac:payload-1".to_string(),
|
|
&refund,
|
|
1_752_411_443_000_000,
|
|
)
|
|
.expect("verified refund fact should build");
|
|
|
|
assert_eq!(observation.total_cents, 600);
|
|
assert_eq!(observation.refund_cents, 600);
|
|
assert_eq!(observation.order_id, "order-1");
|
|
assert!(observation.success_at_micros.is_some());
|
|
|
|
let mut missing_success_time = refund;
|
|
missing_success_time.success_time = None;
|
|
assert!(
|
|
build_wechat_pay_refund_observation(
|
|
"callback:event-2".to_string(),
|
|
RuntimeProfileRechargeRefundObservationSource::Callback,
|
|
None,
|
|
"hmac:payload-2".to_string(),
|
|
&missing_success_time,
|
|
1,
|
|
)
|
|
.is_err()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn v3_payment_notify_requires_local_channel_amount_and_transaction_match() {
|
|
let order =
|
|
build_runtime_profile_recharge_order_record(RuntimeProfileRechargeOrderSnapshot {
|
|
order_id: "order-1".to_string(),
|
|
user_id: "user-1".to_string(),
|
|
product_id: "points-60".to_string(),
|
|
product_title: "60 points".to_string(),
|
|
kind: RuntimeProfileRechargeProductKind::Points,
|
|
amount_cents: 600,
|
|
status: RuntimeProfileRechargeOrderStatus::Paid,
|
|
payment_channel: "wechat_native".to_string(),
|
|
paid_at_micros: Some(1),
|
|
provider_transaction_id: Some("tx-1".to_string()),
|
|
created_at_micros: 1,
|
|
points_delta: 60,
|
|
membership_expires_at_micros: None,
|
|
expired_at_micros: None,
|
|
expiration_checked_at_micros: None,
|
|
expiration_provider_state: None,
|
|
expiration_last_error: None,
|
|
});
|
|
|
|
validate_wechat_pay_notify_order(&order, 600, "tx-1")
|
|
.expect("matching payment fact should pass");
|
|
assert!(validate_wechat_pay_notify_order(&order, 601, "tx-1").is_err());
|
|
assert!(validate_wechat_pay_notify_order(&order, 600, "tx-2").is_err());
|
|
}
|
|
}
|
|
|
|
fn build_virtual_payment_notify_error_response(
|
|
error: WechatPayError,
|
|
response_format: VirtualPaymentNotifyResponseFormat,
|
|
) -> Response {
|
|
let message = match error {
|
|
WechatPayError::Disabled => "微信虚拟支付暂未启用".to_string(),
|
|
WechatPayError::InvalidConfig(message)
|
|
| WechatPayError::InvalidRequest(message)
|
|
| WechatPayError::OrderNotExist(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,
|
|
}
|
|
}
|