Merge pull request #511

This commit is contained in:
fawney19
2026-05-20 01:22:55 +08:00
40 changed files with 4674 additions and 365 deletions
@@ -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>,