支持官方直连支付、退款配置与套餐联动

This commit is contained in:
zhiqicloud
2026-05-19 19:01:10 +08:00
parent f5deed8709
commit 3a318a86b2
40 changed files with 4674 additions and 365 deletions

74
Cargo.lock generated
View File

@@ -8,6 +8,16 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"generic-array",
]
[[package]] [[package]]
name = "aes" name = "aes"
version = "0.8.4" version = "0.8.4"
@@ -19,6 +29,20 @@ dependencies = [
"cpufeatures", "cpufeatures",
] ]
[[package]]
name = "aes-gcm"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
dependencies = [
"aead",
"aes",
"cipher",
"ctr",
"ghash",
"subtle",
]
[[package]] [[package]]
name = "aether-admin" name = "aether-admin"
version = "0.1.0" version = "0.1.0"
@@ -190,6 +214,7 @@ dependencies = [
name = "aether-gateway" name = "aether-gateway"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"aes-gcm",
"aether-admin", "aether-admin",
"aether-ai-formats", "aether-ai-formats",
"aether-ai-serving", "aether-ai-serving",
@@ -234,6 +259,7 @@ dependencies = [
"parking_lot", "parking_lot",
"regex", "regex",
"reqwest", "reqwest",
"rsa",
"rustls 0.23.37", "rustls 0.23.37",
"serde", "serde",
"serde_json", "serde_json",
@@ -1361,6 +1387,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [ dependencies = [
"generic-array", "generic-array",
"rand_core 0.6.4",
"typenum", "typenum",
] ]
@@ -1374,6 +1401,15 @@ dependencies = [
"phf 0.11.3", "phf 0.11.3",
] ]
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
]
[[package]] [[package]]
name = "darling" name = "darling"
version = "0.23.0" version = "0.23.0"
@@ -1880,6 +1916,16 @@ dependencies = [
"wasip3", "wasip3",
] ]
[[package]]
name = "ghash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
dependencies = [
"opaque-debug",
"polyval",
]
[[package]] [[package]]
name = "glob" name = "glob"
version = "0.3.3" version = "0.3.3"
@@ -2790,6 +2836,12 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]] [[package]]
name = "openssl-macros" name = "openssl-macros"
version = "0.1.1" version = "0.1.1"
@@ -3028,6 +3080,18 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "polyval"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
"cfg-if",
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]] [[package]]
name = "portable-atomic" name = "portable-atomic"
version = "1.13.1" version = "1.13.1"
@@ -4873,6 +4937,16 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"subtle",
]
[[package]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.7.1" version = "0.7.1"

View File

@@ -31,6 +31,7 @@ aether-task-runtime.workspace = true
aether-usage-runtime.workspace = true aether-usage-runtime.workspace = true
aether-video-tasks-core.workspace = true aether-video-tasks-core.workspace = true
aether-wallet.workspace = true aether-wallet.workspace = true
aes-gcm = "0.10"
async-stream.workspace = true async-stream.workspace = true
async-trait.workspace = true async-trait.workspace = true
axum = { version = "0.8", features = ["ws"] } axum = { version = "0.8", features = ["ws"] }
@@ -50,11 +51,12 @@ md-5 = "0.10"
parking_lot = "0.12" parking_lot = "0.12"
regex.workspace = true regex.workspace = true
reqwest.workspace = true reqwest.workspace = true
rsa = "0.9.10"
rustls.workspace = true rustls.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
sha1 = "0.10" sha1 = "0.10"
sha2.workspace = true sha2 = { workspace = true, features = ["oid"] }
sqlx.workspace = true sqlx.workspace = true
thiserror.workspace = true thiserror.workspace = true
tokio.workspace = true tokio.workspace = true

View File

@@ -559,41 +559,62 @@ pub(super) fn classify_admin_basic_family_route(
false, false,
)) ))
} else if method == http::Method::GET } else if method == http::Method::GET
&& matches!( && has_single_segment_after_prefix(
normalized_path, normalized_path_no_trailing,
"/api/admin/payments/gateways/epay" | "/api/admin/payments/gateways/epay/" "/api/admin/payments/gateways/",
) )
{ {
Some(classified( Some(classified(
"admin_proxy", "admin_proxy",
"payments_manage", "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", "admin:payments",
false, false,
)) ))
} else if method == http::Method::PUT } else if method == http::Method::PUT
&& matches!( && has_single_segment_after_prefix(
normalized_path, normalized_path_no_trailing,
"/api/admin/payments/gateways/epay" | "/api/admin/payments/gateways/epay/" "/api/admin/payments/gateways/",
) )
{ {
Some(classified( Some(classified(
"admin_proxy", "admin_proxy",
"payments_manage", "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", "admin:payments",
false, false,
)) ))
} else if method == http::Method::POST } else if method == http::Method::POST
&& matches!( && normalized_path_no_trailing.starts_with("/api/admin/payments/gateways/")
normalized_path, && normalized_path_no_trailing.ends_with("/test")
"/api/admin/payments/gateways/epay/test" | "/api/admin/payments/gateways/epay/test/" && normalized_path_no_trailing.matches('/').count() == 6
)
{ {
Some(classified( Some(classified(
"admin_proxy", "admin_proxy",
"payments_manage", "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", "admin:payments",
false, false,
)) ))
@@ -748,3 +769,10 @@ pub(super) fn classify_admin_basic_family_route(
None 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('/')
}

View File

@@ -332,6 +332,7 @@ pub(super) fn classify_public_support_route(
| "/api/wallet/recharge" | "/api/wallet/recharge"
| "/api/wallet/recharge/options" | "/api/wallet/recharge/options"
| "/api/wallet/refunds" | "/api/wallet/refunds"
| "/api/wallet/refunds/eligible-providers"
) )
{ {
let route_kind = match normalized_path { 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" => "list_recharge_orders",
"/api/wallet/recharge/options" => "recharge_options", "/api/wallet/recharge/options" => "recharge_options",
"/api/wallet/refunds" => "list_refunds", "/api/wallet/refunds" => "list_refunds",
"/api/wallet/refunds/eligible-providers" => "refund_eligible_providers",
_ => "balance", _ => "balance",
}; };
Some(classified( Some(classified(
@@ -436,6 +438,45 @@ pub(super) fn classify_public_support_route(
"public:payment", "public:payment",
false, 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) } else if matches!(method, &http::Method::GET | &http::Method::POST)
&& matches!( && matches!(
normalized_path, normalized_path,

View File

@@ -317,6 +317,11 @@ fn classifies_wallet_routes_as_public_support_route() {
"/api/wallet/refunds?limit=20", "/api/wallet/refunds?limit=20",
"list_refunds", "list_refunds",
), ),
(
http::Method::GET,
"/api/wallet/refunds/eligible-providers",
"refund_eligible_providers",
),
(http::Method::POST, "/api/wallet/refunds", "create_refund"), (http::Method::POST, "/api/wallet/refunds", "create_refund"),
( (
http::Method::GET, http::Method::GET,

View File

@@ -2,6 +2,11 @@ use super::{
build_admin_payments_backend_unavailable_response, build_admin_payments_bad_request_response, build_admin_payments_backend_unavailable_response, build_admin_payments_bad_request_response,
}; };
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext}; 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 crate::{GatewayError, LocalMutationOutcome};
use aether_data_contracts::repository::billing::PaymentGatewayConfigWriteInput; use aether_data_contracts::repository::billing::PaymentGatewayConfigWriteInput;
use axum::{ use axum::{
@@ -11,15 +16,17 @@ use axum::{
Json, Json,
}; };
use serde::Deserialize; use serde::Deserialize;
use serde_json::json; use serde_json::{json, Value};
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct EpayGatewayConfigRequest { struct PaymentGatewayConfigRequest {
#[serde(default)] #[serde(default)]
enabled: bool, enabled: bool,
#[serde(default)]
endpoint_url: String, endpoint_url: String,
#[serde(default)] #[serde(default)]
callback_base_url: Option<String>, callback_base_url: Option<String>,
#[serde(default)]
merchant_id: String, merchant_id: String,
#[serde(default)] #[serde(default)]
merchant_key: Option<String>, merchant_key: Option<String>,
@@ -30,7 +37,15 @@ struct EpayGatewayConfigRequest {
#[serde(default = "default_min_recharge_usd")] #[serde(default = "default_min_recharge_usd")]
min_recharge_usd: f64, min_recharge_usd: f64,
#[serde(default = "default_channels")] #[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 { fn default_pay_currency() -> String {
@@ -45,10 +60,10 @@ fn default_min_recharge_usd() -> f64 {
1.0 1.0
} }
fn default_channels() -> serde_json::Value { fn default_channels() -> Value {
json!([ json!([
{"channel": "alipay", "display_name": "支付宝"}, {"channel": "alipay", "display_name": "支付宝", "fee_rate": 0.0},
{"channel": "wxpay", "display_name": "微信支付"} {"channel": "wxpay", "display_name": "微信支付", "fee_rate": 0.0}
]) ])
} }
@@ -81,9 +96,66 @@ fn normalize_optional_text(
Ok(Some(trimmed.to_string())) 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( fn gateway_config_payload(
record: aether_data_contracts::repository::billing::PaymentGatewayConfigRecord, 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!({ json!({
"provider": record.provider, "provider": record.provider,
"enabled": record.enabled, "enabled": record.enabled,
@@ -91,36 +163,196 @@ fn gateway_config_payload(
"callback_base_url": record.callback_base_url, "callback_base_url": record.callback_base_url,
"merchant_id": record.merchant_id, "merchant_id": record.merchant_id,
"has_secret": record.merchant_key_encrypted.as_deref().is_some_and(|value| !value.trim().is_empty()), "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, "pay_currency": record.pay_currency,
"usd_exchange_rate": record.usd_exchange_rate, "usd_exchange_rate": record.usd_exchange_rate,
"min_recharge_usd": record.min_recharge_usd, "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, "created_at": record.created_at_unix_secs,
"updated_at": record.updated_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( pub(super) async fn maybe_build_local_admin_payment_gateways_response(
state: &AdminAppState<'_>, state: &AdminAppState<'_>,
_request_context: &AdminRequestContext<'_>, request_context: &AdminRequestContext<'_>,
request_body: Option<&axum::body::Bytes>, request_body: Option<&axum::body::Bytes>,
route_kind: Option<&str>, route_kind: Option<&str>,
) -> Result<Option<Response<Body>>, GatewayError> { ) -> Result<Option<Response<Body>>, GatewayError> {
match route_kind { match route_kind {
Some("get_epay_gateway") => { Some("get_epay_gateway") | Some("get_payment_gateway") => {
let record = state.app().find_payment_gateway_config("epay").await?; let provider = admin_payment_gateway_provider_from_path(request_context.path())
let payload = record.map(gateway_config_payload).unwrap_or_else( .unwrap_or_else(|| "epay".to_string());
|| json!({"provider": "epay", "enabled": false, "has_secret": false}), 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())) 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 { let Some(body) = request_body else {
return Ok(Some(build_admin_payments_bad_request_response( 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, Ok(value) => value,
Err(_) => { Err(_) => {
return Ok(Some(build_admin_payments_bad_request_response( 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", "min_recharge_usd must be positive",
))); )));
} }
let merchant_key_encrypted = match payload
.merchant_key let merchant_key_encrypted = match encrypted_gateway_secret(state, &provider, &payload)
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{ {
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, 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) { let callback_base_url = match normalize_optional_text(payload.callback_base_url, 512) {
Ok(value) => value, Ok(value) => value,
Err(detail) => return Ok(Some(build_admin_payments_bad_request_response(detail))), Err(detail) => return Ok(Some(build_admin_payments_bad_request_response(detail))),
}; };
let merchant_id = match normalize_text(payload.merchant_id, "merchant_id", 128) { let merchant_id = if provider == "epay" {
match normalize_text(payload.merchant_id, "merchant_id", 128) {
Ok(value) => value, Ok(value) => value,
Err(detail) => return Ok(Some(build_admin_payments_bad_request_response(detail))), 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) { let pay_currency = match normalize_text(payload.pay_currency, "pay_currency", 16) {
Ok(value) => value, Ok(value) => value,
Err(detail) => return Ok(Some(build_admin_payments_bad_request_response(detail))), 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 { let input = PaymentGatewayConfigWriteInput {
provider: "epay".to_string(), provider: provider.clone(),
enabled: payload.enabled, enabled: payload.enabled,
endpoint_url, endpoint_url,
callback_base_url, callback_base_url,
@@ -181,7 +460,7 @@ pub(super) async fn maybe_build_local_admin_payment_gateways_response(
pay_currency, pay_currency,
usd_exchange_rate: payload.usd_exchange_rate, usd_exchange_rate: payload.usd_exchange_rate,
min_recharge_usd: payload.min_recharge_usd, min_recharge_usd: payload.min_recharge_usd,
channels_json: payload.channels, channels_json,
}; };
match state.app().upsert_payment_gateway_config(&input).await? { match state.app().upsert_payment_gateway_config(&input).await? {
LocalMutationOutcome::Applied(record) => { LocalMutationOutcome::Applied(record) => {
@@ -192,8 +471,10 @@ pub(super) async fn maybe_build_local_admin_payment_gateways_response(
))), ))),
} }
} }
Some("test_epay_gateway") => { Some("test_epay_gateway") | Some("test_payment_gateway") => {
let status = state.app().find_payment_gateway_config("epay").await?; 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 let ok = status
.as_ref() .as_ref()
.is_some_and(|record| record.enabled && record.merchant_key_encrypted.is_some()); .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 { } else {
http::StatusCode::BAD_REQUEST http::StatusCode::BAD_REQUEST
}, },
Json(json!({"ok": ok, "provider": "epay"})), Json(json!({"ok": ok, "provider": provider})),
) )
.into_response(), .into_response(),
)) ))

View File

@@ -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( async fn build_admin_payment_expire_order_response(
state: &AdminAppState<'_>, state: &AdminAppState<'_>,
request_context: &AdminRequestContext<'_>, request_context: &AdminRequestContext<'_>,
@@ -117,12 +144,18 @@ async fn build_admin_payment_expire_order_response(
else { else {
return Ok(build_admin_payment_order_not_found_response()); 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? { match state.admin_expire_payment_order(&order_id).await? {
crate::AdminWalletMutationOutcome::Applied((order, expired)) => { crate::AdminWalletMutationOutcome::Applied((order, expired)) => {
Ok(attach_admin_audit_response( Ok(attach_admin_audit_response(
Json(json!({ Json(json!({
"order": build_admin_payment_order_payload(&order), "order": build_admin_payment_order_payload(&order),
"expired": expired, "expired": expired,
"gateway_close": gateway_close,
})) }))
.into_response(), .into_response(),
"admin_payment_order_expired", "admin_payment_order_expired",
@@ -259,10 +292,16 @@ async fn build_admin_payment_fail_order_response(
else { else {
return Ok(build_admin_payment_order_not_found_response()); 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? { match state.admin_fail_payment_order(&order_id).await? {
crate::AdminWalletMutationOutcome::Applied(order) => Ok(attach_admin_audit_response( crate::AdminWalletMutationOutcome::Applied(order) => Ok(attach_admin_audit_response(
Json(json!({ Json(json!({
"order": build_admin_payment_order_payload(&order), "order": build_admin_payment_order_payload(&order),
"gateway_close": gateway_close,
})) }))
.into_response(), .into_response(),
"admin_payment_order_failed", "admin_payment_order_failed",

View File

@@ -33,9 +33,9 @@ pub(super) async fn maybe_build_local_admin_payments_response(
|| (matches!( || (matches!(
request_context.method(), request_context.method(),
&http::Method::GET | &http::Method::PUT &http::Method::GET | &http::Method::PUT
) && path == "/api/admin/payments/gateways/epay") ) && admin_payment_gateway_path_matches(path))
|| (request_context.method() == http::Method::POST || (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 || (request_context.method() == http::Method::GET
&& path.starts_with("/api/admin/payments/orders/") && path.starts_with("/api/admin/payments/orders/")
&& path.matches('/').count() == 5) && 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())) 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('/')
}

View File

@@ -7,15 +7,39 @@ use super::super::shared::{
}; };
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext}; use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::handlers::admin::shared::attach_admin_audit_response; 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 crate::GatewayError;
use axum::{ use axum::{
body::Body, body::Body,
response::{IntoResponse, Response}, response::{IntoResponse, Response},
Json, Json,
}; };
use serde_json::json; use serde_json::{json, Value};
use tracing::warn; 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( pub(in super::super) async fn build_admin_wallet_complete_refund_response(
state: &AdminAppState<'_>, state: &AdminAppState<'_>,
request_context: &AdminRequestContext<'_>, 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 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 match state
.admin_complete_wallet_refund( .admin_complete_wallet_refund(
&wallet_id, &wallet_id,
&refund_id, &refund_id,
gateway_refund_id.as_deref(), gateway_refund_id.as_deref(),
payout_reference.as_deref(), payout_reference.as_deref(),
payload.payout_proof, payout_proof,
) )
.await? .await?
{ {

View File

@@ -34,6 +34,8 @@ pub(in super::super) struct AdminWalletRefundCompleteRequest {
#[serde(default)] #[serde(default)]
pub(in super::super) gateway_refund_id: Option<String>, pub(in super::super) gateway_refund_id: Option<String>,
#[serde(default)] #[serde(default)]
pub(in super::super) gateway_refund: bool,
#[serde(default)]
pub(in super::super) payout_reference: Option<String>, pub(in super::super) payout_reference: Option<String>,
#[serde(default)] #[serde(default)]
pub(in super::super) payout_proof: Option<serde_json::Value>, pub(in super::super) payout_proof: Option<serde_json::Value>,

View File

@@ -277,6 +277,15 @@ impl<'a> AdminAppState<'a> {
self.app.admin_fail_payment_order(order_id).await 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( pub(crate) async fn list_admin_redeem_code_batches(
&self, &self,
status: Option<&str>, status: Option<&str>,

View File

@@ -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_test_connection::maybe_build_local_test_connection_response;
use self::support_user_me::maybe_build_local_users_me_response; use self::support_user_me::maybe_build_local_users_me_response;
use self::support_wallet::{ 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, wallet_normalize_optional_string_field,
}; };

View File

@@ -6,6 +6,10 @@ use super::{
build_auth_error_response, build_auth_json_response, resolve_authenticated_local_user, build_auth_error_response, build_auth_json_response, resolve_authenticated_local_user,
sanitize_wallet_gateway_response, unix_secs_to_rfc3339, AppState, GatewayPublicRequestContext, 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::{ use axum::{
body::{Body, Bytes}, body::{Body, Bytes},
http, http,
@@ -14,7 +18,7 @@ use axum::{
}; };
use chrono::Utc; use chrono::Utc;
use serde::Deserialize; use serde::Deserialize;
use serde_json::json; use serde_json::{json, Value};
use uuid::Uuid; use uuid::Uuid;
const BILLING_STORAGE_UNAVAILABLE_DETAIL: &str = "套餐后端暂不可用"; 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) let payment_provider = normalize_optional_checkout_string(payload.payment_provider, 30)
.or_else(|| normalize_optional_checkout_string(payload.payment_method.clone(), 30)) .or_else(|| normalize_optional_checkout_string(payload.payment_method.clone(), 30))
.unwrap_or_else(|| "epay".to_string()); .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"); return Err("unsupported payment_provider");
} }
let payment_method = normalize_optional_checkout_string(payload.payment_method, 30) 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) let payment_channel = normalize_optional_checkout_string(payload.payment_channel, 30)
.or_else(|| (payment_method != "epay").then_some(payment_method.clone())); .or_else(|| (payment_method != "epay").then_some(payment_method.clone()));
Ok(NormalizedBillingPlanCheckoutRequest { Ok(NormalizedBillingPlanCheckoutRequest {
payment_method: "epay".to_string(), payment_method: payment_provider.clone(),
payment_provider, payment_provider,
payment_channel, payment_channel,
}) })
@@ -320,6 +327,22 @@ pub(super) async fn handle_billing_plan_checkout(
false, 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 =
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 { let config = match load_epay_config(state).await {
Ok(value) => value, Ok(value) => value,
Err(detail) => { Err(detail) => {
@@ -333,8 +356,12 @@ pub(super) async fn handle_billing_plan_checkout(
return build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false); return build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false);
} }
}; };
let (amount_usd, pay_amount) = let payment_channel_id = payment_channel.channel.clone();
match compute_plan_payment_amounts(&plan, &config.pay_currency, config.usd_exchange_rate) { let (amount_usd, pay_amount) = match compute_plan_payment_amounts(
&plan,
&config.pay_currency,
config.usd_exchange_rate,
) {
Ok(value) => value, Ok(value) => value,
Err(detail) => { Err(detail) => {
return build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false) return build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false)
@@ -351,14 +378,11 @@ pub(super) async fn handle_billing_plan_checkout(
false, 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( let checkout = build_epay_checkout_url(
&config, &config,
&EpayCheckoutInput { &EpayCheckoutInput {
order_no: order_no.clone(), order_no: order_no.clone(),
channel: payment_channel.clone(), channel: payment_channel_id.clone(),
subject: plan.title.clone(), subject: plan.title.clone(),
pay_amount, pay_amount,
notify_url: format!("{callback_base_url}/api/payment/epay/notify"), notify_url: format!("{callback_base_url}/api/payment/epay/notify"),
@@ -374,12 +398,12 @@ pub(super) async fn handle_billing_plan_checkout(
pay_amount, pay_amount,
pay_currency: config.pay_currency.clone(), pay_currency: config.pay_currency.clone(),
exchange_rate: config.usd_exchange_rate, exchange_rate: config.usd_exchange_rate,
payment_method: checkout_request.payment_method, payment_method: payment_method.clone(),
payment_provider: Some(checkout_request.payment_provider), payment_provider: Some(checkout_request.payment_provider.clone()),
payment_channel: Some(payment_channel), payment_channel: Some(payment_channel_id),
gateway_order_id: order_no.clone(), gateway_order_id: order_no.clone(),
gateway_response: checkout.clone(), gateway_response: checkout.clone(),
order_no, order_no: order_no.clone(),
product_id: plan.id.clone(), product_id: plan.id.clone(),
product_snapshot: billing_plan_snapshot(&plan), product_snapshot: billing_plan_snapshot(&plan),
expires_at_unix_secs: expires_at.timestamp().max(0) as u64, expires_at_unix_secs: expires_at.timestamp().max(0) as u64,
@@ -424,6 +448,176 @@ pub(super) async fn handle_billing_plan_checkout(
}), }),
None, 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::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,
},
)
.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( pub(super) async fn maybe_build_local_billing_response(

View File

@@ -4,6 +4,8 @@ pub(super) use super::{
build_auth_error_response, build_auth_json_response, AppState, GatewayPublicRequestContext, build_auth_error_response, build_auth_json_response, AppState, GatewayPublicRequestContext,
}; };
#[path = "payment/alipay.rs"]
mod payment_alipay;
#[path = "payment/epay.rs"] #[path = "payment/epay.rs"]
pub(super) mod payment_epay; pub(super) mod payment_epay;
#[path = "payment/gateway.rs"] #[path = "payment/gateway.rs"]
@@ -14,11 +16,19 @@ mod payment_repository;
mod payment_route; mod payment_route;
#[path = "payment/shared.rs"] #[path = "payment/shared.rs"]
mod payment_shared; mod payment_shared;
#[path = "payment/stripe.rs"]
mod payment_stripe;
#[cfg(test)] #[cfg(test)]
#[path = "payment/test_support.rs"] #[path = "payment/test_support.rs"]
mod payment_test_support; 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; use self::payment_shared::NormalizedPaymentCallbackRequest;
const PAYMENT_CALLBACK_STORAGE_UNAVAILABLE_DETAIL: &str = "支付回调存储暂不可用"; const PAYMENT_CALLBACK_STORAGE_UNAVAILABLE_DETAIL: &str = "支付回调存储暂不可用";

View File

@@ -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,
}
}

View File

@@ -19,10 +19,11 @@ pub(crate) struct EpayMerchantConfig {
pub(crate) channels: serde_json::Value, pub(crate) channels: serde_json::Value,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq)]
pub(crate) struct EpayChannelConfig { pub(crate) struct EpayChannelConfig {
pub(crate) channel: String, pub(crate) channel: String,
pub(crate) display_name: String, pub(crate) display_name: String,
pub(crate) fee_rate: f64,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -35,8 +36,22 @@ pub(crate) struct EpayCheckoutInput {
pub(crate) return_url: String, 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> { 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(); return Vec::new();
}; };
channels channels
@@ -58,6 +73,7 @@ pub(crate) fn configured_epay_channels(config: &EpayMerchantConfig) -> Vec<EpayC
Some(EpayChannelConfig { Some(EpayChannelConfig {
channel: channel_id.to_string(), channel: channel_id.to_string(),
display_name, display_name,
fee_rate: epay_channel_fee_rate(channel.get("fee_rate")),
}) })
}) })
.collect() .collect()
@@ -66,7 +82,7 @@ pub(crate) fn configured_epay_channels(config: &EpayMerchantConfig) -> Vec<EpayC
pub(crate) fn resolve_epay_channel( pub(crate) fn resolve_epay_channel(
config: &EpayMerchantConfig, config: &EpayMerchantConfig,
requested_channel: Option<&str>, requested_channel: Option<&str>,
) -> Result<String, &'static str> { ) -> Result<EpayChannelConfig, &'static str> {
let channels = configured_epay_channels(config); let channels = configured_epay_channels(config);
if channels.is_empty() { if channels.is_empty() {
return Err("支付网关未配置可用通道"); return Err("支付网关未配置可用通道");
@@ -79,11 +95,11 @@ pub(crate) fn resolve_epay_channel(
.iter() .iter()
.find(|channel| channel.channel.eq_ignore_ascii_case(&requested_channel)) .find(|channel| channel.channel.eq_ignore_ascii_case(&requested_channel))
{ {
return Ok(channel.channel.clone()); return Ok(channel.clone());
} }
return Err("支付通道未配置或已停用"); return Err("支付通道未配置或已停用");
} }
Ok(channels[0].channel.clone()) Ok(channels[0].clone())
} }
pub(crate) fn epay_sign(params: &BTreeMap<String, String>, merchant_key: &str) -> String { pub(crate) fn epay_sign(params: &BTreeMap<String, String>, merchant_key: &str) -> String {
@@ -444,8 +460,8 @@ mod tests {
#[test] #[test]
fn configured_epay_channels_do_not_invent_defaults() { fn configured_epay_channels_do_not_invent_defaults() {
let mut config = test_epay_config(json!([ let mut config = test_epay_config(json!([
{"channel": " Alipay ", "display_name": "支付宝"}, {"channel": " Alipay ", "display_name": "支付宝", "fee_rate": 2.5},
{"type": "wxpay", "display_name": ""}, {"type": "wxpay", "display_name": "", "fee_rate": "1.2"},
{"display_name": "缺少通道值"} {"display_name": "缺少通道值"}
])); ]));
@@ -453,14 +469,16 @@ mod tests {
assert_eq!(channels.len(), 2); assert_eq!(channels.len(), 2);
assert_eq!(channels[0].channel, "Alipay"); assert_eq!(channels[0].channel, "Alipay");
assert_eq!(channels[0].display_name, "支付宝"); assert_eq!(channels[0].display_name, "支付宝");
assert_eq!(channels[0].fee_rate, 2.5);
assert_eq!(channels[1].channel, "wxpay"); assert_eq!(channels[1].channel, "wxpay");
assert_eq!(channels[1].display_name, "wxpay"); assert_eq!(channels[1].display_name, "wxpay");
assert_eq!(channels[1].fee_rate, 1.2);
assert_eq!( assert_eq!(
resolve_epay_channel(&config, None), resolve_epay_channel(&config, None).map(|channel| channel.channel),
Ok("Alipay".to_string()) Ok("Alipay".to_string())
); );
assert_eq!( assert_eq!(
resolve_epay_channel(&config, Some("WXPAY")), resolve_epay_channel(&config, Some("WXPAY")).map(|channel| channel.channel),
Ok("wxpay".to_string()) Ok("wxpay".to_string())
); );
assert_eq!( assert_eq!(

View File

@@ -2,6 +2,7 @@ use super::payment_shared::{
payment_callback_mark_failed_response, payment_callback_payload_hash, payment_callback_mark_failed_response, payment_callback_payload_hash,
NormalizedPaymentCallbackRequest, NormalizedPaymentCallbackRequest,
}; };
use aether_data::repository::wallet::{ProcessPaymentCallbackInput, ProcessPaymentCallbackOutcome};
use axum::{body::Body, http, response::Response}; use axum::{body::Body, http, response::Response};
use serde_json::json; use serde_json::json;
@@ -29,9 +30,10 @@ pub(super) async fn handle_payment_callback_with_wallet_repository(
return build_auth_error_response(http::StatusCode::INTERNAL_SERVER_ERROR, err, false) return build_auth_error_response(http::StatusCode::INTERNAL_SERVER_ERROR, err, false)
} }
}; };
let outcome = match state handle_payment_callback_input_with_wallet_repository(
.process_payment_callback( state,
aether_data::repository::wallet::ProcessPaymentCallbackInput { request_context,
ProcessPaymentCallbackInput {
payment_method: payment_method.to_string(), payment_method: payment_method.to_string(),
payment_provider: None, payment_provider: None,
payment_channel: None, payment_channel: None,
@@ -48,16 +50,17 @@ pub(super) async fn handle_payment_callback_with_wallet_repository(
}, },
) )
.await .await
{ }
Ok(Some(value)) => value,
Ok(None) => return build_payment_callback_storage_unavailable_response(), pub(super) async fn handle_payment_callback_input_with_wallet_repository(
Err(err) => { state: &AppState,
return build_auth_error_response( request_context: &GatewayPublicRequestContext,
http::StatusCode::INTERNAL_SERVER_ERROR, input: ProcessPaymentCallbackInput,
format!("payment callback failed: {err:?}"), ) -> Response<Body> {
false, 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 { match outcome {
@@ -81,7 +84,7 @@ pub(super) async fn handle_payment_callback_with_wallet_repository(
} => payment_callback_mark_failed_response( } => payment_callback_mark_failed_response(
duplicate, duplicate,
&error, &error,
payment_method, &payment_method,
&request_context.request_path, &request_context.request_path,
), ),
aether_data::repository::wallet::ProcessPaymentCallbackOutcome::AlreadyCredited { 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)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{

View File

@@ -7,8 +7,8 @@ use super::payment_shared::{
}; };
use super::{ use super::{
build_auth_error_response, build_payment_callback_storage_unavailable_response, build_auth_error_response, build_payment_callback_storage_unavailable_response,
handle_payment_callback_with_wallet_repository, payment_epay, AppState, handle_payment_callback_with_wallet_repository, payment_alipay, payment_epay, payment_stripe,
GatewayPublicRequestContext, payment_wxpay, AppState, GatewayPublicRequestContext,
}; };
pub(super) async fn maybe_build_local_payment_callback_route_response( 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); 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") { if decision.route_kind.as_deref() != Some("callback") {
return None; return None;
} }

View File

@@ -0,0 +1,339 @@
use axum::{body::Body, http, response::Response};
use hmac::{Hmac, Mac};
use serde_json::{json, Value};
use sha2::Sha256;
use super::{
build_auth_error_response, build_auth_json_response,
handle_payment_callback_input_with_wallet_repository,
payment_shared::payment_callback_payload_hash, AppState, GatewayPublicRequestContext,
};
const STRIPE_SIGNATURE_HEADER: &str = "stripe-signature";
const STRIPE_SIGNATURE_TOLERANCE_SECONDS: i64 = 300;
type HmacSha256 = Hmac<Sha256>;
fn decrypt_gateway_secrets(
state: &AppState,
record: &aether_data_contracts::repository::billing::PaymentGatewayConfigRecord,
) -> Result<serde_json::Map<String, Value>, String> {
let Some(encrypted) = record.merchant_key_encrypted.as_deref() else {
return Err("Stripe webhook_secret 未配置".to_string());
};
let Some(plaintext) = crate::handlers::shared::decrypt_catalog_secret_with_fallbacks(
state.encryption_key(),
encrypted,
) else {
return Err("Stripe 密钥解密失败".to_string());
};
serde_json::from_str::<Value>(&plaintext)
.ok()
.and_then(|value| value.as_object().cloned())
.ok_or_else(|| "Stripe 密钥格式无效".to_string())
}
fn gateway_secret_string(secrets: &serde_json::Map<String, Value>, key: &str) -> Option<String> {
secrets
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
async fn stripe_webhook_secret(state: &AppState) -> Result<String, String> {
let Some(record) = state
.find_payment_gateway_config("stripe")
.await
.map_err(|err| format!("Stripe 配置读取失败: {err:?}"))?
else {
return Err("Stripe 未配置".to_string());
};
if !record.enabled {
return Err("Stripe 未启用".to_string());
}
let secrets = decrypt_gateway_secrets(state, &record)?;
gateway_secret_string(&secrets, "webhook_secret")
.ok_or_else(|| "Stripe webhook_secret 未配置".to_string())
}
fn parse_stripe_signature_header(value: &str) -> Option<(i64, Vec<&str>)> {
let mut timestamp = None;
let mut signatures = Vec::new();
for part in value.split(',') {
let Some((key, value)) = part.trim().split_once('=') else {
continue;
};
match key.trim() {
"t" => timestamp = value.trim().parse::<i64>().ok(),
"v1" => {
let signature = value.trim();
if !signature.is_empty() {
signatures.push(signature);
}
}
_ => {}
}
}
timestamp.map(|value| (value, signatures))
}
fn hex_to_bytes(value: &str) -> Option<Vec<u8>> {
let value = value.trim();
if !value.len().is_multiple_of(2) {
return None;
}
let mut bytes = Vec::with_capacity(value.len() / 2);
let mut chars = value.as_bytes().chunks_exact(2);
for chunk in &mut chars {
let hex = std::str::from_utf8(chunk).ok()?;
bytes.push(u8::from_str_radix(hex, 16).ok()?);
}
Some(bytes)
}
fn stripe_signature_matches_at(
secret: &str,
signature_header: &str,
body: &[u8],
now_unix_secs: i64,
) -> Result<bool, String> {
let Some((timestamp, signatures)) = parse_stripe_signature_header(signature_header) else {
return Ok(false);
};
if signatures.is_empty() {
return Ok(false);
}
if (now_unix_secs - timestamp).abs() > STRIPE_SIGNATURE_TOLERANCE_SECONDS {
return Ok(false);
}
for signature in signatures {
let Some(signature_bytes) = hex_to_bytes(signature) else {
continue;
};
let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
.map_err(|err| format!("Stripe webhook HMAC 初始化失败: {err}"))?;
mac.update(timestamp.to_string().as_bytes());
mac.update(b".");
mac.update(body);
if mac.verify_slice(&signature_bytes).is_ok() {
return Ok(true);
}
}
Ok(false)
}
fn stripe_amount_multiplier(currency: &str) -> f64 {
match currency.trim().to_ascii_lowercase().as_str() {
"bif" | "clp" | "djf" | "gnf" | "jpy" | "kmf" | "krw" | "mga" | "pyg" | "rwf" | "ugx"
| "vnd" | "vuv" | "xaf" | "xof" | "xpf" => 1.0,
_ => 100.0,
}
}
fn stripe_amount_to_major(amount_minor: i64, currency: &str) -> f64 {
amount_minor as f64 / stripe_amount_multiplier(currency)
}
fn stripe_string_field<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
value
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
}
fn stripe_payment_intent_channel(intent: &Value) -> Option<String> {
intent
.get("payment_method_types")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(str::trim)
.find(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
async fn build_stripe_callback_input(
state: &AppState,
event: Value,
) -> Result<Option<aether_data::repository::wallet::ProcessPaymentCallbackInput>, String> {
let event_type = stripe_string_field(&event, "type").unwrap_or("");
if event_type != "payment_intent.succeeded" {
return Ok(None);
}
let event_id = stripe_string_field(&event, "id")
.ok_or_else(|| "Stripe 事件缺少 id".to_string())?
.to_string();
let intent = event
.get("data")
.and_then(|value| value.get("object"))
.ok_or_else(|| "Stripe 事件缺少 PaymentIntent".to_string())?;
let intent_id = stripe_string_field(intent, "id")
.ok_or_else(|| "Stripe PaymentIntent 缺少 id".to_string())?
.to_string();
let order_no = intent
.get("metadata")
.and_then(Value::as_object)
.and_then(|metadata| metadata.get("order_no"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let Some(order_no) = order_no else {
return Err("Stripe PaymentIntent 缺少 metadata.order_no".to_string());
};
let currency = stripe_string_field(intent, "currency")
.unwrap_or("usd")
.to_ascii_uppercase();
let amount_minor = intent
.get("amount_received")
.or_else(|| intent.get("amount"))
.and_then(Value::as_i64)
.filter(|value| *value > 0)
.ok_or_else(|| "Stripe PaymentIntent 金额无效".to_string())?;
let pay_amount = stripe_amount_to_major(amount_minor, &currency);
let record = state
.find_payment_gateway_config("stripe")
.await
.map_err(|err| format!("Stripe 配置读取失败: {err:?}"))?
.ok_or_else(|| "Stripe 未配置".to_string())?;
let exchange_rate = record.usd_exchange_rate;
let amount_usd = if exchange_rate > 0.0 {
pay_amount / exchange_rate
} else {
pay_amount
};
let payload_hash = payment_callback_payload_hash(&event)?;
Ok(Some(
aether_data::repository::wallet::ProcessPaymentCallbackInput {
payment_method: "stripe".to_string(),
payment_provider: Some("stripe".to_string()),
payment_channel: stripe_payment_intent_channel(intent),
callback_key: event_id,
order_no: Some(order_no),
gateway_order_id: Some(intent_id),
amount_usd,
pay_amount: Some(pay_amount),
pay_currency: Some(currency),
exchange_rate: Some(exchange_rate),
payload_hash,
payload: event,
signature_valid: true,
},
))
}
pub(super) async fn handle_stripe_webhook(
state: &AppState,
request_context: &GatewayPublicRequestContext,
headers: &http::HeaderMap,
request_body: Option<&axum::body::Bytes>,
) -> Response<Body> {
let Some(request_body) = request_body else {
return build_auth_error_response(http::StatusCode::BAD_REQUEST, "缺少请求体", false);
};
let Some(signature_header) = crate::headers::header_value_str(headers, STRIPE_SIGNATURE_HEADER)
else {
return build_auth_error_response(
http::StatusCode::UNAUTHORIZED,
"缺少 Stripe-Signature",
false,
);
};
let webhook_secret = match stripe_webhook_secret(state).await {
Ok(value) => value,
Err(detail) => {
return build_auth_error_response(http::StatusCode::SERVICE_UNAVAILABLE, detail, false)
}
};
let now = chrono::Utc::now().timestamp();
match stripe_signature_matches_at(&webhook_secret, &signature_header, request_body, now) {
Ok(true) => {}
Ok(false) => {
return build_auth_error_response(
http::StatusCode::UNAUTHORIZED,
"Stripe webhook 签名无效",
false,
)
}
Err(detail) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
detail,
false,
)
}
}
let event = match serde_json::from_slice::<Value>(request_body) {
Ok(value) => value,
Err(_) => {
return build_auth_error_response(
http::StatusCode::BAD_REQUEST,
"Stripe webhook 请求体无效",
false,
)
}
};
let input = match build_stripe_callback_input(state, event).await {
Ok(Some(value)) => value,
Ok(None) => {
return build_auth_json_response(
http::StatusCode::OK,
json!({ "ok": true, "ignored": true, "payment_method": "stripe" }),
None,
)
}
Err(detail) => {
return build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false)
}
};
handle_payment_callback_input_with_wallet_repository(state, request_context, input).await
}
#[cfg(test)]
mod tests {
use super::{stripe_amount_to_major, stripe_signature_matches_at};
use hmac::{Hmac, Mac};
use sha2::Sha256;
#[test]
fn stripe_signature_matches_raw_body() {
let body = br#"{"id":"evt_1","type":"payment_intent.succeeded"}"#;
let secret = "whsec_test";
let timestamp = 1_800_000_000_i64;
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("hmac");
mac.update(timestamp.to_string().as_bytes());
mac.update(b".");
mac.update(body);
let signature = mac
.finalize()
.into_bytes()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
let header = format!("t={timestamp},v1={signature}");
assert!(
stripe_signature_matches_at(secret, &header, body, timestamp)
.expect("signature check should run")
);
assert!(
!stripe_signature_matches_at("wrong", &header, body, timestamp)
.expect("signature check should run")
);
}
#[test]
fn stripe_amount_handles_zero_decimal_currencies() {
assert_eq!(stripe_amount_to_major(1234, "usd"), 12.34);
assert_eq!(stripe_amount_to_major(1234, "jpy"), 1234.0);
}
}

View File

@@ -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,
}
}

View File

@@ -35,14 +35,15 @@ use self::reads::{
handle_wallet_today_cost, handle_wallet_transactions, parse_wallet_limit, parse_wallet_offset, handle_wallet_today_cost, handle_wallet_transactions, parse_wallet_limit, parse_wallet_offset,
wallet_fixed_offset, wallet_transaction_payload_from_record, 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::{ use self::recharge::{
handle_wallet_create_recharge, handle_wallet_recharge_detail, handle_wallet_recharge_list, handle_wallet_create_recharge, handle_wallet_recharge_detail, handle_wallet_recharge_list,
handle_wallet_recharge_options, wallet_recharge_detail_path_matches, handle_wallet_recharge_options, wallet_recharge_detail_path_matches,
}; };
use self::redeem::handle_wallet_redeem; use self::redeem::handle_wallet_redeem;
use self::refunds::{ 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, wallet_refund_detail_path_matches,
}; };
@@ -59,8 +60,23 @@ const WALLET_SAFE_GATEWAY_RESPONSE_KEYS: &[&str] = &[
"qr_code", "qr_code",
"expires_at", "expires_at",
"pay_amount", "pay_amount",
"base_pay_amount",
"fee_rate",
"fee_amount",
"pay_currency", "pay_currency",
"payment_channel", "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", "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); 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") if decision.route_kind.as_deref() == Some("refund_detail")
&& wallet_refund_detail_path_matches(&request_context.request_path) && wallet_refund_detail_path_matches(&request_context.request_path)
{ {

View File

@@ -17,9 +17,13 @@ use super::{
record_wallet_test_recharge, wallet_test_recharge_order_by_id, record_wallet_test_recharge, wallet_test_recharge_order_by_id,
wallet_test_recharge_orders_for_user, 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 chrono::Utc;
use serde::Deserialize; use serde::Deserialize;
use serde_json::json; use serde_json::{json, Value};
use uuid::Uuid; use uuid::Uuid;
#[derive(Debug, Deserialize)] #[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> { fn wallet_order_id_from_path(request_path: &str) -> Option<String> {
let trimmed = request_path.trim_end_matches('/'); let trimmed = request_path.trim_end_matches('/');
let order_id = trimmed.strip_prefix("/api/wallet/recharge/")?.trim(); 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( pub(super) async fn handle_wallet_create_recharge(
state: &AppState, state: &AppState,
request_context: &GatewayPublicRequestContext, 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); 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( let Some(callback_base_url) = epay_callback_base_url(
config.callback_base_url.as_deref(), config.callback_base_url.as_deref(),
headers, headers,
@@ -357,13 +633,19 @@ pub(super) async fn handle_wallet_create_recharge(
&config, &config,
&EpayCheckoutInput { &EpayCheckoutInput {
order_no: order_no.clone(), order_no: order_no.clone(),
channel: payment_channel.clone(), channel: payment_channel.channel.clone(),
subject: "钱包充值".to_string(), subject: "钱包充值".to_string(),
pay_amount, pay_amount,
notify_url: format!("{callback_base_url}/api/payment/epay/notify"), notify_url: format!("{callback_base_url}/api/payment/epay/notify"),
return_url: format!("{callback_base_url}/api/payment/epay/return"), 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 let outcome = match state
.create_wallet_recharge_order( .create_wallet_recharge_order(
aether_data::repository::wallet::CreateWalletRechargeOrderInput { aether_data::repository::wallet::CreateWalletRechargeOrderInput {
@@ -375,7 +657,7 @@ pub(super) async fn handle_wallet_create_recharge(
exchange_rate: Some(config.usd_exchange_rate), exchange_rate: Some(config.usd_exchange_rate),
payment_method: "epay".to_string(), payment_method: "epay".to_string(),
payment_provider: Some("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_order_id: order_no.clone(),
gateway_response: checkout.clone(), gateway_response: checkout.clone(),
order_no, order_no,
@@ -415,6 +697,169 @@ pub(super) async fn handle_wallet_create_recharge(
None, 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( build_auth_error_response(
http::StatusCode::BAD_REQUEST, http::StatusCode::BAD_REQUEST,
format!("unsupported payment_method: {}", payload.payment_method), format!("unsupported payment_method: {}", payload.payment_method),
@@ -433,14 +878,43 @@ pub(super) async fn handle_wallet_recharge_options(
let mut methods = Vec::new(); let mut methods = Vec::new();
if let Ok(config) = load_epay_config(state).await { if let Ok(config) = load_epay_config(state).await {
for channel in configured_epay_channels(&config) { 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!({ methods.push(json!({
"payment_method": "epay", "payment_method": "epay",
"payment_provider": "epay", "payment_provider": "epay",
"payment_channel": channel.channel, "payment_channel": payment_channel,
"display_name": channel.display_name, "display_name": display_name,
"pay_currency": config.pay_currency, "pay_currency": config.pay_currency,
"usd_exchange_rate": config.usd_exchange_rate, "usd_exchange_rate": config.usd_exchange_rate,
"min_recharge_usd": config.min_recharge_usd, "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,
})); }));
} }
} }

View File

@@ -14,6 +14,12 @@ use serde::Deserialize;
use serde_json::json; use serde_json::json;
use uuid::Uuid; 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)] #[derive(Debug, Deserialize)]
struct WalletCreateRefundRequest { struct WalletCreateRefundRequest {
amount_usd: f64, 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() 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( fn wallet_refund_payload_from_record(
record: &aether_data::repository::wallet::StoredAdminWalletRefund, record: &aether_data::repository::wallet::StoredAdminWalletRefund,
) -> serde_json::Value { ) -> 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() { if !state.has_database_wallet_data_writer() {
#[cfg(test)] #[cfg(test)]
{ {

View File

@@ -5,6 +5,8 @@ mod email_templates;
mod external_models; mod external_models;
mod normalize; mod normalize;
mod payloads; mod payloads;
mod payment_direct;
mod payment_gateway_config;
pub(crate) mod provider_pool; pub(crate) mod provider_pool;
mod request_utils; mod request_utils;
mod system_config_values; mod system_config_values;
@@ -41,6 +43,18 @@ pub(crate) use self::payloads::{
InternalGatewayAuthContextRequest, InternalGatewayExecuteRequest, InternalGatewayAuthContextRequest, InternalGatewayExecuteRequest,
InternalGatewayResolveRequest, InternalTunnelHeartbeatRequest, InternalTunnelNodeStatusRequest, 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::{ pub(crate) use self::request_utils::{
admin_proxy_local_requires_buffered_body, internal_proxy_local_requires_buffered_body, admin_proxy_local_requires_buffered_body, internal_proxy_local_requires_buffered_body,
json_string_list, local_proxy_route_requires_buffered_body, json_string_list, local_proxy_route_requires_buffered_body,

File diff suppressed because it is too large Load Diff

View File

@@ -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,
}
}

View File

@@ -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::POST, Some("create_plan"))
| (Some("billing_manage"), http::Method::PUT, Some("update_plan")) | (Some("billing_manage"), http::Method::PUT, Some("update_plan"))
| (Some("billing_manage"), http::Method::PATCH, Some("set_plan_status")) | (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("credit_order"))
| (Some("payments_manage"), http::Method::POST, Some("create_redeem_code_batch")) | (Some("payments_manage"), http::Method::POST, Some("create_redeem_code_batch"))
| (Some("payments_manage"), http::Method::POST, Some("delete_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"), Some("payment_callback"),
http::Method::POST, http::Method::POST,
Some("callback" | "epay_notify" | "epay_return"), Some(
"callback"
| "epay_notify"
| "epay_return"
| "alipay_notify"
| "wxpay_notify"
| "stripe_webhook",
),
) )
) )
}) })

View File

@@ -144,9 +144,51 @@ impl AppState {
refund_id: &str, refund_id: &str,
) -> Result<Option<aether_data::repository::wallet::StoredAdminWalletRefund>, GatewayError> ) -> 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 self.data
.find_wallet_refund(wallet_id, refund_id) .find_wallet_refund(wallet_id, refund_id)
.await .await
.map_err(|err| GatewayError::Internal(err.to_string())) .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,
}
}

View File

@@ -14,7 +14,7 @@ pub enum SchemaError {
#[error("failed to parse schema file {path}: {source}")] #[error("failed to parse schema file {path}: {source}")]
Parse { Parse {
path: PathBuf, path: PathBuf,
source: toml::de::Error, source: Box<toml::de::Error>,
}, },
#[error("failed to write generated schema file {path}: {source}")] #[error("failed to write generated schema file {path}: {source}")]
Write { Write {
@@ -221,7 +221,7 @@ pub fn load_schema_sources(root: impl AsRef<Path>) -> Result<LoadedSchema, Schem
})?; })?;
let file: SchemaFile = toml::from_str(&text).map_err(|source| SchemaError::Parse { let file: SchemaFile = toml::from_str(&text).map_err(|source| SchemaError::Parse {
path: path.clone(), path: path.clone(),
source, source: Box::new(source),
})?; })?;
let mut source_tables = Vec::new(); let mut source_tables = Vec::new();
for (name, table) in file.table { for (name, table) in file.table {

View File

@@ -8,6 +8,7 @@
"name": "frontend", "name": "frontend",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@stripe/stripe-js": "^9.6.0",
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
"@types/marked": "^5.0.2", "@types/marked": "^5.0.2",
"@types/three": "^0.180.0", "@types/three": "^0.180.0",
@@ -1574,6 +1575,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@stripe/stripe-js": {
"version": "9.6.0",
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-9.6.0.tgz",
"integrity": "sha512-v5MebYvJbddSRn15fknxTVwypJPzjeIXI1Q2HBxCBrQieWna8PC+RXEVUZ3F4ANAeILEj97HzFlr0r7CXvG7ZA==",
"license": "MIT",
"engines": {
"node": ">=12.16"
}
},
"node_modules/@swc/helpers": { "node_modules/@swc/helpers": {
"version": "0.5.17", "version": "0.5.17",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",

View File

@@ -16,6 +16,7 @@
"version": "git describe --tags --always" "version": "git describe --tags --always"
}, },
"dependencies": { "dependencies": {
"@stripe/stripe-js": "^9.6.0",
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
"@types/marked": "^5.0.2", "@types/marked": "^5.0.2",
"@types/three": "^0.180.0", "@types/three": "^0.180.0",

View File

@@ -8,33 +8,44 @@ export type WalletCreditBucket = 'recharge' | 'gift'
export interface EpayChannelConfig { export interface EpayChannelConfig {
channel: string channel: string
display_name: string display_name: string
fee_rate?: number
} }
export interface EpayGatewayConfig { export interface EpayGatewayConfig {
provider: 'epay' provider: PaymentGatewayProvider
enabled: boolean enabled: boolean
endpoint_url?: string | null endpoint_url?: string | null
callback_base_url?: string | null callback_base_url?: string | null
merchant_id?: string | null merchant_id?: string | null
has_secret: boolean has_secret: boolean
has_secret_keys?: string[]
pay_currency?: string | null pay_currency?: string | null
usd_exchange_rate?: number | null usd_exchange_rate?: number | null
min_recharge_usd?: number | null min_recharge_usd?: number | null
channels?: EpayChannelConfig[] channels?: EpayChannelConfig[]
refund_enabled?: boolean
allow_user_refund?: boolean
config?: Record<string, unknown>
created_at?: number | null created_at?: number | null
updated_at?: number | null updated_at?: number | null
} }
export type PaymentGatewayProvider = 'epay' | 'alipay' | 'wxpay' | 'stripe'
export interface UpdateEpayGatewayConfigRequest { export interface UpdateEpayGatewayConfigRequest {
enabled: boolean enabled: boolean
endpoint_url: string endpoint_url?: string
callback_base_url?: string | null callback_base_url?: string | null
merchant_id: string merchant_id?: string
merchant_key?: string merchant_key?: string
pay_currency: string pay_currency: string
usd_exchange_rate: number usd_exchange_rate: number
min_recharge_usd: number min_recharge_usd: number
channels: EpayChannelConfig[] channels: EpayChannelConfig[]
refund_enabled?: boolean
allow_user_refund?: boolean
config?: Record<string, unknown>
secrets?: Record<string, string>
} }
export interface GatewayTestResponse { export interface GatewayTestResponse {
@@ -142,9 +153,11 @@ function normalizeChannels(channels: EpayGatewayConfig['channels']): EpayChannel
.map((item) => { .map((item) => {
const raw = item as EpayChannelConfig & { type?: string } const raw = item as EpayChannelConfig & { type?: string }
const channel = String(raw.channel || raw.type || '').trim() const channel = String(raw.channel || raw.type || '').trim()
const feeRate = Number(raw.fee_rate ?? 0)
return { return {
channel, channel,
display_name: String(raw.display_name || channel).trim(), display_name: String(raw.display_name || channel).trim(),
fee_rate: Number.isFinite(feeRate) && feeRate >= 0 ? feeRate : 0,
} }
}) })
.filter((item) => item.channel && item.display_name) .filter((item) => item.channel && item.display_name)
@@ -152,39 +165,49 @@ function normalizeChannels(channels: EpayGatewayConfig['channels']): EpayChannel
} }
function normalizeGatewayConfig(config: EpayGatewayConfig): EpayGatewayConfig { function normalizeGatewayConfig(config: EpayGatewayConfig): EpayGatewayConfig {
const refundEnabled = Boolean(config.refund_enabled)
return { return {
provider: 'epay', provider: config.provider || 'epay',
enabled: Boolean(config.enabled), enabled: Boolean(config.enabled),
endpoint_url: config.endpoint_url ?? '', endpoint_url: config.endpoint_url ?? '',
callback_base_url: config.callback_base_url ?? '', callback_base_url: config.callback_base_url ?? '',
merchant_id: config.merchant_id ?? '', merchant_id: config.merchant_id ?? '',
has_secret: Boolean(config.has_secret), has_secret: Boolean(config.has_secret),
has_secret_keys: Array.isArray(config.has_secret_keys) ? config.has_secret_keys : [],
pay_currency: config.pay_currency ?? 'CNY', pay_currency: config.pay_currency ?? 'CNY',
usd_exchange_rate: Number(config.usd_exchange_rate ?? 7.2), usd_exchange_rate: Number(config.usd_exchange_rate ?? 7.2),
min_recharge_usd: Number(config.min_recharge_usd ?? 1), min_recharge_usd: Number(config.min_recharge_usd ?? 1),
channels: normalizeChannels(config.channels), channels: normalizeChannels(config.channels),
refund_enabled: refundEnabled,
allow_user_refund: refundEnabled && Boolean(config.allow_user_refund),
config: config.config && typeof config.config === 'object' ? config.config : {},
created_at: config.created_at ?? null, created_at: config.created_at ?? null,
updated_at: config.updated_at ?? null, updated_at: config.updated_at ?? null,
} }
} }
export const epayGatewayApi = { export const epayGatewayApi = {
async get(): Promise<EpayGatewayConfig> { async get(provider: PaymentGatewayProvider = 'epay'): Promise<EpayGatewayConfig> {
const response = await apiClient.get<EpayGatewayConfig>('/api/admin/payments/gateways/epay') const response = await apiClient.get<EpayGatewayConfig>(`/api/admin/payments/gateways/${provider}`)
return normalizeGatewayConfig(response.data) return normalizeGatewayConfig(response.data)
}, },
async update(payload: UpdateEpayGatewayConfigRequest): Promise<EpayGatewayConfig> { async update(
payload: UpdateEpayGatewayConfigRequest,
provider: PaymentGatewayProvider = 'epay'
): Promise<EpayGatewayConfig> {
const request: UpdateEpayGatewayConfigRequest = { const request: UpdateEpayGatewayConfigRequest = {
...payload, ...payload,
channels: normalizeChannels(payload.channels), channels: normalizeChannels(payload.channels),
refund_enabled: Boolean(payload.refund_enabled),
allow_user_refund: Boolean(payload.refund_enabled && payload.allow_user_refund),
} }
const response = await apiClient.put<EpayGatewayConfig>('/api/admin/payments/gateways/epay', request) const response = await apiClient.put<EpayGatewayConfig>(`/api/admin/payments/gateways/${provider}`, request)
return normalizeGatewayConfig(response.data) return normalizeGatewayConfig(response.data)
}, },
async test(): Promise<GatewayTestResponse> { async test(provider: PaymentGatewayProvider = 'epay'): Promise<GatewayTestResponse> {
const response = await apiClient.post<GatewayTestResponse>('/api/admin/payments/gateways/epay/test', {}) const response = await apiClient.post<GatewayTestResponse>(`/api/admin/payments/gateways/${provider}/test`, {})
return response.data return response.data
}, },
} }

View File

@@ -171,6 +171,7 @@ export interface WalletRechargeOption {
pay_currency?: string pay_currency?: string
usd_exchange_rate?: number usd_exchange_rate?: number
min_recharge_usd?: number min_recharge_usd?: number
fee_rate?: number
} }
export interface WalletRefundCreateRequest { export interface WalletRefundCreateRequest {
@@ -183,6 +184,10 @@ export interface WalletRefundCreateRequest {
idempotency_key?: string idempotency_key?: string
} }
export interface WalletRefundEligibilityResponse {
payment_methods: string[]
}
export interface WalletRedeemRequest { export interface WalletRedeemRequest {
code: string code: string
} }
@@ -258,6 +263,11 @@ export const walletApi = {
return response.data return response.data
}, },
async listRefundEligibleProviders(): Promise<WalletRefundEligibilityResponse> {
const response = await apiClient.get<WalletRefundEligibilityResponse>('/api/wallet/refunds/eligible-providers')
return response.data
},
async createRefund(payload: WalletRefundCreateRequest): Promise<RefundRequest> { async createRefund(payload: WalletRefundCreateRequest): Promise<RefundRequest> {
const response = await apiClient.post<RefundRequest>('/api/wallet/refunds', payload) const response = await apiClient.post<RefundRequest>('/api/wallet/refunds', payload)
return response.data return response.data

View File

@@ -0,0 +1,394 @@
<template>
<Dialog
v-model:open="dialogOpen"
max-width="2xl"
:persistent="initializing || submitting"
:close-on-backdrop="!(initializing || submitting)"
>
<template #header>
<DialogHeader>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<DialogTitle>
{{ title }}
</DialogTitle>
<DialogDescription>
{{ description }}
</DialogDescription>
</div>
<Badge
variant="outline"
class="shrink-0"
>
Stripe
</Badge>
</div>
</DialogHeader>
</template>
<div class="space-y-4">
<div class="rounded-xl border border-border/60 bg-muted/20 p-4">
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="space-y-1">
<div class="text-xs text-muted-foreground">
PaymentIntent
</div>
<div class="break-all font-mono text-sm text-foreground">
{{ stripeIntentId || stripeGatewayOrderId || '-' }}
</div>
</div>
<div class="space-y-1">
<div class="text-xs text-muted-foreground">
支付方式
</div>
<div class="text-sm text-foreground">
{{ stripeDisplayName }}
</div>
</div>
<div class="space-y-1">
<div class="text-xs text-muted-foreground">
应付金额
</div>
<div class="text-sm text-foreground">
{{ stripeAmountLabel }}
</div>
</div>
<div class="space-y-1">
<div class="text-xs text-muted-foreground">
支付通道
</div>
<div class="flex flex-wrap gap-1.5">
<Badge
v-for="method in stripePaymentMethodTypes"
:key="method"
variant="outline"
>
{{ paymentMethodTypeLabel(method) }}
</Badge>
<span
v-if="stripePaymentMethodTypes.length === 0"
class="text-sm text-muted-foreground"
>
-
</span>
</div>
</div>
</div>
</div>
<div class="relative rounded-xl border border-border/60 bg-background p-3">
<div
ref="paymentElementRoot"
class="min-h-[360px]"
:class="{ 'pointer-events-none opacity-20': initializing }"
/>
<div
v-if="initializing"
class="absolute inset-3 flex items-center justify-center gap-2 rounded-lg bg-background/80 text-sm text-muted-foreground backdrop-blur-sm"
>
<Loader2 class="h-4 w-4 animate-spin" />
正在加载 Stripe 支付组件...
</div>
</div>
<div
v-if="errorMessage"
class="rounded-xl border border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-700 dark:text-rose-300"
>
{{ errorMessage }}
</div>
</div>
<template #footer>
<Button
variant="outline"
:disabled="initializing || submitting"
@click="closeDialog"
>
关闭
</Button>
<Button
:disabled="!canSubmit"
@click="submitPayment"
>
<Loader2
v-if="submitting"
class="mr-2 h-4 w-4 animate-spin"
/>
{{ submitting ? '支付中...' : confirmText }}
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { loadStripe, type Stripe, type StripeElements, type StripePaymentElement } from '@stripe/stripe-js'
import { Loader2 } from 'lucide-vue-next'
import { Badge, Button, Dialog, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui'
import {
getStripePaymentInstructions,
type PaymentInstructionMap,
} from '@/utils/paymentInstructions'
import { paymentMethodLabel } from '@/utils/walletDisplay'
interface Props {
open: boolean
instructions: PaymentInstructionMap | null
title?: string
description?: string
confirmText?: string
returnUrl?: string
}
const props = withDefaults(defineProps<Props>(), {
title: 'Stripe 支付',
description: '请在弹窗内完成支付,成功后系统会自动入账。',
confirmText: '确认支付',
returnUrl: '',
})
const emit = defineEmits<{
'update:open': [value: boolean]
success: [payload: { intentId: string; status?: string | null }]
}>()
const dialogOpen = computed({
get: () => props.open,
set: value => emit('update:open', value),
})
const paymentElementRoot = ref<HTMLDivElement | null>(null)
const stripeInstance = ref<Stripe | null>(null)
const elementsInstance = ref<StripeElements | null>(null)
const paymentElement = ref<StripePaymentElement | null>(null)
const initializing = ref(false)
const submitting = ref(false)
const errorMessage = ref('')
const mountedSignature = ref('')
let mountSequence = 0
const stripeInstructions = computed(() => getStripePaymentInstructions(props.instructions))
const stripeIntentId = computed(() => stripeInstructions.value?.intentId || '')
const stripeGatewayOrderId = computed(() => stripeInstructions.value?.gatewayOrderId || '')
const stripeDisplayName = computed(() => stripeInstructions.value?.displayName || 'Stripe')
const stripePaymentMethodTypes = computed(() => stripeInstructions.value?.paymentMethodTypes || [])
const stripeAmountLabel = computed(() => {
const amount = stripeInstructions.value?.payAmount
if (typeof amount !== 'number' || !Number.isFinite(amount)) {
return '-'
}
const currency = stripeInstructions.value?.payCurrency || ''
return `${amount.toFixed(2)}${currency ? ` ${currency}` : ''}`
})
const canSubmit = computed(() =>
Boolean(
stripeInstance.value
&& elementsInstance.value
&& stripeInstructions.value
&& !initializing.value
&& !submitting.value
)
)
const stripeLoaderCache = new Map<string, Promise<Stripe | null>>()
watch(
[
() => props.open,
() => stripeInstructions.value?.clientSecret || '',
() => stripeInstructions.value?.publishableKey || '',
],
async ([open]) => {
if (!open) {
cleanupStripe()
return
}
await initializeStripe()
},
{ immediate: true }
)
onBeforeUnmount(() => {
cleanupStripe()
})
async function initializeStripe() {
const instructions = stripeInstructions.value
if (!props.open) return
if (!instructions) {
errorMessage.value = '缺少 Stripe 支付参数'
return
}
const signature = [
instructions.publishableKey,
instructions.clientSecret,
instructions.intentId,
instructions.paymentChannel,
].join('::')
if (mountedSignature.value === signature && stripeInstance.value && elementsInstance.value && paymentElement.value) {
return
}
cleanupStripe()
if (!props.open) return
initializing.value = true
errorMessage.value = ''
const sequence = ++mountSequence
try {
await nextTick()
if (!paymentElementRoot.value) {
throw new Error('支付容器未准备好')
}
const stripe = await loadStripeCached(instructions.publishableKey)
if (!stripe) {
throw new Error('Stripe 初始化失败')
}
if (sequence !== mountSequence) return
const elements = stripe.elements({
clientSecret: instructions.clientSecret,
})
const element = elements.create('payment', {
layout: 'tabs',
})
element.mount(paymentElementRoot.value)
stripeInstance.value = stripe
elementsInstance.value = elements
paymentElement.value = element
mountedSignature.value = signature
} catch (error) {
if (sequence !== mountSequence) return
errorMessage.value = formatStripeError(error)
} finally {
if (sequence === mountSequence) {
initializing.value = false
}
}
}
async function submitPayment() {
const instructions = stripeInstructions.value
if (!instructions || !stripeInstance.value || !elementsInstance.value) {
errorMessage.value = 'Stripe 支付组件未就绪'
return
}
submitting.value = true
errorMessage.value = ''
try {
const submitResult = await elementsInstance.value.submit()
if (submitResult.error) {
errorMessage.value = submitResult.error.message || '请检查支付信息'
return
}
const { error, paymentIntent } = await stripeInstance.value.confirmPayment({
elements: elementsInstance.value,
confirmParams: {
return_url: props.returnUrl || buildReturnUrl(),
},
redirect: 'if_required',
})
if (error) {
errorMessage.value = error.message || '支付失败'
return
}
const intentId = paymentIntent?.id || instructions.intentId || instructions.gatewayOrderId || ''
if (paymentIntent?.status === 'succeeded' || paymentIntent?.status === 'processing') {
emit('success', {
intentId,
status: paymentIntent.status,
})
emit('update:open', false)
return
}
errorMessage.value = paymentIntent?.status
? `当前支付状态: ${paymentIntent.status}`
: '支付已提交,请稍后刷新订单状态'
} catch (error) {
errorMessage.value = formatStripeError(error)
} finally {
submitting.value = false
}
}
function closeDialog() {
emit('update:open', false)
}
function cleanupStripe() {
mountSequence += 1
initializing.value = false
submitting.value = false
errorMessage.value = ''
mountedSignature.value = ''
try {
paymentElement.value?.unmount()
} catch {
// ignore
}
paymentElement.value = null
elementsInstance.value = null
stripeInstance.value = null
if (paymentElementRoot.value) {
paymentElementRoot.value.innerHTML = ''
}
}
async function loadStripeCached(publishableKey: string): Promise<Stripe | null> {
if (!stripeLoaderCache.has(publishableKey)) {
stripeLoaderCache.set(publishableKey, loadStripe(publishableKey))
}
return stripeLoaderCache.get(publishableKey) || null
}
function formatStripeError(error: unknown): string {
if (error instanceof Error && error.message) {
return error.message
}
if (typeof error === 'string' && error.trim()) {
return error.trim()
}
if (typeof error === 'object' && error && 'message' in error) {
const message = (error as { message?: unknown }).message
if (typeof message === 'string' && message.trim()) {
return message.trim()
}
}
return 'Stripe 支付处理失败'
}
function paymentMethodTypeLabel(method: string): string {
const labels: Record<string, string> = {
card: '银行卡/信用卡',
alipay: '支付宝',
wechat_pay: '微信支付',
link: 'Link',
us_bank_account: '美国银行账户',
}
return labels[method] || paymentMethodLabel(method) || method
}
function buildReturnUrl(): string {
if (typeof window === 'undefined') return ''
const url = new URL(window.location.href)
url.hash = ''
url.searchParams.set('stripe_return', '1')
return url.toString()
}
</script>

View File

@@ -7,6 +7,7 @@
export { default as EmptyState } from './EmptyState.vue' export { default as EmptyState } from './EmptyState.vue'
export { default as AlertDialog } from './AlertDialog.vue' export { default as AlertDialog } from './AlertDialog.vue'
export { default as LoadingState } from './LoadingState.vue' export { default as LoadingState } from './LoadingState.vue'
export { default as StripePaymentDialog } from './StripePaymentDialog.vue'
// 表单组件 // 表单组件
export { default as MultiSelect } from './MultiSelect.vue' export { default as MultiSelect } from './MultiSelect.vue'

View File

@@ -0,0 +1,99 @@
export type PaymentInstructionMap = Record<string, unknown>
export interface StripePaymentInstructions {
gateway: string
displayName: string
gatewayOrderId: string
intentId: string
clientSecret: string
publishableKey: string
expiresAt: string
payAmount: number | null
payCurrency: string
paymentChannel: string
paymentMethodTypes: string[]
submitMethod: string
}
function getInstructionValue(
instructions: PaymentInstructionMap | null | undefined,
key: string
): unknown {
if (!instructions || typeof instructions !== 'object' || Array.isArray(instructions)) {
return undefined
}
return instructions[key]
}
export function getPaymentInstructionString(
instructions: PaymentInstructionMap | null | undefined,
key: string
): string {
const value = getInstructionValue(instructions, key)
return typeof value === 'string' ? value.trim() : ''
}
export function getPaymentInstructionNumber(
instructions: PaymentInstructionMap | null | undefined,
key: string
): number | null {
const value = getInstructionValue(instructions, key)
const parsed = typeof value === 'number' ? value : Number(value)
return Number.isFinite(parsed) ? parsed : null
}
export function getPaymentInstructionStringArray(
instructions: PaymentInstructionMap | null | undefined,
key: string
): string[] {
const value = getInstructionValue(instructions, key)
if (Array.isArray(value)) {
return value
.map(item => (typeof item === 'string' ? item.trim() : ''))
.filter(Boolean)
}
if (typeof value === 'string') {
return value
.split(',')
.map(item => item.trim())
.filter(Boolean)
}
return []
}
export function getStripePaymentInstructions(
instructions: PaymentInstructionMap | null | undefined
): StripePaymentInstructions | null {
const clientSecret = getPaymentInstructionString(instructions, 'client_secret')
const publishableKey = getPaymentInstructionString(instructions, 'publishable_key')
if (!clientSecret || !publishableKey) {
return null
}
const intentId = getPaymentInstructionString(instructions, 'intent_id')
|| getPaymentInstructionString(instructions, 'gateway_order_id')
const gatewayOrderId = getPaymentInstructionString(instructions, 'gateway_order_id')
|| intentId
const paymentChannel = getPaymentInstructionString(instructions, 'payment_channel')
return {
gateway: getPaymentInstructionString(instructions, 'gateway') || 'stripe',
displayName: getPaymentInstructionString(instructions, 'display_name') || paymentChannel || 'Stripe',
gatewayOrderId,
intentId,
clientSecret,
publishableKey,
expiresAt: getPaymentInstructionString(instructions, 'expires_at'),
payAmount: getPaymentInstructionNumber(instructions, 'pay_amount'),
payCurrency: getPaymentInstructionString(instructions, 'pay_currency'),
paymentChannel,
paymentMethodTypes: getPaymentInstructionStringArray(instructions, 'payment_method_types'),
submitMethod: getPaymentInstructionString(instructions, 'submit_method') || 'stripe_payment_intent',
}
}
export function hasStripePaymentInstructions(
instructions: PaymentInstructionMap | null | undefined
): boolean {
return getStripePaymentInstructions(instructions) !== null
}

View File

@@ -72,7 +72,11 @@ export function paymentMethodLabel(method: string | null | undefined): string {
alipay: '支付宝', alipay: '支付宝',
wechat: '微信支付', wechat: '微信支付',
wxpay: '微信支付', wxpay: '微信支付',
wechat_pay: '微信支付',
epay: '易支付', epay: '易支付',
stripe: 'Stripe',
card: '银行卡/信用卡',
link: 'Stripe Link',
admin_manual: '人工充值', admin_manual: '人工充值',
card_code: '充值卡', card_code: '充值卡',
gift_code: '礼品卡', gift_code: '礼品卡',

View File

@@ -2,7 +2,7 @@
<PageContainer> <PageContainer>
<PageHeader <PageHeader
title="支付配置" title="支付配置"
description="配置易支付商户、回调地址、汇率和可用通道" description="配置易支付、支付宝官方、微信支付官方和 Stripe"
> >
<template #actions> <template #actions>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -28,6 +28,20 @@
</PageHeader> </PageHeader>
<div class="mt-6 space-y-6"> <div class="mt-6 space-y-6">
<Card class="p-4">
<div class="grid grid-cols-2 gap-2 md:grid-cols-4">
<Button
v-for="provider in providers"
:key="provider.key"
:variant="activeProvider === provider.key ? 'default' : 'outline'"
size="sm"
@click="selectProvider(provider.key)"
>
{{ provider.label }}
</Button>
</div>
</Card>
<div <div
v-if="loading" v-if="loading"
class="py-16" class="py-16"
@@ -36,7 +50,7 @@
</div> </div>
<template v-else> <template v-else>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-5">
<Card class="p-5"> <Card class="p-5">
<div class="text-xs uppercase tracking-wider text-muted-foreground"> <div class="text-xs uppercase tracking-wider text-muted-foreground">
网关状态 网关状态
@@ -50,7 +64,36 @@
</Card> </Card>
<Card class="p-5"> <Card class="p-5">
<div class="text-xs uppercase tracking-wider text-muted-foreground"> <div class="text-xs uppercase tracking-wider text-muted-foreground">
商户密钥 退款能力
</div>
<div class="mt-3 flex items-center gap-3">
<Badge :variant="form.refund_enabled ? 'success' : 'secondary'">
{{ form.refund_enabled ? '允许退款' : '关闭退款' }}
</Badge>
<Switch
:model-value="form.refund_enabled"
@update:model-value="setRefundEnabled"
/>
</div>
</Card>
<Card class="p-5">
<div class="text-xs uppercase tracking-wider text-muted-foreground">
用户退款
</div>
<div class="mt-3 flex items-center gap-3">
<Badge :variant="form.allow_user_refund && form.refund_enabled ? 'success' : 'secondary'">
{{ form.allow_user_refund && form.refund_enabled ? '允许用户退款' : '关闭用户退款' }}
</Badge>
<Switch
:model-value="form.allow_user_refund"
:disabled="!form.refund_enabled"
@update:model-value="setAllowUserRefund"
/>
</div>
</Card>
<Card class="p-5">
<div class="text-xs uppercase tracking-wider text-muted-foreground">
密钥状态
</div> </div>
<div class="mt-3"> <div class="mt-3">
<Badge :variant="hasSecret ? 'success' : 'warning'"> <Badge :variant="hasSecret ? 'success' : 'warning'">
@@ -68,54 +111,197 @@
</Card> </Card>
</div> </div>
<CardSection <CardSection>
title="易支付商户" <template #header>
description="密钥留空会保留原密钥;回调地址留空时后端会使用当前 API 访问地址,生产环境建议显式填写公网根地址" <div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
<h3 class="text-lg font-medium leading-6 text-foreground">
{{ activeProviderMeta.label }}商户
</h3>
<div
v-if="activeProvider === 'alipay'"
ref="paymentHelpRef"
class="relative inline-flex"
> >
<button
type="button"
class="inline-flex h-6 w-6 cursor-pointer list-none items-center justify-center rounded-full border border-border/70 bg-background/60 text-muted-foreground transition hover:border-primary/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 [&::-webkit-details-marker]:hidden"
title="支付宝支付模式说明"
aria-label="支付宝支付模式说明"
:aria-expanded="paymentHelpOpen === 'alipay'"
aria-controls="alipay-payment-help"
@click.stop="togglePaymentHelp('alipay')"
>
<CircleHelp class="h-3.5 w-3.5" />
</button>
<div
v-if="paymentHelpOpen === 'alipay'"
id="alipay-payment-help"
class="absolute left-0 top-full z-[240] mt-2 w-[320px] max-w-[calc(100vw-2rem)] overflow-hidden rounded-xl border border-border/60 bg-card/95 p-0 text-card-foreground shadow-xl shadow-black/5 backdrop-blur supports-[backdrop-filter]:bg-card/90"
role="dialog"
aria-label="支付宝支付模式说明"
>
<div class="space-y-4 p-4 text-xs leading-6">
<p class="font-medium text-foreground">
桌面优先扫码单失败再走收银台移动优先手机网站支付
</p>
<div class="border-t border-border/60 pt-3">
<h4 class="mb-1 font-semibold text-foreground">
当面付 / 扫码支付
</h4>
<p>开通需开通当面付或扫码支付能力</p>
<p>
调用桌面端下单时优先调用 <code class="rounded bg-muted px-1 py-0.5 text-foreground">alipay.trade.precreate</code>前台直接渲染二维码
</p>
<p>降级接口不可用或返回失败时自动降级到电脑网站支付</p>
</div>
<div class="border-t border-border/60 pt-3">
<h4 class="mb-1 font-semibold text-foreground">
电脑网站支付
</h4>
<p>开通需开通电脑网站支付</p>
<p>
调用桌面端当面付不可用时调用 <code class="rounded bg-muted px-1 py-0.5 text-foreground">alipay.trade.page.pay</code>并继续以返回链接渲染成二维码
</p>
<p>降级同时保留打开收银台入口用户可手动重新拉起支付页</p>
</div>
<div class="border-t border-border/60 pt-3">
<h4 class="mb-1 font-semibold text-foreground">
手机网站支付
</h4>
<p>开通需开通手机网站支付</p>
<p>
调用移动端优先调用 <code class="rounded bg-muted px-1 py-0.5 text-foreground">alipay.trade.wap.pay</code>跳转支付宝收银台
</p>
<p>降级未开通或返回异常时前端自动改走扫码支付并提示未开通移动支付</p>
</div>
</div>
</div>
</div>
<div
v-else-if="activeProvider === 'wxpay'"
ref="paymentHelpRef"
class="relative inline-flex"
>
<button
type="button"
class="inline-flex h-6 w-6 cursor-pointer list-none items-center justify-center rounded-full border border-border/70 bg-background/60 text-muted-foreground transition hover:border-primary/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 [&::-webkit-details-marker]:hidden"
title="微信支付模式说明"
aria-label="微信支付模式说明"
:aria-expanded="paymentHelpOpen === 'wxpay'"
aria-controls="wxpay-payment-help"
@click.stop="togglePaymentHelp('wxpay')"
>
<CircleHelp class="h-3.5 w-3.5" />
</button>
<div
v-if="paymentHelpOpen === 'wxpay'"
id="wxpay-payment-help"
class="absolute left-0 top-full z-[240] mt-2 w-[340px] max-w-[calc(100vw-2rem)] overflow-hidden rounded-xl border border-border/60 bg-card/95 p-0 text-card-foreground shadow-xl shadow-black/5 backdrop-blur supports-[backdrop-filter]:bg-card/90"
role="dialog"
aria-label="微信支付模式说明"
>
<div class="space-y-4 p-4 text-xs leading-6">
<p class="font-medium text-foreground">
桌面优先 Native 扫码移动端微信浏览器优先 JSAPI非微信浏览器兜底 H5
</p>
<div class="border-t border-border/60 pt-3">
<h4 class="mb-1 font-semibold text-foreground">
Native / 扫码支付
</h4>
<p>开通需开通 Native 或扫码支付能力</p>
<p>
调用桌面端默认调用 Native前台会渲染二维码内容
</p>
<p>降级移动端无法走 JSAPI H5 也会自动回退到这里</p>
</div>
<div class="border-t border-border/60 pt-3">
<h4 class="mb-1 font-semibold text-foreground">
JSAPI / 公众号支付
</h4>
<p>开通需开通公众号支付并保证当前浏览器在微信内且能拿到 OpenID</p>
<p>
调用微信内浏览器完成授权后调用 JSAPI直接拉起微信收银台
</p>
<p>降级未配置或拉起失败时自动改走扫码支付</p>
</div>
<div class="border-t border-border/60 pt-3">
<h4 class="mb-1 font-semibold text-foreground">
H5 支付
</h4>
<p>开通需开通 H5 支付</p>
<p>
调用移动端非微信浏览器且客户端 IP 可用时调用 H5跳转微信收银台
</p>
<p>降级未开通 H5 或下单失败时自动改走扫码支付</p>
</div>
<p class="border-t border-border/60 pt-3">
当前表单默认共用一个 App ID适合同主体下统一配置网页移动和公众号场景
</p>
</div>
</div>
</div>
</div>
<p class="mt-1 text-sm text-muted-foreground">
{{ activeProviderMeta.description }}
</p>
</div>
</div>
</template>
<div class="grid grid-cols-1 gap-5 md:grid-cols-2"> <div class="grid grid-cols-1 gap-5 md:grid-cols-2">
<div class="space-y-1.5"> <div
<Label for="epay-endpoint">易支付接口地址</Label> v-if="activeProvider === 'epay'"
class="space-y-1.5"
>
<Label for="gateway-endpoint">易支付接口地址</Label>
<Input <Input
id="epay-endpoint" id="gateway-endpoint"
v-model="form.endpoint_url" v-model="form.endpoint_url"
placeholder="https://pay.example.com/submit.php" placeholder="https://pay.example.com/submit.php"
/> />
</div> </div>
<div class="space-y-1.5"> <div
<Label for="epay-callback-base">回调站点根地址</Label> v-if="activeProvider !== 'stripe'"
class="space-y-1.5"
>
<Label for="gateway-callback-base">回调站点根地址</Label>
<Input <Input
id="epay-callback-base" id="gateway-callback-base"
v-model="form.callback_base_url" v-model="form.callback_base_url"
:placeholder="defaultCallbackBaseUrl || 'https://aether.example.com'" :placeholder="defaultCallbackBaseUrl || 'https://aether.example.com'"
/> />
<p class="text-xs text-muted-foreground">
留空保存为空配置下单时默认使用当前 API 地址或 AETHER_PUBLIC_BASE_URL
</p>
</div> </div>
<div class="space-y-1.5"> <div
<Label for="epay-merchant-id">商户 ID</Label> v-for="field in visibleFields"
<Input :key="field.key"
id="epay-merchant-id" class="space-y-1.5"
v-model="form.merchant_id" >
placeholder="1000" <Label :for="`gateway-field-${field.key}`">
autocomplete="off" {{ field.label }}
/> <span
</div> v-if="field.secret && hasSecretKey(field.key)"
class="text-xs font-normal text-muted-foreground"
<div class="space-y-1.5"> >
<Label for="epay-merchant-key"> 留空保持不变
商户密钥
<span class="text-xs font-normal text-muted-foreground">
{{ hasSecret ? '(留空保持不变)' : '' }}
</span> </span>
</Label> </Label>
<Input <Input
id="epay-merchant-key" :id="`gateway-field-${field.key}`"
v-model="form.merchant_key" v-model="fieldValues[field.key]"
masked :masked="field.secret"
:placeholder="hasSecret ? '已设置,输入新密钥后覆盖' : '请输入商户密钥'" :placeholder="field.placeholder"
autocomplete="off"
/> />
</div> </div>
</div> </div>
@@ -123,22 +309,22 @@
<CardSection <CardSection
title="计费参数" title="计费参数"
description="用户充值按美元金额下单,易支付按这里的币种汇率收款" description="用户充值按美元金额下单,实际收款金额按这里的币种汇率和通道手续费率计算"
> >
<div class="grid grid-cols-1 gap-5 md:grid-cols-3"> <div class="grid grid-cols-1 gap-5 md:grid-cols-3">
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label for="epay-currency">支付币种</Label> <Label for="gateway-currency">支付币种</Label>
<Input <Input
id="epay-currency" id="gateway-currency"
v-model="form.pay_currency" v-model="form.pay_currency"
maxlength="16" maxlength="16"
placeholder="CNY" placeholder="CNY"
/> />
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label for="epay-rate">USD 汇率</Label> <Label for="gateway-rate">USD 汇率</Label>
<Input <Input
id="epay-rate" id="gateway-rate"
v-model.number="form.usd_exchange_rate" v-model.number="form.usd_exchange_rate"
type="number" type="number"
min="0.0001" min="0.0001"
@@ -146,9 +332,9 @@
/> />
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label for="epay-min">最低充值金额 (USD)</Label> <Label for="gateway-min">最低充值金额 (USD)</Label>
<Input <Input
id="epay-min" id="gateway-min"
v-model.number="form.min_recharge_usd" v-model.number="form.min_recharge_usd"
type="number" type="number"
min="0.01" min="0.01"
@@ -160,7 +346,7 @@
<CardSection <CardSection
title="支付通道" title="支付通道"
description="通道值会传给易支付 type 字段" :description="activeProvider === 'epay' ? '通道值会传给易支付 type 字段' : '通道值决定用户侧展示和后端创建订单模式'"
> >
<template #actions> <template #actions>
<Button <Button
@@ -177,24 +363,35 @@
<div <div
v-for="(channel, index) in form.channels" v-for="(channel, index) in form.channels"
:key="index" :key="index"
class="grid grid-cols-1 gap-3 rounded-lg border border-border/60 bg-muted/20 p-3 md:grid-cols-[1fr_1fr_auto]" class="grid grid-cols-1 gap-3 rounded-lg border border-border/60 bg-muted/20 p-3 md:grid-cols-[1fr_1fr_160px_auto]"
> >
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label :for="`epay-channel-${index}`">通道值</Label> <Label :for="`gateway-channel-${index}`">通道值</Label>
<Input <Input
:id="`epay-channel-${index}`" :id="`gateway-channel-${index}`"
v-model="channel.channel" v-model="channel.channel"
placeholder="alipay" placeholder="alipay"
/> />
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label :for="`epay-channel-name-${index}`">显示名称</Label> <Label :for="`gateway-channel-name-${index}`">显示名称</Label>
<Input <Input
:id="`epay-channel-name-${index}`" :id="`gateway-channel-name-${index}`"
v-model="channel.display_name" v-model="channel.display_name"
placeholder="支付宝" placeholder="支付宝"
/> />
</div> </div>
<div class="space-y-1.5">
<Label :for="`gateway-channel-fee-${index}`">手续费率 (%)</Label>
<Input
:id="`gateway-channel-fee-${index}`"
v-model.number="channel.fee_rate"
type="number"
min="0"
step="0.01"
placeholder="0"
/>
</div>
<div class="flex items-end"> <div class="flex items-end">
<Button <Button
variant="ghost" variant="ghost"
@@ -222,9 +419,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { PlugZap, Plus, Save, Trash2 } from 'lucide-vue-next' import { CircleHelp, PlugZap, Plus, Save, Trash2 } from 'lucide-vue-next'
import { epayGatewayApi, type EpayChannelConfig } from '@/api/billing' import { epayGatewayApi, type EpayChannelConfig, type PaymentGatewayProvider } from '@/api/billing'
import { import {
Badge, Badge,
Button, Button,
@@ -239,29 +436,112 @@ import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser' import { parseApiError } from '@/utils/errorParser'
import { log } from '@/utils/logger' import { log } from '@/utils/logger'
type ProviderField = {
key: string
label: string
secret?: boolean
placeholder?: string
}
const { success, error: showError } = useToast() const { success, error: showError } = useToast()
const providers: Array<{
key: PaymentGatewayProvider
label: string
description: string
fields: ProviderField[]
defaultChannels: EpayChannelConfig[]
}> = [
{
key: 'epay',
label: '易支付',
description: '密钥留空会保留原密钥;回调地址留空时后端会使用当前 API 访问地址',
fields: [
{ key: 'merchant_id', label: '商户 ID', placeholder: '1000' },
{ key: 'merchant_key', label: '商户密钥', secret: true, placeholder: '请输入商户密钥' },
],
defaultChannels: [
{ channel: 'alipay', display_name: '支付宝', fee_rate: 0 },
{ channel: 'wxpay', display_name: '微信支付', fee_rate: 0 },
],
},
{
key: 'alipay',
label: '支付宝官方',
description: '用于支付宝官方当面付、手机网站支付或电脑网站支付',
fields: [
{ key: 'app_id', label: 'App ID', placeholder: '202100...' },
{ key: 'payment_mode', label: '支付模式', placeholder: 'precreate / page / wap' },
{ key: 'private_key', label: '应用私钥', secret: true, placeholder: 'PKCS#1 或 PKCS#8 私钥' },
{ key: 'alipay_public_key', label: '支付宝公钥', secret: true, placeholder: '支付宝开放平台公钥' },
],
defaultChannels: [{ channel: 'alipay', display_name: '支付宝官方', fee_rate: 0 }],
},
{
key: 'wxpay',
label: '微信支付官方',
description: '用于微信支付 Native/H5JSAPI 还需要后续接入 OpenID 获取流程',
fields: [
{ key: 'app_id', label: 'App ID', placeholder: 'wx...' },
{ key: 'mch_id', label: '商户号', placeholder: '1900000000' },
{ key: 'cert_serial', label: '商户证书序列号', placeholder: '证书序列号' },
{ key: 'public_key_id', label: '微信支付公钥 ID', placeholder: 'PUB_KEY_ID_...' },
{ key: 'private_key', label: '商户 API 私钥', secret: true, placeholder: 'BEGIN PRIVATE KEY' },
{ key: 'api_v3_key', label: 'API v3 密钥', secret: true, placeholder: '32 位 API v3 key' },
{ key: 'public_key', label: '微信支付公钥', secret: true, placeholder: 'BEGIN PUBLIC KEY' },
],
defaultChannels: [
{ channel: 'native', display_name: '微信 Native', fee_rate: 0 },
{ channel: 'h5', display_name: '微信 H5', fee_rate: 0 },
],
},
{
key: 'stripe',
label: 'Stripe',
description: '用于 Stripe PaymentIntentWebhook Secret 用于回调验签',
fields: [
{ key: 'publishable_key', label: 'Publishable Key', placeholder: 'pk_live_...' },
{ key: 'secret_key', label: 'Secret Key', secret: true, placeholder: 'sk_live_...' },
{ key: 'webhook_secret', label: 'Webhook Secret', secret: true, placeholder: 'whsec_...' },
],
defaultChannels: [
{ channel: 'card', display_name: 'Card', fee_rate: 0 },
{ channel: 'alipay', display_name: 'Alipay', fee_rate: 0 },
{ channel: 'wechat_pay', display_name: 'WeChat Pay', fee_rate: 0 },
{ channel: 'link', display_name: 'Link', fee_rate: 0 },
],
},
]
const activeProvider = ref<PaymentGatewayProvider>('epay')
const loading = ref(true) const loading = ref(true)
const saving = ref(false) const saving = ref(false)
const testing = ref(false) const testing = ref(false)
const hasSecret = ref(false) const hasSecret = ref(false)
const hasSecretKeys = ref<string[]>([])
const updatedAt = ref<number | null>(null) const updatedAt = ref<number | null>(null)
const fieldValues = reactive<Record<string, string>>({})
const paymentHelpOpen = ref<PaymentGatewayProvider | null>(null)
const paymentHelpRef = ref<HTMLElement | null>(null)
const form = reactive({ const form = reactive({
enabled: false, enabled: false,
endpoint_url: '', endpoint_url: '',
callback_base_url: '', callback_base_url: '',
merchant_id: '',
merchant_key: '',
pay_currency: 'CNY', pay_currency: 'CNY',
usd_exchange_rate: 7.2, usd_exchange_rate: 7.2,
min_recharge_usd: 1, min_recharge_usd: 1,
channels: [ refund_enabled: false,
{ channel: 'alipay', display_name: '支付宝' }, allow_user_refund: false,
{ channel: 'wxpay', display_name: '微信支付' }, channels: providers[0].defaultChannels.map((item) => ({ ...item })) as EpayChannelConfig[],
] as EpayChannelConfig[],
}) })
const activeProviderMeta = computed(() =>
providers.find(provider => provider.key === activeProvider.value) || providers[0]
)
const visibleFields = computed(() => activeProviderMeta.value.fields)
const updatedAtText = computed(() => { const updatedAtText = computed(() => {
if (!updatedAt.value) return '' if (!updatedAt.value) return ''
return new Date(updatedAt.value * 1000).toLocaleString('zh-CN') return new Date(updatedAt.value * 1000).toLocaleString('zh-CN')
@@ -273,50 +553,114 @@ const defaultCallbackBaseUrl = computed(() => {
}) })
onMounted(() => { onMounted(() => {
document.addEventListener('pointerdown', handlePaymentHelpOutsidePointerDown, true)
void loadConfig() void loadConfig()
}) })
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', handlePaymentHelpOutsidePointerDown, true)
})
async function selectProvider(provider: PaymentGatewayProvider) {
if (activeProvider.value === provider) return
closePaymentHelp()
activeProvider.value = provider
await loadConfig()
}
function togglePaymentHelp(provider: PaymentGatewayProvider) {
paymentHelpOpen.value = paymentHelpOpen.value === provider ? null : provider
}
function closePaymentHelp() {
paymentHelpOpen.value = null
}
function handlePaymentHelpOutsidePointerDown(event: PointerEvent) {
const target = event.target
if (!(target instanceof Node)) return
if (paymentHelpRef.value?.contains(target)) return
closePaymentHelp()
}
async function loadConfig() { async function loadConfig() {
loading.value = true loading.value = true
try { try {
const config = await epayGatewayApi.get() const config = await epayGatewayApi.get(activeProvider.value)
form.enabled = config.enabled form.enabled = config.enabled
form.endpoint_url = config.endpoint_url || '' form.endpoint_url = config.endpoint_url || ''
form.callback_base_url = config.callback_base_url || '' form.callback_base_url = config.callback_base_url || ''
form.merchant_id = config.merchant_id || ''
form.merchant_key = ''
form.pay_currency = config.pay_currency || 'CNY' form.pay_currency = config.pay_currency || 'CNY'
form.usd_exchange_rate = Number(config.usd_exchange_rate || 7.2) form.usd_exchange_rate = Number(config.usd_exchange_rate || 7.2)
form.min_recharge_usd = Number(config.min_recharge_usd || 1) form.min_recharge_usd = Number(config.min_recharge_usd || 1)
form.refund_enabled = Boolean(config.refund_enabled)
form.allow_user_refund = form.refund_enabled && Boolean(config.allow_user_refund)
form.channels = config.channels?.length form.channels = config.channels?.length
? config.channels.map((item) => ({ ...item })) ? config.channels.map((item) => {
: [ const feeRate = Number(item.fee_rate ?? 0)
{ channel: 'alipay', display_name: '支付宝' }, return { ...item, fee_rate: Number.isFinite(feeRate) && feeRate >= 0 ? feeRate : 0 }
{ channel: 'wxpay', display_name: '微信支付' }, })
] : activeProviderMeta.value.defaultChannels.map((item) => ({ ...item }))
hasSecret.value = config.has_secret hasSecret.value = config.has_secret
hasSecretKeys.value = config.has_secret_keys || []
updatedAt.value = config.updated_at ?? null updatedAt.value = config.updated_at ?? null
resetFieldValues()
const savedConfig = config.config || {}
for (const field of visibleFields.value) {
if (field.key === 'merchant_id') {
fieldValues[field.key] = config.merchant_id || ''
} else {
const value = savedConfig[field.key]
fieldValues[field.key] = typeof value === 'string' ? value : ''
}
}
} catch (err) { } catch (err) {
log.error('加载支付配置失败:', err) log.error('加载支付配置失败:', err)
showError(parseApiError(err, '加载支付配置失败')) showError(parseApiError(err, '加载支付配置失败'))
} finally { } finally {
loading.value = false loading.value = false
} }
} }
function resetFieldValues() {
for (const key of Object.keys(fieldValues)) {
delete fieldValues[key]
}
for (const field of visibleFields.value) {
fieldValues[field.key] = ''
}
}
function hasSecretKey(key: string): boolean {
if (activeProvider.value === 'epay' && key === 'merchant_key') return hasSecret.value
return hasSecretKeys.value.includes(key)
}
function setRefundEnabled(value: boolean) {
form.refund_enabled = value
if (!value) form.allow_user_refund = false
}
function setAllowUserRefund(value: boolean) {
form.allow_user_refund = form.refund_enabled && value
}
function normalizeChannels(): EpayChannelConfig[] { function normalizeChannels(): EpayChannelConfig[] {
return form.channels return form.channels
.map((item) => ({ .map((item) => {
const feeRate = Number(item.fee_rate ?? 0)
return {
channel: item.channel.trim(), channel: item.channel.trim(),
display_name: item.display_name.trim(), display_name: item.display_name.trim(),
})) fee_rate: Number.isFinite(feeRate) && feeRate >= 0 ? feeRate : 0,
}
})
.filter((item) => item.channel && item.display_name) .filter((item) => item.channel && item.display_name)
} }
function validateForm(): string | null { function validateForm(): string | null {
if (!form.endpoint_url.trim()) return '请输入易支付接口地址' if (activeProvider.value === 'epay' && !form.endpoint_url.trim()) return '请输入易支付接口地址'
if (!form.merchant_id.trim()) return '请输入商户 ID'
if (!hasSecret.value && !form.merchant_key.trim()) return '首次配置需要填写商户密钥'
if (!form.pay_currency.trim()) return '请输入支付币种' if (!form.pay_currency.trim()) return '请输入支付币种'
if (!Number.isFinite(Number(form.usd_exchange_rate)) || Number(form.usd_exchange_rate) <= 0) { if (!Number.isFinite(Number(form.usd_exchange_rate)) || Number(form.usd_exchange_rate) <= 0) {
return 'USD 汇率必须大于 0' return 'USD 汇率必须大于 0'
@@ -324,7 +668,20 @@ function validateForm(): string | null {
if (!Number.isFinite(Number(form.min_recharge_usd)) || Number(form.min_recharge_usd) <= 0) { if (!Number.isFinite(Number(form.min_recharge_usd)) || Number(form.min_recharge_usd) <= 0) {
return '最低充值金额必须大于 0' return '最低充值金额必须大于 0'
} }
if (normalizeChannels().length === 0) return '至少需要一个支付通道' const channels = normalizeChannels()
if (channels.length === 0) return '至少需要一个支付通道'
for (const [index, channel] of form.channels.entries()) {
if (!channel.channel.trim() || !channel.display_name.trim()) continue
const feeRate = Number(channel.fee_rate ?? 0)
if (!Number.isFinite(feeRate) || feeRate < 0) {
return `${index + 1} 个通道手续费率必须大于等于 0`
}
}
for (const field of visibleFields.value) {
const value = fieldValues[field.key]?.trim() || ''
if (!field.secret && !value) return `请输入${field.label}`
if (field.secret && !hasSecretKey(field.key) && !value) return `首次配置需要填写${field.label}`
}
return null return null
} }
@@ -337,27 +694,47 @@ async function saveConfig() {
saving.value = true saving.value = true
try { try {
const callbackBaseUrl = form.callback_base_url.trim() const configFields: Record<string, unknown> = {}
const secrets: Record<string, string> = {}
for (const field of visibleFields.value) {
const value = fieldValues[field.key]?.trim() || ''
if (field.secret) {
if (value) secrets[field.key] = value
} else if (field.key !== 'merchant_id') {
configFields[field.key] = value
}
}
const payload = { const payload = {
enabled: form.enabled, enabled: form.enabled,
endpoint_url: form.endpoint_url.trim(), endpoint_url: form.endpoint_url.trim(),
callback_base_url: callbackBaseUrl || null, callback_base_url: form.callback_base_url.trim() || null,
merchant_id: form.merchant_id.trim(), merchant_id: fieldValues.merchant_id?.trim() || '',
pay_currency: form.pay_currency.trim().toUpperCase(), pay_currency: form.pay_currency.trim().toUpperCase(),
usd_exchange_rate: Number(form.usd_exchange_rate), usd_exchange_rate: Number(form.usd_exchange_rate),
min_recharge_usd: Number(form.min_recharge_usd), min_recharge_usd: Number(form.min_recharge_usd),
channels: normalizeChannels(), channels: normalizeChannels(),
...(form.merchant_key.trim() ? { merchant_key: form.merchant_key.trim() } : {}), refund_enabled: form.refund_enabled,
allow_user_refund: form.refund_enabled && form.allow_user_refund,
config: configFields,
secrets,
...(activeProvider.value === 'epay' && fieldValues.merchant_key?.trim()
? { merchant_key: fieldValues.merchant_key.trim() }
: {}),
} }
const config = await epayGatewayApi.update(payload) const config = await epayGatewayApi.update(payload, activeProvider.value)
hasSecret.value = config.has_secret hasSecret.value = config.has_secret
hasSecretKeys.value = config.has_secret_keys || []
updatedAt.value = config.updated_at ?? null updatedAt.value = config.updated_at ?? null
form.callback_base_url = config.callback_base_url || '' form.refund_enabled = Boolean(config.refund_enabled)
form.merchant_key = '' form.allow_user_refund = form.refund_enabled && Boolean(config.allow_user_refund)
fieldValues.merchant_key = ''
for (const field of visibleFields.value) {
if (field.secret) fieldValues[field.key] = ''
}
success('支付配置已保存') success('支付配置已保存')
} catch (err) { } catch (err) {
log.error('保存支付配置失败:', err) log.error('保存支付配置失败:', err)
showError(parseApiError(err, '保存支付配置失败')) showError(parseApiError(err, '保存支付配置失败'))
} finally { } finally {
saving.value = false saving.value = false
} }
@@ -366,18 +743,18 @@ async function saveConfig() {
async function testGateway() { async function testGateway() {
testing.value = true testing.value = true
try { try {
await epayGatewayApi.test() await epayGatewayApi.test(activeProvider.value)
success('支付配置可用') success('支付配置可用')
} catch (err) { } catch (err) {
log.error('测试支付配置失败:', err) log.error('测试支付配置失败:', err)
showError(parseApiError(err, '测试支付配置失败')) showError(parseApiError(err, '测试支付配置失败'))
} finally { } finally {
testing.value = false testing.value = false
} }
} }
function addChannel() { function addChannel() {
form.channels.push({ channel: '', display_name: '' }) form.channels.push({ channel: '', display_name: '', fee_rate: 0 })
} }
function removeChannel(index: number) { function removeChannel(index: number) {

View File

@@ -114,14 +114,14 @@
<Select v-model="selectedChannel"> <Select v-model="selectedChannel">
<SelectTrigger> <SelectTrigger>
<SelectValue <SelectValue
:placeholder="epayOptions.length ? '选择支付通道' : '暂无可用支付通道'" :placeholder="checkoutOptions.length ? '选择支付通道' : '暂无可用支付通道'"
/> />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem <SelectItem
v-for="option in epayOptions" v-for="option in checkoutOptions"
:key="`${option.payment_channel}-${option.display_name}`" :key="option.key"
:value="option.payment_channel || ''" :value="option.key"
> >
{{ option.display_name }} {{ option.display_name }}
</SelectItem> </SelectItem>
@@ -131,7 +131,7 @@
class="w-full" class="w-full"
:disabled=" :disabled="
checkoutLoadingPlanId === plan.id checkoutLoadingPlanId === plan.id
|| epayOptions.length === 0 || checkoutOptions.length === 0
|| !selectedChannel || !selectedChannel
" "
@click="checkoutPlan(plan)" @click="checkoutPlan(plan)"
@@ -168,16 +168,25 @@
</div> </div>
</div> </div>
<Button <Button
v-if="latestPaymentUrl" v-if="latestCheckoutActionLabel"
variant="outline" variant="outline"
@click="openPaymentUrl(latestPaymentUrl)" @click="openLatestPayment"
> >
打开支付链接 {{ latestCheckoutActionLabel }}
</Button> </Button>
</div> </div>
</Card> </Card>
</template> </template>
</div> </div>
<StripePaymentDialog
v-model:open="stripeDialogOpen"
:instructions="stripePaymentInstructions"
title="套餐 Stripe 支付"
description="完成支付后,套餐权益会由 Stripe Webhook 自动发放。"
confirm-text="支付套餐"
@success="handleStripePaymentSuccess"
/>
</PageContainer> </PageContainer>
</template> </template>
@@ -203,11 +212,16 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui' } from '@/components/ui'
import { EmptyState, LoadingState } from '@/components/common' import { EmptyState, LoadingState, StripePaymentDialog } from '@/components/common'
import { CardSection, PageContainer, PageHeader } from '@/components/layout' import { CardSection, PageContainer, PageHeader } from '@/components/layout'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser' import { parseApiError } from '@/utils/errorParser'
import { log } from '@/utils/logger' import { log } from '@/utils/logger'
import {
getPaymentInstructionString,
getStripePaymentInstructions,
type PaymentInstructionMap,
} from '@/utils/paymentInstructions'
const { success, error: showError } = useToast() const { success, error: showError } = useToast()
@@ -218,14 +232,29 @@ const rechargeOptions = ref<WalletRechargeOption[]>([])
const selectedChannel = ref('') const selectedChannel = ref('')
const checkoutLoadingPlanId = ref<string | null>(null) const checkoutLoadingPlanId = ref<string | null>(null)
const latestCheckout = ref<BillingCheckoutResponse | null>(null) const latestCheckout = ref<BillingCheckoutResponse | null>(null)
const stripeDialogOpen = ref(false)
const stripePaymentInstructions = ref<PaymentInstructionMap | null>(null)
const epayOptions = computed(() => const checkoutOptions = computed(() =>
rechargeOptions.value.filter((option) => rechargeOptions.value
(option.payment_provider === 'epay' || option.payment_method === 'epay') .filter((option) => Boolean(paymentOptionProvider(option)) && Boolean(option.payment_channel))
&& Boolean(option.payment_channel) .map((option, index) => ({
) ...option,
key: [
paymentOptionProvider(option),
option.payment_method,
option.payment_channel || '',
index,
].join(':'),
}))
) )
const selectedCheckoutOption = computed(() => {
if (checkoutOptions.value.length === 0) return null
return checkoutOptions.value.find(option => option.key === selectedChannel.value)
|| checkoutOptions.value[0]
})
const activeEntitlements = computed(() => const activeEntitlements = computed(() =>
entitlements.value.filter((item) => entitlements.value.filter((item) =>
item.active !== false item.active !== false
@@ -243,12 +272,17 @@ const latestPaymentUrl = computed(() => {
return typeof value === 'string' && value ? value : '' return typeof value === 'string' && value ? value : ''
}) })
watch(epayOptions, (options) => { const latestCheckoutActionLabel = computed(() => {
const channels = options const instructions = latestCheckout.value?.payment_instructions
.map(option => option.payment_channel || '') if (getStripePaymentInstructions(instructions)) return '打开 Stripe 支付'
.filter(Boolean) if (latestPaymentUrl.value) return '打开支付链接'
if (!channels.includes(selectedChannel.value)) { return ''
selectedChannel.value = channels[0] || '' })
watch(checkoutOptions, (options) => {
const keys = options.map(option => option.key)
if (!keys.includes(selectedChannel.value)) {
selectedChannel.value = keys[0] || ''
} }
}, { immediate: true }) }, { immediate: true })
@@ -285,8 +319,8 @@ async function loadRechargeOptions() {
try { try {
const response = await walletApi.listRechargeOptions() const response = await walletApi.listRechargeOptions()
rechargeOptions.value = response.items rechargeOptions.value = response.items
if (!selectedChannel.value && epayOptions.value.length > 0) { if (!selectedChannel.value && checkoutOptions.value.length > 0) {
selectedChannel.value = epayOptions.value[0].payment_channel || '' selectedChannel.value = checkoutOptions.value[0].key
} }
} catch (err) { } catch (err) {
log.error('加载支付通道失败:', err) log.error('加载支付通道失败:', err)
@@ -299,12 +333,22 @@ async function checkoutPlan(plan: BillingPlan) {
const confirmed = window.confirm('购买成功后,同类旧套餐会自动失效。确定继续购买吗?') const confirmed = window.confirm('购买成功后,同类旧套餐会自动失效。确定继续购买吗?')
if (!confirmed) return if (!confirmed) return
} }
const option = selectedCheckoutOption.value
if (!option) {
showError('请选择支付方式')
return
}
const provider = paymentOptionProvider(option)
if (!provider) {
showError('支付方式配置无效')
return
}
checkoutLoadingPlanId.value = plan.id checkoutLoadingPlanId.value = plan.id
try { try {
const response = await billingApi.checkout(plan.id, { const response = await billingApi.checkout(plan.id, {
payment_method: 'epay', payment_method: option.payment_method || provider,
payment_provider: 'epay', payment_provider: provider,
payment_channel: selectedChannel.value, payment_channel: option.payment_channel,
}) })
latestCheckout.value = response latestCheckout.value = response
success('套餐订单已创建') success('套餐订单已创建')
@@ -317,14 +361,20 @@ async function checkoutPlan(plan: BillingPlan) {
} }
} }
function openPaymentUrl(url: string) { function openLatestPayment() {
submitPaymentInstructions(latestCheckout.value?.payment_instructions || { payment_url: url }) submitPaymentInstructions(latestCheckout.value?.payment_instructions || { payment_url: latestPaymentUrl.value })
} }
function submitPaymentInstructions(instructions: Record<string, unknown> | null | undefined) { function submitPaymentInstructions(instructions: Record<string, unknown> | null | undefined) {
if (!instructions) return if (!instructions) return
const paymentUrl = instructions.payment_url const stripeInstructions = getStripePaymentInstructions(instructions)
if (typeof paymentUrl !== 'string' || !paymentUrl) return if (stripeInstructions) {
stripePaymentInstructions.value = instructions
stripeDialogOpen.value = true
return
}
const paymentUrl = getPaymentInstructionString(instructions, 'payment_url')
if (!paymentUrl) return
const paymentParams = instructions.payment_params const paymentParams = instructions.payment_params
if (paymentParams && typeof paymentParams === 'object' && !Array.isArray(paymentParams)) { if (paymentParams && typeof paymentParams === 'object' && !Array.isArray(paymentParams)) {
submitPaymentForm(paymentUrl, paymentParams as Record<string, unknown>) submitPaymentForm(paymentUrl, paymentParams as Record<string, unknown>)
@@ -360,6 +410,15 @@ function isSafariBrowser(): boolean {
return navigator.userAgent.includes('Safari') && !navigator.userAgent.includes('Chrome') return navigator.userAgent.includes('Safari') && !navigator.userAgent.includes('Chrome')
} }
async function handleStripePaymentSuccess() {
success('支付已完成,正在刷新套餐状态')
await Promise.all([loadEntitlements(), loadPlans()])
}
function paymentOptionProvider(option: WalletRechargeOption): string {
return (option.payment_provider || option.provider || option.payment_method || '').trim()
}
function planTitle(planId: string): string { function planTitle(planId: string): string {
return plans.value.find((plan) => plan.id === planId)?.title || planId return plans.value.find((plan) => plan.id === planId)?.title || planId
} }

View File

@@ -195,6 +195,12 @@
> >
· {{ option.pay_currency }} · {{ option.pay_currency }}
</span> </span>
<span
v-if="Number(option.fee_rate || 0) > 0"
class="text-xs text-muted-foreground"
>
· 手续费 {{ Number(option.fee_rate || 0).toFixed(2) }}%
</span>
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -208,10 +214,14 @@
预计支付: 预计支付:
<span class="font-medium text-foreground"> <span class="font-medium text-foreground">
{{ estimatedRechargePayAmount }} {{ estimatedRechargePayAmount }}
{{ selectedRechargeOption.pay_currency || 'CNY' }} {{ rechargePayCurrency }}
</span> </span>
· 1 USD = {{ Number(selectedRechargeOption.usd_exchange_rate).toFixed(4) }} · 1 USD = {{ Number(selectedRechargeOption.usd_exchange_rate).toFixed(4) }}
{{ selectedRechargeOption.pay_currency || 'CNY' }} {{ rechargePayCurrency }}
<template v-if="estimatedRechargeFeeAmount > 0">
· 手续费 {{ estimatedRechargeFeeAmount.toFixed(2) }} {{ rechargePayCurrency }}
({{ estimatedRechargeFeeRate.toFixed(2) }}%)
</template>
</div> </div>
<Button <Button
@@ -239,15 +249,23 @@
</Badge> </Badge>
</div> </div>
<a <a
v-if="latestRecharge.payment_instructions?.payment_url" v-if="latestRechargePaymentUrl"
class="inline-flex text-xs text-primary hover:underline" class="inline-flex text-xs text-primary hover:underline"
:href="String(latestRecharge.payment_instructions.payment_url)" :href="latestRechargePaymentUrl"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
@click.prevent="submitPaymentInstructions(latestRecharge.payment_instructions)" @click.prevent="submitPaymentInstructions(latestRecharge.payment_instructions)"
> >
打开支付链接 打开支付链接
</a> </a>
<button
v-if="latestRechargeStripeInstructions"
type="button"
class="inline-flex text-xs text-primary hover:underline"
@click="submitPaymentInstructions(latestRecharge?.payment_instructions)"
>
打开 Stripe 支付
</button>
<div <div
v-if="latestRecharge.payment_instructions?.qr_code" v-if="latestRecharge.payment_instructions?.qr_code"
class="text-xs text-muted-foreground break-all" class="text-xs text-muted-foreground break-all"
@@ -263,11 +281,18 @@
申请退款 申请退款
</h3> </h3>
<RefreshButton <RefreshButton
:loading="loadingRefunds" :loading="loadingRefunds || loadingRefundEligibility"
@click="loadRefunds" @click="refreshRefundPanel"
/> />
</div> </div>
<div
v-if="!loadingRefundEligibility && refundableOrders.length === 0"
class="rounded-xl border border-border/60 bg-muted/20 p-3 text-xs text-muted-foreground"
>
当前没有开启用户自助退款的可退充值订单
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label>退款金额 (USD)</Label> <Label>退款金额 (USD)</Label>
@@ -299,15 +324,12 @@
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label>关联充值订单可选</Label> <Label>关联充值订单</Label>
<Select v-model="refundForm.payment_order_id"> <Select v-model="refundForm.payment_order_id">
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="不指定订单,直接从钱包余额退款" /> <SelectValue placeholder="选择允许用户退款的订单" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="__none__">
不指定
</SelectItem>
<SelectItem <SelectItem
v-for="order in refundableOrders" v-for="order in refundableOrders"
:key="order.id" :key="order.id"
@@ -329,13 +351,13 @@
</div> </div>
<div class="rounded-xl border border-border/60 bg-muted/20 p-3 text-xs text-muted-foreground"> <div class="rounded-xl border border-border/60 bg-muted/20 p-3 text-xs text-muted-foreground">
充值余额可退款赠款余额不可退款 开启允许用户退款的支付方式可由用户自助提交退款申请
</div> </div>
<Button <Button
class="w-full" class="w-full"
variant="outline" variant="outline"
:disabled="submittingRefund" :disabled="submittingRefund || refundableOrders.length === 0"
@click="submitRefund" @click="submitRefund"
> >
{{ submittingRefund ? '提交中...' : '提交退款申请' }} {{ submittingRefund ? '提交中...' : '提交退款申请' }}
@@ -647,6 +669,15 @@
</div> </div>
</Card> </Card>
</template> </template>
<StripePaymentDialog
v-model:open="stripeDialogOpen"
:instructions="stripePaymentInstructions"
title="钱包 Stripe 支付"
description="完成支付后,钱包余额会由 Stripe Webhook 自动入账。"
confirm-text="支付充值"
@success="handleStripePaymentSuccess"
/>
</div> </div>
</template> </template>
@@ -677,7 +708,7 @@ import {
TabsTrigger, TabsTrigger,
Textarea, Textarea,
} from '@/components/ui' } from '@/components/ui'
import { EmptyState, LoadingState } from '@/components/common' import { EmptyState, LoadingState, StripePaymentDialog } from '@/components/common'
import { import {
walletApi, walletApi,
type DailyUsageRecord, type DailyUsageRecord,
@@ -691,6 +722,11 @@ import {
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser' import { parseApiError } from '@/utils/errorParser'
import { log } from '@/utils/logger' import { log } from '@/utils/logger'
import {
getPaymentInstructionString,
getStripePaymentInstructions,
type PaymentInstructionMap,
} from '@/utils/paymentInstructions'
import { import {
dailyUsageCategoryLabel, dailyUsageCategoryLabel,
formatTokenCount, formatTokenCount,
@@ -715,6 +751,7 @@ const loadingInitial = ref(true)
const loadingTransactions = ref(false) const loadingTransactions = ref(false)
const loadingOrders = ref(false) const loadingOrders = ref(false)
const loadingRefunds = ref(false) const loadingRefunds = ref(false)
const loadingRefundEligibility = ref(false)
const submittingRedeem = ref(false) const submittingRedeem = ref(false)
const submittingRecharge = ref(false) const submittingRecharge = ref(false)
const submittingRefund = ref(false) const submittingRefund = ref(false)
@@ -723,6 +760,8 @@ const walletBalance = ref<WalletBalanceResponse | null>(null)
const latestRecharge = ref<{ order: PaymentOrder; payment_instructions: Record<string, unknown> } | null>(null) const latestRecharge = ref<{ order: PaymentOrder; payment_instructions: Record<string, unknown> } | null>(null)
const latestRedeem = ref<WalletRedeemResponse | null>(null) const latestRedeem = ref<WalletRedeemResponse | null>(null)
const rechargeOptions = ref<WalletRechargeOption[]>([]) const rechargeOptions = ref<WalletRechargeOption[]>([])
const stripeDialogOpen = ref(false)
const stripePaymentInstructions = ref<PaymentInstructionMap | null>(null)
const flowItems = ref<FlowItem[]>([]) const flowItems = ref<FlowItem[]>([])
const todayUsage = ref<DailyUsageRecord | null>(null) const todayUsage = ref<DailyUsageRecord | null>(null)
@@ -734,6 +773,7 @@ const rechargeOrders = ref<PaymentOrder[]>([])
const orderTotal = ref(0) const orderTotal = ref(0)
const orderPage = ref(1) const orderPage = ref(1)
const orderPageSize = ref(20) const orderPageSize = ref(20)
const refundEligiblePaymentMethods = ref<Set<string>>(new Set())
const refunds = ref<RefundRequest[]>([]) const refunds = ref<RefundRequest[]>([])
const refundTotal = ref(0) const refundTotal = ref(0)
@@ -750,7 +790,7 @@ const rechargeForm = reactive({
const refundForm = reactive({ const refundForm = reactive({
amount_usd: 0, amount_usd: 0,
payment_order_id: '__none__', payment_order_id: '',
refund_mode: 'offline_payout', refund_mode: 'offline_payout',
reason: '', reason: '',
}) })
@@ -760,7 +800,10 @@ const redeemForm = reactive({
}) })
const refundableOrders = computed(() => const refundableOrders = computed(() =>
rechargeOrders.value.filter(o => (o.refundable_amount_usd || 0) > 0) rechargeOrders.value.filter(order =>
(order.refundable_amount_usd || 0) > 0
&& refundEligiblePaymentMethods.value.has(refundPaymentMethod(order))
)
) )
const rechargeOptionsWithKey = computed(() => const rechargeOptionsWithKey = computed(() =>
@@ -781,12 +824,45 @@ const selectedRechargeOption = computed(() => {
|| rechargeOptionsWithKey.value[0] || rechargeOptionsWithKey.value[0]
}) })
const estimatedRechargePayAmount = computed(() => { function roundPayAmount(value: number): number {
return Math.round(value * 100) / 100
}
const rechargePaymentBreakdown = computed(() => {
const rate = Number(selectedRechargeOption.value?.usd_exchange_rate || 0) const rate = Number(selectedRechargeOption.value?.usd_exchange_rate || 0)
if (!Number.isFinite(rate) || rate <= 0) return '-' const amount = Number(rechargeForm.amount_usd || 0)
return (Number(rechargeForm.amount_usd || 0) * rate).toFixed(2) if (!Number.isFinite(rate) || rate <= 0 || !Number.isFinite(amount) || amount <= 0) return null
const rawFeeRate = Number(selectedRechargeOption.value?.fee_rate || 0)
const feeRate = Number.isFinite(rawFeeRate) && rawFeeRate > 0 ? rawFeeRate : 0
const basePayAmount = roundPayAmount(amount * rate)
const feeAmount = roundPayAmount(basePayAmount * feeRate / 100)
return {
basePayAmount,
feeAmount,
feeRate,
totalPayAmount: roundPayAmount(basePayAmount + feeAmount),
}
}) })
const rechargePayCurrency = computed(() => selectedRechargeOption.value?.pay_currency || 'CNY')
const estimatedRechargePayAmount = computed(() => {
if (!rechargePaymentBreakdown.value) return '-'
return rechargePaymentBreakdown.value.totalPayAmount.toFixed(2)
})
const estimatedRechargeFeeAmount = computed(() =>
rechargePaymentBreakdown.value?.feeAmount || 0
)
const estimatedRechargeFeeRate = computed(() =>
rechargePaymentBreakdown.value?.feeRate || 0
)
const latestRechargePaymentUrl = computed(() =>
getPaymentInstructionString(latestRecharge.value?.payment_instructions, 'payment_url')
)
const latestRechargeStripeInstructions = computed(() =>
getStripePaymentInstructions(latestRecharge.value?.payment_instructions)
)
const dailyQuota = computed(() => walletBalance.value?.daily_quota ?? null) const dailyQuota = computed(() => walletBalance.value?.daily_quota ?? null)
const hasActiveDailyQuota = computed(() => Boolean(dailyQuota.value?.has_active)) const hasActiveDailyQuota = computed(() => Boolean(dailyQuota.value?.has_active))
const walletOnlyBalance = computed(() => { const walletOnlyBalance = computed(() => {
@@ -836,6 +912,7 @@ onMounted(async () => {
loadTodayCost(), loadTodayCost(),
loadOrders(), loadOrders(),
loadRefunds(), loadRefunds(),
loadRefundEligibility(),
loadRechargeOptions(), loadRechargeOptions(),
]) ])
syncTodayCostPolling() syncTodayCostPolling()
@@ -853,6 +930,10 @@ watch(activeTab, () => {
syncTodayCostPolling() syncTodayCostPolling()
}) })
watch(refundableOrders, () => {
syncRefundOrderSelection()
})
async function loadBalance() { async function loadBalance() {
walletBalance.value = await walletApi.getBalance() walletBalance.value = await walletApi.getBalance()
} }
@@ -928,6 +1009,7 @@ async function loadOrders() {
const resp = await walletApi.listRechargeOrders({ limit: orderPageSize.value, offset }) const resp = await walletApi.listRechargeOrders({ limit: orderPageSize.value, offset })
rechargeOrders.value = resp.items rechargeOrders.value = resp.items
orderTotal.value = resp.total orderTotal.value = resp.total
syncRefundOrderSelection()
} catch (error) { } catch (error) {
log.error('加载充值订单失败:', error) log.error('加载充值订单失败:', error)
showError(parseApiError(error, '加载充值订单失败')) showError(parseApiError(error, '加载充值订单失败'))
@@ -936,6 +1018,24 @@ async function loadOrders() {
} }
} }
async function loadRefundEligibility() {
loadingRefundEligibility.value = true
try {
const resp = await walletApi.listRefundEligibleProviders()
refundEligiblePaymentMethods.value = new Set(
(resp.payment_methods || [])
.map(item => item.trim().toLowerCase())
.filter(Boolean)
)
syncRefundOrderSelection()
} catch (error) {
refundEligiblePaymentMethods.value = new Set()
log.error('加载退款资格失败:', error)
} finally {
loadingRefundEligibility.value = false
}
}
async function loadRefunds() { async function loadRefunds() {
loadingRefunds.value = true loadingRefunds.value = true
try { try {
@@ -951,6 +1051,10 @@ async function loadRefunds() {
} }
} }
async function refreshRefundPanel() {
await Promise.all([loadRefunds(), loadRefundEligibility(), loadOrders()])
}
async function submitRedeem() { async function submitRedeem() {
if (!redeemForm.code.trim()) { if (!redeemForm.code.trim()) {
showError('请输入兑换码') showError('请输入兑换码')
@@ -1011,14 +1115,23 @@ async function submitRecharge() {
function submitPaymentInstructions(instructions: Record<string, unknown> | null | undefined) { function submitPaymentInstructions(instructions: Record<string, unknown> | null | undefined) {
if (!instructions) return if (!instructions) return
const paymentUrl = instructions.payment_url const stripeInstructions = getStripePaymentInstructions(instructions)
if (typeof paymentUrl !== 'string' || !paymentUrl) return if (stripeInstructions) {
stripePaymentInstructions.value = instructions
stripeDialogOpen.value = true
return
}
const paymentUrl = getPaymentInstructionString(instructions, 'payment_url')
if (!paymentUrl) return
const paymentParams = instructions.payment_params const paymentParams = instructions.payment_params
if (paymentParams && typeof paymentParams === 'object' && !Array.isArray(paymentParams)) { if (paymentParams && typeof paymentParams === 'object' && !Array.isArray(paymentParams)) {
submitPaymentForm(paymentUrl, paymentParams as Record<string, unknown>) submitPaymentForm(paymentUrl, paymentParams as Record<string, unknown>)
return return
} }
window.open(paymentUrl, '_blank', 'noopener,noreferrer') const opened = window.open(paymentUrl, '_blank', 'noopener,noreferrer')
if (!opened) {
window.location.href = paymentUrl
}
} }
function submitPaymentForm(url: string, params: Record<string, unknown>) { function submitPaymentForm(url: string, params: Record<string, unknown>) {
@@ -1045,11 +1158,26 @@ function isSafariBrowser(): boolean {
return navigator.userAgent.includes('Safari') && !navigator.userAgent.includes('Chrome') return navigator.userAgent.includes('Safari') && !navigator.userAgent.includes('Chrome')
} }
async function handleStripePaymentSuccess() {
success('支付已完成,正在刷新钱包余额')
await Promise.all([loadBalance(), loadOrders(), loadTransactions(), loadTodayCost()])
activeTab.value = 'orders'
}
async function submitRefund() { async function submitRefund() {
if (!refundForm.amount_usd || refundForm.amount_usd <= 0) { if (!refundForm.amount_usd || refundForm.amount_usd <= 0) {
showError('请输入有效的退款金额') showError('请输入有效的退款金额')
return return
} }
const selectedOrder = refundableOrders.value.find(order => order.id === refundForm.payment_order_id)
if (!selectedOrder) {
showError('请选择允许用户退款的充值订单')
return
}
if (refundForm.amount_usd > (selectedOrder.refundable_amount_usd || 0)) {
showError(`退款金额超过该订单可退金额(当前可退 ${formatCurrency(selectedOrder.refundable_amount_usd || 0)}`)
return
}
const refundableBalance = const refundableBalance =
walletBalance.value?.wallet?.refundable_balance ?? walletBalance.value?.refundable_balance ?? null walletBalance.value?.wallet?.refundable_balance ?? walletBalance.value?.refundable_balance ?? null
if (refundableBalance !== null && refundForm.amount_usd > refundableBalance) { if (refundableBalance !== null && refundForm.amount_usd > refundableBalance) {
@@ -1061,19 +1189,15 @@ async function submitRefund() {
try { try {
await walletApi.createRefund({ await walletApi.createRefund({
amount_usd: refundForm.amount_usd, amount_usd: refundForm.amount_usd,
payment_order_id: payment_order_id: selectedOrder.id,
refundForm.payment_order_id && refundForm.payment_order_id !== '__none__'
? refundForm.payment_order_id
: undefined,
refund_mode: refundForm.refund_mode || undefined, refund_mode: refundForm.refund_mode || undefined,
reason: refundForm.reason || undefined, reason: refundForm.reason || undefined,
idempotency_key: `web_refund_${buildRefundIdempotencyKey()}`, idempotency_key: `web_refund_${buildRefundIdempotencyKey()}`,
}) })
success('退款申请已提交') success('退款申请已提交')
refundForm.amount_usd = 0 refundForm.amount_usd = 0
refundForm.payment_order_id = '__none__'
refundForm.reason = '' refundForm.reason = ''
await Promise.all([loadRefunds(), loadBalance(), loadOrders(), loadTransactions(), loadTodayCost()]) await Promise.all([loadRefunds(), loadBalance(), loadOrders(), loadRefundEligibility(), loadTransactions(), loadTodayCost()])
activeTab.value = 'refunds' activeTab.value = 'refunds'
} catch (error) { } catch (error) {
log.error('提交退款申请失败:', error) log.error('提交退款申请失败:', error)
@@ -1083,6 +1207,15 @@ async function submitRefund() {
} }
} }
function refundPaymentMethod(order: PaymentOrder): string {
return String(order.payment_provider || order.payment_method || '').trim().toLowerCase()
}
function syncRefundOrderSelection() {
if (refundableOrders.value.some(order => order.id === refundForm.payment_order_id)) return
refundForm.payment_order_id = refundableOrders.value[0]?.id || ''
}
function buildRefundIdempotencyKey(): string { function buildRefundIdempotencyKey(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID().replaceAll('-', '') return crypto.randomUUID().replaceAll('-', '')