Merge pull request #511

This commit is contained in:
fawney19
2026-05-20 01:22:55 +08:00
40 changed files with 4674 additions and 365 deletions
@@ -83,7 +83,7 @@ use self::support_payment::maybe_build_local_payment_callback_response;
use self::support_test_connection::maybe_build_local_test_connection_response;
use self::support_user_me::maybe_build_local_users_me_response;
use self::support_wallet::{
maybe_build_local_wallet_response, sanitize_wallet_gateway_response,
direct_gateway_channels, maybe_build_local_wallet_response, sanitize_wallet_gateway_response,
wallet_normalize_optional_string_field,
};
@@ -6,6 +6,10 @@ use super::{
build_auth_error_response, build_auth_json_response, resolve_authenticated_local_user,
sanitize_wallet_gateway_response, unix_secs_to_rfc3339, AppState, GatewayPublicRequestContext,
};
use crate::handlers::shared::{
create_alipay_direct_checkout, create_stripe_direct_checkout, create_wxpay_direct_checkout,
direct_payment_client_ip, DirectPaymentCheckoutInput,
};
use axum::{
body::{Body, Bytes},
http,
@@ -14,7 +18,7 @@ use axum::{
};
use chrono::Utc;
use serde::Deserialize;
use serde_json::json;
use serde_json::{json, Value};
use uuid::Uuid;
const BILLING_STORAGE_UNAVAILABLE_DETAIL: &str = "套餐后端暂不可用";
@@ -56,15 +60,18 @@ fn normalize_checkout_request(
let payment_provider = normalize_optional_checkout_string(payload.payment_provider, 30)
.or_else(|| normalize_optional_checkout_string(payload.payment_method.clone(), 30))
.unwrap_or_else(|| "epay".to_string());
if payment_provider != "epay" {
if !matches!(
payment_provider.as_str(),
"epay" | "alipay" | "wxpay" | "stripe"
) {
return Err("unsupported payment_provider");
}
let payment_method = normalize_optional_checkout_string(payload.payment_method, 30)
.unwrap_or_else(|| "epay".to_string());
.unwrap_or_else(|| payment_provider.clone());
let payment_channel = normalize_optional_checkout_string(payload.payment_channel, 30)
.or_else(|| (payment_method != "epay").then_some(payment_method.clone()));
Ok(NormalizedBillingPlanCheckoutRequest {
payment_method: "epay".to_string(),
payment_method: payment_provider.clone(),
payment_provider,
payment_channel,
})
@@ -320,110 +327,297 @@ pub(super) async fn handle_billing_plan_checkout(
false,
);
}
let config = match load_epay_config(state).await {
Ok(value) => value,
Err(detail) => {
return build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false)
}
};
let now = Utc::now();
let order_no = billing_order_no(now);
let expires_at = now + chrono::Duration::minutes(30);
let requested_provider = checkout_request.payment_provider.as_str();
let payment_method = checkout_request.payment_method.clone();
let payment_channel =
match resolve_epay_channel(&config, checkout_request.payment_channel.as_deref()) {
Ok(value) => value,
Err(detail) => {
return build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false);
}
};
let (amount_usd, pay_amount) =
match compute_plan_payment_amounts(&plan, &config.pay_currency, config.usd_exchange_rate) {
checkout_request
.payment_channel
.clone()
.or_else(|| match requested_provider {
"alipay" => Some("alipay".to_string()),
"wxpay" => Some("native".to_string()),
"stripe" => Some("card".to_string()),
_ => None,
});
if requested_provider == "epay" {
let config = match load_epay_config(state).await {
Ok(value) => value,
Err(detail) => {
return build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false)
}
};
let Some(callback_base_url) = epay_callback_base_url(
config.callback_base_url.as_deref(),
headers,
request_context,
) else {
return build_auth_error_response(
http::StatusCode::BAD_REQUEST,
"epay callback_base_url is required",
false,
);
};
let now = Utc::now();
let order_no = billing_order_no(now);
let expires_at = now + chrono::Duration::minutes(30);
let checkout = build_epay_checkout_url(
&config,
&EpayCheckoutInput {
order_no: order_no.clone(),
channel: payment_channel.clone(),
subject: plan.title.clone(),
pay_amount,
notify_url: format!("{callback_base_url}/api/payment/epay/notify"),
return_url: format!("{callback_base_url}/api/payment/epay/return"),
},
);
let outcome = match state
.create_plan_purchase_order(
aether_data::repository::wallet::CreatePlanPurchaseOrderInput {
preferred_wallet_id: None,
user_id: auth.user.id.clone(),
amount_usd,
pay_amount,
pay_currency: config.pay_currency.clone(),
exchange_rate: config.usd_exchange_rate,
payment_method: checkout_request.payment_method,
payment_provider: Some(checkout_request.payment_provider),
payment_channel: Some(payment_channel),
gateway_order_id: order_no.clone(),
gateway_response: checkout.clone(),
order_no,
product_id: plan.id.clone(),
product_snapshot: billing_plan_snapshot(&plan),
expires_at_unix_secs: expires_at.timestamp().max(0) as u64,
},
)
.await
{
Ok(Some(value)) => value,
Ok(None) => return billing_storage_unavailable_response(),
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("billing checkout create failed: {err:?}"),
false,
)
}
};
let order = match outcome {
aether_data::repository::wallet::CreatePlanPurchaseOrderOutcome::Created(order) => {
payment_order_payload(&order, &plan)
}
aether_data::repository::wallet::CreatePlanPurchaseOrderOutcome::WalletInactive => {
let payment_channel =
match resolve_epay_channel(&config, checkout_request.payment_channel.as_deref()) {
Ok(value) => value,
Err(detail) => {
return build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false);
}
};
let payment_channel_id = payment_channel.channel.clone();
let (amount_usd, pay_amount) = match compute_plan_payment_amounts(
&plan,
&config.pay_currency,
config.usd_exchange_rate,
) {
Ok(value) => value,
Err(detail) => {
return build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false)
}
};
let Some(callback_base_url) = epay_callback_base_url(
config.callback_base_url.as_deref(),
headers,
request_context,
) else {
return build_auth_error_response(
http::StatusCode::BAD_REQUEST,
"wallet is not active",
"epay callback_base_url is required",
false,
);
};
let checkout = build_epay_checkout_url(
&config,
&EpayCheckoutInput {
order_no: order_no.clone(),
channel: payment_channel_id.clone(),
subject: plan.title.clone(),
pay_amount,
notify_url: format!("{callback_base_url}/api/payment/epay/notify"),
return_url: format!("{callback_base_url}/api/payment/epay/return"),
},
);
let outcome = match state
.create_plan_purchase_order(
aether_data::repository::wallet::CreatePlanPurchaseOrderInput {
preferred_wallet_id: None,
user_id: auth.user.id.clone(),
amount_usd,
pay_amount,
pay_currency: config.pay_currency.clone(),
exchange_rate: config.usd_exchange_rate,
payment_method: payment_method.clone(),
payment_provider: Some(checkout_request.payment_provider.clone()),
payment_channel: Some(payment_channel_id),
gateway_order_id: order_no.clone(),
gateway_response: checkout.clone(),
order_no: order_no.clone(),
product_id: plan.id.clone(),
product_snapshot: billing_plan_snapshot(&plan),
expires_at_unix_secs: expires_at.timestamp().max(0) as u64,
},
)
}
aether_data::repository::wallet::CreatePlanPurchaseOrderOutcome::ActivePlanLimitReached => {
.await
{
Ok(Some(value)) => value,
Ok(None) => return billing_storage_unavailable_response(),
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("billing checkout create failed: {err:?}"),
false,
)
}
};
let order = match outcome {
aether_data::repository::wallet::CreatePlanPurchaseOrderOutcome::Created(order) => {
payment_order_payload(&order, &plan)
}
aether_data::repository::wallet::CreatePlanPurchaseOrderOutcome::WalletInactive => {
return build_auth_error_response(
http::StatusCode::BAD_REQUEST,
"wallet is not active",
false,
)
}
aether_data::repository::wallet::CreatePlanPurchaseOrderOutcome::ActivePlanLimitReached => {
return build_auth_error_response(
http::StatusCode::CONFLICT,
"套餐购买限制已达到上限",
false,
)
}
};
build_auth_json_response(
http::StatusCode::OK,
json!({
"order": order,
"payment_instructions": sanitize_wallet_gateway_response(Some(checkout)),
}),
None,
)
} else {
let (payment_channel, display_name, pay_currency, usd_exchange_rate, callback_base_url) = {
let record = match state.find_payment_gateway_config(requested_provider).await {
Ok(Some(value)) if value.enabled && value.merchant_key_encrypted.is_some() => value,
Ok(Some(_)) | Ok(None) => {
return build_auth_error_response(
http::StatusCode::BAD_REQUEST,
"支付网关未启用或密钥未配置",
false,
)
}
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("payment gateway lookup failed: {err:?}"),
false,
)
}
};
let payment_channel =
payment_channel
.clone()
.unwrap_or_else(|| match requested_provider {
"alipay" => "alipay".to_string(),
"wxpay" => "native".to_string(),
"stripe" => "card".to_string(),
_ => "alipay".to_string(),
});
let display_name = match requested_provider {
"alipay" => "支付宝官方".to_string(),
"wxpay" => match payment_channel.as_str() {
"h5" => "微信 H5".to_string(),
"jsapi" => "微信 JSAPI".to_string(),
_ => "微信 Native".to_string(),
},
"stripe" => match payment_channel.as_str() {
"alipay" => "Stripe Alipay".to_string(),
"wechat_pay" => "Stripe WeChat Pay".to_string(),
"link" => "Stripe Link".to_string(),
_ => "Stripe Card".to_string(),
},
_ => "支付".to_string(),
};
(
payment_channel,
display_name,
record.pay_currency,
record.usd_exchange_rate,
record.callback_base_url,
)
};
let (amount_usd, pay_amount) =
match compute_plan_payment_amounts(&plan, &pay_currency, usd_exchange_rate) {
Ok(value) => value,
Err(detail) => {
return build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false)
}
};
let Some(callback_base_url) =
epay_callback_base_url(callback_base_url.as_deref(), headers, request_context)
else {
return build_auth_error_response(
http::StatusCode::CONFLICT,
"套餐购买限制已达到上限",
http::StatusCode::BAD_REQUEST,
"支付网关 callback_base_url is required",
false,
);
};
let direct_input = DirectPaymentCheckoutInput {
payment_channel: payment_channel.clone(),
display_name,
order_no: order_no.clone(),
subject: plan.title.clone(),
pay_amount,
pay_currency: pay_currency.clone(),
notify_url: format!("{callback_base_url}/api/payment/{requested_provider}/notify"),
return_url: Some(format!("{callback_base_url}/dashboard/billing")),
client_ip: direct_payment_client_ip(headers),
expires_at,
};
let checkout = match requested_provider {
"alipay" => match create_alipay_direct_checkout(state, &direct_input).await {
Ok(value) => value,
Err(detail) => {
return build_auth_error_response(http::StatusCode::BAD_GATEWAY, detail, false)
}
},
"wxpay" => match create_wxpay_direct_checkout(state, &direct_input).await {
Ok(value) => value,
Err(detail) => {
return build_auth_error_response(http::StatusCode::BAD_GATEWAY, detail, false)
}
},
"stripe" => match create_stripe_direct_checkout(state, &direct_input).await {
Ok(value) => value,
Err(detail) => {
return build_auth_error_response(http::StatusCode::BAD_GATEWAY, detail, false)
}
},
_ => {
return build_auth_error_response(
http::StatusCode::BAD_REQUEST,
"unsupported payment provider",
false,
)
}
};
let outcome = match state
.create_plan_purchase_order(
aether_data::repository::wallet::CreatePlanPurchaseOrderInput {
preferred_wallet_id: None,
user_id: auth.user.id.clone(),
amount_usd,
pay_amount,
pay_currency: pay_currency.clone(),
exchange_rate: usd_exchange_rate,
payment_method,
payment_provider: Some(requested_provider.to_string()),
payment_channel: Some(payment_channel.clone()),
gateway_order_id: checkout
.get("gateway_order_id")
.and_then(Value::as_str)
.unwrap_or(&order_no)
.to_string(),
gateway_response: checkout.clone(),
order_no: order_no.clone(),
product_id: plan.id.clone(),
product_snapshot: billing_plan_snapshot(&plan),
expires_at_unix_secs: expires_at.timestamp().max(0) as u64,
},
)
}
};
build_auth_json_response(
http::StatusCode::OK,
json!({
"order": order,
"payment_instructions": sanitize_wallet_gateway_response(Some(checkout)),
}),
None,
)
.await
{
Ok(Some(value)) => value,
Ok(None) => return billing_storage_unavailable_response(),
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("billing checkout create failed: {err:?}"),
false,
)
}
};
let order = match outcome {
aether_data::repository::wallet::CreatePlanPurchaseOrderOutcome::Created(order) => {
payment_order_payload(&order, &plan)
}
aether_data::repository::wallet::CreatePlanPurchaseOrderOutcome::WalletInactive => {
return build_auth_error_response(
http::StatusCode::BAD_REQUEST,
"wallet is not active",
false,
)
}
aether_data::repository::wallet::CreatePlanPurchaseOrderOutcome::ActivePlanLimitReached => {
return build_auth_error_response(
http::StatusCode::CONFLICT,
"套餐购买限制已达到上限",
false,
)
}
};
build_auth_json_response(
http::StatusCode::OK,
json!({
"order": order,
"payment_instructions": sanitize_wallet_gateway_response(Some(checkout)),
}),
None,
)
}
}
pub(super) async fn maybe_build_local_billing_response(
@@ -4,6 +4,8 @@ pub(super) use super::{
build_auth_error_response, build_auth_json_response, AppState, GatewayPublicRequestContext,
};
#[path = "payment/alipay.rs"]
mod payment_alipay;
#[path = "payment/epay.rs"]
pub(super) mod payment_epay;
#[path = "payment/gateway.rs"]
@@ -14,11 +16,19 @@ mod payment_repository;
mod payment_route;
#[path = "payment/shared.rs"]
mod payment_shared;
#[path = "payment/stripe.rs"]
mod payment_stripe;
#[cfg(test)]
#[path = "payment/test_support.rs"]
mod payment_test_support;
#[path = "payment/wxpay.rs"]
mod payment_wxpay;
use self::payment_repository::handle_payment_callback_with_wallet_repository;
use self::payment_repository::{
handle_payment_callback_input_with_wallet_repository,
handle_payment_callback_with_wallet_repository,
process_payment_callback_input_with_wallet_repository,
};
use self::payment_shared::NormalizedPaymentCallbackRequest;
const PAYMENT_CALLBACK_STORAGE_UNAVAILABLE_DETAIL: &str = "支付回调存储暂不可用";
@@ -0,0 +1,64 @@
use axum::{body::Body, http, response::Response};
use super::{
process_payment_callback_input_with_wallet_repository, AppState, GatewayPublicRequestContext,
};
use tracing::warn;
fn alipay_plain(status: http::StatusCode, body: &'static str) -> Response<Body> {
Response::builder()
.status(status)
.header(http::header::CONTENT_TYPE, "text/plain; charset=utf-8")
.body(Body::from(body))
.expect("alipay plain response should build")
}
pub(super) async fn handle_alipay_notify(
state: &AppState,
request_context: &GatewayPublicRequestContext,
request_body: Option<&axum::body::Bytes>,
) -> Response<Body> {
let Some(request_body) = request_body else {
return alipay_plain(http::StatusCode::OK, "fail");
};
let input =
match crate::handlers::shared::verify_alipay_notify_callback(state, request_body).await {
Ok(value) => value,
Err(detail) => {
warn!(error = %detail, "alipay notify verification failed");
return alipay_plain(http::StatusCode::OK, "fail");
}
};
match process_payment_callback_input_with_wallet_repository(state, input).await {
Ok(aether_data::repository::wallet::ProcessPaymentCallbackOutcome::Applied {
order,
order_id,
..
}) => {
if let Err(err) = state.apply_referral_rewards_for_paid_order(&order).await {
warn!(
error = ?err,
order_id = %order_id,
"failed to apply referral rewards for alipay callback"
);
}
alipay_plain(http::StatusCode::OK, "success")
}
Ok(
aether_data::repository::wallet::ProcessPaymentCallbackOutcome::AlreadyCredited {
..
}
| aether_data::repository::wallet::ProcessPaymentCallbackOutcome::DuplicateProcessed {
..
},
) => alipay_plain(http::StatusCode::OK, "success"),
Ok(aether_data::repository::wallet::ProcessPaymentCallbackOutcome::Failed {
error,
..
}) => {
warn!(error = %error, path = %request_context.request_path, "alipay notify processing failed");
alipay_plain(http::StatusCode::OK, "fail")
}
Err(response) => response,
}
}
@@ -19,10 +19,11 @@ pub(crate) struct EpayMerchantConfig {
pub(crate) channels: serde_json::Value,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct EpayChannelConfig {
pub(crate) channel: String,
pub(crate) display_name: String,
pub(crate) fee_rate: f64,
}
#[derive(Debug, Clone)]
@@ -35,8 +36,22 @@ pub(crate) struct EpayCheckoutInput {
pub(crate) return_url: String,
}
fn epay_channel_fee_rate(value: Option<&serde_json::Value>) -> f64 {
let fee_rate = match value {
Some(serde_json::Value::Number(number)) => number.as_f64().unwrap_or(0.0),
Some(serde_json::Value::String(value)) => value.trim().parse::<f64>().unwrap_or(0.0),
_ => 0.0,
};
if fee_rate.is_finite() && fee_rate >= 0.0 {
fee_rate
} else {
0.0
}
}
pub(crate) fn configured_epay_channels(config: &EpayMerchantConfig) -> Vec<EpayChannelConfig> {
let Some(channels) = config.channels.as_array() else {
let channels_value = crate::handlers::shared::payment_gateway_channels_json(&config.channels);
let Some(channels) = channels_value.as_array() else {
return Vec::new();
};
channels
@@ -58,6 +73,7 @@ pub(crate) fn configured_epay_channels(config: &EpayMerchantConfig) -> Vec<EpayC
Some(EpayChannelConfig {
channel: channel_id.to_string(),
display_name,
fee_rate: epay_channel_fee_rate(channel.get("fee_rate")),
})
})
.collect()
@@ -66,7 +82,7 @@ pub(crate) fn configured_epay_channels(config: &EpayMerchantConfig) -> Vec<EpayC
pub(crate) fn resolve_epay_channel(
config: &EpayMerchantConfig,
requested_channel: Option<&str>,
) -> Result<String, &'static str> {
) -> Result<EpayChannelConfig, &'static str> {
let channels = configured_epay_channels(config);
if channels.is_empty() {
return Err("支付网关未配置可用通道");
@@ -79,11 +95,11 @@ pub(crate) fn resolve_epay_channel(
.iter()
.find(|channel| channel.channel.eq_ignore_ascii_case(&requested_channel))
{
return Ok(channel.channel.clone());
return Ok(channel.clone());
}
return Err("支付通道未配置或已停用");
}
Ok(channels[0].channel.clone())
Ok(channels[0].clone())
}
pub(crate) fn epay_sign(params: &BTreeMap<String, String>, merchant_key: &str) -> String {
@@ -444,8 +460,8 @@ mod tests {
#[test]
fn configured_epay_channels_do_not_invent_defaults() {
let mut config = test_epay_config(json!([
{"channel": " Alipay ", "display_name": "支付宝"},
{"type": "wxpay", "display_name": ""},
{"channel": " Alipay ", "display_name": "支付宝", "fee_rate": 2.5},
{"type": "wxpay", "display_name": "", "fee_rate": "1.2"},
{"display_name": "缺少通道值"}
]));
@@ -453,14 +469,16 @@ mod tests {
assert_eq!(channels.len(), 2);
assert_eq!(channels[0].channel, "Alipay");
assert_eq!(channels[0].display_name, "支付宝");
assert_eq!(channels[0].fee_rate, 2.5);
assert_eq!(channels[1].channel, "wxpay");
assert_eq!(channels[1].display_name, "wxpay");
assert_eq!(channels[1].fee_rate, 1.2);
assert_eq!(
resolve_epay_channel(&config, None),
resolve_epay_channel(&config, None).map(|channel| channel.channel),
Ok("Alipay".to_string())
);
assert_eq!(
resolve_epay_channel(&config, Some("WXPAY")),
resolve_epay_channel(&config, Some("WXPAY")).map(|channel| channel.channel),
Ok("wxpay".to_string())
);
assert_eq!(
@@ -2,6 +2,7 @@ use super::payment_shared::{
payment_callback_mark_failed_response, payment_callback_payload_hash,
NormalizedPaymentCallbackRequest,
};
use aether_data::repository::wallet::{ProcessPaymentCallbackInput, ProcessPaymentCallbackOutcome};
use axum::{body::Body, http, response::Response};
use serde_json::json;
@@ -29,35 +30,37 @@ pub(super) async fn handle_payment_callback_with_wallet_repository(
return build_auth_error_response(http::StatusCode::INTERNAL_SERVER_ERROR, err, false)
}
};
let outcome = match state
.process_payment_callback(
aether_data::repository::wallet::ProcessPaymentCallbackInput {
payment_method: payment_method.to_string(),
payment_provider: None,
payment_channel: None,
callback_key: payload.callback_key.clone(),
order_no: payload.order_no.clone(),
gateway_order_id: payload.gateway_order_id.clone(),
amount_usd: payload.amount_usd,
pay_amount: payload.pay_amount,
pay_currency: payload.pay_currency.clone(),
exchange_rate: payload.exchange_rate,
payload_hash: callback_payload_hash,
payload: payload.payload.clone(),
signature_valid,
},
)
.await
{
Ok(Some(value)) => value,
Ok(None) => return build_payment_callback_storage_unavailable_response(),
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("payment callback failed: {err:?}"),
false,
)
}
handle_payment_callback_input_with_wallet_repository(
state,
request_context,
ProcessPaymentCallbackInput {
payment_method: payment_method.to_string(),
payment_provider: None,
payment_channel: None,
callback_key: payload.callback_key.clone(),
order_no: payload.order_no.clone(),
gateway_order_id: payload.gateway_order_id.clone(),
amount_usd: payload.amount_usd,
pay_amount: payload.pay_amount,
pay_currency: payload.pay_currency.clone(),
exchange_rate: payload.exchange_rate,
payload_hash: callback_payload_hash,
payload: payload.payload.clone(),
signature_valid,
},
)
.await
}
pub(super) async fn handle_payment_callback_input_with_wallet_repository(
state: &AppState,
request_context: &GatewayPublicRequestContext,
input: ProcessPaymentCallbackInput,
) -> Response<Body> {
let payment_method = input.payment_method.clone();
let outcome = match process_payment_callback_input_with_wallet_repository(state, input).await {
Ok(value) => value,
Err(response) => return response,
};
match outcome {
@@ -81,7 +84,7 @@ pub(super) async fn handle_payment_callback_with_wallet_repository(
} => payment_callback_mark_failed_response(
duplicate,
&error,
payment_method,
&payment_method,
&request_context.request_path,
),
aether_data::repository::wallet::ProcessPaymentCallbackOutcome::AlreadyCredited {
@@ -137,6 +140,25 @@ pub(super) async fn handle_payment_callback_with_wallet_repository(
}
}
pub(super) async fn process_payment_callback_input_with_wallet_repository(
state: &AppState,
input: ProcessPaymentCallbackInput,
) -> Result<ProcessPaymentCallbackOutcome, Response<Body>> {
if !state.has_database_wallet_data_writer() {
return Err(build_payment_callback_storage_unavailable_response());
}
match state.process_payment_callback(input).await {
Ok(Some(value)) => Ok(value),
Ok(None) => Err(build_payment_callback_storage_unavailable_response()),
Err(err) => Err(build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("payment callback failed: {err:?}"),
false,
)),
}
}
#[cfg(test)]
mod tests {
use super::{
@@ -7,8 +7,8 @@ use super::payment_shared::{
};
use super::{
build_auth_error_response, build_payment_callback_storage_unavailable_response,
handle_payment_callback_with_wallet_repository, payment_epay, AppState,
GatewayPublicRequestContext,
handle_payment_callback_with_wallet_repository, payment_alipay, payment_epay, payment_stripe,
payment_wxpay, AppState, GatewayPublicRequestContext,
};
pub(super) async fn maybe_build_local_payment_callback_route_response(
@@ -30,6 +30,25 @@ pub(super) async fn maybe_build_local_payment_callback_route_response(
return Some(payment_epay::handle_epay_return(state, request_context, request_body).await);
}
if decision.route_kind.as_deref() == Some("alipay_notify") {
return Some(
payment_alipay::handle_alipay_notify(state, request_context, request_body).await,
);
}
if decision.route_kind.as_deref() == Some("wxpay_notify") {
return Some(
payment_wxpay::handle_wxpay_notify(state, request_context, headers, request_body).await,
);
}
if decision.route_kind.as_deref() == Some("stripe_webhook") {
return Some(
payment_stripe::handle_stripe_webhook(state, request_context, headers, request_body)
.await,
);
}
if decision.route_kind.as_deref() != Some("callback") {
return None;
}
@@ -0,0 +1,339 @@
use axum::{body::Body, http, response::Response};
use hmac::{Hmac, Mac};
use serde_json::{json, Value};
use sha2::Sha256;
use super::{
build_auth_error_response, build_auth_json_response,
handle_payment_callback_input_with_wallet_repository,
payment_shared::payment_callback_payload_hash, AppState, GatewayPublicRequestContext,
};
const STRIPE_SIGNATURE_HEADER: &str = "stripe-signature";
const STRIPE_SIGNATURE_TOLERANCE_SECONDS: i64 = 300;
type HmacSha256 = Hmac<Sha256>;
fn decrypt_gateway_secrets(
state: &AppState,
record: &aether_data_contracts::repository::billing::PaymentGatewayConfigRecord,
) -> Result<serde_json::Map<String, Value>, String> {
let Some(encrypted) = record.merchant_key_encrypted.as_deref() else {
return Err("Stripe webhook_secret 未配置".to_string());
};
let Some(plaintext) = crate::handlers::shared::decrypt_catalog_secret_with_fallbacks(
state.encryption_key(),
encrypted,
) else {
return Err("Stripe 密钥解密失败".to_string());
};
serde_json::from_str::<Value>(&plaintext)
.ok()
.and_then(|value| value.as_object().cloned())
.ok_or_else(|| "Stripe 密钥格式无效".to_string())
}
fn gateway_secret_string(secrets: &serde_json::Map<String, Value>, key: &str) -> Option<String> {
secrets
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
async fn stripe_webhook_secret(state: &AppState) -> Result<String, String> {
let Some(record) = state
.find_payment_gateway_config("stripe")
.await
.map_err(|err| format!("Stripe 配置读取失败: {err:?}"))?
else {
return Err("Stripe 未配置".to_string());
};
if !record.enabled {
return Err("Stripe 未启用".to_string());
}
let secrets = decrypt_gateway_secrets(state, &record)?;
gateway_secret_string(&secrets, "webhook_secret")
.ok_or_else(|| "Stripe webhook_secret 未配置".to_string())
}
fn parse_stripe_signature_header(value: &str) -> Option<(i64, Vec<&str>)> {
let mut timestamp = None;
let mut signatures = Vec::new();
for part in value.split(',') {
let Some((key, value)) = part.trim().split_once('=') else {
continue;
};
match key.trim() {
"t" => timestamp = value.trim().parse::<i64>().ok(),
"v1" => {
let signature = value.trim();
if !signature.is_empty() {
signatures.push(signature);
}
}
_ => {}
}
}
timestamp.map(|value| (value, signatures))
}
fn hex_to_bytes(value: &str) -> Option<Vec<u8>> {
let value = value.trim();
if !value.len().is_multiple_of(2) {
return None;
}
let mut bytes = Vec::with_capacity(value.len() / 2);
let mut chars = value.as_bytes().chunks_exact(2);
for chunk in &mut chars {
let hex = std::str::from_utf8(chunk).ok()?;
bytes.push(u8::from_str_radix(hex, 16).ok()?);
}
Some(bytes)
}
fn stripe_signature_matches_at(
secret: &str,
signature_header: &str,
body: &[u8],
now_unix_secs: i64,
) -> Result<bool, String> {
let Some((timestamp, signatures)) = parse_stripe_signature_header(signature_header) else {
return Ok(false);
};
if signatures.is_empty() {
return Ok(false);
}
if (now_unix_secs - timestamp).abs() > STRIPE_SIGNATURE_TOLERANCE_SECONDS {
return Ok(false);
}
for signature in signatures {
let Some(signature_bytes) = hex_to_bytes(signature) else {
continue;
};
let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
.map_err(|err| format!("Stripe webhook HMAC 初始化失败: {err}"))?;
mac.update(timestamp.to_string().as_bytes());
mac.update(b".");
mac.update(body);
if mac.verify_slice(&signature_bytes).is_ok() {
return Ok(true);
}
}
Ok(false)
}
fn stripe_amount_multiplier(currency: &str) -> f64 {
match currency.trim().to_ascii_lowercase().as_str() {
"bif" | "clp" | "djf" | "gnf" | "jpy" | "kmf" | "krw" | "mga" | "pyg" | "rwf" | "ugx"
| "vnd" | "vuv" | "xaf" | "xof" | "xpf" => 1.0,
_ => 100.0,
}
}
fn stripe_amount_to_major(amount_minor: i64, currency: &str) -> f64 {
amount_minor as f64 / stripe_amount_multiplier(currency)
}
fn stripe_string_field<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
value
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
}
fn stripe_payment_intent_channel(intent: &Value) -> Option<String> {
intent
.get("payment_method_types")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(str::trim)
.find(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
async fn build_stripe_callback_input(
state: &AppState,
event: Value,
) -> Result<Option<aether_data::repository::wallet::ProcessPaymentCallbackInput>, String> {
let event_type = stripe_string_field(&event, "type").unwrap_or("");
if event_type != "payment_intent.succeeded" {
return Ok(None);
}
let event_id = stripe_string_field(&event, "id")
.ok_or_else(|| "Stripe 事件缺少 id".to_string())?
.to_string();
let intent = event
.get("data")
.and_then(|value| value.get("object"))
.ok_or_else(|| "Stripe 事件缺少 PaymentIntent".to_string())?;
let intent_id = stripe_string_field(intent, "id")
.ok_or_else(|| "Stripe PaymentIntent 缺少 id".to_string())?
.to_string();
let order_no = intent
.get("metadata")
.and_then(Value::as_object)
.and_then(|metadata| metadata.get("order_no"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let Some(order_no) = order_no else {
return Err("Stripe PaymentIntent 缺少 metadata.order_no".to_string());
};
let currency = stripe_string_field(intent, "currency")
.unwrap_or("usd")
.to_ascii_uppercase();
let amount_minor = intent
.get("amount_received")
.or_else(|| intent.get("amount"))
.and_then(Value::as_i64)
.filter(|value| *value > 0)
.ok_or_else(|| "Stripe PaymentIntent 金额无效".to_string())?;
let pay_amount = stripe_amount_to_major(amount_minor, &currency);
let record = state
.find_payment_gateway_config("stripe")
.await
.map_err(|err| format!("Stripe 配置读取失败: {err:?}"))?
.ok_or_else(|| "Stripe 未配置".to_string())?;
let exchange_rate = record.usd_exchange_rate;
let amount_usd = if exchange_rate > 0.0 {
pay_amount / exchange_rate
} else {
pay_amount
};
let payload_hash = payment_callback_payload_hash(&event)?;
Ok(Some(
aether_data::repository::wallet::ProcessPaymentCallbackInput {
payment_method: "stripe".to_string(),
payment_provider: Some("stripe".to_string()),
payment_channel: stripe_payment_intent_channel(intent),
callback_key: event_id,
order_no: Some(order_no),
gateway_order_id: Some(intent_id),
amount_usd,
pay_amount: Some(pay_amount),
pay_currency: Some(currency),
exchange_rate: Some(exchange_rate),
payload_hash,
payload: event,
signature_valid: true,
},
))
}
pub(super) async fn handle_stripe_webhook(
state: &AppState,
request_context: &GatewayPublicRequestContext,
headers: &http::HeaderMap,
request_body: Option<&axum::body::Bytes>,
) -> Response<Body> {
let Some(request_body) = request_body else {
return build_auth_error_response(http::StatusCode::BAD_REQUEST, "缺少请求体", false);
};
let Some(signature_header) = crate::headers::header_value_str(headers, STRIPE_SIGNATURE_HEADER)
else {
return build_auth_error_response(
http::StatusCode::UNAUTHORIZED,
"缺少 Stripe-Signature",
false,
);
};
let webhook_secret = match stripe_webhook_secret(state).await {
Ok(value) => value,
Err(detail) => {
return build_auth_error_response(http::StatusCode::SERVICE_UNAVAILABLE, detail, false)
}
};
let now = chrono::Utc::now().timestamp();
match stripe_signature_matches_at(&webhook_secret, &signature_header, request_body, now) {
Ok(true) => {}
Ok(false) => {
return build_auth_error_response(
http::StatusCode::UNAUTHORIZED,
"Stripe webhook 签名无效",
false,
)
}
Err(detail) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
detail,
false,
)
}
}
let event = match serde_json::from_slice::<Value>(request_body) {
Ok(value) => value,
Err(_) => {
return build_auth_error_response(
http::StatusCode::BAD_REQUEST,
"Stripe webhook 请求体无效",
false,
)
}
};
let input = match build_stripe_callback_input(state, event).await {
Ok(Some(value)) => value,
Ok(None) => {
return build_auth_json_response(
http::StatusCode::OK,
json!({ "ok": true, "ignored": true, "payment_method": "stripe" }),
None,
)
}
Err(detail) => {
return build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false)
}
};
handle_payment_callback_input_with_wallet_repository(state, request_context, input).await
}
#[cfg(test)]
mod tests {
use super::{stripe_amount_to_major, stripe_signature_matches_at};
use hmac::{Hmac, Mac};
use sha2::Sha256;
#[test]
fn stripe_signature_matches_raw_body() {
let body = br#"{"id":"evt_1","type":"payment_intent.succeeded"}"#;
let secret = "whsec_test";
let timestamp = 1_800_000_000_i64;
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("hmac");
mac.update(timestamp.to_string().as_bytes());
mac.update(b".");
mac.update(body);
let signature = mac
.finalize()
.into_bytes()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
let header = format!("t={timestamp},v1={signature}");
assert!(
stripe_signature_matches_at(secret, &header, body, timestamp)
.expect("signature check should run")
);
assert!(
!stripe_signature_matches_at("wrong", &header, body, timestamp)
.expect("signature check should run")
);
}
#[test]
fn stripe_amount_handles_zero_decimal_currencies() {
assert_eq!(stripe_amount_to_major(1234, "usd"), 12.34);
assert_eq!(stripe_amount_to_major(1234, "jpy"), 1234.0);
}
}
@@ -0,0 +1,77 @@
use axum::{body::Body, http, response::Response};
use super::{
process_payment_callback_input_with_wallet_repository, AppState, GatewayPublicRequestContext,
};
use serde_json::json;
use tracing::warn;
fn wxpay_json(
status: http::StatusCode,
code: &'static str,
message: impl Into<String>,
) -> Response<Body> {
Response::builder()
.status(status)
.header(
http::header::CONTENT_TYPE,
"application/json; charset=utf-8",
)
.body(Body::from(
json!({ "code": code, "message": message.into() }).to_string(),
))
.expect("wxpay json response should build")
}
pub(super) async fn handle_wxpay_notify(
state: &AppState,
request_context: &GatewayPublicRequestContext,
headers: &http::HeaderMap,
request_body: Option<&axum::body::Bytes>,
) -> Response<Body> {
let Some(request_body) = request_body else {
return wxpay_json(http::StatusCode::BAD_REQUEST, "FAIL", "缺少请求体");
};
let input =
match crate::handlers::shared::verify_wxpay_notify_callback(state, headers, request_body)
.await
{
Ok(value) => value,
Err(detail) => {
warn!(error = %detail, "wxpay notify verification failed");
return wxpay_json(http::StatusCode::BAD_REQUEST, "FAIL", detail);
}
};
match process_payment_callback_input_with_wallet_repository(state, input).await {
Ok(aether_data::repository::wallet::ProcessPaymentCallbackOutcome::Applied {
order,
order_id,
..
}) => {
if let Err(err) = state.apply_referral_rewards_for_paid_order(&order).await {
warn!(
error = ?err,
order_id = %order_id,
"failed to apply referral rewards for wxpay callback"
);
}
wxpay_json(http::StatusCode::OK, "SUCCESS", "成功")
}
Ok(
aether_data::repository::wallet::ProcessPaymentCallbackOutcome::AlreadyCredited {
..
}
| aether_data::repository::wallet::ProcessPaymentCallbackOutcome::DuplicateProcessed {
..
},
) => wxpay_json(http::StatusCode::OK, "SUCCESS", "成功"),
Ok(aether_data::repository::wallet::ProcessPaymentCallbackOutcome::Failed {
error,
..
}) => {
warn!(error = %error, path = %request_context.request_path, "wxpay notify processing failed");
wxpay_json(http::StatusCode::INTERNAL_SERVER_ERROR, "FAIL", error)
}
Err(response) => response,
}
}
@@ -35,14 +35,15 @@ use self::reads::{
handle_wallet_today_cost, handle_wallet_transactions, parse_wallet_limit, parse_wallet_offset,
wallet_fixed_offset, wallet_transaction_payload_from_record,
};
pub(crate) use self::recharge::sanitize_wallet_gateway_response;
pub(crate) use self::recharge::{direct_gateway_channels, sanitize_wallet_gateway_response};
use self::recharge::{
handle_wallet_create_recharge, handle_wallet_recharge_detail, handle_wallet_recharge_list,
handle_wallet_recharge_options, wallet_recharge_detail_path_matches,
};
use self::redeem::handle_wallet_redeem;
use self::refunds::{
handle_wallet_create_refund, handle_wallet_refund_detail, handle_wallet_refunds_list,
handle_wallet_create_refund, handle_wallet_refund_detail,
handle_wallet_refund_eligible_providers, handle_wallet_refunds_list,
wallet_refund_detail_path_matches,
};
@@ -59,8 +60,23 @@ const WALLET_SAFE_GATEWAY_RESPONSE_KEYS: &[&str] = &[
"qr_code",
"expires_at",
"pay_amount",
"base_pay_amount",
"fee_rate",
"fee_amount",
"pay_currency",
"payment_channel",
"code_url",
"h5_url",
"jsapi",
"client_secret",
"publishable_key",
"intent_id",
"payment_method_types",
"provider_label",
"subject",
"callback_url",
"return_url",
"integration_status",
"manual_credit",
];
@@ -138,6 +154,14 @@ pub(super) async fn maybe_build_local_wallet_response(
return Some(handle_wallet_refunds_list(state, request_context, headers).await);
}
if decision.route_kind.as_deref() == Some("refund_eligible_providers")
&& request_context.request_path == "/api/wallet/refunds/eligible-providers"
{
return Some(
handle_wallet_refund_eligible_providers(state, request_context, headers).await,
);
}
if decision.route_kind.as_deref() == Some("refund_detail")
&& wallet_refund_detail_path_matches(&request_context.request_path)
{
@@ -17,9 +17,13 @@ use super::{
record_wallet_test_recharge, wallet_test_recharge_order_by_id,
wallet_test_recharge_orders_for_user,
};
use crate::handlers::shared::{
create_alipay_direct_checkout, create_wxpay_direct_checkout, direct_payment_client_ip,
DirectPaymentCheckoutInput,
};
use chrono::Utc;
use serde::Deserialize;
use serde_json::json;
use serde_json::{json, Value};
use uuid::Uuid;
#[derive(Debug, Deserialize)]
@@ -93,6 +97,17 @@ fn wallet_build_order_no(now: chrono::DateTime<chrono::Utc>) -> String {
)
}
fn wallet_payment_return_url(callback_base_url: &str, provider: &str, order_no: &str) -> String {
let mut serializer = url::form_urlencoded::Serializer::new(String::new());
serializer.append_pair("payment_provider", provider);
serializer.append_pair("payment_status", "pending");
serializer.append_pair("order_no", order_no);
format!(
"{callback_base_url}/dashboard/wallet?{}",
serializer.finish()
)
}
fn wallet_order_id_from_path(request_path: &str) -> Option<String> {
let trimmed = request_path.trim_end_matches('/');
let order_id = trimmed.strip_prefix("/api/wallet/recharge/")?.trim();
@@ -192,6 +207,263 @@ fn wallet_payment_order_payload_from_record(
)
}
#[derive(Debug, Clone)]
pub(crate) struct DirectGatewayChannelConfig {
pub(crate) channel: String,
pub(crate) display_name: String,
pub(crate) fee_rate: f64,
}
fn configured_channel_fee_rate(value: Option<&Value>) -> f64 {
let fee_rate = match value {
Some(Value::Number(number)) => number.as_f64().unwrap_or(0.0),
Some(Value::String(value)) => value.trim().parse::<f64>().unwrap_or(0.0),
_ => 0.0,
};
if fee_rate.is_finite() && fee_rate >= 0.0 {
fee_rate
} else {
0.0
}
}
fn round_payment_amount(value: f64) -> f64 {
(value * 100.0).round() / 100.0
}
fn wallet_recharge_payment_breakdown(
amount_usd: f64,
usd_exchange_rate: f64,
fee_rate: f64,
) -> (f64, f64, f64) {
let safe_fee_rate = if fee_rate.is_finite() && fee_rate > 0.0 {
fee_rate
} else {
0.0
};
let base_pay_amount = round_payment_amount(amount_usd * usd_exchange_rate);
let fee_amount = round_payment_amount(base_pay_amount * safe_fee_rate / 100.0);
let pay_amount = round_payment_amount(base_pay_amount + fee_amount);
(base_pay_amount, fee_amount, pay_amount)
}
fn add_wallet_recharge_fee_metadata(
mut checkout: Value,
base_pay_amount: f64,
fee_rate: f64,
fee_amount: f64,
) -> Value {
if let Some(object) = checkout.as_object_mut() {
object.insert("base_pay_amount".to_string(), json!(base_pay_amount));
object.insert("fee_rate".to_string(), json!(fee_rate));
object.insert("fee_amount".to_string(), json!(fee_amount));
}
checkout
}
pub(crate) fn direct_gateway_channels(
provider: &str,
record: &aether_data_contracts::repository::billing::PaymentGatewayConfigRecord,
) -> Vec<DirectGatewayChannelConfig> {
let channels_value =
crate::handlers::shared::payment_gateway_channels_json(&record.channels_json);
let channels = channels_value.as_array().into_iter().flatten();
channels
.filter_map(|channel| {
let channel_id = channel
.get("channel")
.or_else(|| channel.get("type"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let display_name = channel
.get("display_name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(channel_id);
Some(DirectGatewayChannelConfig {
channel: channel_id.to_string(),
display_name: display_name.to_string(),
fee_rate: configured_channel_fee_rate(channel.get("fee_rate")),
})
})
.filter(|channel| match provider {
"alipay" => channel.channel == "alipay",
"wxpay" => matches!(channel.channel.as_str(), "native" | "h5" | "jsapi"),
"stripe" => matches!(
channel.channel.as_str(),
"card" | "alipay" | "wechat_pay" | "link"
),
_ => false,
})
.collect()
}
fn resolve_direct_gateway_channel(
provider: &str,
record: &aether_data_contracts::repository::billing::PaymentGatewayConfigRecord,
requested: Option<&str>,
) -> Result<DirectGatewayChannelConfig, String> {
let channels = direct_gateway_channels(provider, record);
if channels.is_empty() {
return Err("支付网关没有可用通道".to_string());
}
let Some(requested) = requested.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(channels[0].clone());
};
channels
.into_iter()
.find(|channel| channel.channel.eq_ignore_ascii_case(requested))
.ok_or_else(|| "支付通道不可用".to_string())
}
fn direct_gateway_public_config_string(
record: &aether_data_contracts::repository::billing::PaymentGatewayConfigRecord,
key: &str,
) -> Option<String> {
crate::handlers::shared::payment_gateway_config_json(&record.channels_json)
.as_object()?
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn decrypt_direct_gateway_secrets(
state: &AppState,
record: &aether_data_contracts::repository::billing::PaymentGatewayConfigRecord,
) -> Result<serde_json::Map<String, Value>, String> {
let Some(encrypted) = record.merchant_key_encrypted.as_deref() else {
return Err("支付网关密钥未配置".to_string());
};
let Some(plaintext) = crate::handlers::shared::decrypt_catalog_secret_with_fallbacks(
state.encryption_key(),
encrypted,
) else {
return Err("支付网关密钥解密失败".to_string());
};
serde_json::from_str::<Value>(&plaintext)
.ok()
.and_then(|value| value.as_object().cloned())
.ok_or_else(|| "支付网关密钥格式无效".to_string())
}
fn direct_gateway_secret_string(
secrets: &serde_json::Map<String, Value>,
key: &str,
) -> Option<String> {
secrets
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn stripe_minor_unit_amount(pay_amount: f64, pay_currency: &str) -> Result<i64, String> {
let currency = pay_currency.trim().to_ascii_lowercase();
let multiplier = match currency.as_str() {
"bif" | "clp" | "djf" | "gnf" | "jpy" | "kmf" | "krw" | "mga" | "pyg" | "rwf" | "ugx"
| "vnd" | "vuv" | "xaf" | "xof" | "xpf" => 1.0,
_ => 100.0,
};
let amount = (pay_amount * multiplier).round();
if !amount.is_finite() || amount <= 0.0 {
return Err("Stripe 支付金额无效".to_string());
}
Ok(amount as i64)
}
async fn create_stripe_wallet_recharge_checkout(
state: &AppState,
record: &aether_data_contracts::repository::billing::PaymentGatewayConfigRecord,
payment_channel: &str,
display_name: &str,
order_no: &str,
pay_amount: f64,
expires_at: chrono::DateTime<chrono::Utc>,
) -> Result<Value, String> {
let secrets = decrypt_direct_gateway_secrets(state, record)?;
let Some(secret_key) = direct_gateway_secret_string(&secrets, "secret_key") else {
return Err("Stripe secret_key 未配置".to_string());
};
let Some(publishable_key) = direct_gateway_public_config_string(record, "publishable_key")
else {
return Err("Stripe publishable_key 未配置".to_string());
};
let amount = stripe_minor_unit_amount(pay_amount, &record.pay_currency)?;
let currency = record.pay_currency.trim().to_ascii_lowercase();
let mut form = vec![
("amount".to_string(), amount.to_string()),
("currency".to_string(), currency.clone()),
("description".to_string(), "钱包充值".to_string()),
("metadata[order_no]".to_string(), order_no.to_string()),
(
"metadata[payment_provider]".to_string(),
"stripe".to_string(),
),
(
"metadata[payment_channel]".to_string(),
payment_channel.to_string(),
),
(
"payment_method_types[]".to_string(),
payment_channel.to_string(),
),
];
if payment_channel == "wechat_pay" {
form.push((
"payment_method_options[wechat_pay][client]".to_string(),
"web".to_string(),
));
}
let response = state
.client
.post("https://api.stripe.com/v1/payment_intents")
.basic_auth(secret_key, Some(""))
.form(&form)
.send()
.await
.map_err(|err| format!("Stripe PaymentIntent 创建失败: {err}"))?;
let status = response.status();
let body = response
.text()
.await
.map_err(|err| format!("Stripe 响应读取失败: {err}"))?;
let value =
serde_json::from_str::<Value>(&body).map_err(|_| "Stripe 响应格式无效".to_string())?;
if !status.is_success() {
let message = value
.get("error")
.and_then(|error| error.get("message"))
.and_then(Value::as_str)
.unwrap_or("Stripe PaymentIntent 创建失败");
return Err(message.to_string());
}
let Some(intent_id) = value.get("id").and_then(Value::as_str) else {
return Err("Stripe 响应缺少 PaymentIntent ID".to_string());
};
let Some(client_secret) = value.get("client_secret").and_then(Value::as_str) else {
return Err("Stripe 响应缺少 client_secret".to_string());
};
Ok(json!({
"gateway": "stripe",
"display_name": display_name,
"gateway_order_id": intent_id,
"intent_id": intent_id,
"client_secret": client_secret,
"publishable_key": publishable_key,
"expires_at": expires_at.to_rfc3339(),
"pay_amount": pay_amount,
"pay_currency": record.pay_currency,
"payment_channel": payment_channel,
"payment_method_types": [payment_channel],
"submit_method": "stripe_payment_intent"
}))
}
pub(super) async fn handle_wallet_create_recharge(
state: &AppState,
request_context: &GatewayPublicRequestContext,
@@ -341,7 +613,11 @@ pub(super) async fn handle_wallet_create_recharge(
return build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false);
}
};
let pay_amount = (payload.amount_usd * config.usd_exchange_rate * 100.0).round() / 100.0;
let (base_pay_amount, fee_amount, pay_amount) = wallet_recharge_payment_breakdown(
payload.amount_usd,
config.usd_exchange_rate,
payment_channel.fee_rate,
);
let Some(callback_base_url) = epay_callback_base_url(
config.callback_base_url.as_deref(),
headers,
@@ -357,13 +633,19 @@ pub(super) async fn handle_wallet_create_recharge(
&config,
&EpayCheckoutInput {
order_no: order_no.clone(),
channel: payment_channel.clone(),
channel: payment_channel.channel.clone(),
subject: "钱包充值".to_string(),
pay_amount,
notify_url: format!("{callback_base_url}/api/payment/epay/notify"),
return_url: format!("{callback_base_url}/api/payment/epay/return"),
},
);
let checkout = add_wallet_recharge_fee_metadata(
checkout,
base_pay_amount,
payment_channel.fee_rate,
fee_amount,
);
let outcome = match state
.create_wallet_recharge_order(
aether_data::repository::wallet::CreateWalletRechargeOrderInput {
@@ -375,7 +657,7 @@ pub(super) async fn handle_wallet_create_recharge(
exchange_rate: Some(config.usd_exchange_rate),
payment_method: "epay".to_string(),
payment_provider: Some("epay".to_string()),
payment_channel: Some(payment_channel),
payment_channel: Some(payment_channel.channel),
gateway_order_id: order_no.clone(),
gateway_response: checkout.clone(),
order_no,
@@ -415,6 +697,169 @@ pub(super) async fn handle_wallet_create_recharge(
None,
);
}
let requested_provider = payload
.payment_provider
.as_deref()
.unwrap_or(payload.payment_method.as_str());
if matches!(requested_provider, "alipay" | "wxpay" | "stripe") {
let record = match state.find_payment_gateway_config(requested_provider).await {
Ok(Some(value)) if value.enabled && value.merchant_key_encrypted.is_some() => value,
Ok(Some(_)) | Ok(None) => {
return build_auth_error_response(
http::StatusCode::BAD_REQUEST,
"支付网关未启用或密钥未配置",
false,
)
}
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("payment gateway lookup failed: {err:?}"),
false,
)
}
};
if payload.amount_usd < record.min_recharge_usd {
return build_auth_error_response(
http::StatusCode::BAD_REQUEST,
"充值金额低于支付网关最小金额",
false,
);
}
let payment_channel = match resolve_direct_gateway_channel(
requested_provider,
&record,
payload.payment_channel.as_deref(),
) {
Ok(value) => value,
Err(detail) => {
return build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false)
}
};
let (base_pay_amount, fee_amount, pay_amount) = wallet_recharge_payment_breakdown(
payload.amount_usd,
record.usd_exchange_rate,
payment_channel.fee_rate,
);
let checkout = if requested_provider == "stripe" {
match create_stripe_wallet_recharge_checkout(
state,
&record,
&payment_channel.channel,
&payment_channel.display_name,
&order_no,
pay_amount,
expires_at,
)
.await
{
Ok(value) => value,
Err(detail) => {
return build_auth_error_response(http::StatusCode::BAD_GATEWAY, detail, false)
}
}
} else {
let Some(callback_base_url) = epay_callback_base_url(
record.callback_base_url.as_deref(),
headers,
request_context,
) else {
return build_auth_error_response(
http::StatusCode::BAD_REQUEST,
"支付网关 callback_base_url is required",
false,
);
};
let direct_input = DirectPaymentCheckoutInput {
payment_channel: payment_channel.channel.clone(),
display_name: payment_channel.display_name.clone(),
order_no: order_no.clone(),
subject: "钱包充值".to_string(),
pay_amount,
pay_currency: record.pay_currency.clone(),
notify_url: format!("{callback_base_url}/api/payment/{requested_provider}/notify"),
return_url: Some(wallet_payment_return_url(
&callback_base_url,
requested_provider,
&order_no,
)),
client_ip: direct_payment_client_ip(headers),
expires_at,
};
let result = match requested_provider {
"alipay" => create_alipay_direct_checkout(state, &direct_input).await,
"wxpay" => create_wxpay_direct_checkout(state, &direct_input).await,
_ => Err("支付网关不支持".to_string()),
};
match result {
Ok(value) => value,
Err(detail) => {
return build_auth_error_response(http::StatusCode::BAD_GATEWAY, detail, false)
}
}
};
let checkout = add_wallet_recharge_fee_metadata(
checkout,
base_pay_amount,
payment_channel.fee_rate,
fee_amount,
);
let gateway_order_id = checkout
.get("gateway_order_id")
.and_then(Value::as_str)
.unwrap_or(&order_no)
.to_string();
let outcome = match state
.create_wallet_recharge_order(
aether_data::repository::wallet::CreateWalletRechargeOrderInput {
preferred_wallet_id: wallet.as_ref().map(|value| value.id.clone()),
user_id: auth.user.id.clone(),
amount_usd: payload.amount_usd,
pay_amount: Some(pay_amount),
pay_currency: Some(record.pay_currency.clone()),
exchange_rate: Some(record.usd_exchange_rate),
payment_method: requested_provider.to_string(),
payment_provider: Some(requested_provider.to_string()),
payment_channel: Some(payment_channel.channel),
gateway_order_id,
gateway_response: checkout.clone(),
order_no,
expires_at_unix_secs: expires_at.timestamp().max(0) as u64,
},
)
.await
{
Ok(Some(value)) => value,
Ok(None) => return build_wallet_recharge_storage_unavailable_response(),
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("wallet recharge create failed: {err:?}"),
false,
)
}
};
let order_payload = match outcome {
aether_data::repository::wallet::CreateWalletRechargeOrderOutcome::Created(order) => {
wallet_payment_order_payload_from_record(&order)
}
aether_data::repository::wallet::CreateWalletRechargeOrderOutcome::WalletInactive => {
return build_auth_error_response(
http::StatusCode::BAD_REQUEST,
"wallet is not active",
false,
)
}
};
return build_auth_json_response(
http::StatusCode::OK,
json!({
"order": order_payload,
"payment_instructions": sanitize_wallet_gateway_response(Some(checkout)),
}),
None,
);
}
build_auth_error_response(
http::StatusCode::BAD_REQUEST,
format!("unsupported payment_method: {}", payload.payment_method),
@@ -433,14 +878,43 @@ pub(super) async fn handle_wallet_recharge_options(
let mut methods = Vec::new();
if let Ok(config) = load_epay_config(state).await {
for channel in configured_epay_channels(&config) {
let payment_channel = channel.channel.clone();
let display_name = channel.display_name.clone();
let fee_rate = channel.fee_rate;
methods.push(json!({
"payment_method": "epay",
"payment_provider": "epay",
"payment_channel": channel.channel,
"display_name": channel.display_name,
"payment_channel": payment_channel,
"display_name": display_name,
"pay_currency": config.pay_currency,
"usd_exchange_rate": config.usd_exchange_rate,
"min_recharge_usd": config.min_recharge_usd,
"fee_rate": fee_rate,
}));
}
}
for provider in ["alipay", "wxpay", "stripe"] {
let Ok(Some(record)) = state.find_payment_gateway_config(provider).await else {
continue;
};
if !record.enabled || record.merchant_key_encrypted.is_none() {
continue;
}
for DirectGatewayChannelConfig {
channel: payment_channel,
display_name,
fee_rate,
} in direct_gateway_channels(provider, &record)
{
methods.push(json!({
"payment_method": provider,
"payment_provider": provider,
"payment_channel": payment_channel,
"display_name": display_name,
"pay_currency": record.pay_currency,
"usd_exchange_rate": record.usd_exchange_rate,
"min_recharge_usd": record.min_recharge_usd,
"fee_rate": fee_rate,
}));
}
}
@@ -14,6 +14,12 @@ use serde::Deserialize;
use serde_json::json;
use uuid::Uuid;
use crate::handlers::shared::{
payment_gateway_allow_user_refund, payment_gateway_provider_for_payment_method,
};
const WALLET_REFUND_CONFIGURED_PROVIDERS: &[&str] = &["epay", "alipay", "wxpay", "stripe"];
#[derive(Debug, Deserialize)]
struct WalletCreateRefundRequest {
amount_usd: f64,
@@ -92,6 +98,41 @@ pub(super) fn wallet_refund_detail_path_matches(request_path: &str) -> bool {
wallet_refund_id_from_path(request_path).is_some()
}
pub(super) async fn handle_wallet_refund_eligible_providers(
state: &AppState,
request_context: &GatewayPublicRequestContext,
headers: &http::HeaderMap,
) -> Response<Body> {
if let Err(response) = resolve_authenticated_local_user(state, request_context, headers).await {
return response;
}
let mut payment_methods = Vec::new();
for provider in WALLET_REFUND_CONFIGURED_PROVIDERS {
match state.find_payment_gateway_config(provider).await {
Ok(Some(record)) if payment_gateway_allow_user_refund(&record.channels_json) => {
payment_methods.push((*provider).to_string());
}
Ok(_) => {}
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("payment gateway lookup failed: {err:?}"),
false,
)
}
}
}
build_auth_json_response(
http::StatusCode::OK,
json!({
"payment_methods": payment_methods,
}),
None,
)
}
fn wallet_refund_payload_from_record(
record: &aether_data::repository::wallet::StoredAdminWalletRefund,
) -> serde_json::Value {
@@ -348,6 +389,56 @@ pub(super) async fn handle_wallet_create_refund(
);
};
if let Some(payment_order_id) = payload.payment_order_id.as_deref() {
let order = match state
.find_wallet_payment_order_by_user_id(&auth.user.id, payment_order_id)
.await
{
Ok(Some(value)) => value,
Ok(None) => {
return build_auth_error_response(
http::StatusCode::NOT_FOUND,
"Payment order not found",
false,
)
}
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("payment order lookup failed: {err:?}"),
false,
)
}
};
let Some(provider) = payment_gateway_provider_for_payment_method(&order.payment_method)
else {
return build_auth_error_response(
http::StatusCode::FORBIDDEN,
"该支付方式未开放用户自助退款",
false,
);
};
let allow_user_refund = match state.find_payment_gateway_config(provider).await {
Ok(Some(record)) => payment_gateway_allow_user_refund(&record.channels_json),
Ok(None) => false,
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("payment gateway lookup failed: {err:?}"),
false,
)
}
};
if !allow_user_refund {
return build_auth_error_response(
http::StatusCode::FORBIDDEN,
"该支付方式未开放用户自助退款",
false,
);
}
}
if !state.has_database_wallet_data_writer() {
#[cfg(test)]
{