feat(security): harden gateway boundaries and usage policies

Consolidate subscription usage policy enforcement, privacy-safe persistence, and gateway security hardening into one reviewable change.

Includes bounded HTTP and execution envelopes, header and protocol guards, DNS and relay validation, authentication and secret projection hardening, secure backup/install paths, and regression coverage.
This commit is contained in:
elky
2026-09-04 03:45:52 +08:00
parent ddcbeb3ae9
commit 579f2c7cc1
1019 changed files with 190437 additions and 26080 deletions
@@ -5,6 +5,7 @@ use super::super::{
use crate::handlers::admin::request::AdminAppState;
use crate::handlers::admin::shared::unix_secs_to_rfc3339;
use crate::GatewayError;
use aether_data::repository::wallet::stored_timestamp_unix_secs;
use axum::{
body::{Body, Bytes},
http,
@@ -53,7 +54,7 @@ pub(super) fn build_admin_billing_collector_payload_from_record(
"default_value": record.default_value,
"priority": record.priority,
"is_enabled": record.is_enabled,
"created_at": unix_secs_to_rfc3339(record.created_at_unix_ms),
"created_at": unix_secs_to_rfc3339(stored_timestamp_unix_secs(record.created_at_unix_ms)),
"updated_at": unix_secs_to_rfc3339(record.updated_at_unix_secs),
})
}
@@ -174,17 +175,7 @@ pub(super) async fn parse_admin_billing_collector_request(
));
}
Ok(false) => {}
Err(err) => {
let detail = match err {
GatewayError::Internal(message) => message,
other => format!("{other:?}"),
};
return Err((
http::StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "detail": detail })),
)
.into_response());
}
Err(_err) => return Err(build_admin_billing_internal_error_response()),
}
}
@@ -202,6 +193,16 @@ pub(super) async fn parse_admin_billing_collector_request(
})
}
fn build_admin_billing_internal_error_response() -> Response<Body> {
(
http::StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"detail": "计费采集器服务暂不可用,请稍后重试"
})),
)
.into_response()
}
pub(in super::super) fn admin_billing_parse_page(query: Option<&str>) -> Result<u32, String> {
super::super::admin_billing_parse_page(query)
}
@@ -20,6 +20,7 @@ mod routes;
mod rules;
mod wallets;
pub(in crate::handlers::admin) use self::payments::admin_payment_gateway_response_projection;
pub(super) use self::payments::maybe_build_local_admin_payments_response;
pub(super) use self::routes::maybe_build_local_admin_billing_routes_response;
pub(super) use self::wallets::maybe_build_local_admin_wallets_response;
@@ -3,12 +3,16 @@ use super::{
};
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::handlers::shared::{
normalize_payment_callback_base_url, normalize_payment_currency, normalize_payment_https_url,
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,
payment_gateway_secret_is_legacy_unbound, payment_gateway_secret_keys_json,
PaymentGatewaySecretBinding,
};
use crate::{GatewayError, LocalMutationOutcome};
use aether_data_contracts::repository::billing::PaymentGatewayConfigWriteInput;
use aether_data_contracts::repository::billing::{
PaymentGatewayConfigCasWriteInput, PaymentGatewayConfigWriteInput,
};
use axum::{
body::Body,
http,
@@ -18,7 +22,9 @@ use axum::{
use serde::Deserialize;
use serde_json::{json, Value};
#[derive(Debug, Deserialize)]
const PAYMENT_GATEWAY_CONFIG_CAS_MAX_ATTEMPTS: usize = 8;
#[derive(Deserialize)]
struct PaymentGatewayConfigRequest {
#[serde(default)]
enabled: bool,
@@ -60,6 +66,14 @@ fn default_min_recharge_usd() -> f64 {
1.0
}
fn build_payment_gateway_conflict_response(detail: impl Into<String>) -> Response<Body> {
(
http::StatusCode::CONFLICT,
Json(json!({ "detail": detail.into() })),
)
.into_response()
}
fn default_channels() -> Value {
json!([
{"channel": "alipay", "display_name": "支付宝", "fee_rate": 0.0},
@@ -121,6 +135,18 @@ fn admin_payment_gateway_provider_from_path(path: &str) -> Option<String> {
Some(provider)
}
fn resolve_admin_payment_gateway_provider(path: &str, route_kind: &str) -> Option<String> {
match route_kind {
"get_epay_gateway" | "update_epay_gateway" | "test_epay_gateway" => {
Some("epay".to_string())
}
"get_payment_gateway" | "update_payment_gateway" | "test_payment_gateway" => {
admin_payment_gateway_provider_from_path(path)
}
_ => None,
}
}
fn default_provider_channels(provider: &str) -> Value {
match provider {
"epay" => default_channels(),
@@ -263,29 +289,97 @@ fn normalize_config_object(config: Value) -> Result<Value, String> {
Err("config must be an object".to_string())
}
fn merge_gateway_secret_maps(
existing_plaintext: Option<&str>,
updates: serde_json::Map<String, Value>,
) -> Result<serde_json::Map<String, Value>, &'static str> {
let mut merged = match existing_plaintext {
Some(plaintext) => serde_json::from_str::<Value>(plaintext)
.ok()
.and_then(|value| value.as_object().cloned())
.ok_or("existing gateway secrets have invalid format")?,
None => serde_json::Map::new(),
};
merged.extend(updates);
Ok(merged)
}
/// A legacy gateway ciphertext has no authenticated destination (or only the
/// provider in v2). Reusing it while changing endpoint/merchant would carry
/// an unknown credential into a different payment account. Require the
/// administrator to provide a replacement secret in that case.
fn legacy_secret_reuse_requires_reentry(
existing: Option<&aether_data_contracts::repository::billing::PaymentGatewayConfigRecord>,
requested_binding: &PaymentGatewaySecretBinding,
) -> bool {
let Some(record) = existing else {
return false;
};
let Some(ciphertext) = record.merchant_key_encrypted.as_deref() else {
return false;
};
if !payment_gateway_secret_is_legacy_unbound(ciphertext) {
return false;
}
// An invalid historical binding cannot establish that the legacy value
// belongs to the requested destination, so fail closed as well.
PaymentGatewaySecretBinding::from_record(record)
.map(|stored_binding| stored_binding != requested_binding.clone())
.unwrap_or(true)
}
fn encrypted_gateway_secret(
state: &AdminAppState<'_>,
provider: &str,
binding: &PaymentGatewaySecretBinding,
payload: &PaymentGatewayConfigRequest,
) -> Result<Option<String>, Response<Body>> {
existing: Option<&aether_data_contracts::repository::billing::PaymentGatewayConfigRecord>,
) -> Result<(Option<String>, Vec<Value>), Response<Body>> {
let provider = binding.provider.as_str();
let decrypt_existing = || {
if legacy_secret_reuse_requires_reentry(existing, binding) {
return Err(build_admin_payments_bad_request_response(
"endpoint_url or merchant_id changed; re-enter the gateway secret",
));
}
existing
.and_then(|record| record.merchant_key_encrypted.as_deref())
.map(|ciphertext| {
crate::handlers::shared::open_payment_gateway_secret(
state.app(), binding, ciphertext,
)
.map(|projection| projection.plaintext)
.map_err(|_| {
build_admin_payments_backend_unavailable_response(
"existing gateway secrets are not valid for the requested destination; re-enter the secret",
)
})
})
.transpose()
};
let secret_plaintext = if provider == "epay" {
payload
let supplied = payload
.merchant_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.map(ToOwned::to_owned);
if supplied.is_none() {
decrypt_existing()?;
}
supplied
} else {
let Some(secrets) = payload.secrets.as_object() else {
return if payload.secrets.is_null() {
Ok(None)
decrypt_existing()?;
Ok((None, existing_gateway_secret_keys(existing)))
} else {
Err(build_admin_payments_bad_request_response(
"secrets must be an object",
))
};
};
let filtered = secrets
let updates = secrets
.iter()
.filter_map(|(key, value)| {
let value = value.as_str()?.trim();
@@ -293,39 +387,60 @@ fn encrypted_gateway_secret(
.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())
if updates.is_empty() {
decrypt_existing()?;
return Ok((None, existing_gateway_secret_keys(existing)));
}
let existing_plaintext = decrypt_existing()?;
let merged = match merge_gateway_secret_maps(existing_plaintext.as_deref(), updates) {
Ok(value) => value,
Err(detail) => {
return Err(build_admin_payments_backend_unavailable_response(detail));
}
};
Some(Value::Object(merged).to_string())
};
let Some(secret_plaintext) = secret_plaintext else {
return Ok(None);
return Ok((None, existing_gateway_secret_keys(existing)));
};
state
.encrypt_catalog_secret_with_fallbacks(&secret_plaintext)
.ok_or_else(|| {
build_admin_payments_backend_unavailable_response("encryption key is not configured")
})
.map(Some)
let encrypted = crate::handlers::shared::seal_payment_gateway_secret(
state.app(),
binding,
&secret_plaintext,
)
.map_err(build_admin_payments_backend_unavailable_response)?;
let secret_keys = if provider == "epay" {
Vec::new()
} else {
let mut keys = serde_json::from_str::<Value>(&secret_plaintext)
.ok()
.and_then(|value| value.as_object().cloned())
.unwrap_or_default()
.into_iter()
.map(|(key, _)| Value::String(key))
.collect::<Vec<_>>();
keys.sort_by(|left, right| left.as_str().cmp(&right.as_str()));
keys
};
Ok((Some(encrypted), secret_keys))
}
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());
fn existing_gateway_secret_keys(
record: Option<&aether_data_contracts::repository::billing::PaymentGatewayConfigRecord>,
) -> Vec<Value> {
let Some(record) = record else {
return Vec::new();
};
let (_, _, secret_keys, _, _) = split_gateway_channels_config(&record);
Ok(secret_keys
let (_, _, secret_keys, _, _) = split_gateway_channels_config(record);
secret_keys
.as_array()
.cloned()
.unwrap_or_default()
.into_iter()
.filter(|value| value.as_str().is_some_and(|item| !item.trim().is_empty()))
.collect())
.collect()
}
pub(super) async fn maybe_build_local_admin_payment_gateways_response(
@@ -336,8 +451,14 @@ pub(super) async fn maybe_build_local_admin_payment_gateways_response(
) -> Result<Option<Response<Body>>, GatewayError> {
match route_kind {
Some("get_epay_gateway") | Some("get_payment_gateway") => {
let provider = admin_payment_gateway_provider_from_path(request_context.path())
.unwrap_or_else(|| "epay".to_string());
let Some(provider) = resolve_admin_payment_gateway_provider(
request_context.path(),
route_kind.expect("matched payment gateway route kind"),
) else {
return Ok(Some(build_admin_payments_bad_request_response(
"unsupported payment gateway provider",
)));
};
let record = state.app().find_payment_gateway_config(&provider).await?;
let payload = record
.map(gateway_config_payload)
@@ -345,8 +466,14 @@ pub(super) async fn maybe_build_local_admin_payment_gateways_response(
Ok(Some(Json(payload).into_response()))
}
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(provider) = resolve_admin_payment_gateway_provider(
request_context.path(),
route_kind.expect("matched payment gateway route kind"),
) else {
return Ok(Some(build_admin_payments_bad_request_response(
"unsupported payment gateway provider",
)));
};
let Some(body) = request_body else {
return Ok(Some(build_admin_payments_bad_request_response(
"缺少请求体",
@@ -371,109 +498,167 @@ pub(super) async fn maybe_build_local_admin_payment_gateways_response(
)));
}
let merchant_key_encrypted = match encrypted_gateway_secret(state, &provider, &payload)
{
Ok(value) => value,
Err(response) => return Ok(Some(response)),
};
let endpoint_url = if provider == "epay" {
match normalize_text(payload.endpoint_url, "endpoint_url", 512) {
Ok(value) => value,
match normalize_text(payload.endpoint_url.clone(), "endpoint_url", 512) {
Ok(value) => match normalize_payment_https_url(&value, "endpoint_url") {
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.endpoint_url), 512) {
Ok(value) => value.unwrap_or_default(),
match normalize_optional_text(Some(payload.endpoint_url.clone()), 512) {
Ok(Some(value)) => match normalize_payment_https_url(&value, "endpoint_url") {
Ok(value) => value,
Err(detail) => {
return Ok(Some(build_admin_payments_bad_request_response(detail)))
}
},
Ok(None) => String::new(),
Err(detail) => {
return Ok(Some(build_admin_payments_bad_request_response(detail)))
}
}
};
let callback_base_url = match normalize_optional_text(payload.callback_base_url, 512) {
Ok(value) => value,
Err(detail) => return Ok(Some(build_admin_payments_bad_request_response(detail))),
};
let callback_base_url =
match normalize_optional_text(payload.callback_base_url.clone(), 512) {
Ok(Some(value)) => match normalize_payment_callback_base_url(&value) {
Ok(value) => Some(value),
Err(detail) => {
return Ok(Some(build_admin_payments_bad_request_response(detail)))
}
},
Ok(None) => None,
Err(detail) => {
return Ok(Some(build_admin_payments_bad_request_response(detail)))
}
};
let merchant_id = if provider == "epay" {
match normalize_text(payload.merchant_id, "merchant_id", 128) {
match normalize_text(payload.merchant_id.clone(), "merchant_id", 128) {
Ok(value) => value,
Err(detail) => {
return Ok(Some(build_admin_payments_bad_request_response(detail)))
}
}
} else {
match normalize_optional_text(Some(payload.merchant_id), 128) {
match normalize_optional_text(Some(payload.merchant_id.clone()), 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_payment_currency(&payload.pay_currency, "pay_currency") {
Ok(value) => value,
Err(detail) => {
return Ok(Some(build_admin_payments_bad_request_response(detail)))
}
};
let config = match normalize_config_object(payload.config.clone()) {
Ok(value) => value,
Err(detail) => return Ok(Some(build_admin_payments_bad_request_response(detail))),
};
let config = match normalize_config_object(payload.config) {
Ok(value) => value,
Err(detail) => return Ok(Some(build_admin_payments_bad_request_response(detail))),
};
let submitted_secret_keys = payload
.secrets
.as_object()
.map(|secrets| {
secrets
.iter()
.filter(|(_, value)| {
value.as_str().is_some_and(|value| !value.trim().is_empty())
})
.map(|(key, _)| Value::String(key.clone()))
.collect::<Vec<_>>()
})
.unwrap_or_default();
let secret_keys = if provider == "epay" || !submitted_secret_keys.is_empty() {
submitted_secret_keys
} else {
existing_gateway_secret_keys(state, &provider).await?
};
let channels = match normalize_gateway_channels(&provider, payload.channels) {
let channels = match normalize_gateway_channels(&provider, payload.channels.clone()) {
Ok(value) => value,
Err(detail) => return Ok(Some(build_admin_payments_bad_request_response(detail))),
};
let refund_enabled = payload.refund_enabled;
let allow_user_refund = refund_enabled && payload.allow_user_refund;
let channels_json = payment_gateway_channels_config_json(
channels,
config,
Value::Array(secret_keys),
refund_enabled,
allow_user_refund,
);
let input = PaymentGatewayConfigWriteInput {
provider: provider.clone(),
enabled: payload.enabled,
endpoint_url,
callback_base_url,
merchant_id,
preserve_existing_secret: merchant_key_encrypted.is_none(),
merchant_key_encrypted,
pay_currency,
usd_exchange_rate: payload.usd_exchange_rate,
min_recharge_usd: payload.min_recharge_usd,
channels_json,
};
match state.app().upsert_payment_gateway_config(&input).await? {
LocalMutationOutcome::Applied(record) => {
Ok(Some(Json(gateway_config_payload(record)).into_response()))
let binding =
match PaymentGatewaySecretBinding::new(&provider, &endpoint_url, &merchant_id) {
Ok(value) => value,
Err(detail) => {
return Ok(Some(build_admin_payments_bad_request_response(detail)))
}
};
let mut existing_record = state.app().find_payment_gateway_config(&provider).await?;
let expected_existing = existing_record.is_some();
for _ in 0..PAYMENT_GATEWAY_CONFIG_CAS_MAX_ATTEMPTS {
if expected_existing && existing_record.is_none() {
return Ok(Some(build_payment_gateway_conflict_response(
"payment gateway config was removed concurrently",
)));
}
let (merchant_key_encrypted, secret_keys) = match encrypted_gateway_secret(
state,
&binding,
&payload,
existing_record.as_ref(),
) {
Ok(value) => value,
Err(response) => return Ok(Some(response)),
};
let channels_json = payment_gateway_channels_config_json(
channels.clone(),
config.clone(),
Value::Array(secret_keys),
refund_enabled,
allow_user_refund,
);
let mutation = PaymentGatewayConfigCasWriteInput {
input: PaymentGatewayConfigWriteInput {
provider: provider.clone(),
enabled: payload.enabled,
endpoint_url: endpoint_url.clone(),
callback_base_url: callback_base_url.clone(),
merchant_id: merchant_id.clone(),
preserve_existing_secret: merchant_key_encrypted.is_none(),
merchant_key_encrypted,
pay_currency: pay_currency.clone(),
usd_exchange_rate: payload.usd_exchange_rate,
min_recharge_usd: payload.min_recharge_usd,
channels_json,
},
expected_existing,
expected_merchant_key_encrypted: existing_record
.as_ref()
.and_then(|record| record.merchant_key_encrypted.clone()),
};
match state
.app()
.compare_and_swap_payment_gateway_config(&mutation)
.await?
{
LocalMutationOutcome::Applied(record) => {
return Ok(Some(Json(gateway_config_payload(record)).into_response()));
}
LocalMutationOutcome::NotFound if !expected_existing => {
return Ok(Some(build_payment_gateway_conflict_response(
"payment gateway config was created concurrently",
)));
}
LocalMutationOutcome::NotFound => {
existing_record =
state.app().find_payment_gateway_config(&provider).await?;
}
LocalMutationOutcome::Invalid(detail) => {
return Ok(Some(build_admin_payments_bad_request_response(detail)));
}
LocalMutationOutcome::Unavailable => {
return Ok(Some(build_admin_payments_backend_unavailable_response(
"payment gateway config backend unavailable",
)));
}
}
_ => Ok(Some(build_admin_payments_backend_unavailable_response(
"payment gateway config backend unavailable",
))),
}
Ok(Some(build_payment_gateway_conflict_response(
"payment gateway config changed too frequently; retry the request",
)))
}
Some("test_epay_gateway") | Some("test_payment_gateway") => {
let provider = admin_payment_gateway_provider_from_path(request_context.path())
.unwrap_or_else(|| "epay".to_string());
let Some(provider) = resolve_admin_payment_gateway_provider(
request_context.path(),
route_kind.expect("matched payment gateway route kind"),
) else {
return Ok(Some(build_admin_payments_bad_request_response(
"unsupported payment gateway provider",
)));
};
let status = state.app().find_payment_gateway_config(&provider).await?;
let ok = status
.as_ref()
@@ -493,3 +678,164 @@ pub(super) async fn maybe_build_local_admin_payment_gateways_response(
_ => Ok(None),
}
}
#[cfg(test)]
mod tests {
use aether_crypto::{encrypt_python_fernet_plaintext, DEVELOPMENT_ENCRYPTION_KEY};
use aether_data_contracts::repository::billing::PaymentGatewayConfigRecord;
use serde_json::{json, Value};
use super::{
legacy_secret_reuse_requires_reentry, merge_gateway_secret_maps,
resolve_admin_payment_gateway_provider,
};
use crate::handlers::shared::PaymentGatewaySecretBinding;
fn gateway_record(
endpoint_url: &str,
merchant_id: &str,
merchant_key_encrypted: Option<String>,
) -> PaymentGatewayConfigRecord {
PaymentGatewayConfigRecord {
provider: "stripe".to_string(),
enabled: true,
endpoint_url: endpoint_url.to_string(),
callback_base_url: None,
merchant_id: merchant_id.to_string(),
merchant_key_encrypted,
pay_currency: "USD".to_string(),
usd_exchange_rate: 1.0,
min_recharge_usd: 1.0,
channels_json: json!({}),
created_at_unix_secs: 1,
updated_at_unix_secs: 1,
}
}
#[test]
fn legacy_secret_reuse_requires_reentry_after_binding_change() {
let legacy = encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "legacy-secret")
.expect("legacy secret should encrypt");
let old_record = gateway_record("https://api.stripe.com", "merchant-old", Some(legacy));
let changed_binding =
PaymentGatewaySecretBinding::new("stripe", "https://api.stripe.com", "merchant-new")
.expect("changed binding should be valid");
assert!(legacy_secret_reuse_requires_reentry(
Some(&old_record),
&changed_binding,
));
let v2_record = gateway_record(
"https://api.stripe.com",
"merchant-old",
Some("aether-payment-gateway-secret-v2:legacy".to_string()),
);
assert!(legacy_secret_reuse_requires_reentry(
Some(&v2_record),
&changed_binding,
));
}
#[test]
fn legacy_secret_reuse_is_allowed_only_for_same_binding_or_bound_v3() {
let legacy = encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "legacy-secret")
.expect("legacy secret should encrypt");
let old_record =
gateway_record("https://API.STRIPE.COM:443/", "merchant-old", Some(legacy));
let same_binding = PaymentGatewaySecretBinding::new(
"stripe",
"https://api.stripe.com:443/",
" merchant-old ",
)
.expect("same binding should be valid");
assert!(!legacy_secret_reuse_requires_reentry(
Some(&old_record),
&same_binding,
));
let v3_record = gateway_record(
"https://api.stripe.com",
"merchant-old",
Some("aether-payment-gateway-secret-v3:bound".to_string()),
);
let changed_binding =
PaymentGatewaySecretBinding::new("stripe", "https://api.stripe.com", "merchant-new")
.expect("changed binding should be valid");
assert!(!legacy_secret_reuse_requires_reentry(
Some(&v3_record),
&changed_binding,
));
}
#[test]
fn stripe_secret_rotation_preserves_omitted_secret_fields() {
let existing = json!({
"secret_key": "old-secret-key",
"webhook_secret": "old-webhook"
})
.to_string();
let updates = json!({"webhook_secret": "new-webhook"})
.as_object()
.cloned()
.expect("updates should be an object");
let merged = Value::Object(
merge_gateway_secret_maps(Some(&existing), updates)
.expect("valid secret maps should merge"),
);
assert_eq!(merged["secret_key"], "old-secret-key");
assert_eq!(merged["webhook_secret"], "new-webhook");
}
#[test]
fn wxpay_secret_rotation_preserves_omitted_secret_fields() {
let existing = json!({
"private_key": "old-private",
"api_v3_key": "old-api-v3-key",
"public_key": "old-public"
})
.to_string();
let updates = json!({"api_v3_key": "new-api-v3-key"})
.as_object()
.cloned()
.expect("updates should be an object");
let merged = Value::Object(
merge_gateway_secret_maps(Some(&existing), updates)
.expect("valid secret maps should merge"),
);
assert_eq!(merged["private_key"], "old-private");
assert_eq!(merged["api_v3_key"], "new-api-v3-key");
assert_eq!(merged["public_key"], "old-public");
}
#[test]
fn generic_gateway_routes_never_fall_back_to_epay() {
assert_eq!(
resolve_admin_payment_gateway_provider(
"/api/admin/payments/gateways/stripe",
"update_payment_gateway",
)
.as_deref(),
Some("stripe")
);
assert!(resolve_admin_payment_gateway_provider(
"/api/admin/payments/gateways/unsupported",
"update_payment_gateway",
)
.is_none());
assert!(resolve_admin_payment_gateway_provider(
"/api/admin/payments/gateways/stripe/extra",
"get_payment_gateway",
)
.is_none());
assert_eq!(
resolve_admin_payment_gateway_provider(
"/api/admin/payments/epay",
"update_epay_gateway",
)
.as_deref(),
Some("epay")
);
}
}
@@ -11,6 +11,7 @@ mod redeem_codes;
mod routes;
mod shared;
pub(in crate::handlers::admin) use self::shared::admin_payment_gateway_response_projection;
use self::shared::{
admin_payment_operator_id, admin_payment_order_id_from_detail_path,
admin_payment_order_id_from_suffix_path, build_admin_payment_callback_payload_from_record,
@@ -19,7 +20,8 @@ use self::shared::{
build_admin_payments_bad_request_response, build_admin_payments_data_unavailable_response,
normalize_admin_payment_currency, normalize_admin_payment_optional_string,
normalize_admin_payment_positive_number, parse_admin_payments_limit,
parse_admin_payments_offset, AdminPaymentOrderCreditRequest,
parse_admin_payments_offset, prepare_admin_payment_gateway_response_for_storage,
AdminPaymentOrderCreditRequest,
};
pub(crate) async fn maybe_build_local_admin_payments_response(
@@ -5,7 +5,8 @@ use super::{
build_admin_payments_backend_unavailable_response, build_admin_payments_bad_request_response,
normalize_admin_payment_currency, normalize_admin_payment_optional_string,
normalize_admin_payment_positive_number, parse_admin_payments_limit,
parse_admin_payments_offset, AdminPaymentOrderCreditRequest,
parse_admin_payments_offset, prepare_admin_payment_gateway_response_for_storage,
AdminPaymentOrderCreditRequest,
};
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::handlers::admin::shared::{attach_admin_audit_response, query_param_value};
@@ -124,7 +125,9 @@ async fn close_direct_gateway_order_before_terminal_mark(
)))
}
};
if order.status != "pending" || !matches!(order.payment_method.as_str(), "alipay" | "wxpay") {
if order.status != "pending"
|| !matches!(order.payment_method.as_str(), "alipay" | "wxpay" | "stripe")
{
return Ok(None);
}
crate::handlers::shared::close_direct_gateway_order(state.app(), &order)
@@ -231,6 +234,8 @@ async fn build_admin_payment_credit_order_response(
"gateway_response 必须为对象",
));
}
let gateway_response =
prepare_admin_payment_gateway_response_for_storage(payload.gateway_response);
let operator_id = admin_payment_operator_id(request_context);
match state
.admin_credit_payment_order(
@@ -239,7 +244,7 @@ async fn build_admin_payment_credit_order_response(
pay_amount,
pay_currency.as_deref(),
exchange_rate,
payload.gateway_response,
gateway_response,
operator_id.as_deref(),
)
.await?
@@ -8,6 +8,7 @@ use crate::handlers::admin::shared::{
attach_admin_audit_response, query_param_value, unix_secs_to_rfc3339,
};
use crate::GatewayError;
use aether_data::repository::wallet::stored_timestamp_unix_secs;
use axum::{
body::Body,
http,
@@ -122,7 +123,7 @@ fn build_batch_payload(
"description": batch.description,
"created_by": batch.created_by,
"expires_at": batch.expires_at_unix_secs.and_then(unix_secs_to_rfc3339),
"created_at": unix_secs_to_rfc3339(batch.created_at_unix_ms),
"created_at": unix_secs_to_rfc3339(stored_timestamp_unix_secs(batch.created_at_unix_ms)),
"updated_at": unix_secs_to_rfc3339(batch.updated_at_unix_secs),
})
}
@@ -146,7 +147,7 @@ fn build_code_payload(
"redeemed_at": code.redeemed_at_unix_secs.and_then(unix_secs_to_rfc3339),
"disabled_by": code.disabled_by,
"expires_at": code.expires_at_unix_secs.and_then(unix_secs_to_rfc3339),
"created_at": unix_secs_to_rfc3339(code.created_at_unix_ms),
"created_at": unix_secs_to_rfc3339(stored_timestamp_unix_secs(code.created_at_unix_ms)),
"updated_at": unix_secs_to_rfc3339(code.updated_at_unix_secs),
})
}
@@ -1,17 +1,19 @@
use crate::handlers::admin::request::AdminRequestContext;
use crate::handlers::admin::shared::{query_param_value, unix_secs_to_rfc3339};
use crate::handlers::shared::normalize_payment_currency;
use crate::GatewayAdminPaymentCallbackView;
use aether_data::repository::wallet::stored_timestamp_unix_secs;
use axum::{
body::Body,
http,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use serde_json::{json, Value};
const ADMIN_PAYMENTS_DATA_UNAVAILABLE_DETAIL: &str = "Admin payments data unavailable";
#[derive(Debug, Default, serde::Deserialize)]
#[derive(Default, serde::Deserialize)]
pub(super) struct AdminPaymentOrderCreditRequest {
#[serde(default)]
pub(super) gateway_order_id: Option<String>,
@@ -25,6 +27,22 @@ pub(super) struct AdminPaymentOrderCreditRequest {
pub(super) gateway_response: Option<serde_json::Value>,
}
impl std::fmt::Debug for AdminPaymentOrderCreditRequest {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("AdminPaymentOrderCreditRequest")
.field("gateway_order_id", &self.gateway_order_id)
.field("pay_amount", &self.pay_amount)
.field("pay_currency", &self.pay_currency)
.field("exchange_rate", &self.exchange_rate)
.field(
"gateway_response",
&self.gateway_response.as_ref().map(|_| "[REDACTED]"),
)
.finish()
}
}
pub(super) fn build_admin_payments_data_unavailable_response() -> Response<Body> {
(
http::StatusCode::SERVICE_UNAVAILABLE,
@@ -153,11 +171,9 @@ pub(super) fn normalize_admin_payment_currency(
let Some(value) = normalize_admin_payment_optional_string(value, "pay_currency", 3)? else {
return Ok(None);
};
let normalized = value.to_ascii_uppercase();
if normalized.len() != 3 {
return Err("pay_currency 必须是 3 位货币代码".to_string());
}
Ok(Some(normalized))
normalize_payment_currency(&value, "pay_currency")
.map(Some)
.map_err(|_| "pay_currency 必须是 3 位 ASCII 货币代码".to_string())
}
pub(super) fn normalize_admin_payment_positive_number(
@@ -187,13 +203,184 @@ pub(super) fn admin_payment_effective_status(
expires_at_unix_secs: Option<u64>,
) -> String {
let now_unix_secs = chrono::Utc::now().timestamp().max(0) as u64;
if status == "pending" && expires_at_unix_secs.is_some_and(|value| value < now_unix_secs) {
if status == "pending" && expires_at_unix_secs.is_some_and(|value| value <= now_unix_secs) {
"expired".to_string()
} else {
status.to_string()
}
}
fn admin_payment_bounded_string(value: &Value, max_chars: usize) -> Option<Value> {
let value = value.as_str()?.trim();
(!value.is_empty() && value.chars().count() <= max_chars)
.then(|| Value::String(value.to_string()))
}
fn admin_payment_identifier(value: &Value, max_chars: usize) -> Option<Value> {
let value = value.as_str()?.trim();
(!value.is_empty()
&& value.chars().count() <= max_chars
&& value
.chars()
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-')))
.then(|| Value::String(value.to_string()))
}
fn admin_payment_gateway_response_field(key: &str, value: &Value) -> Option<Value> {
match key {
"gateway" | "submit_method" | "payment_channel" => admin_payment_identifier(value, 64),
"pay_currency" => admin_payment_identifier(value, 16),
"display_name" | "provider_label" => admin_payment_bounded_string(value, 128),
"gateway_order_id" | "intent_id" => admin_payment_bounded_string(value, 256),
"expires_at" => admin_payment_bounded_string(value, 64),
"pay_amount" | "base_pay_amount" | "fee_rate" | "fee_amount" => {
value.is_number().then(|| value.clone())
}
"manual_credit" => value.as_bool().map(Value::Bool),
"payment_method_types" => {
let values = value.as_array()?;
if values.len() > 16 {
return None;
}
values
.iter()
.map(|value| admin_payment_identifier(value, 64))
.collect::<Option<Vec<_>>>()
.map(Value::Array)
}
_ => None,
}
}
pub(in crate::handlers::admin) fn admin_payment_gateway_response_projection(
value: Option<&Value>,
) -> Value {
let Some(object) = value.and_then(Value::as_object) else {
return Value::Null;
};
Value::Object(
object
.iter()
.filter_map(|(key, value)| {
admin_payment_gateway_response_field(key, value).map(|value| (key.clone(), value))
})
.collect(),
)
}
pub(super) fn prepare_admin_payment_gateway_response_for_storage(
value: Option<Value>,
) -> Option<Value> {
value.map(|value| admin_payment_gateway_response_projection(Some(&value)))
}
#[derive(Default)]
struct AdminPaymentJsonShape {
objects: u64,
arrays: u64,
strings: u64,
numbers: u64,
booleans: u64,
nulls: u64,
object_fields: u64,
array_items: u64,
max_depth: u64,
}
impl AdminPaymentJsonShape {
fn observe(&mut self, value: &Value, depth: u64) {
self.max_depth = self.max_depth.max(depth);
match value {
Value::Object(object) => {
self.objects = self.objects.saturating_add(1);
self.object_fields = self
.object_fields
.saturating_add(u64::try_from(object.len()).unwrap_or(u64::MAX));
for value in object.values() {
self.observe(value, depth.saturating_add(1));
}
}
Value::Array(values) => {
self.arrays = self.arrays.saturating_add(1);
self.array_items = self
.array_items
.saturating_add(u64::try_from(values.len()).unwrap_or(u64::MAX));
for value in values {
self.observe(value, depth.saturating_add(1));
}
}
Value::String(_) => self.strings = self.strings.saturating_add(1),
Value::Number(_) => self.numbers = self.numbers.saturating_add(1),
Value::Bool(_) => self.booleans = self.booleans.saturating_add(1),
Value::Null => self.nulls = self.nulls.saturating_add(1),
}
}
}
fn admin_payment_json_kind(value: &Value) -> &'static str {
match value {
Value::Null => "null",
Value::Bool(_) => "boolean",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
}
}
fn admin_payment_payload_summary(value: Option<&Value>) -> Value {
let Some(value) = value else {
return Value::Null;
};
let mut shape = AdminPaymentJsonShape::default();
shape.observe(value, 1);
json!({
"kind": admin_payment_json_kind(value),
"serialized_bytes": serde_json::to_vec(value).map_or(0, |encoded| encoded.len()),
"objects": shape.objects,
"arrays": shape.arrays,
"strings": shape.strings,
"numbers": shape.numbers,
"booleans": shape.booleans,
"nulls": shape.nulls,
"object_fields": shape.object_fields,
"array_items": shape.array_items,
"max_depth": shape.max_depth,
})
}
fn admin_payment_callback_error_projection(value: Option<&str>) -> Option<String> {
const SAFE_ERRORS: &[&str] = &[
"callback amount mismatch",
"callback key reused with different payment payload",
"invalid callback signature",
"invalid payment callback numeric or identity fields",
"payment channel mismatch",
"payment currency mismatch",
"payment gateway order belongs to another payment order",
"payment gateway order identifier mismatch",
"payment gateway order mismatch",
"payment method mismatch",
"payment order expired",
"payment order not found",
"payment order number mismatch",
"payment order user missing",
"payment provider mismatch",
"plan purchase limit reached",
"wallet is not active",
"wallet not found",
];
let value = value?.trim();
if SAFE_ERRORS.contains(&value) {
return Some(value.to_string());
}
if value.starts_with("payment order is not creditable:") {
return Some("payment order is not creditable".to_string());
}
Some("payment callback processing failed".to_string())
}
pub(super) fn build_admin_payment_order_payload(
record: &crate::AdminWalletPaymentOrderRecord,
) -> serde_json::Value {
@@ -210,9 +397,10 @@ pub(super) fn build_admin_payment_order_payload(
"refundable_amount_usd": record.refundable_amount_usd,
"payment_method": record.payment_method,
"gateway_order_id": record.gateway_order_id,
"gateway_response": record.gateway_response,
"gateway_response": admin_payment_gateway_response_projection(record.gateway_response.as_ref()),
"has_gateway_response": record.gateway_response.is_some(),
"status": admin_payment_effective_status(&record.status, record.expires_at_unix_secs),
"created_at": unix_secs_to_rfc3339(record.created_at_unix_ms),
"created_at": unix_secs_to_rfc3339(stored_timestamp_unix_secs(record.created_at_unix_ms)),
"paid_at": record.paid_at_unix_secs.and_then(unix_secs_to_rfc3339),
"credited_at": record.credited_at_unix_secs.and_then(unix_secs_to_rfc3339),
"expires_at": record.expires_at_unix_secs.and_then(unix_secs_to_rfc3339),
@@ -232,9 +420,196 @@ pub(super) fn build_admin_payment_callback_payload_from_record(
"payload_hash": record.payload_hash,
"signature_valid": record.signature_valid,
"status": record.status,
"payload": record.payload,
"error_message": record.error_message,
"created_at": unix_secs_to_rfc3339(record.created_at_unix_ms),
"payload": Value::Null,
"has_payload": record.payload.is_some(),
"payload_summary": admin_payment_payload_summary(record.payload.as_ref()),
"error_message": admin_payment_callback_error_projection(record.error_message.as_deref()),
"has_error_message": record.error_message.is_some(),
"created_at": unix_secs_to_rfc3339(stored_timestamp_unix_secs(record.created_at_unix_ms)),
"processed_at": record.processed_at_unix_secs.and_then(unix_secs_to_rfc3339),
})
}
#[cfg(test)]
mod tests {
use super::{
build_admin_payment_callback_payload_from_record, build_admin_payment_order_payload,
prepare_admin_payment_gateway_response_for_storage,
};
use crate::{AdminWalletPaymentOrderRecord, GatewayAdminPaymentCallbackView};
use serde_json::json;
#[test]
fn admin_payment_order_projection_excludes_replayable_gateway_fields() {
let record = AdminWalletPaymentOrderRecord {
id: "order-1".to_string(),
order_no: "merchant-order-1".to_string(),
wallet_id: "wallet-1".to_string(),
user_id: Some("user-1".to_string()),
amount_usd: 10.0,
pay_amount: Some(72.0),
pay_currency: Some("CNY".to_string()),
exchange_rate: Some(7.2),
refunded_amount_usd: 0.0,
refundable_amount_usd: 0.0,
payment_method: "stripe".to_string(),
gateway_order_id: Some("pi_1".to_string()),
status: "pending".to_string(),
gateway_response: Some(json!({
"gateway": "stripe",
"intent_id": "pi_1",
"client_secret": "pi_1_secret_replayable",
"payment_url": "https://pay.example/checkout?token=secret",
"payment_params": {"sign": "signed-secret"},
"customer_email": "[email protected]"
})),
created_at_unix_ms: 1,
paid_at_unix_secs: None,
credited_at_unix_secs: None,
expires_at_unix_secs: None,
};
let payload = build_admin_payment_order_payload(&record);
assert_eq!(payload["has_gateway_response"], true);
assert_eq!(
payload.pointer("/gateway_response/gateway"),
Some(&json!("stripe"))
);
assert_eq!(
payload.pointer("/gateway_response/intent_id"),
Some(&json!("pi_1"))
);
for key in [
"client_secret",
"payment_url",
"payment_params",
"customer_email",
] {
assert!(payload
.pointer(&format!("/gateway_response/{key}"))
.is_none());
}
}
#[test]
fn admin_payment_order_projection_rejects_nested_or_mistyped_safe_fields() {
let mut record = AdminWalletPaymentOrderRecord {
id: "order-1".to_string(),
order_no: "merchant-order-1".to_string(),
wallet_id: "wallet-1".to_string(),
user_id: Some("user-1".to_string()),
amount_usd: 10.0,
pay_amount: Some(72.0),
pay_currency: Some("CNY".to_string()),
exchange_rate: Some(7.2),
refunded_amount_usd: 0.0,
refundable_amount_usd: 0.0,
payment_method: "stripe".to_string(),
gateway_order_id: Some("pi_1".to_string()),
status: "pending".to_string(),
gateway_response: None,
created_at_unix_ms: 1,
paid_at_unix_secs: None,
credited_at_unix_secs: None,
expires_at_unix_secs: None,
};
record.gateway_response = Some(json!({
"gateway": {"client_secret": "secret-in-nested-object"},
"intent_id": ["pi_1", "secret-in-array"],
"payment_method_types": ["card", {"secret": "nested"}],
"manual_credit": "secret-in-string",
}));
let encoded = build_admin_payment_order_payload(&record).to_string();
assert!(!encoded.contains("secret-in-nested-object"));
assert!(!encoded.contains("secret-in-array"));
assert!(!encoded.contains("nested"));
assert!(!encoded.contains("secret-in-string"));
}
#[test]
fn admin_payment_gateway_response_is_projected_before_storage() {
let projected = prepare_admin_payment_gateway_response_for_storage(Some(json!({
"gateway": "stripe",
"intent_id": "pi_1",
"client_secret": "pi_1_secret_replayable",
"customer": {"email": "[email protected]"},
"payment_params": {"authorization": "Bearer secret"},
})))
.expect("provided gateway response should remain present");
assert_eq!(projected, json!({"gateway": "stripe", "intent_id": "pi_1"}));
let encoded = projected.to_string();
for forbidden in [
"client_secret",
"replayable",
"customer",
"[email protected]",
"authorization",
"Bearer secret",
] {
assert!(!encoded.contains(forbidden), "persisted {forbidden}");
}
}
#[test]
fn admin_payment_callback_projection_does_not_return_raw_payload() {
let record = GatewayAdminPaymentCallbackView {
id: "callback-1".to_string(),
payment_order_id: Some("order-1".to_string()),
payment_method: "stripe".to_string(),
callback_key: "stripe:event-1".to_string(),
order_no: Some("merchant-order-1".to_string()),
gateway_order_id: Some("pi_1".to_string()),
payload_hash: Some("hash-1".to_string()),
signature_valid: true,
status: "processed".to_string(),
payload: Some(json!({
"data": {"object": {"client_secret": "secret", "customer_email": "[email protected]"}}
})),
error_message: None,
created_at_unix_ms: 1,
processed_at_unix_secs: Some(1),
};
let payload = build_admin_payment_callback_payload_from_record(&record);
assert_eq!(payload["has_payload"], true);
assert!(payload["payload"].is_null());
assert_eq!(payload["payload_summary"]["kind"], "object");
assert_eq!(payload["payload_summary"]["objects"], 3);
assert_eq!(payload["payload_summary"]["strings"], 2);
assert_eq!(payload["payload_summary"]["max_depth"], 4);
let encoded = payload.to_string();
assert!(!encoded.contains("customer_email"));
assert!(!encoded.contains("[email protected]"));
assert!(!encoded.contains("client_secret"));
assert!(!encoded.contains("secret"));
}
#[test]
fn admin_payment_callback_projection_does_not_return_unknown_historical_errors() {
let record = GatewayAdminPaymentCallbackView {
id: "callback-1".to_string(),
payment_order_id: None,
payment_method: "stripe".to_string(),
callback_key: "stripe:event-1".to_string(),
order_no: None,
gateway_order_id: None,
payload_hash: None,
signature_valid: false,
status: "failed".to_string(),
payload: None,
error_message: Some("upstream rejected sk_live_secret_value".to_string()),
created_at_unix_ms: 1,
processed_at_unix_secs: Some(1),
};
let payload = build_admin_payment_callback_payload_from_record(&record);
assert_eq!(payload["has_error_message"], true);
assert_eq!(
payload["error_message"],
"payment callback processing failed"
);
assert!(!payload.to_string().contains("sk_live_secret_value"));
}
}
@@ -3,8 +3,12 @@ use super::{
build_admin_billing_data_unavailable_response, build_admin_billing_not_found_response,
};
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::handlers::shared::normalize_payment_currency;
use crate::{GatewayError, LocalMutationOutcome};
use aether_data_contracts::repository::billing::{BillingPlanRecord, BillingPlanWriteInput};
use aether_data_contracts::repository::billing::{
checked_plan_duration_days, parse_usage_policy_entitlements,
validate_entitlement_replacement_groups, BillingPlanRecord, BillingPlanWriteInput,
};
use axum::{
body::{Body, Bytes},
http,
@@ -172,11 +176,16 @@ fn validate_entitlements(value: &serde_json::Value) -> Result<(), String> {
}
}
}
"usage_policy" => {}
_ => return Err(format!("unsupported entitlement type: {kind}")),
}
}
validate_entitlement_replacement_groups(value).map_err(|error| error.to_string())?;
parse_usage_policy_entitlements(value).map_err(|error| error.to_string())?;
if !entitlements_include_package_rights(items) {
return Err("套餐至少需要包含每日额度或会员分组;钱包充值请使用充值功能".to_string());
return Err(
"套餐至少需要包含每日额度、会员分组或使用限制;钱包充值请使用充值功能".to_string(),
);
}
Ok(())
}
@@ -185,7 +194,7 @@ fn entitlements_include_package_rights(items: &[serde_json::Value]) -> bool {
items.iter().any(|item| {
matches!(
item.get("type").and_then(|value| value.as_str()),
Some("daily_quota" | "membership_group")
Some("daily_quota" | "membership_group" | "usage_policy")
)
})
}
@@ -204,6 +213,7 @@ fn normalize_plan_input(payload: BillingPlanRequest) -> Result<BillingPlanWriteI
if !matches!(duration_unit.as_str(), "day" | "month" | "year" | "custom") {
return Err("duration_unit must be day/month/year/custom".to_string());
}
checked_plan_duration_days(&duration_unit, payload.duration_value)?;
let purchase_limit_scope =
normalize_text(payload.purchase_limit_scope, "purchase_limit_scope", 32)?;
if !matches!(
@@ -213,11 +223,12 @@ fn normalize_plan_input(payload: BillingPlanRequest) -> Result<BillingPlanWriteI
return Err("purchase_limit_scope must be active_period/lifetime/unlimited".to_string());
}
validate_entitlements(&payload.entitlements)?;
let price_currency = normalize_payment_currency(&payload.price_currency, "price_currency")?;
Ok(BillingPlanWriteInput {
title: normalize_text(payload.title, "title", 128)?,
description: normalize_optional_text(payload.description, 2048)?,
price_amount: payload.price_amount,
price_currency: normalize_text(payload.price_currency, "price_currency", 16)?,
price_currency,
duration_unit,
duration_value: payload.duration_value,
enabled: payload.enabled,
@@ -9,6 +9,7 @@ use super::{
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::handlers::admin::shared::unix_secs_to_rfc3339;
use crate::GatewayError;
use aether_data::repository::wallet::stored_timestamp_unix_secs;
use axum::{
body::{Body, Bytes},
http,
@@ -53,7 +54,7 @@ fn build_admin_billing_rule_payload_from_record(
"variables": record.variables,
"dimension_mappings": record.dimension_mappings,
"is_enabled": record.is_enabled,
"created_at": unix_secs_to_rfc3339(record.created_at_unix_ms),
"created_at": unix_secs_to_rfc3339(stored_timestamp_unix_secs(record.created_at_unix_ms)),
"updated_at": unix_secs_to_rfc3339(record.updated_at_unix_secs),
})
}
@@ -10,6 +10,7 @@ use super::super::shared::{
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::handlers::admin::shared::{attach_admin_audit_response, unix_secs_to_rfc3339};
use crate::GatewayError;
use aether_data::repository::wallet::stored_timestamp_unix_secs;
use axum::{
body::Body,
response::{IntoResponse, Response},
@@ -98,7 +99,7 @@ pub(in super::super) async fn build_admin_wallet_adjust_response(
transaction.link_id.as_deref(),
transaction.operator_id.as_deref(),
transaction.description.as_deref(),
unix_secs_to_rfc3339(transaction.created_at_unix_ms),
unix_secs_to_rfc3339(stored_timestamp_unix_secs(transaction.created_at_unix_ms)),
);
let response = Json(json!({
"wallet": wallet_payload,
@@ -13,12 +13,55 @@ use crate::handlers::shared::{
use crate::GatewayError;
use axum::{
body::Body,
http,
response::{IntoResponse, Response},
Json,
};
use serde_json::{json, Value};
use tracing::warn;
fn is_safe_gateway_refund_id(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 128
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
}
fn gateway_refund_mode_allowed(refund_mode: &str) -> bool {
refund_mode.trim().eq_ignore_ascii_case("original_channel")
}
fn stored_refund_to_gateway(
refund: aether_data::repository::wallet::StoredAdminWalletRefund,
) -> crate::AdminWalletRefundRecord {
crate::AdminWalletRefundRecord {
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,
}
}
fn merge_gateway_refund_proof(
proof: Option<Value>,
gateway_refund: Option<&crate::handlers::shared::DirectGatewayRefundResult>,
@@ -29,14 +72,7 @@ fn merge_gateway_refund_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,
}),
);
object.insert("gateway_refund".to_string(), gateway_refund.proof.clone());
Some(Value::Object(object))
}
@@ -64,7 +100,12 @@ pub(in super::super) async fn build_admin_wallet_complete_refund_response(
"gateway_refund_id",
128,
) {
Ok(value) => value,
Ok(value) if value.as_deref().is_none_or(is_safe_gateway_refund_id) => value,
Ok(_) => {
return Ok(build_admin_wallets_bad_request_response(
"gateway_refund_id 格式无效",
))
}
Err(detail) => return Ok(build_admin_wallets_bad_request_response(detail)),
};
let payout_reference = match normalize_admin_wallet_optional_text(
@@ -107,9 +148,55 @@ pub(in super::super) async fn build_admin_wallet_complete_refund_response(
else {
return Ok(build_admin_wallet_refund_not_found_response());
};
let refund_before_complete = stored_refund_to_gateway(refund_before_complete);
if !refund_before_complete.amount_usd.is_finite() || refund_before_complete.amount_usd <= 0.0 {
return Ok(build_admin_wallets_bad_request_response("退款金额无效"));
}
if refund_before_complete.status == "succeeded" {
if let Some(order_id) = refund_before_complete.payment_order_id.as_deref() {
if let Err(err) = state
.app()
.reverse_referral_rewards_for_order(order_id, refund_before_complete.amount_usd)
.await
{
warn!(
error = ?err,
order_id = %order_id,
refund_id = %refund_before_complete.id,
"failed to reconcile referral rewards for completed refund"
);
return Ok(build_admin_wallets_data_unavailable_response());
}
}
let response = Json(json!({
"refund": build_admin_wallet_refund_payload(
&wallet,
&owner,
&refund_before_complete,
),
}))
.into_response();
return Ok(attach_admin_audit_response(
response,
"admin_wallet_refund_completed",
"complete_wallet_refund",
"wallet_refund",
&refund_id,
));
}
let mut gateway_refund_id = gateway_refund_id;
let mut payout_proof = payload.payout_proof;
if payload.gateway_refund {
// A line-item refund in `offline_payout` mode has no provider-side
// settlement contract. Calling a gateway before recording evidence
// would let `/fail` concurrently release the local reservation and
// leave an external refund with no durable proof. Keep the mode
// constraint at the boundary, before any network request.
if !gateway_refund_mode_allowed(&refund_before_complete.refund_mode) {
return Ok(build_admin_wallets_bad_request_response(
"只有原支付渠道退款可以调用支付网关",
));
}
let Some(payment_order_id) = refund_before_complete.payment_order_id.as_deref() else {
return Ok(build_admin_wallets_bad_request_response(
"网关原路退款需要退款申请关联支付订单",
@@ -120,8 +207,10 @@ pub(in super::super) async fn build_admin_wallet_complete_refund_response(
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::Invalid(_) => {
return Ok(build_admin_wallets_bad_request_response(
"支付订单状态或数据无效",
))
}
crate::AdminWalletMutationOutcome::Unavailable => {
return Ok(build_admin_wallets_data_unavailable_response())
@@ -150,14 +239,96 @@ pub(in super::super) async fn build_admin_wallet_complete_refund_response(
{
Ok(Some(result)) => {
gateway_refund_id = Some(result.gateway_refund_id.clone());
if result.is_pending() {
let persisted = match state
.app()
.update_admin_wallet_refund_gateway(
aether_data::repository::wallet::UpdateAdminWalletRefundGatewayInput {
wallet_id: wallet_id.clone(),
refund_id: refund_id.clone(),
gateway_refund_id: result.gateway_refund_id.clone(),
payout_proof: Some(result.proof.clone()),
},
)
.await?
{
Some(aether_data::repository::wallet::WalletMutationOutcome::Applied(
refund,
)) => refund,
Some(aether_data::repository::wallet::WalletMutationOutcome::NotFound) => {
return Ok(build_admin_wallet_refund_not_found_response())
}
Some(aether_data::repository::wallet::WalletMutationOutcome::Invalid(
detail,
)) => return Ok(build_admin_wallets_bad_request_response(detail)),
None => return Ok(build_admin_wallets_data_unavailable_response()),
};
let persisted = stored_refund_to_gateway(persisted);
let response = (
http::StatusCode::ACCEPTED,
Json(json!({
"refund": build_admin_wallet_refund_payload(&wallet, &owner, &persisted),
"gateway_refund": {
"id": result.gateway_refund_id,
"status": result.status,
},
})),
)
.into_response();
return Ok(attach_admin_audit_response(
response,
"admin_wallet_refund_pending",
"complete_wallet_refund",
"wallet_refund",
&refund_id,
));
}
if !result.is_succeeded() {
return Ok(build_admin_wallets_bad_request_response("上游退款未成功"));
}
payout_proof = merge_gateway_refund_proof(payout_proof, Some(&result));
// Persist the provider evidence before releasing the local refund reservation.
// If the local completion transaction fails after a successful gateway call,
// a retry can reuse the idempotent gateway identifier instead of issuing a
// second refund with no durable proof of the first one.
match state
.app()
.update_admin_wallet_refund_gateway(
aether_data::repository::wallet::UpdateAdminWalletRefundGatewayInput {
wallet_id: wallet_id.clone(),
refund_id: refund_id.clone(),
gateway_refund_id: result.gateway_refund_id.clone(),
payout_proof: payout_proof.clone(),
},
)
.await?
{
Some(aether_data::repository::wallet::WalletMutationOutcome::Applied(_)) => {}
Some(aether_data::repository::wallet::WalletMutationOutcome::NotFound) => {
return Ok(build_admin_wallet_refund_not_found_response())
}
Some(aether_data::repository::wallet::WalletMutationOutcome::Invalid(
detail,
)) => return Ok(build_admin_wallets_bad_request_response(detail)),
None => return Ok(build_admin_wallets_data_unavailable_response()),
}
}
Ok(None) => {
return Ok(build_admin_wallets_bad_request_response(
"该支付方式不支持官方直连退款,请使用线下完成",
))
}
Err(detail) => return Ok(build_admin_wallets_bad_request_response(detail)),
Err(detail) => {
warn!(
error = %detail,
refund_id = %refund_id,
"direct payment gateway refund failed"
);
return Ok(build_admin_wallets_bad_request_response(
"支付网关退款请求失败",
));
}
}
}
match state
@@ -183,6 +354,7 @@ pub(in super::super) async fn build_admin_wallet_complete_refund_response(
refund_id = %refund.id,
"failed to reverse referral rewards for completed refund"
);
return Ok(build_admin_wallets_data_unavailable_response());
}
}
let response = Json(json!({
@@ -204,7 +376,7 @@ pub(in super::super) async fn build_admin_wallet_complete_refund_response(
let detail = if detail == "refund status must be processing before completion" {
"只有 processing 状态的退款可以标记完成".to_string()
} else {
detail
"退款状态或参数无效".to_string()
};
Ok(build_admin_wallets_bad_request_response(detail))
}
@@ -213,3 +385,70 @@ pub(in super::super) async fn build_admin_wallet_complete_refund_response(
}
}
}
#[cfg(test)]
mod tests {
use super::{
gateway_refund_mode_allowed, is_safe_gateway_refund_id, merge_gateway_refund_proof,
};
use crate::handlers::shared::DirectGatewayRefundResult;
use serde_json::json;
#[test]
fn gateway_refund_merge_replaces_legacy_raw_payload() {
let existing = json!({
"channel": "manual",
"gateway_refund": {
"payload": {
"authorization": "Bearer legacy-secret",
"payer": {"openid": "openid-secret"}
}
}
});
let result = DirectGatewayRefundResult {
gateway_refund_id: "refund-1".to_string(),
status: "success".to_string(),
proof: json!({
"gateway": "wxpay",
"id": "refund-1",
"status": "success",
"order_no": "order-1",
"refund_no": "request-1",
"amount": 8.5,
"currency": "CNY",
"processed_at": "2026-08-27T12:00:00Z"
}),
};
let merged = merge_gateway_refund_proof(Some(existing), Some(&result))
.expect("gateway proof should be merged");
assert_eq!(merged["channel"], "manual");
assert_eq!(merged["gateway_refund"], result.proof);
let encoded = merged.to_string();
assert!(!encoded.contains("legacy-secret"));
assert!(!encoded.contains("openid-secret"));
assert!(!encoded.contains("payload"));
}
#[test]
fn manual_gateway_refund_ids_use_the_same_strict_identifier_policy() {
assert!(is_safe_gateway_refund_id("refund_123-ABC"));
for value in [
"Authorization: Bearer secret",
"https://internal.example/refund?token=secret",
"refund id",
"payer/openid",
] {
assert!(!is_safe_gateway_refund_id(value));
}
assert!(!is_safe_gateway_refund_id(&"a".repeat(129)));
}
#[test]
fn gateway_refunds_are_limited_to_original_channel_mode() {
assert!(gateway_refund_mode_allowed("original_channel"));
assert!(gateway_refund_mode_allowed(" Original_Channel "));
assert!(!gateway_refund_mode_allowed("offline_payout"));
assert!(!gateway_refund_mode_allowed(""));
}
}
@@ -10,6 +10,7 @@ use super::super::shared::{
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::handlers::admin::shared::{attach_admin_audit_response, unix_secs_to_rfc3339};
use crate::GatewayError;
use aether_data::repository::wallet::stored_timestamp_unix_secs;
use axum::{
body::Body,
response::{IntoResponse, Response},
@@ -86,7 +87,7 @@ pub(in super::super) async fn build_admin_wallet_fail_refund_response(
transaction.link_id.as_deref(),
transaction.operator_id.as_deref(),
transaction.description.as_deref(),
unix_secs_to_rfc3339(transaction.created_at_unix_ms),
unix_secs_to_rfc3339(stored_timestamp_unix_secs(transaction.created_at_unix_ms)),
)
})
.unwrap_or(serde_json::Value::Null),
@@ -9,6 +9,7 @@ use super::super::shared::{
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::handlers::admin::shared::{attach_admin_audit_response, unix_secs_to_rfc3339};
use crate::GatewayError;
use aether_data::repository::wallet::stored_timestamp_unix_secs;
use axum::{
body::Body,
response::{IntoResponse, Response},
@@ -71,7 +72,7 @@ pub(in super::super) async fn build_admin_wallet_process_refund_response(
transaction.link_id.as_deref(),
transaction.operator_id.as_deref(),
transaction.description.as_deref(),
unix_secs_to_rfc3339(transaction.created_at_unix_ms),
unix_secs_to_rfc3339(stored_timestamp_unix_secs(transaction.created_at_unix_ms)),
),
}))
.into_response();
@@ -10,6 +10,7 @@ use super::super::shared::{
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::handlers::admin::shared::{attach_admin_audit_response, unix_secs_to_rfc3339};
use crate::GatewayError;
use aether_data::repository::wallet::stored_timestamp_unix_secs;
use axum::{
body::Body,
response::{IntoResponse, Response},
@@ -89,7 +90,7 @@ pub(in super::super) async fn build_admin_wallet_recharge_response(
payment_order.amount_usd,
payment_order.payment_method,
payment_order.status,
unix_secs_to_rfc3339(payment_order.created_at_unix_ms),
unix_secs_to_rfc3339(stored_timestamp_unix_secs(payment_order.created_at_unix_ms)),
payment_order
.credited_at_unix_secs
.and_then(unix_secs_to_rfc3339),
@@ -6,6 +6,7 @@ use super::super::shared::{
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::handlers::admin::shared::{query_param_value, unix_secs_to_rfc3339};
use crate::GatewayError;
use aether_data::repository::wallet::stored_timestamp_unix_secs;
use axum::{
body::Body,
response::{IntoResponse, Response},
@@ -69,7 +70,10 @@ pub(in super::super) async fn build_admin_wallet_ledger_response(
"operator_name": entry.operator_name,
"operator_email": entry.operator_email,
"description": entry.description,
"created_at": entry.created_at_unix_ms.and_then(unix_secs_to_rfc3339),
"created_at": entry
.created_at_unix_ms
.map(stored_timestamp_unix_secs)
.and_then(unix_secs_to_rfc3339),
})
})
.collect::<Vec<_>>();
@@ -6,6 +6,7 @@ use super::super::shared::{
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::handlers::admin::shared::{query_param_value, unix_secs_to_rfc3339};
use crate::GatewayError;
use aether_data::repository::wallet::stored_timestamp_unix_secs;
use axum::{
body::Body,
response::{IntoResponse, Response},
@@ -61,7 +62,10 @@ pub(in super::super) async fn build_admin_wallet_list_response(
"total_consumed": wallet.total_consumed,
"total_refunded": wallet.total_refunded,
"total_adjusted": wallet.total_adjusted,
"created_at": wallet.created_at_unix_ms.and_then(unix_secs_to_rfc3339),
"created_at": wallet
.created_at_unix_ms
.map(stored_timestamp_unix_secs)
.and_then(unix_secs_to_rfc3339),
"updated_at": wallet.updated_at_unix_secs.and_then(unix_secs_to_rfc3339),
});
enrich_admin_wallet_package_summary(
@@ -1,12 +1,13 @@
use super::super::shared::{
build_admin_wallets_bad_request_response, parse_admin_wallets_limit,
parse_admin_wallets_offset, parse_admin_wallets_owner_type_filter,
admin_wallet_payout_proof_projection, build_admin_wallets_bad_request_response,
parse_admin_wallets_limit, parse_admin_wallets_offset, parse_admin_wallets_owner_type_filter,
resolve_admin_wallet_owner_summary, wallet_owner_summary_from_fields,
ADMIN_WALLETS_API_KEY_REFUND_DETAIL,
};
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::handlers::admin::shared::{query_param_value, unix_secs_to_rfc3339};
use crate::GatewayError;
use aether_data::repository::wallet::stored_timestamp_unix_secs;
use axum::{
body::Body,
response::{IntoResponse, Response},
@@ -40,6 +41,7 @@ pub(in super::super) async fn build_admin_wallet_refund_requests_response(
.await?;
let mut items = Vec::with_capacity(refunds.len());
for refund in refunds {
let payout_proof = admin_wallet_payout_proof_projection(refund.payout_proof.as_ref());
let mut owner = wallet_owner_summary_from_fields(
refund.wallet_user_id.as_deref(),
refund.wallet_user_name.clone(),
@@ -75,11 +77,14 @@ pub(in super::super) async fn build_admin_wallet_refund_requests_response(
"gateway_refund_id": refund.gateway_refund_id,
"payout_method": refund.payout_method,
"payout_reference": refund.payout_reference,
"payout_proof": refund.payout_proof,
"payout_proof": payout_proof,
"requested_by": refund.requested_by,
"approved_by": refund.approved_by,
"processed_by": refund.processed_by,
"created_at": refund.created_at_unix_ms.and_then(unix_secs_to_rfc3339),
"created_at": refund
.created_at_unix_ms
.map(stored_timestamp_unix_secs)
.and_then(unix_secs_to_rfc3339),
"updated_at": refund.updated_at_unix_secs.and_then(unix_secs_to_rfc3339),
"processed_at": refund.processed_at_unix_secs.and_then(unix_secs_to_rfc3339),
"completed_at": refund.completed_at_unix_secs.and_then(unix_secs_to_rfc3339),
@@ -6,6 +6,7 @@ use super::super::shared::{
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::handlers::admin::shared::unix_secs_to_rfc3339;
use crate::GatewayError;
use aether_data::repository::wallet::stored_timestamp_unix_secs;
use axum::{
body::Body,
response::{IntoResponse, Response},
@@ -81,7 +82,10 @@ pub(in super::super) async fn build_admin_wallet_transactions_response(
"operator_name": operator_name,
"operator_email": operator_email,
"description": transaction.description,
"created_at": transaction.created_at_unix_ms.and_then(unix_secs_to_rfc3339),
"created_at": transaction
.created_at_unix_ms
.map(stored_timestamp_unix_secs)
.and_then(unix_secs_to_rfc3339),
}));
}
@@ -51,11 +51,12 @@ pub(in super::super) fn normalize_admin_wallet_optional_text(
pub(in super::super) fn normalize_admin_wallet_payment_method(
value: String,
) -> Result<String, String> {
let normalized = value.trim();
if normalized.is_empty() {
return Err("payment_method 不能为空".to_string());
let normalized = aether_data::repository::wallet::canonicalize_payment_method(&value)
.map_err(|detail| format!("payment_method 无效: {detail}"))?;
if normalized.chars().count() > 30 {
return Err("payment_method 长度不能超过 30".to_string());
}
Ok(normalized.chars().take(30).collect())
Ok(normalized)
}
pub(in super::super) fn normalize_admin_wallet_balance_type(
@@ -2,7 +2,8 @@ use crate::handlers::admin::request::AdminAppState;
use crate::handlers::admin::shared::unix_secs_to_rfc3339;
use crate::handlers::shared::round_to;
use crate::GatewayError;
use serde_json::json;
use aether_data::repository::wallet::stored_timestamp_unix_secs;
use serde_json::{json, Map, Value};
#[derive(Clone)]
pub(in super::super) struct AdminWalletOwnerSummary {
@@ -10,6 +11,10 @@ pub(in super::super) struct AdminWalletOwnerSummary {
pub(in super::super) owner_name: Option<String>,
}
fn api_key_display_prefix(api_key_id: &str) -> String {
api_key_id.chars().take(8).collect()
}
pub(in super::super) fn build_admin_wallet_payment_order_payload(
order_id: String,
order_no: String,
@@ -92,7 +97,7 @@ pub(in super::super) fn wallet_owner_summary_from_fields(
owner_type: "api_key",
owner_name: api_key_name
.filter(|value| !value.trim().is_empty())
.or_else(|| Some(format!("Key-{}", &api_key_id[..api_key_id.len().min(8)]))),
.or_else(|| Some(format!("Key-{}", api_key_display_prefix(api_key_id)))),
};
}
AdminWalletOwnerSummary {
@@ -121,7 +126,7 @@ pub(in super::super) async fn resolve_admin_wallet_owner_summary(
.find(|snapshot| snapshot.api_key_id == api_key_id)
.and_then(|snapshot| snapshot.api_key_name)
.filter(|value| !value.trim().is_empty())
.or_else(|| Some(format!("Key-{}", &api_key_id[..api_key_id.len().min(8)])));
.or_else(|| Some(format!("Key-{}", api_key_display_prefix(api_key_id))));
Ok(AdminWalletOwnerSummary {
owner_type: "api_key",
owner_name,
@@ -234,6 +239,104 @@ pub(in super::super) async fn enrich_admin_wallet_package_summary(
Ok(())
}
fn admin_refund_proof_identifier(value: Option<&Value>, max_bytes: usize) -> Option<String> {
let value = value?.as_str()?.trim();
if value.is_empty()
|| value.len() > max_bytes
|| !value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
{
return None;
}
Some(value.to_string())
}
fn admin_gateway_refund_proof_projection(value: &Value) -> Option<Value> {
let source = value.as_object()?;
let mut projected = Map::new();
if let Some(gateway) = source
.get("gateway")
.and_then(Value::as_str)
.map(str::trim)
.map(str::to_ascii_lowercase)
.filter(|value| matches!(value.as_str(), "alipay" | "wxpay"))
{
projected.insert("gateway".to_string(), json!(gateway));
}
for (key, max_bytes) in [
("id", 128usize),
("order_no", 64usize),
("refund_no", 64usize),
] {
if let Some(value) = admin_refund_proof_identifier(source.get(key), max_bytes) {
projected.insert(key.to_string(), json!(value));
}
}
if let Some(status) = source
.get("status")
.and_then(Value::as_str)
.map(str::trim)
.map(str::to_ascii_lowercase)
.and_then(|value| match value.as_str() {
"success" | "succeeded" => Some("success"),
"pending" | "processing" => Some("processing"),
"failed" | "closed" | "abnormal" => Some("failed"),
_ => None,
})
{
projected.insert("status".to_string(), json!(status));
}
if let Some(amount) = source
.get("amount")
.and_then(Value::as_f64)
.filter(|value| value.is_finite() && *value > 0.0)
{
projected.insert("amount".to_string(), json!(amount));
}
if let Some(currency) = source
.get("currency")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| value.len() == 3 && value.bytes().all(|byte| byte.is_ascii_alphabetic()))
.map(str::to_ascii_uppercase)
{
projected.insert("currency".to_string(), json!(currency));
}
if let Some(processed_at) = source
.get("processed_at")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| chrono::DateTime::parse_from_rfc3339(value).is_ok())
{
projected.insert("processed_at".to_string(), json!(processed_at));
}
(!projected.is_empty()).then_some(Value::Object(projected))
}
pub(in super::super) fn admin_wallet_payout_proof_projection(
payout_proof: Option<&Value>,
) -> Option<Value> {
let source = payout_proof?.as_object()?;
let mut projected = source.clone();
if source.contains_key("gateway_refund") {
match source
.get("gateway_refund")
.and_then(admin_gateway_refund_proof_projection)
{
Some(gateway_refund) => {
projected.insert("gateway_refund".to_string(), gateway_refund);
}
None => {
projected.remove("gateway_refund");
}
}
}
Some(Value::Object(projected))
}
pub(in super::super) fn build_admin_wallet_refund_payload(
wallet: &aether_data::repository::wallet::StoredWalletSnapshot,
owner: &AdminWalletOwnerSummary,
@@ -258,13 +361,94 @@ pub(in super::super) fn build_admin_wallet_refund_payload(
"gateway_refund_id": refund.gateway_refund_id.clone(),
"payout_method": refund.payout_method.clone(),
"payout_reference": refund.payout_reference.clone(),
"payout_proof": refund.payout_proof.clone(),
"payout_proof": admin_wallet_payout_proof_projection(refund.payout_proof.as_ref()),
"requested_by": refund.requested_by.clone(),
"approved_by": refund.approved_by.clone(),
"processed_by": refund.processed_by.clone(),
"created_at": unix_secs_to_rfc3339(refund.created_at_unix_ms),
"created_at": unix_secs_to_rfc3339(stored_timestamp_unix_secs(refund.created_at_unix_ms)),
"updated_at": unix_secs_to_rfc3339(refund.updated_at_unix_secs),
"processed_at": refund.processed_at_unix_secs.and_then(unix_secs_to_rfc3339),
"completed_at": refund.completed_at_unix_secs.and_then(unix_secs_to_rfc3339),
})
}
#[cfg(test)]
mod tests {
use super::admin_wallet_payout_proof_projection;
use serde_json::json;
#[test]
fn admin_refund_proof_projection_removes_historical_gateway_payloads() {
let proof = json!({
"channel": "manual",
"gateway_refund": {
"gateway": "WXPAY",
"id": "refund-1",
"status": "SUCCESS",
"order_no": "order-1",
"refund_no": "request-1",
"amount": 8.5,
"currency": "cny",
"processed_at": "2026-08-27T12:00:00Z",
"payload": {
"authorization": "Bearer payment-secret",
"url": "https://internal.example/refund?token=secret",
"payer": {"openid": "openid-secret"},
"credential": "gateway-credential"
},
"message": "upstream secret message"
}
});
let projection = admin_wallet_payout_proof_projection(Some(&proof))
.expect("object payout proof should be projected");
assert_eq!(projection["channel"], "manual");
assert_eq!(projection["gateway_refund"]["gateway"], "wxpay");
assert_eq!(projection["gateway_refund"]["status"], "success");
assert_eq!(
projection["gateway_refund"]
.as_object()
.expect("gateway proof should be an object")
.len(),
8
);
let encoded = projection.to_string();
for sensitive in [
"payment-secret",
"?token=secret",
"openid-secret",
"gateway-credential",
"upstream secret message",
"payload",
"authorization",
"payer",
"openid",
"credential",
"message",
] {
assert!(!encoded.contains(sensitive));
}
}
#[test]
fn admin_refund_proof_projection_drops_mistyped_gateway_fields() {
let proof = json!({
"operator": "finance",
"gateway_refund": {
"gateway": {"credential": "secret"},
"id": ["refund-1"],
"status": "unknown-secret-status",
"order_no": "https://example.test/?token=secret",
"refund_no": 123,
"amount": "8.5",
"currency": "CNY?token=secret",
"processed_at": "Bearer secret"
}
});
let projection = admin_wallet_payout_proof_projection(Some(&proof))
.expect("manual payout proof should remain available");
assert_eq!(projection, json!({"operator": "finance"}));
assert!(!projection.to_string().contains("secret"));
}
}