mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-08 02:17:46 +08:00
Merge pull request #511
This commit is contained in:
@@ -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('/')
|
||||
}
|
||||
|
||||
+86
-2
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user