mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
Merge pull request #511
This commit is contained in:
@@ -31,6 +31,7 @@ aether-task-runtime.workspace = true
|
||||
aether-usage-runtime.workspace = true
|
||||
aether-video-tasks-core.workspace = true
|
||||
aether-wallet.workspace = true
|
||||
aes-gcm = "0.10"
|
||||
async-stream.workspace = true
|
||||
async-trait.workspace = true
|
||||
axum = { version = "0.8", features = ["ws"] }
|
||||
@@ -50,11 +51,12 @@ md-5 = "0.10"
|
||||
parking_lot = "0.12"
|
||||
regex.workspace = true
|
||||
reqwest.workspace = true
|
||||
rsa = "0.9.10"
|
||||
rustls.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha1 = "0.10"
|
||||
sha2.workspace = true
|
||||
sha2 = { workspace = true, features = ["oid"] }
|
||||
sqlx.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
|
||||
@@ -559,41 +559,62 @@ pub(super) fn classify_admin_basic_family_route(
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::GET
|
||||
&& matches!(
|
||||
normalized_path,
|
||||
"/api/admin/payments/gateways/epay" | "/api/admin/payments/gateways/epay/"
|
||||
&& has_single_segment_after_prefix(
|
||||
normalized_path_no_trailing,
|
||||
"/api/admin/payments/gateways/",
|
||||
)
|
||||
{
|
||||
Some(classified(
|
||||
"admin_proxy",
|
||||
"payments_manage",
|
||||
"get_epay_gateway",
|
||||
if matches!(
|
||||
normalized_path,
|
||||
"/api/admin/payments/gateways/epay" | "/api/admin/payments/gateways/epay/"
|
||||
) {
|
||||
"get_epay_gateway"
|
||||
} else {
|
||||
"get_payment_gateway"
|
||||
},
|
||||
"admin:payments",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::PUT
|
||||
&& matches!(
|
||||
normalized_path,
|
||||
"/api/admin/payments/gateways/epay" | "/api/admin/payments/gateways/epay/"
|
||||
&& has_single_segment_after_prefix(
|
||||
normalized_path_no_trailing,
|
||||
"/api/admin/payments/gateways/",
|
||||
)
|
||||
{
|
||||
Some(classified(
|
||||
"admin_proxy",
|
||||
"payments_manage",
|
||||
"update_epay_gateway",
|
||||
if matches!(
|
||||
normalized_path,
|
||||
"/api/admin/payments/gateways/epay" | "/api/admin/payments/gateways/epay/"
|
||||
) {
|
||||
"update_epay_gateway"
|
||||
} else {
|
||||
"update_payment_gateway"
|
||||
},
|
||||
"admin:payments",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::POST
|
||||
&& matches!(
|
||||
normalized_path,
|
||||
"/api/admin/payments/gateways/epay/test" | "/api/admin/payments/gateways/epay/test/"
|
||||
)
|
||||
&& normalized_path_no_trailing.starts_with("/api/admin/payments/gateways/")
|
||||
&& normalized_path_no_trailing.ends_with("/test")
|
||||
&& normalized_path_no_trailing.matches('/').count() == 6
|
||||
{
|
||||
Some(classified(
|
||||
"admin_proxy",
|
||||
"payments_manage",
|
||||
"test_epay_gateway",
|
||||
if matches!(
|
||||
normalized_path,
|
||||
"/api/admin/payments/gateways/epay/test"
|
||||
| "/api/admin/payments/gateways/epay/test/"
|
||||
) {
|
||||
"test_epay_gateway"
|
||||
} else {
|
||||
"test_payment_gateway"
|
||||
},
|
||||
"admin:payments",
|
||||
false,
|
||||
))
|
||||
@@ -748,3 +769,10 @@ pub(super) fn classify_admin_basic_family_route(
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn has_single_segment_after_prefix(path: &str, prefix: &str) -> bool {
|
||||
let Some(suffix) = path.strip_prefix(prefix) else {
|
||||
return false;
|
||||
};
|
||||
!suffix.is_empty() && !suffix.contains('/')
|
||||
}
|
||||
|
||||
@@ -332,6 +332,7 @@ pub(super) fn classify_public_support_route(
|
||||
| "/api/wallet/recharge"
|
||||
| "/api/wallet/recharge/options"
|
||||
| "/api/wallet/refunds"
|
||||
| "/api/wallet/refunds/eligible-providers"
|
||||
)
|
||||
{
|
||||
let route_kind = match normalized_path {
|
||||
@@ -342,6 +343,7 @@ pub(super) fn classify_public_support_route(
|
||||
"/api/wallet/recharge" => "list_recharge_orders",
|
||||
"/api/wallet/recharge/options" => "recharge_options",
|
||||
"/api/wallet/refunds" => "list_refunds",
|
||||
"/api/wallet/refunds/eligible-providers" => "refund_eligible_providers",
|
||||
_ => "balance",
|
||||
};
|
||||
Some(classified(
|
||||
@@ -436,6 +438,45 @@ pub(super) fn classify_public_support_route(
|
||||
"public:payment",
|
||||
false,
|
||||
))
|
||||
} else if matches!(method, &http::Method::GET | &http::Method::POST)
|
||||
&& matches!(
|
||||
normalized_path,
|
||||
"/api/payment/alipay/notify" | "/api/payment/alipay/notify/"
|
||||
)
|
||||
{
|
||||
Some(classified(
|
||||
"public_support",
|
||||
"payment_callback",
|
||||
"alipay_notify",
|
||||
"public:payment",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::POST
|
||||
&& matches!(
|
||||
normalized_path,
|
||||
"/api/payment/wxpay/notify" | "/api/payment/wxpay/notify/"
|
||||
)
|
||||
{
|
||||
Some(classified(
|
||||
"public_support",
|
||||
"payment_callback",
|
||||
"wxpay_notify",
|
||||
"public:payment",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::POST
|
||||
&& matches!(
|
||||
normalized_path,
|
||||
"/api/payment/stripe/webhook" | "/api/payment/stripe/webhook/"
|
||||
)
|
||||
{
|
||||
Some(classified(
|
||||
"public_support",
|
||||
"payment_callback",
|
||||
"stripe_webhook",
|
||||
"public:payment",
|
||||
false,
|
||||
))
|
||||
} else if matches!(method, &http::Method::GET | &http::Method::POST)
|
||||
&& matches!(
|
||||
normalized_path,
|
||||
|
||||
@@ -317,6 +317,11 @@ fn classifies_wallet_routes_as_public_support_route() {
|
||||
"/api/wallet/refunds?limit=20",
|
||||
"list_refunds",
|
||||
),
|
||||
(
|
||||
http::Method::GET,
|
||||
"/api/wallet/refunds/eligible-providers",
|
||||
"refund_eligible_providers",
|
||||
),
|
||||
(http::Method::POST, "/api/wallet/refunds", "create_refund"),
|
||||
(
|
||||
http::Method::GET,
|
||||
|
||||
@@ -2,6 +2,11 @@ use super::{
|
||||
build_admin_payments_backend_unavailable_response, build_admin_payments_bad_request_response,
|
||||
};
|
||||
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
||||
use crate::handlers::shared::{
|
||||
payment_gateway_allow_user_refund, payment_gateway_channels_config_json,
|
||||
payment_gateway_channels_json, payment_gateway_config_json, payment_gateway_refund_enabled,
|
||||
payment_gateway_secret_keys_json,
|
||||
};
|
||||
use crate::{GatewayError, LocalMutationOutcome};
|
||||
use aether_data_contracts::repository::billing::PaymentGatewayConfigWriteInput;
|
||||
use axum::{
|
||||
@@ -11,15 +16,17 @@ use axum::{
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct EpayGatewayConfigRequest {
|
||||
struct PaymentGatewayConfigRequest {
|
||||
#[serde(default)]
|
||||
enabled: bool,
|
||||
#[serde(default)]
|
||||
endpoint_url: String,
|
||||
#[serde(default)]
|
||||
callback_base_url: Option<String>,
|
||||
#[serde(default)]
|
||||
merchant_id: String,
|
||||
#[serde(default)]
|
||||
merchant_key: Option<String>,
|
||||
@@ -30,7 +37,15 @@ struct EpayGatewayConfigRequest {
|
||||
#[serde(default = "default_min_recharge_usd")]
|
||||
min_recharge_usd: f64,
|
||||
#[serde(default = "default_channels")]
|
||||
channels: serde_json::Value,
|
||||
channels: Value,
|
||||
#[serde(default)]
|
||||
refund_enabled: bool,
|
||||
#[serde(default)]
|
||||
allow_user_refund: bool,
|
||||
#[serde(default)]
|
||||
config: Value,
|
||||
#[serde(default)]
|
||||
secrets: Value,
|
||||
}
|
||||
|
||||
fn default_pay_currency() -> String {
|
||||
@@ -45,10 +60,10 @@ fn default_min_recharge_usd() -> f64 {
|
||||
1.0
|
||||
}
|
||||
|
||||
fn default_channels() -> serde_json::Value {
|
||||
fn default_channels() -> Value {
|
||||
json!([
|
||||
{"channel": "alipay", "display_name": "支付宝"},
|
||||
{"channel": "wxpay", "display_name": "微信支付"}
|
||||
{"channel": "alipay", "display_name": "支付宝", "fee_rate": 0.0},
|
||||
{"channel": "wxpay", "display_name": "微信支付", "fee_rate": 0.0}
|
||||
])
|
||||
}
|
||||
|
||||
@@ -81,9 +96,66 @@ fn normalize_optional_text(
|
||||
Ok(Some(trimmed.to_string()))
|
||||
}
|
||||
|
||||
fn supported_payment_gateway_provider(provider: &str) -> bool {
|
||||
matches!(provider, "epay" | "alipay" | "wxpay" | "stripe")
|
||||
}
|
||||
|
||||
fn admin_payment_gateway_provider_from_path(path: &str) -> Option<String> {
|
||||
let trimmed = path.trim_end_matches('/');
|
||||
let provider = trimmed
|
||||
.strip_prefix("/api/admin/payments/gateways/")?
|
||||
.strip_suffix("/test")
|
||||
.unwrap_or_else(|| {
|
||||
trimmed
|
||||
.strip_prefix("/api/admin/payments/gateways/")
|
||||
.unwrap_or("")
|
||||
})
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
if provider.is_empty()
|
||||
|| provider.contains('/')
|
||||
|| !supported_payment_gateway_provider(&provider)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(provider)
|
||||
}
|
||||
|
||||
fn default_provider_channels(provider: &str) -> Value {
|
||||
match provider {
|
||||
"epay" => default_channels(),
|
||||
"alipay" => json!([{"channel": "alipay", "display_name": "支付宝官方", "fee_rate": 0.0}]),
|
||||
"wxpay" => json!([
|
||||
{"channel": "native", "display_name": "微信 Native", "fee_rate": 0.0},
|
||||
{"channel": "h5", "display_name": "微信 H5", "fee_rate": 0.0}
|
||||
]),
|
||||
"stripe" => json!([
|
||||
{"channel": "card", "display_name": "Card", "fee_rate": 0.0},
|
||||
{"channel": "alipay", "display_name": "Alipay", "fee_rate": 0.0},
|
||||
{"channel": "wechat_pay", "display_name": "WeChat Pay", "fee_rate": 0.0},
|
||||
{"channel": "link", "display_name": "Link", "fee_rate": 0.0}
|
||||
]),
|
||||
_ => json!([]),
|
||||
}
|
||||
}
|
||||
|
||||
fn split_gateway_channels_config(
|
||||
record: &aether_data_contracts::repository::billing::PaymentGatewayConfigRecord,
|
||||
) -> (Value, Value, Value, bool, bool) {
|
||||
(
|
||||
payment_gateway_channels_json(&record.channels_json),
|
||||
payment_gateway_config_json(&record.channels_json),
|
||||
payment_gateway_secret_keys_json(&record.channels_json),
|
||||
payment_gateway_refund_enabled(&record.channels_json),
|
||||
payment_gateway_allow_user_refund(&record.channels_json),
|
||||
)
|
||||
}
|
||||
|
||||
fn gateway_config_payload(
|
||||
record: aether_data_contracts::repository::billing::PaymentGatewayConfigRecord,
|
||||
) -> serde_json::Value {
|
||||
) -> Value {
|
||||
let (channels, config, secret_keys, refund_enabled, allow_user_refund) =
|
||||
split_gateway_channels_config(&record);
|
||||
json!({
|
||||
"provider": record.provider,
|
||||
"enabled": record.enabled,
|
||||
@@ -91,36 +163,196 @@ fn gateway_config_payload(
|
||||
"callback_base_url": record.callback_base_url,
|
||||
"merchant_id": record.merchant_id,
|
||||
"has_secret": record.merchant_key_encrypted.as_deref().is_some_and(|value| !value.trim().is_empty()),
|
||||
"has_secret_keys": secret_keys,
|
||||
"pay_currency": record.pay_currency,
|
||||
"usd_exchange_rate": record.usd_exchange_rate,
|
||||
"min_recharge_usd": record.min_recharge_usd,
|
||||
"channels": record.channels_json,
|
||||
"channels": channels,
|
||||
"refund_enabled": refund_enabled,
|
||||
"allow_user_refund": allow_user_refund,
|
||||
"config": config,
|
||||
"created_at": record.created_at_unix_secs,
|
||||
"updated_at": record.updated_at_unix_secs,
|
||||
})
|
||||
}
|
||||
|
||||
fn gateway_config_not_found_payload(provider: &str) -> Value {
|
||||
json!({
|
||||
"provider": provider,
|
||||
"enabled": false,
|
||||
"has_secret": false,
|
||||
"has_secret_keys": [],
|
||||
"channels": default_provider_channels(provider),
|
||||
"refund_enabled": false,
|
||||
"allow_user_refund": false,
|
||||
"config": {},
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_gateway_channel_fee_rate(value: Option<&Value>, index: usize) -> Result<f64, String> {
|
||||
let Some(value) = value else {
|
||||
return Ok(0.0);
|
||||
};
|
||||
let fee_rate = match value {
|
||||
Value::Null => 0.0,
|
||||
Value::Number(number) => number
|
||||
.as_f64()
|
||||
.ok_or_else(|| format!("channels[{index}].fee_rate must be a number"))?,
|
||||
Value::String(value) => value
|
||||
.trim()
|
||||
.parse::<f64>()
|
||||
.map_err(|_| format!("channels[{index}].fee_rate must be a number"))?,
|
||||
_ => return Err(format!("channels[{index}].fee_rate must be a number")),
|
||||
};
|
||||
if !fee_rate.is_finite() || fee_rate < 0.0 {
|
||||
return Err(format!("channels[{index}].fee_rate must be non-negative"));
|
||||
}
|
||||
Ok(fee_rate)
|
||||
}
|
||||
|
||||
fn normalize_gateway_channels(provider: &str, channels: Value) -> Result<Value, String> {
|
||||
if channels.is_null() {
|
||||
return Ok(default_provider_channels(provider));
|
||||
}
|
||||
let Some(items) = channels.as_array() else {
|
||||
return Err("channels must be an array".to_string());
|
||||
};
|
||||
if items.is_empty() {
|
||||
return Ok(default_provider_channels(provider));
|
||||
}
|
||||
|
||||
let normalized = items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, item)| {
|
||||
let Some(object) = item.as_object() else {
|
||||
return Err(format!("channels[{index}] must be an object"));
|
||||
};
|
||||
let channel = object
|
||||
.get("channel")
|
||||
.or_else(|| object.get("type"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| format!("channels[{index}].channel must not be empty"))?;
|
||||
let display_name = object
|
||||
.get("display_name")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(channel);
|
||||
let fee_rate = normalize_gateway_channel_fee_rate(object.get("fee_rate"), index)?;
|
||||
Ok(json!({
|
||||
"channel": channel,
|
||||
"display_name": display_name,
|
||||
"fee_rate": fee_rate,
|
||||
}))
|
||||
})
|
||||
.collect::<Result<Vec<_>, String>>()?;
|
||||
|
||||
Ok(Value::Array(normalized))
|
||||
}
|
||||
|
||||
fn normalize_config_object(config: Value) -> Result<Value, String> {
|
||||
if config.is_null() {
|
||||
return Ok(json!({}));
|
||||
}
|
||||
if config.is_object() {
|
||||
return Ok(config);
|
||||
}
|
||||
Err("config must be an object".to_string())
|
||||
}
|
||||
|
||||
fn encrypted_gateway_secret(
|
||||
state: &AdminAppState<'_>,
|
||||
provider: &str,
|
||||
payload: &PaymentGatewayConfigRequest,
|
||||
) -> Result<Option<String>, Response<Body>> {
|
||||
let secret_plaintext = if provider == "epay" {
|
||||
payload
|
||||
.merchant_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
} else {
|
||||
let Some(secrets) = payload.secrets.as_object() else {
|
||||
return if payload.secrets.is_null() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Err(build_admin_payments_bad_request_response(
|
||||
"secrets must be an object",
|
||||
))
|
||||
};
|
||||
};
|
||||
let filtered = secrets
|
||||
.iter()
|
||||
.filter_map(|(key, value)| {
|
||||
let value = value.as_str()?.trim();
|
||||
(!key.trim().is_empty() && !value.is_empty())
|
||||
.then(|| (key.trim().to_string(), Value::String(value.to_string())))
|
||||
})
|
||||
.collect::<serde_json::Map<_, _>>();
|
||||
if filtered.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Value::Object(filtered).to_string())
|
||||
}
|
||||
};
|
||||
|
||||
let Some(secret_plaintext) = secret_plaintext else {
|
||||
return Ok(None);
|
||||
};
|
||||
state
|
||||
.encrypt_catalog_secret_with_fallbacks(&secret_plaintext)
|
||||
.ok_or_else(|| {
|
||||
build_admin_payments_backend_unavailable_response("encryption key is not configured")
|
||||
})
|
||||
.map(Some)
|
||||
}
|
||||
|
||||
async fn existing_gateway_secret_keys(
|
||||
state: &AdminAppState<'_>,
|
||||
provider: &str,
|
||||
) -> Result<Vec<Value>, GatewayError> {
|
||||
let Some(record) = state.app().find_payment_gateway_config(provider).await? else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let (_, _, secret_keys, _, _) = split_gateway_channels_config(&record);
|
||||
Ok(secret_keys
|
||||
.as_array()
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|value| value.as_str().is_some_and(|item| !item.trim().is_empty()))
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub(super) async fn maybe_build_local_admin_payment_gateways_response(
|
||||
state: &AdminAppState<'_>,
|
||||
_request_context: &AdminRequestContext<'_>,
|
||||
request_context: &AdminRequestContext<'_>,
|
||||
request_body: Option<&axum::body::Bytes>,
|
||||
route_kind: Option<&str>,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
match route_kind {
|
||||
Some("get_epay_gateway") => {
|
||||
let record = state.app().find_payment_gateway_config("epay").await?;
|
||||
let payload = record.map(gateway_config_payload).unwrap_or_else(
|
||||
|| json!({"provider": "epay", "enabled": false, "has_secret": false}),
|
||||
);
|
||||
Some("get_epay_gateway") | Some("get_payment_gateway") => {
|
||||
let provider = admin_payment_gateway_provider_from_path(request_context.path())
|
||||
.unwrap_or_else(|| "epay".to_string());
|
||||
let record = state.app().find_payment_gateway_config(&provider).await?;
|
||||
let payload = record
|
||||
.map(gateway_config_payload)
|
||||
.unwrap_or_else(|| gateway_config_not_found_payload(&provider));
|
||||
Ok(Some(Json(payload).into_response()))
|
||||
}
|
||||
Some("update_epay_gateway") => {
|
||||
Some("update_epay_gateway") | Some("update_payment_gateway") => {
|
||||
let provider = admin_payment_gateway_provider_from_path(request_context.path())
|
||||
.unwrap_or_else(|| "epay".to_string());
|
||||
let Some(body) = request_body else {
|
||||
return Ok(Some(build_admin_payments_bad_request_response(
|
||||
"缺少请求体",
|
||||
)));
|
||||
};
|
||||
let payload = match serde_json::from_slice::<EpayGatewayConfigRequest>(body) {
|
||||
let payload = match serde_json::from_slice::<PaymentGatewayConfigRequest>(body) {
|
||||
Ok(value) => value,
|
||||
Err(_) => {
|
||||
return Ok(Some(build_admin_payments_bad_request_response(
|
||||
@@ -138,40 +370,87 @@ pub(super) async fn maybe_build_local_admin_payment_gateways_response(
|
||||
"min_recharge_usd must be positive",
|
||||
)));
|
||||
}
|
||||
let merchant_key_encrypted = match payload
|
||||
.merchant_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
|
||||
let merchant_key_encrypted = match encrypted_gateway_secret(state, &provider, &payload)
|
||||
{
|
||||
Some(secret) => match state.encrypt_catalog_secret_with_fallbacks(secret) {
|
||||
Some(value) => Some(value),
|
||||
None => {
|
||||
return Ok(Some(build_admin_payments_backend_unavailable_response(
|
||||
"encryption key is not configured",
|
||||
)))
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
let endpoint_url = match normalize_text(payload.endpoint_url, "endpoint_url", 512) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(Some(build_admin_payments_bad_request_response(detail))),
|
||||
Err(response) => return Ok(Some(response)),
|
||||
};
|
||||
let endpoint_url = if provider == "epay" {
|
||||
match normalize_text(payload.endpoint_url, "endpoint_url", 512) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => {
|
||||
return Ok(Some(build_admin_payments_bad_request_response(detail)))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match normalize_optional_text(Some(payload.endpoint_url), 512) {
|
||||
Ok(value) => value.unwrap_or_default(),
|
||||
Err(detail) => {
|
||||
return Ok(Some(build_admin_payments_bad_request_response(detail)))
|
||||
}
|
||||
}
|
||||
};
|
||||
let callback_base_url = match normalize_optional_text(payload.callback_base_url, 512) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(Some(build_admin_payments_bad_request_response(detail))),
|
||||
};
|
||||
let merchant_id = match normalize_text(payload.merchant_id, "merchant_id", 128) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(Some(build_admin_payments_bad_request_response(detail))),
|
||||
let merchant_id = if provider == "epay" {
|
||||
match normalize_text(payload.merchant_id, "merchant_id", 128) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => {
|
||||
return Ok(Some(build_admin_payments_bad_request_response(detail)))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match normalize_optional_text(Some(payload.merchant_id), 128) {
|
||||
Ok(value) => value.unwrap_or_default(),
|
||||
Err(detail) => {
|
||||
return Ok(Some(build_admin_payments_bad_request_response(detail)))
|
||||
}
|
||||
}
|
||||
};
|
||||
let pay_currency = match normalize_text(payload.pay_currency, "pay_currency", 16) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(Some(build_admin_payments_bad_request_response(detail))),
|
||||
};
|
||||
let config = match normalize_config_object(payload.config) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(Some(build_admin_payments_bad_request_response(detail))),
|
||||
};
|
||||
let submitted_secret_keys = payload
|
||||
.secrets
|
||||
.as_object()
|
||||
.map(|secrets| {
|
||||
secrets
|
||||
.iter()
|
||||
.filter(|(_, value)| {
|
||||
value.as_str().is_some_and(|value| !value.trim().is_empty())
|
||||
})
|
||||
.map(|(key, _)| Value::String(key.clone()))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let secret_keys = if provider == "epay" || !submitted_secret_keys.is_empty() {
|
||||
submitted_secret_keys
|
||||
} else {
|
||||
existing_gateway_secret_keys(state, &provider).await?
|
||||
};
|
||||
let channels = match normalize_gateway_channels(&provider, payload.channels) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(Some(build_admin_payments_bad_request_response(detail))),
|
||||
};
|
||||
let refund_enabled = payload.refund_enabled;
|
||||
let allow_user_refund = refund_enabled && payload.allow_user_refund;
|
||||
let channels_json = payment_gateway_channels_config_json(
|
||||
channels,
|
||||
config,
|
||||
Value::Array(secret_keys),
|
||||
refund_enabled,
|
||||
allow_user_refund,
|
||||
);
|
||||
let input = PaymentGatewayConfigWriteInput {
|
||||
provider: "epay".to_string(),
|
||||
provider: provider.clone(),
|
||||
enabled: payload.enabled,
|
||||
endpoint_url,
|
||||
callback_base_url,
|
||||
@@ -181,7 +460,7 @@ pub(super) async fn maybe_build_local_admin_payment_gateways_response(
|
||||
pay_currency,
|
||||
usd_exchange_rate: payload.usd_exchange_rate,
|
||||
min_recharge_usd: payload.min_recharge_usd,
|
||||
channels_json: payload.channels,
|
||||
channels_json,
|
||||
};
|
||||
match state.app().upsert_payment_gateway_config(&input).await? {
|
||||
LocalMutationOutcome::Applied(record) => {
|
||||
@@ -192,8 +471,10 @@ pub(super) async fn maybe_build_local_admin_payment_gateways_response(
|
||||
))),
|
||||
}
|
||||
}
|
||||
Some("test_epay_gateway") => {
|
||||
let status = state.app().find_payment_gateway_config("epay").await?;
|
||||
Some("test_epay_gateway") | Some("test_payment_gateway") => {
|
||||
let provider = admin_payment_gateway_provider_from_path(request_context.path())
|
||||
.unwrap_or_else(|| "epay".to_string());
|
||||
let status = state.app().find_payment_gateway_config(&provider).await?;
|
||||
let ok = status
|
||||
.as_ref()
|
||||
.is_some_and(|record| record.enabled && record.merchant_key_encrypted.is_some());
|
||||
@@ -204,7 +485,7 @@ pub(super) async fn maybe_build_local_admin_payment_gateways_response(
|
||||
} else {
|
||||
http::StatusCode::BAD_REQUEST
|
||||
},
|
||||
Json(json!({"ok": ok, "provider": "epay"})),
|
||||
Json(json!({"ok": ok, "provider": provider})),
|
||||
)
|
||||
.into_response(),
|
||||
))
|
||||
|
||||
@@ -109,6 +109,33 @@ async fn build_admin_payment_get_order_response(
|
||||
}
|
||||
}
|
||||
|
||||
async fn close_direct_gateway_order_before_terminal_mark(
|
||||
state: &AdminAppState<'_>,
|
||||
order_id: &str,
|
||||
) -> Result<Option<serde_json::Value>, Response<Body>> {
|
||||
let order = match state.read_admin_payment_order(order_id).await {
|
||||
Ok(crate::AdminWalletMutationOutcome::Applied(order)) => order,
|
||||
Ok(crate::AdminWalletMutationOutcome::NotFound) => return Ok(None),
|
||||
Ok(crate::AdminWalletMutationOutcome::Invalid(_)) => return Ok(None),
|
||||
Ok(crate::AdminWalletMutationOutcome::Unavailable) => return Ok(None),
|
||||
Err(err) => {
|
||||
return Err(build_admin_payments_backend_unavailable_response(format!(
|
||||
"Payment order read failed: {err:?}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
if order.status != "pending" || !matches!(order.payment_method.as_str(), "alipay" | "wxpay") {
|
||||
return Ok(None);
|
||||
}
|
||||
crate::handlers::shared::close_direct_gateway_order(state.app(), &order)
|
||||
.await
|
||||
.map_err(|detail| {
|
||||
build_admin_payments_backend_unavailable_response(format!(
|
||||
"payment gateway close failed: {detail}"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
async fn build_admin_payment_expire_order_response(
|
||||
state: &AdminAppState<'_>,
|
||||
request_context: &AdminRequestContext<'_>,
|
||||
@@ -117,12 +144,18 @@ async fn build_admin_payment_expire_order_response(
|
||||
else {
|
||||
return Ok(build_admin_payment_order_not_found_response());
|
||||
};
|
||||
let gateway_close =
|
||||
match close_direct_gateway_order_before_terminal_mark(state, &order_id).await {
|
||||
Ok(value) => value,
|
||||
Err(response) => return Ok(response),
|
||||
};
|
||||
match state.admin_expire_payment_order(&order_id).await? {
|
||||
crate::AdminWalletMutationOutcome::Applied((order, expired)) => {
|
||||
Ok(attach_admin_audit_response(
|
||||
Json(json!({
|
||||
"order": build_admin_payment_order_payload(&order),
|
||||
"expired": expired,
|
||||
"gateway_close": gateway_close,
|
||||
}))
|
||||
.into_response(),
|
||||
"admin_payment_order_expired",
|
||||
@@ -259,10 +292,16 @@ async fn build_admin_payment_fail_order_response(
|
||||
else {
|
||||
return Ok(build_admin_payment_order_not_found_response());
|
||||
};
|
||||
let gateway_close =
|
||||
match close_direct_gateway_order_before_terminal_mark(state, &order_id).await {
|
||||
Ok(value) => value,
|
||||
Err(response) => return Ok(response),
|
||||
};
|
||||
match state.admin_fail_payment_order(&order_id).await? {
|
||||
crate::AdminWalletMutationOutcome::Applied(order) => Ok(attach_admin_audit_response(
|
||||
Json(json!({
|
||||
"order": build_admin_payment_order_payload(&order),
|
||||
"gateway_close": gateway_close,
|
||||
}))
|
||||
.into_response(),
|
||||
"admin_payment_order_failed",
|
||||
|
||||
@@ -33,9 +33,9 @@ pub(super) async fn maybe_build_local_admin_payments_response(
|
||||
|| (matches!(
|
||||
request_context.method(),
|
||||
&http::Method::GET | &http::Method::PUT
|
||||
) && path == "/api/admin/payments/gateways/epay")
|
||||
) && admin_payment_gateway_path_matches(path))
|
||||
|| (request_context.method() == http::Method::POST
|
||||
&& path == "/api/admin/payments/gateways/epay/test")
|
||||
&& admin_payment_gateway_test_path_matches(path))
|
||||
|| (request_context.method() == http::Method::GET
|
||||
&& path.starts_with("/api/admin/payments/orders/")
|
||||
&& path.matches('/').count() == 5)
|
||||
@@ -121,3 +121,20 @@ pub(super) async fn maybe_build_local_admin_payments_response(
|
||||
|
||||
Ok(Some(build_admin_payments_data_unavailable_response()))
|
||||
}
|
||||
|
||||
fn admin_payment_gateway_path_matches(path: &str) -> bool {
|
||||
let Some(provider) = path.strip_prefix("/api/admin/payments/gateways/") else {
|
||||
return false;
|
||||
};
|
||||
!provider.is_empty() && !provider.contains('/')
|
||||
}
|
||||
|
||||
fn admin_payment_gateway_test_path_matches(path: &str) -> bool {
|
||||
let Some(provider) = path
|
||||
.strip_prefix("/api/admin/payments/gateways/")
|
||||
.and_then(|value| value.strip_suffix("/test"))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
!provider.is_empty() && !provider.contains('/')
|
||||
}
|
||||
|
||||
@@ -7,15 +7,39 @@ use super::super::shared::{
|
||||
};
|
||||
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
||||
use crate::handlers::admin::shared::attach_admin_audit_response;
|
||||
use crate::handlers::shared::{
|
||||
payment_gateway_provider_for_payment_method, payment_gateway_refund_enabled,
|
||||
};
|
||||
use crate::GatewayError;
|
||||
use axum::{
|
||||
body::Body,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::warn;
|
||||
|
||||
fn merge_gateway_refund_proof(
|
||||
proof: Option<Value>,
|
||||
gateway_refund: Option<&crate::handlers::shared::DirectGatewayRefundResult>,
|
||||
) -> Option<Value> {
|
||||
let Some(gateway_refund) = gateway_refund else {
|
||||
return proof;
|
||||
};
|
||||
let mut object = proof
|
||||
.and_then(|value| value.as_object().cloned())
|
||||
.unwrap_or_default();
|
||||
object.insert(
|
||||
"gateway_refund".to_string(),
|
||||
json!({
|
||||
"id": gateway_refund.gateway_refund_id,
|
||||
"status": gateway_refund.status,
|
||||
"payload": gateway_refund.payload,
|
||||
}),
|
||||
);
|
||||
Some(Value::Object(object))
|
||||
}
|
||||
|
||||
pub(in super::super) async fn build_admin_wallet_complete_refund_response(
|
||||
state: &AdminAppState<'_>,
|
||||
request_context: &AdminRequestContext<'_>,
|
||||
@@ -76,13 +100,73 @@ pub(in super::super) async fn build_admin_wallet_complete_refund_response(
|
||||
}
|
||||
|
||||
let owner = resolve_admin_wallet_owner_summary(state, &wallet).await?;
|
||||
let Some(refund_before_complete) = state
|
||||
.app()
|
||||
.find_wallet_refund(&wallet_id, &refund_id)
|
||||
.await?
|
||||
else {
|
||||
return Ok(build_admin_wallet_refund_not_found_response());
|
||||
};
|
||||
let mut gateway_refund_id = gateway_refund_id;
|
||||
let mut payout_proof = payload.payout_proof;
|
||||
if payload.gateway_refund {
|
||||
let Some(payment_order_id) = refund_before_complete.payment_order_id.as_deref() else {
|
||||
return Ok(build_admin_wallets_bad_request_response(
|
||||
"网关原路退款需要退款申请关联支付订单",
|
||||
));
|
||||
};
|
||||
let order = match state.read_admin_payment_order(payment_order_id).await? {
|
||||
crate::AdminWalletMutationOutcome::Applied(order) => order,
|
||||
crate::AdminWalletMutationOutcome::NotFound => {
|
||||
return Ok(build_admin_wallets_bad_request_response("支付订单不存在"))
|
||||
}
|
||||
crate::AdminWalletMutationOutcome::Invalid(detail) => {
|
||||
return Ok(build_admin_wallets_bad_request_response(detail))
|
||||
}
|
||||
crate::AdminWalletMutationOutcome::Unavailable => {
|
||||
return Ok(build_admin_wallets_data_unavailable_response())
|
||||
}
|
||||
};
|
||||
if let Some(provider) = payment_gateway_provider_for_payment_method(&order.payment_method) {
|
||||
let refund_enabled = state
|
||||
.app()
|
||||
.find_payment_gateway_config(provider)
|
||||
.await?
|
||||
.is_some_and(|record| payment_gateway_refund_enabled(&record.channels_json));
|
||||
if !refund_enabled {
|
||||
return Ok(build_admin_wallets_bad_request_response(
|
||||
"该支付方式未启用退款",
|
||||
));
|
||||
}
|
||||
}
|
||||
match crate::handlers::shared::refund_direct_gateway_order(
|
||||
state.app(),
|
||||
&order,
|
||||
&refund_before_complete.refund_no,
|
||||
refund_before_complete.amount_usd,
|
||||
refund_before_complete.reason.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(result)) => {
|
||||
gateway_refund_id = Some(result.gateway_refund_id.clone());
|
||||
payout_proof = merge_gateway_refund_proof(payout_proof, Some(&result));
|
||||
}
|
||||
Ok(None) => {
|
||||
return Ok(build_admin_wallets_bad_request_response(
|
||||
"该支付方式不支持官方直连退款,请使用线下完成",
|
||||
))
|
||||
}
|
||||
Err(detail) => return Ok(build_admin_wallets_bad_request_response(detail)),
|
||||
}
|
||||
}
|
||||
match state
|
||||
.admin_complete_wallet_refund(
|
||||
&wallet_id,
|
||||
&refund_id,
|
||||
gateway_refund_id.as_deref(),
|
||||
payout_reference.as_deref(),
|
||||
payload.payout_proof,
|
||||
payout_proof,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
|
||||
@@ -34,6 +34,8 @@ pub(in super::super) struct AdminWalletRefundCompleteRequest {
|
||||
#[serde(default)]
|
||||
pub(in super::super) gateway_refund_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(in super::super) gateway_refund: bool,
|
||||
#[serde(default)]
|
||||
pub(in super::super) payout_reference: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(in super::super) payout_proof: Option<serde_json::Value>,
|
||||
|
||||
@@ -277,6 +277,15 @@ impl<'a> AdminAppState<'a> {
|
||||
self.app.admin_fail_payment_order(order_id).await
|
||||
}
|
||||
|
||||
pub(crate) async fn find_wallet_refund(
|
||||
&self,
|
||||
wallet_id: &str,
|
||||
refund_id: &str,
|
||||
) -> Result<Option<aether_data::repository::wallet::StoredAdminWalletRefund>, GatewayError>
|
||||
{
|
||||
self.app.find_wallet_refund(wallet_id, refund_id).await
|
||||
}
|
||||
|
||||
pub(crate) async fn list_admin_redeem_code_batches(
|
||||
&self,
|
||||
status: Option<&str>,
|
||||
|
||||
@@ -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, ¤cy);
|
||||
|
||||
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)]
|
||||
{
|
||||
|
||||
@@ -5,6 +5,8 @@ mod email_templates;
|
||||
mod external_models;
|
||||
mod normalize;
|
||||
mod payloads;
|
||||
mod payment_direct;
|
||||
mod payment_gateway_config;
|
||||
pub(crate) mod provider_pool;
|
||||
mod request_utils;
|
||||
mod system_config_values;
|
||||
@@ -41,6 +43,18 @@ pub(crate) use self::payloads::{
|
||||
InternalGatewayAuthContextRequest, InternalGatewayExecuteRequest,
|
||||
InternalGatewayResolveRequest, InternalTunnelHeartbeatRequest, InternalTunnelNodeStatusRequest,
|
||||
};
|
||||
pub(crate) use self::payment_direct::{
|
||||
close_direct_gateway_order, create_alipay_direct_checkout, create_stripe_direct_checkout,
|
||||
create_wxpay_direct_checkout, direct_payment_client_ip, refund_direct_gateway_order,
|
||||
verify_alipay_notify_callback, verify_wxpay_notify_callback, DirectGatewayRefundResult,
|
||||
DirectPaymentCheckoutInput,
|
||||
};
|
||||
pub(crate) use self::payment_gateway_config::{
|
||||
payment_gateway_allow_user_refund, payment_gateway_channels_config_json,
|
||||
payment_gateway_channels_json, payment_gateway_config_json,
|
||||
payment_gateway_provider_for_payment_method, payment_gateway_refund_enabled,
|
||||
payment_gateway_secret_keys_json,
|
||||
};
|
||||
pub(crate) use self::request_utils::{
|
||||
admin_proxy_local_requires_buffered_body, internal_proxy_local_requires_buffered_body,
|
||||
json_string_list, local_proxy_route_requires_buffered_body,
|
||||
|
||||
1132
apps/aether-gateway/src/handlers/shared/payment_direct.rs
Normal file
1132
apps/aether-gateway/src/handlers/shared/payment_direct.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,85 @@
|
||||
use serde_json::{json, Value};
|
||||
|
||||
const REFUND_ENABLED_KEY: &str = "refund_enabled";
|
||||
const ALLOW_USER_REFUND_KEY: &str = "allow_user_refund";
|
||||
|
||||
fn json_bool(value: Option<&Value>) -> bool {
|
||||
match value {
|
||||
Some(Value::Bool(value)) => *value,
|
||||
Some(Value::Number(value)) => value.as_u64().is_some_and(|value| value != 0),
|
||||
Some(Value::String(value)) => matches!(
|
||||
value.trim().to_ascii_lowercase().as_str(),
|
||||
"1" | "true" | "yes" | "on"
|
||||
),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn payment_gateway_channels_json(value: &Value) -> Value {
|
||||
value
|
||||
.as_object()
|
||||
.and_then(|object| object.get("channels"))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| value.clone())
|
||||
}
|
||||
|
||||
pub(crate) fn payment_gateway_config_json(value: &Value) -> Value {
|
||||
value
|
||||
.as_object()
|
||||
.and_then(|object| object.get("config"))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!({}))
|
||||
}
|
||||
|
||||
pub(crate) fn payment_gateway_secret_keys_json(value: &Value) -> Value {
|
||||
value
|
||||
.as_object()
|
||||
.and_then(|object| object.get("secret_keys"))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!([]))
|
||||
}
|
||||
|
||||
pub(crate) fn payment_gateway_refund_enabled(value: &Value) -> bool {
|
||||
json_bool(
|
||||
value
|
||||
.as_object()
|
||||
.and_then(|object| object.get(REFUND_ENABLED_KEY)),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn payment_gateway_allow_user_refund(value: &Value) -> bool {
|
||||
payment_gateway_refund_enabled(value)
|
||||
&& json_bool(
|
||||
value
|
||||
.as_object()
|
||||
.and_then(|object| object.get(ALLOW_USER_REFUND_KEY)),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn payment_gateway_channels_config_json(
|
||||
channels: Value,
|
||||
config: Value,
|
||||
secret_keys: Value,
|
||||
refund_enabled: bool,
|
||||
allow_user_refund: bool,
|
||||
) -> Value {
|
||||
json!({
|
||||
"channels": channels,
|
||||
"config": config,
|
||||
"secret_keys": secret_keys,
|
||||
"refund_enabled": refund_enabled,
|
||||
"allow_user_refund": refund_enabled && allow_user_refund,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn payment_gateway_provider_for_payment_method(
|
||||
payment_method: &str,
|
||||
) -> Option<&'static str> {
|
||||
match payment_method.trim().to_ascii_lowercase().as_str() {
|
||||
"epay" => Some("epay"),
|
||||
"alipay" => Some("alipay"),
|
||||
"wxpay" => Some("wxpay"),
|
||||
"stripe" => Some("stripe"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -313,7 +313,11 @@ pub(crate) fn admin_proxy_local_requires_buffered_body(
|
||||
| (Some("billing_manage"), http::Method::POST, Some("create_plan"))
|
||||
| (Some("billing_manage"), http::Method::PUT, Some("update_plan"))
|
||||
| (Some("billing_manage"), http::Method::PATCH, Some("set_plan_status"))
|
||||
| (Some("payments_manage"), http::Method::PUT, Some("update_epay_gateway"))
|
||||
| (
|
||||
Some("payments_manage"),
|
||||
http::Method::PUT,
|
||||
Some("update_epay_gateway" | "update_payment_gateway"),
|
||||
)
|
||||
| (Some("payments_manage"), http::Method::POST, Some("credit_order"))
|
||||
| (Some("payments_manage"), http::Method::POST, Some("create_redeem_code_batch"))
|
||||
| (Some("payments_manage"), http::Method::POST, Some("delete_redeem_code_batch"))
|
||||
@@ -483,7 +487,14 @@ pub(crate) fn public_support_local_requires_buffered_body(
|
||||
| (
|
||||
Some("payment_callback"),
|
||||
http::Method::POST,
|
||||
Some("callback" | "epay_notify" | "epay_return"),
|
||||
Some(
|
||||
"callback"
|
||||
| "epay_notify"
|
||||
| "epay_return"
|
||||
| "alipay_notify"
|
||||
| "wxpay_notify"
|
||||
| "stripe_webhook",
|
||||
),
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
@@ -144,9 +144,51 @@ impl AppState {
|
||||
refund_id: &str,
|
||||
) -> Result<Option<aether_data::repository::wallet::StoredAdminWalletRefund>, GatewayError>
|
||||
{
|
||||
#[cfg(test)]
|
||||
if let Some(store) = self.admin_wallet_refund_store.as_ref() {
|
||||
return Ok(store
|
||||
.lock()
|
||||
.expect("admin wallet refund store should lock")
|
||||
.get(refund_id)
|
||||
.filter(|refund| refund.wallet_id == wallet_id)
|
||||
.cloned()
|
||||
.map(test_admin_wallet_refund_to_stored));
|
||||
}
|
||||
|
||||
self.data
|
||||
.find_wallet_refund(wallet_id, refund_id)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn test_admin_wallet_refund_to_stored(
|
||||
refund: crate::AdminWalletRefundRecord,
|
||||
) -> aether_data::repository::wallet::StoredAdminWalletRefund {
|
||||
aether_data::repository::wallet::StoredAdminWalletRefund {
|
||||
id: refund.id,
|
||||
refund_no: refund.refund_no,
|
||||
wallet_id: refund.wallet_id,
|
||||
user_id: refund.user_id,
|
||||
payment_order_id: refund.payment_order_id,
|
||||
source_type: refund.source_type,
|
||||
source_id: refund.source_id,
|
||||
refund_mode: refund.refund_mode,
|
||||
amount_usd: refund.amount_usd,
|
||||
status: refund.status,
|
||||
reason: refund.reason,
|
||||
failure_reason: refund.failure_reason,
|
||||
gateway_refund_id: refund.gateway_refund_id,
|
||||
payout_method: refund.payout_method,
|
||||
payout_reference: refund.payout_reference,
|
||||
payout_proof: refund.payout_proof,
|
||||
requested_by: refund.requested_by,
|
||||
approved_by: refund.approved_by,
|
||||
processed_by: refund.processed_by,
|
||||
created_at_unix_ms: refund.created_at_unix_ms,
|
||||
updated_at_unix_secs: refund.updated_at_unix_secs,
|
||||
processed_at_unix_secs: refund.processed_at_unix_secs,
|
||||
completed_at_unix_secs: refund.completed_at_unix_secs,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user