mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat: add payment gateway and billing plans
This commit is contained in:
@@ -42,6 +42,7 @@ futures-util.workspace = true
|
||||
hmac.workspace = true
|
||||
http.workspace = true
|
||||
ldap3 = { version = "0.11", default-features = false, features = ["sync", "tls-rustls"] }
|
||||
md-5 = "0.10"
|
||||
parking_lot = "0.12"
|
||||
regex.workspace = true
|
||||
reqwest.workspace = true
|
||||
|
||||
@@ -3,8 +3,11 @@ use axum::http::Uri;
|
||||
|
||||
use super::super::GatewayControlDecision;
|
||||
use super::credentials::{contains_string, extract_requested_model};
|
||||
use super::GatewayControlAuthContext;
|
||||
use crate::{AppState, GatewayError};
|
||||
|
||||
const DAILY_QUOTA_EPSILON_USD: f64 = 0.000_000_01;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) enum GatewayLocalAuthRejection {
|
||||
InvalidApiKey,
|
||||
@@ -59,34 +62,309 @@ pub(crate) async fn request_model_local_rejection(
|
||||
let Some(auth_context) = decision.auth_context.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(allowed_models) = auth_context.allowed_models.as_deref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(requested_model) = extract_requested_model(decision, uri, headers, body) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if contains_string(allowed_models, &requested_model) {
|
||||
return Ok(None);
|
||||
}
|
||||
if model_directive_base_model_is_allowed_for_request(
|
||||
state,
|
||||
decision,
|
||||
&requested_model,
|
||||
allowed_models,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
if request_model_resolves_to_allowed_model(state, decision, &requested_model, allowed_models)
|
||||
.await?
|
||||
{
|
||||
return Ok(None);
|
||||
let requested_model = extract_requested_model(decision, uri, headers, body);
|
||||
if let (Some(allowed_models), Some(requested_model)) = (
|
||||
auth_context.allowed_models.as_deref(),
|
||||
requested_model.as_deref(),
|
||||
) {
|
||||
if !contains_string(allowed_models, requested_model)
|
||||
&& !model_directive_base_model_is_allowed_for_request(
|
||||
state,
|
||||
decision,
|
||||
requested_model,
|
||||
allowed_models,
|
||||
)
|
||||
.await
|
||||
&& !request_model_resolves_to_allowed_model(
|
||||
state,
|
||||
decision,
|
||||
requested_model,
|
||||
allowed_models,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(Some(GatewayLocalAuthRejection::ModelNotAllowed {
|
||||
model: requested_model.to_string(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Some(GatewayLocalAuthRejection::ModelNotAllowed {
|
||||
model: requested_model,
|
||||
}))
|
||||
balance_capacity_rejection(
|
||||
state,
|
||||
decision,
|
||||
auth_context,
|
||||
requested_model.as_deref(),
|
||||
body,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn balance_capacity_rejection(
|
||||
state: &AppState,
|
||||
decision: &GatewayControlDecision,
|
||||
auth_context: &GatewayControlAuthContext,
|
||||
requested_model: Option<&str>,
|
||||
body: &Bytes,
|
||||
) -> Result<Option<GatewayLocalAuthRejection>, GatewayError> {
|
||||
if auth_context.api_key_is_standalone || auth_context.admin_bypass_limits {
|
||||
return Ok(None);
|
||||
}
|
||||
if auth_context.local_rejection.is_some() {
|
||||
return Ok(None);
|
||||
}
|
||||
let quota = state
|
||||
.find_user_daily_quota_availability(&auth_context.user_id)
|
||||
.await?
|
||||
.filter(|quota| quota.has_active_daily_quota);
|
||||
let wallet = state
|
||||
.read_wallet_snapshot_for_auth(
|
||||
&auth_context.user_id,
|
||||
&auth_context.api_key_id,
|
||||
auth_context.api_key_is_standalone,
|
||||
)
|
||||
.await?;
|
||||
let wallet_available_usd = wallet.as_ref().and_then(wallet_finite_available_usd);
|
||||
let wallet_is_unlimited = wallet
|
||||
.as_ref()
|
||||
.is_some_and(|wallet| wallet.limit_mode.eq_ignore_ascii_case("unlimited"));
|
||||
let (available_usd, require_cost_estimate) = match quota.as_ref() {
|
||||
Some(quota) if !quota.allow_wallet_overage => (Some(quota.remaining_usd.max(0.0)), true),
|
||||
Some(_) if wallet_is_unlimited => (None, false),
|
||||
Some(quota) => (
|
||||
Some(quota.remaining_usd.max(0.0) + wallet_available_usd.unwrap_or(0.0)),
|
||||
true,
|
||||
),
|
||||
None if wallet_is_unlimited => (None, false),
|
||||
None => (wallet_available_usd, false),
|
||||
};
|
||||
let Some(available_usd) = available_usd else {
|
||||
return Ok(None);
|
||||
};
|
||||
if available_usd <= DAILY_QUOTA_EPSILON_USD {
|
||||
return Ok(Some(GatewayLocalAuthRejection::BalanceDenied {
|
||||
remaining: Some(0.0),
|
||||
}));
|
||||
}
|
||||
let Some(requested_model) = requested_model else {
|
||||
return if require_cost_estimate {
|
||||
Ok(Some(GatewayLocalAuthRejection::BalanceDenied {
|
||||
remaining: Some(available_usd),
|
||||
}))
|
||||
} else {
|
||||
Ok(None)
|
||||
};
|
||||
};
|
||||
let Some(estimated_cost_usd) =
|
||||
estimate_request_cost_upper_bound_usd(state, decision, requested_model, body).await?
|
||||
else {
|
||||
return if require_cost_estimate {
|
||||
Ok(Some(GatewayLocalAuthRejection::BalanceDenied {
|
||||
remaining: Some(available_usd),
|
||||
}))
|
||||
} else {
|
||||
Ok(None)
|
||||
};
|
||||
};
|
||||
if estimated_cost_usd > available_usd + DAILY_QUOTA_EPSILON_USD {
|
||||
return Ok(Some(GatewayLocalAuthRejection::BalanceDenied {
|
||||
remaining: Some(available_usd),
|
||||
}));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn wallet_finite_available_usd(
|
||||
wallet: &aether_data::repository::wallet::StoredWalletSnapshot,
|
||||
) -> Option<f64> {
|
||||
if !wallet.status.eq_ignore_ascii_case("active")
|
||||
|| wallet.limit_mode.eq_ignore_ascii_case("unlimited")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(wallet.balance.max(0.0) + wallet.gift_balance.max(0.0))
|
||||
}
|
||||
|
||||
async fn estimate_request_cost_upper_bound_usd(
|
||||
state: &AppState,
|
||||
decision: &GatewayControlDecision,
|
||||
requested_model: &str,
|
||||
body: &Bytes,
|
||||
) -> Result<Option<f64>, GatewayError> {
|
||||
let Some(api_format) = decision
|
||||
.auth_endpoint_signature
|
||||
.as_deref()
|
||||
.map(crate::ai_serving::normalize_api_format_alias)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let body_json = serde_json::from_slice::<serde_json::Value>(body).ok();
|
||||
let Some(input_tokens) = body_json
|
||||
.as_ref()
|
||||
.map(estimate_json_tokens)
|
||||
.filter(|value| *value > 0)
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let max_output_tokens = body_json.as_ref().and_then(max_output_tokens_from_request);
|
||||
let candidates = state
|
||||
.list_minimal_candidate_selection_rows_for_api_format_and_requested_model(
|
||||
&api_format,
|
||||
requested_model,
|
||||
)
|
||||
.await?;
|
||||
let mut max_estimate = None::<f64>;
|
||||
for candidate in candidates {
|
||||
let context = state
|
||||
.data
|
||||
.find_billing_model_context_by_model_id(
|
||||
&candidate.provider_id,
|
||||
Some(&candidate.key_id),
|
||||
&candidate.model_id,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||
let Some(context) = context else {
|
||||
continue;
|
||||
};
|
||||
let Some(estimate) = estimate_cost_from_billing_context(
|
||||
&context,
|
||||
&api_format,
|
||||
input_tokens,
|
||||
max_output_tokens,
|
||||
) else {
|
||||
return Ok(None);
|
||||
};
|
||||
max_estimate = Some(max_estimate.map_or(estimate, |current| current.max(estimate)));
|
||||
}
|
||||
Ok(max_estimate.filter(|value| value.is_finite() && *value >= 0.0))
|
||||
}
|
||||
|
||||
fn estimate_cost_from_billing_context(
|
||||
context: &aether_data_contracts::repository::billing::StoredBillingModelContext,
|
||||
api_format: &str,
|
||||
input_tokens: u64,
|
||||
max_output_tokens: Option<u64>,
|
||||
) -> Option<f64> {
|
||||
if context
|
||||
.provider_billing_type
|
||||
.as_deref()
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case("free_tier"))
|
||||
{
|
||||
return Some(0.0);
|
||||
}
|
||||
let price_per_request = context
|
||||
.model_price_per_request
|
||||
.or(context.default_price_per_request)
|
||||
.filter(|value| value.is_finite() && *value >= 0.0)
|
||||
.unwrap_or(0.0);
|
||||
let tiered_pricing = effective_tiered_pricing(context);
|
||||
let input_price_per_1m = tiered_price_per_1m(tiered_pricing, "input_price_per_1m")
|
||||
.filter(|value| value.is_finite() && *value >= 0.0)
|
||||
.unwrap_or(0.0);
|
||||
let output_price_per_1m = tiered_price_per_1m(tiered_pricing, "output_price_per_1m")
|
||||
.filter(|value| value.is_finite() && *value >= 0.0)
|
||||
.unwrap_or(0.0);
|
||||
let output_tokens = if output_price_per_1m > 0.0 {
|
||||
max_output_tokens?
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let estimate = price_per_request
|
||||
+ (input_tokens as f64 * input_price_per_1m / 1_000_000.0)
|
||||
+ (output_tokens as f64 * output_price_per_1m / 1_000_000.0);
|
||||
let rate_multiplier = rate_multiplier_for_api_format(context, api_format);
|
||||
Some(estimate * rate_multiplier)
|
||||
}
|
||||
|
||||
fn effective_tiered_pricing(
|
||||
context: &aether_data_contracts::repository::billing::StoredBillingModelContext,
|
||||
) -> Option<&serde_json::Value> {
|
||||
context
|
||||
.model_tiered_pricing
|
||||
.as_ref()
|
||||
.filter(|value| tiered_pricing_has_rates(value))
|
||||
.or(context.default_tiered_pricing.as_ref())
|
||||
}
|
||||
|
||||
fn tiered_pricing_has_rates(value: &serde_json::Value) -> bool {
|
||||
value
|
||||
.get("tiers")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.is_some_and(|tiers| !tiers.is_empty())
|
||||
|| ["input_price_per_1m", "output_price_per_1m"]
|
||||
.iter()
|
||||
.any(|field| {
|
||||
value
|
||||
.get(*field)
|
||||
.and_then(serde_json::Value::as_f64)
|
||||
.is_some()
|
||||
})
|
||||
}
|
||||
|
||||
fn rate_multiplier_for_api_format(
|
||||
context: &aether_data_contracts::repository::billing::StoredBillingModelContext,
|
||||
api_format: &str,
|
||||
) -> f64 {
|
||||
let normalized_api_format = api_format.trim().to_ascii_lowercase();
|
||||
let Some(mapping) = context
|
||||
.provider_api_key_rate_multipliers
|
||||
.as_ref()
|
||||
.and_then(serde_json::Value::as_object)
|
||||
else {
|
||||
return 1.0;
|
||||
};
|
||||
mapping
|
||||
.get(&normalized_api_format)
|
||||
.and_then(serde_json::Value::as_f64)
|
||||
.filter(|value| value.is_finite() && *value >= 0.0)
|
||||
.unwrap_or(1.0)
|
||||
}
|
||||
|
||||
fn tiered_price_per_1m(tiered_pricing: Option<&serde_json::Value>, field: &str) -> Option<f64> {
|
||||
let value = tiered_pricing?;
|
||||
value
|
||||
.get(field)
|
||||
.and_then(serde_json::Value::as_f64)
|
||||
.or_else(|| {
|
||||
value
|
||||
.get("tiers")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|tier| tier.get(field).and_then(serde_json::Value::as_f64))
|
||||
.filter(|price| price.is_finite() && *price >= 0.0)
|
||||
.max_by(|left, right| left.partial_cmp(right).unwrap_or(std::cmp::Ordering::Equal))
|
||||
})
|
||||
}
|
||||
|
||||
fn max_output_tokens_from_request(value: &serde_json::Value) -> Option<u64> {
|
||||
["max_tokens", "max_completion_tokens", "max_output_tokens"]
|
||||
.iter()
|
||||
.find_map(|field| value.get(*field).and_then(serde_json::Value::as_u64))
|
||||
.filter(|value| *value > 0)
|
||||
}
|
||||
|
||||
fn estimate_json_tokens(value: &serde_json::Value) -> u64 {
|
||||
match value {
|
||||
serde_json::Value::String(text) => estimate_text_tokens(text),
|
||||
serde_json::Value::Array(items) => items
|
||||
.iter()
|
||||
.map(estimate_json_tokens)
|
||||
.fold(0u64, u64::saturating_add),
|
||||
serde_json::Value::Object(object) => object
|
||||
.iter()
|
||||
.map(|(key, value)| {
|
||||
estimate_text_tokens(key).saturating_add(estimate_json_tokens(value))
|
||||
})
|
||||
.fold(0u64, u64::saturating_add),
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn estimate_text_tokens(text: &str) -> u64 {
|
||||
let chars = text.chars().count() as u64;
|
||||
chars.div_ceil(4).max(1)
|
||||
}
|
||||
|
||||
async fn model_directive_base_model_is_allowed_for_request(
|
||||
@@ -200,13 +478,18 @@ mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use aether_data::repository::candidate_selection::InMemoryMinimalCandidateSelectionReadRepository;
|
||||
use aether_data_contracts::repository::billing::StoredBillingModelContext;
|
||||
use aether_data_contracts::repository::candidate_selection::{
|
||||
StoredMinimalCandidateSelectionRow, StoredProviderModelMapping,
|
||||
};
|
||||
use axum::body::Bytes;
|
||||
use axum::http::{HeaderMap, Uri};
|
||||
use serde_json::json;
|
||||
|
||||
use super::{request_model_local_rejection, GatewayLocalAuthRejection};
|
||||
use super::{
|
||||
estimate_cost_from_billing_context, request_model_local_rejection,
|
||||
GatewayLocalAuthRejection,
|
||||
};
|
||||
use crate::control::{GatewayControlAuthContext, GatewayControlDecision};
|
||||
use crate::data::GatewayDataState;
|
||||
use crate::AppState;
|
||||
@@ -320,6 +603,32 @@ mod tests {
|
||||
headers
|
||||
}
|
||||
|
||||
fn billing_context_with_pricing(
|
||||
default_tiered_pricing: Option<serde_json::Value>,
|
||||
model_tiered_pricing: Option<serde_json::Value>,
|
||||
rate_multipliers: Option<serde_json::Value>,
|
||||
billing_type: Option<&str>,
|
||||
) -> StoredBillingModelContext {
|
||||
StoredBillingModelContext::new(
|
||||
"provider-1".to_string(),
|
||||
billing_type.map(ToOwned::to_owned),
|
||||
Some("key-1".to_string()),
|
||||
rate_multipliers,
|
||||
Some(60),
|
||||
"global-model-1".to_string(),
|
||||
"gpt-5".to_string(),
|
||||
None,
|
||||
None,
|
||||
default_tiered_pricing,
|
||||
Some("model-1".to_string()),
|
||||
Some("gpt-5-upstream".to_string()),
|
||||
None,
|
||||
None,
|
||||
model_tiered_pricing,
|
||||
)
|
||||
.expect("billing context should build")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_rejection_allows_requested_model_that_resolves_to_allowed_global_model() {
|
||||
let state = state_with_model_mapping();
|
||||
@@ -392,4 +701,70 @@ mod tests {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daily_quota_estimate_falls_back_to_default_tiers_when_model_tiers_empty() {
|
||||
let context = billing_context_with_pricing(
|
||||
Some(json!({
|
||||
"tiers": [{
|
||||
"up_to": null,
|
||||
"input_price_per_1m": 3.0,
|
||||
"output_price_per_1m": 15.0
|
||||
}]
|
||||
})),
|
||||
Some(json!({})),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
let estimate =
|
||||
estimate_cost_from_billing_context(&context, "openai:chat", 1_000_000, Some(1_000_000))
|
||||
.expect("estimate should resolve");
|
||||
|
||||
assert_eq!(estimate, 18.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daily_quota_estimate_applies_provider_key_rate_multiplier() {
|
||||
let context = billing_context_with_pricing(
|
||||
Some(json!({
|
||||
"tiers": [{
|
||||
"up_to": null,
|
||||
"input_price_per_1m": 1.0,
|
||||
"output_price_per_1m": 2.0
|
||||
}]
|
||||
})),
|
||||
None,
|
||||
Some(json!({ "openai:chat": 2.0 })),
|
||||
None,
|
||||
);
|
||||
|
||||
let estimate =
|
||||
estimate_cost_from_billing_context(&context, "openai:chat", 1_000_000, Some(1_000_000))
|
||||
.expect("estimate should resolve");
|
||||
|
||||
assert_eq!(estimate, 6.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daily_quota_estimate_treats_free_tier_as_zero_cost() {
|
||||
let context = billing_context_with_pricing(
|
||||
Some(json!({
|
||||
"tiers": [{
|
||||
"up_to": null,
|
||||
"input_price_per_1m": 3.0,
|
||||
"output_price_per_1m": 15.0
|
||||
}]
|
||||
})),
|
||||
None,
|
||||
Some(json!({ "openai:chat": 10.0 })),
|
||||
Some("free_tier"),
|
||||
);
|
||||
|
||||
let estimate =
|
||||
estimate_cost_from_billing_context(&context, "openai:chat", 1_000_000, Some(1_000_000))
|
||||
.expect("estimate should resolve");
|
||||
|
||||
assert_eq!(estimate, 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -485,6 +485,66 @@ pub(super) fn classify_admin_basic_family_route(
|
||||
"admin:billing",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::GET
|
||||
&& matches!(
|
||||
normalized_path,
|
||||
"/api/admin/billing/plans" | "/api/admin/billing/plans/"
|
||||
)
|
||||
{
|
||||
Some(classified(
|
||||
"admin_proxy",
|
||||
"billing_manage",
|
||||
"list_plans",
|
||||
"admin:billing",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::POST
|
||||
&& matches!(
|
||||
normalized_path,
|
||||
"/api/admin/billing/plans" | "/api/admin/billing/plans/"
|
||||
)
|
||||
{
|
||||
Some(classified(
|
||||
"admin_proxy",
|
||||
"billing_manage",
|
||||
"create_plan",
|
||||
"admin:billing",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::PUT
|
||||
&& normalized_path_no_trailing.starts_with("/api/admin/billing/plans/")
|
||||
&& normalized_path_no_trailing.matches('/').count() == 5
|
||||
{
|
||||
Some(classified(
|
||||
"admin_proxy",
|
||||
"billing_manage",
|
||||
"update_plan",
|
||||
"admin:billing",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::DELETE
|
||||
&& normalized_path_no_trailing.starts_with("/api/admin/billing/plans/")
|
||||
&& normalized_path_no_trailing.matches('/').count() == 5
|
||||
{
|
||||
Some(classified(
|
||||
"admin_proxy",
|
||||
"billing_manage",
|
||||
"delete_plan",
|
||||
"admin:billing",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::PATCH
|
||||
&& normalized_path_no_trailing.starts_with("/api/admin/billing/plans/")
|
||||
&& normalized_path_no_trailing.ends_with("/status")
|
||||
&& normalized_path_no_trailing.matches('/').count() == 6
|
||||
{
|
||||
Some(classified(
|
||||
"admin_proxy",
|
||||
"billing_manage",
|
||||
"set_plan_status",
|
||||
"admin:billing",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::GET
|
||||
&& matches!(
|
||||
normalized_path,
|
||||
@@ -498,6 +558,45 @@ pub(super) fn classify_admin_basic_family_route(
|
||||
"admin:payments",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::GET
|
||||
&& matches!(
|
||||
normalized_path,
|
||||
"/api/admin/payments/gateways/epay" | "/api/admin/payments/gateways/epay/"
|
||||
)
|
||||
{
|
||||
Some(classified(
|
||||
"admin_proxy",
|
||||
"payments_manage",
|
||||
"get_epay_gateway",
|
||||
"admin:payments",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::PUT
|
||||
&& matches!(
|
||||
normalized_path,
|
||||
"/api/admin/payments/gateways/epay" | "/api/admin/payments/gateways/epay/"
|
||||
)
|
||||
{
|
||||
Some(classified(
|
||||
"admin_proxy",
|
||||
"payments_manage",
|
||||
"update_epay_gateway",
|
||||
"admin:payments",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::POST
|
||||
&& matches!(
|
||||
normalized_path,
|
||||
"/api/admin/payments/gateways/epay/test" | "/api/admin/payments/gateways/epay/test/"
|
||||
)
|
||||
{
|
||||
Some(classified(
|
||||
"admin_proxy",
|
||||
"payments_manage",
|
||||
"test_epay_gateway",
|
||||
"admin:payments",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::GET
|
||||
&& normalized_path_no_trailing.starts_with("/api/admin/payments/orders/")
|
||||
&& normalized_path_no_trailing.matches('/').count() == 5
|
||||
|
||||
@@ -710,6 +710,30 @@ pub(super) fn classify_admin_operations_family_route(
|
||||
"admin:users",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::GET
|
||||
&& normalized_path_no_trailing.starts_with("/api/admin/users/")
|
||||
&& normalized_path_no_trailing.ends_with("/billing/entitlements")
|
||||
&& normalized_path_no_trailing.matches('/').count() == 6
|
||||
{
|
||||
Some(classified(
|
||||
"admin_proxy",
|
||||
"users_manage",
|
||||
"list_user_billing_entitlements",
|
||||
"admin:users",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::POST
|
||||
&& normalized_path_no_trailing.starts_with("/api/admin/users/")
|
||||
&& normalized_path_no_trailing.ends_with("/billing/grant-plan")
|
||||
&& normalized_path_no_trailing.matches('/').count() == 6
|
||||
{
|
||||
Some(classified(
|
||||
"admin_proxy",
|
||||
"users_manage",
|
||||
"grant_user_billing_plan",
|
||||
"admin:users",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::GET
|
||||
&& normalized_path.starts_with("/api/admin/users/")
|
||||
&& normalized_path.ends_with("/sessions")
|
||||
|
||||
@@ -316,6 +316,7 @@ pub(super) fn classify_public_support_route(
|
||||
| "/api/wallet/flow"
|
||||
| "/api/wallet/today-cost"
|
||||
| "/api/wallet/recharge"
|
||||
| "/api/wallet/recharge/options"
|
||||
| "/api/wallet/refunds"
|
||||
)
|
||||
{
|
||||
@@ -325,6 +326,7 @@ pub(super) fn classify_public_support_route(
|
||||
"/api/wallet/flow" => "flow",
|
||||
"/api/wallet/today-cost" => "today_cost",
|
||||
"/api/wallet/recharge" => "list_recharge_orders",
|
||||
"/api/wallet/recharge/options" => "recharge_options",
|
||||
"/api/wallet/refunds" => "list_refunds",
|
||||
_ => "balance",
|
||||
};
|
||||
@@ -374,6 +376,42 @@ pub(super) fn classify_public_support_route(
|
||||
"user:wallet",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::GET
|
||||
&& matches!(
|
||||
normalized_path,
|
||||
"/api/billing/plans" | "/api/billing/plans/"
|
||||
)
|
||||
{
|
||||
Some(classified(
|
||||
"public_support",
|
||||
"billing",
|
||||
"plans",
|
||||
"public:billing",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::GET
|
||||
&& matches!(
|
||||
normalized_path,
|
||||
"/api/billing/entitlements" | "/api/billing/entitlements/"
|
||||
)
|
||||
{
|
||||
Some(classified(
|
||||
"public_support",
|
||||
"billing",
|
||||
"entitlements",
|
||||
"user:billing",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::POST
|
||||
&& has_single_nested_suffix_after_prefix(normalized_path, "/api/billing/plans/", "checkout")
|
||||
{
|
||||
Some(classified(
|
||||
"public_support",
|
||||
"billing",
|
||||
"plan_checkout",
|
||||
"user:billing",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::POST
|
||||
&& has_single_segment_after_prefix(normalized_path, "/api/payment/callback/")
|
||||
{
|
||||
@@ -384,6 +422,32 @@ pub(super) fn classify_public_support_route(
|
||||
"public:payment",
|
||||
false,
|
||||
))
|
||||
} else if matches!(method, &http::Method::GET | &http::Method::POST)
|
||||
&& matches!(
|
||||
normalized_path,
|
||||
"/api/payment/epay/notify" | "/api/payment/epay/notify/"
|
||||
)
|
||||
{
|
||||
Some(classified(
|
||||
"public_support",
|
||||
"payment_callback",
|
||||
"epay_notify",
|
||||
"public:payment",
|
||||
false,
|
||||
))
|
||||
} else if matches!(method, &http::Method::GET | &http::Method::POST)
|
||||
&& matches!(
|
||||
normalized_path,
|
||||
"/api/payment/epay/return" | "/api/payment/epay/return/"
|
||||
)
|
||||
{
|
||||
Some(classified(
|
||||
"public_support",
|
||||
"payment_callback",
|
||||
"epay_return",
|
||||
"public:payment",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::GET
|
||||
&& matches!(
|
||||
normalized_path,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use http::Uri;
|
||||
|
||||
use super::{classify_control_route, headers};
|
||||
use super::{classify_control_route, headers, GatewayPublicRequestContext};
|
||||
use crate::handlers::shared::local_proxy_route_requires_buffered_body;
|
||||
|
||||
#[test]
|
||||
fn classifies_admin_billing_presets_as_admin_proxy_route() {
|
||||
@@ -121,3 +122,84 @@ fn classifies_admin_billing_collector_routes_as_admin_proxy_route() {
|
||||
Some("admin:billing")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_admin_billing_plan_routes_as_admin_proxy_route() {
|
||||
let headers = headers(&[]);
|
||||
|
||||
let list_uri: Uri = "/api/admin/billing/plans"
|
||||
.parse()
|
||||
.expect("uri should parse");
|
||||
let list = classify_control_route(&http::Method::GET, &list_uri, &headers)
|
||||
.expect("route should classify");
|
||||
assert_eq!(list.route_class.as_deref(), Some("admin_proxy"));
|
||||
assert_eq!(list.route_family.as_deref(), Some("billing_manage"));
|
||||
assert_eq!(list.route_kind.as_deref(), Some("list_plans"));
|
||||
assert_eq!(
|
||||
list.auth_endpoint_signature.as_deref(),
|
||||
Some("admin:billing")
|
||||
);
|
||||
|
||||
let create_uri: Uri = "/api/admin/billing/plans"
|
||||
.parse()
|
||||
.expect("uri should parse");
|
||||
let create = classify_control_route(&http::Method::POST, &create_uri, &headers)
|
||||
.expect("route should classify");
|
||||
assert_eq!(create.route_family.as_deref(), Some("billing_manage"));
|
||||
assert_eq!(create.route_kind.as_deref(), Some("create_plan"));
|
||||
|
||||
let update_uri: Uri = "/api/admin/billing/plans/plan-1"
|
||||
.parse()
|
||||
.expect("uri should parse");
|
||||
let update = classify_control_route(&http::Method::PUT, &update_uri, &headers)
|
||||
.expect("route should classify");
|
||||
assert_eq!(update.route_family.as_deref(), Some("billing_manage"));
|
||||
assert_eq!(update.route_kind.as_deref(), Some("update_plan"));
|
||||
|
||||
let status_uri: Uri = "/api/admin/billing/plans/plan-1/status"
|
||||
.parse()
|
||||
.expect("uri should parse");
|
||||
let status = classify_control_route(&http::Method::PATCH, &status_uri, &headers)
|
||||
.expect("route should classify");
|
||||
assert_eq!(status.route_family.as_deref(), Some("billing_manage"));
|
||||
assert_eq!(status.route_kind.as_deref(), Some("set_plan_status"));
|
||||
|
||||
let delete_uri: Uri = "/api/admin/billing/plans/plan-1"
|
||||
.parse()
|
||||
.expect("uri should parse");
|
||||
let delete = classify_control_route(&http::Method::DELETE, &delete_uri, &headers)
|
||||
.expect("route should classify");
|
||||
assert_eq!(delete.route_family.as_deref(), Some("billing_manage"));
|
||||
assert_eq!(delete.route_kind.as_deref(), Some("delete_plan"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_billing_plan_write_routes_buffer_request_body() {
|
||||
let headers = headers(&[]);
|
||||
let routes = [
|
||||
(http::Method::POST, "/api/admin/billing/plans"),
|
||||
(http::Method::PUT, "/api/admin/billing/plans/plan-1"),
|
||||
(
|
||||
http::Method::PATCH,
|
||||
"/api/admin/billing/plans/plan-1/status",
|
||||
),
|
||||
];
|
||||
|
||||
for (method, path) in routes {
|
||||
let uri: Uri = path.parse().expect("uri should parse");
|
||||
let decision =
|
||||
classify_control_route(&method, &uri, &headers).expect("route should classify");
|
||||
let context = GatewayPublicRequestContext::from_request_parts(
|
||||
"trace-billing-plan-write",
|
||||
&method,
|
||||
&uri,
|
||||
&headers,
|
||||
Some(decision),
|
||||
);
|
||||
|
||||
assert!(
|
||||
local_proxy_route_requires_buffered_body(&context),
|
||||
"{method} {path} should buffer request body"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use http::Uri;
|
||||
|
||||
use super::{classify_control_route, headers};
|
||||
use super::{classify_control_route, headers, GatewayPublicRequestContext};
|
||||
use crate::handlers::shared::local_proxy_route_requires_buffered_body;
|
||||
|
||||
#[test]
|
||||
fn classifies_admin_payments_list_orders_as_admin_proxy_route() {
|
||||
@@ -205,3 +206,57 @@ fn classifies_admin_payments_redeem_code_routes_as_admin_proxy_route() {
|
||||
Some("delete_redeem_code_batch")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_admin_epay_gateway_routes_as_admin_proxy_route() {
|
||||
let headers = headers(&[]);
|
||||
for (method, uri, route_kind) in [
|
||||
(
|
||||
http::Method::GET,
|
||||
"/api/admin/payments/gateways/epay",
|
||||
"get_epay_gateway",
|
||||
),
|
||||
(
|
||||
http::Method::PUT,
|
||||
"/api/admin/payments/gateways/epay",
|
||||
"update_epay_gateway",
|
||||
),
|
||||
(
|
||||
http::Method::POST,
|
||||
"/api/admin/payments/gateways/epay/test",
|
||||
"test_epay_gateway",
|
||||
),
|
||||
] {
|
||||
let uri: Uri = uri.parse().expect("uri should parse");
|
||||
let decision =
|
||||
classify_control_route(&method, &uri, &headers).expect("route should classify");
|
||||
|
||||
assert_eq!(decision.route_class.as_deref(), Some("admin_proxy"));
|
||||
assert_eq!(decision.route_family.as_deref(), Some("payments_manage"));
|
||||
assert_eq!(decision.route_kind.as_deref(), Some(route_kind));
|
||||
assert_eq!(
|
||||
decision.auth_endpoint_signature.as_deref(),
|
||||
Some("admin:payments")
|
||||
);
|
||||
assert!(!decision.is_execution_runtime_candidate());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_epay_gateway_update_buffers_request_body() {
|
||||
let headers = headers(&[]);
|
||||
let uri: Uri = "/api/admin/payments/gateways/epay"
|
||||
.parse()
|
||||
.expect("uri should parse");
|
||||
let decision =
|
||||
classify_control_route(&http::Method::PUT, &uri, &headers).expect("route should classify");
|
||||
let context = GatewayPublicRequestContext::from_request_parts(
|
||||
"trace-epay-gateway-update",
|
||||
&http::Method::PUT,
|
||||
&uri,
|
||||
&headers,
|
||||
Some(decision),
|
||||
);
|
||||
|
||||
assert!(local_proxy_route_requires_buffered_body(&context));
|
||||
}
|
||||
|
||||
@@ -70,6 +70,49 @@ fn classifies_admin_user_batch_routes_as_admin_proxy_route() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_admin_user_billing_routes_as_admin_proxy_route() {
|
||||
let headers = headers(&[]);
|
||||
|
||||
let entitlements_uri: Uri = "/api/admin/users/user-1/billing/entitlements"
|
||||
.parse()
|
||||
.expect("uri should parse");
|
||||
let entitlements = classify_control_route(&http::Method::GET, &entitlements_uri, &headers)
|
||||
.expect("route should classify");
|
||||
assert_eq!(entitlements.route_class.as_deref(), Some("admin_proxy"));
|
||||
assert_eq!(entitlements.route_family.as_deref(), Some("users_manage"));
|
||||
assert_eq!(
|
||||
entitlements.route_kind.as_deref(),
|
||||
Some("list_user_billing_entitlements")
|
||||
);
|
||||
assert_eq!(
|
||||
entitlements.auth_endpoint_signature.as_deref(),
|
||||
Some("admin:users")
|
||||
);
|
||||
|
||||
let grant_uri: Uri = "/api/admin/users/user-1/billing/grant-plan"
|
||||
.parse()
|
||||
.expect("uri should parse");
|
||||
let grant = classify_control_route(&http::Method::POST, &grant_uri, &headers)
|
||||
.expect("route should classify");
|
||||
assert_eq!(grant.route_class.as_deref(), Some("admin_proxy"));
|
||||
assert_eq!(grant.route_family.as_deref(), Some("users_manage"));
|
||||
assert_eq!(grant.route_kind.as_deref(), Some("grant_user_billing_plan"));
|
||||
assert_eq!(
|
||||
grant.auth_endpoint_signature.as_deref(),
|
||||
Some("admin:users")
|
||||
);
|
||||
|
||||
let context = GatewayPublicRequestContext::from_request_parts(
|
||||
"trace-user-billing-grant",
|
||||
&http::Method::POST,
|
||||
&grant_uri,
|
||||
&headers,
|
||||
Some(grant),
|
||||
);
|
||||
assert!(local_proxy_route_requires_buffered_body(&context));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_admin_user_group_routes_as_admin_proxy_route() {
|
||||
let headers = headers(&[]);
|
||||
|
||||
@@ -496,6 +496,114 @@ fn classifies_payment_callback_as_public_support_route() {
|
||||
assert!(!decision.is_execution_runtime_candidate());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_epay_callback_routes_as_public_support_route() {
|
||||
let headers = headers(&[]);
|
||||
for (method, uri, route_kind) in [
|
||||
(http::Method::GET, "/api/payment/epay/notify", "epay_notify"),
|
||||
(
|
||||
http::Method::POST,
|
||||
"/api/payment/epay/notify",
|
||||
"epay_notify",
|
||||
),
|
||||
(http::Method::GET, "/api/payment/epay/return", "epay_return"),
|
||||
(
|
||||
http::Method::POST,
|
||||
"/api/payment/epay/return",
|
||||
"epay_return",
|
||||
),
|
||||
] {
|
||||
let uri: Uri = uri.parse().expect("uri should parse");
|
||||
let decision =
|
||||
classify_control_route(&method, &uri, &headers).expect("route should classify");
|
||||
|
||||
assert_eq!(decision.route_class.as_deref(), Some("public_support"));
|
||||
assert_eq!(decision.route_family.as_deref(), Some("payment_callback"));
|
||||
assert_eq!(decision.route_kind.as_deref(), Some(route_kind));
|
||||
assert_eq!(
|
||||
decision.auth_endpoint_signature.as_deref(),
|
||||
Some("public:payment")
|
||||
);
|
||||
assert!(!decision.is_execution_runtime_candidate());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn epay_post_callback_routes_buffer_request_body() {
|
||||
let headers = headers(&[]);
|
||||
for path in ["/api/payment/epay/notify", "/api/payment/epay/return"] {
|
||||
let uri: Uri = path.parse().expect("uri should parse");
|
||||
let decision = classify_control_route(&http::Method::POST, &uri, &headers)
|
||||
.expect("route should classify");
|
||||
let context = GatewayPublicRequestContext::from_request_parts(
|
||||
"trace-epay-callback",
|
||||
&http::Method::POST,
|
||||
&uri,
|
||||
&headers,
|
||||
Some(decision),
|
||||
);
|
||||
|
||||
assert!(
|
||||
local_proxy_route_requires_buffered_body(&context),
|
||||
"POST {path} should buffer request body"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_billing_plan_routes_as_public_support_routes() {
|
||||
let headers = headers(&[]);
|
||||
for (method, uri, route_kind, signature) in [
|
||||
(
|
||||
http::Method::GET,
|
||||
"/api/billing/plans",
|
||||
"plans",
|
||||
"public:billing",
|
||||
),
|
||||
(
|
||||
http::Method::POST,
|
||||
"/api/billing/plans/plan-1/checkout",
|
||||
"plan_checkout",
|
||||
"user:billing",
|
||||
),
|
||||
(
|
||||
http::Method::GET,
|
||||
"/api/billing/entitlements",
|
||||
"entitlements",
|
||||
"user:billing",
|
||||
),
|
||||
] {
|
||||
let uri: Uri = uri.parse().expect("uri should parse");
|
||||
let decision =
|
||||
classify_control_route(&method, &uri, &headers).expect("route should classify");
|
||||
|
||||
assert_eq!(decision.route_class.as_deref(), Some("public_support"));
|
||||
assert_eq!(decision.route_family.as_deref(), Some("billing"));
|
||||
assert_eq!(decision.route_kind.as_deref(), Some(route_kind));
|
||||
assert_eq!(decision.auth_endpoint_signature.as_deref(), Some(signature));
|
||||
assert!(!decision.is_execution_runtime_candidate());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn billing_plan_checkout_buffers_request_body() {
|
||||
let headers = headers(&[]);
|
||||
let uri: Uri = "/api/billing/plans/plan-1/checkout"
|
||||
.parse()
|
||||
.expect("uri should parse");
|
||||
let decision =
|
||||
classify_control_route(&http::Method::POST, &uri, &headers).expect("route should classify");
|
||||
let context = GatewayPublicRequestContext::from_request_parts(
|
||||
"trace-billing-checkout",
|
||||
&http::Method::POST,
|
||||
&uri,
|
||||
&headers,
|
||||
Some(decision),
|
||||
);
|
||||
|
||||
assert!(local_proxy_route_requires_buffered_body(&context));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_public_catalog_providers_as_public_support_route() {
|
||||
let headers = headers(&[]);
|
||||
|
||||
@@ -1671,6 +1671,21 @@ impl GatewayDataState {
|
||||
let mut groups = repository
|
||||
.list_user_groups_for_user(&snapshot.user_id)
|
||||
.await?;
|
||||
let dynamic_group_ids = self
|
||||
.active_membership_group_ids_for_user(&snapshot.user_id)
|
||||
.await?;
|
||||
if !dynamic_group_ids.is_empty() {
|
||||
groups.extend(
|
||||
repository
|
||||
.list_user_groups_by_ids(&dynamic_group_ids)
|
||||
.await?,
|
||||
);
|
||||
let mut deduped = std::collections::BTreeMap::new();
|
||||
for group in groups {
|
||||
deduped.insert(group.id.clone(), group);
|
||||
}
|
||||
groups = deduped.into_values().collect();
|
||||
}
|
||||
groups.sort_by(|left, right| {
|
||||
left.name
|
||||
.cmp(&right.name)
|
||||
@@ -1746,6 +1761,51 @@ impl GatewayDataState {
|
||||
);
|
||||
Ok(Some(snapshot))
|
||||
}
|
||||
|
||||
async fn active_membership_group_ids_for_user(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<String>, DataLayerError> {
|
||||
let Some(repository) = self.billing_reader.as_ref() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let Some(entitlements) = repository.list_user_plan_entitlements(user_id).await? else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let now = chrono::Utc::now().timestamp().max(0) as u64;
|
||||
let mut group_ids = std::collections::BTreeSet::new();
|
||||
for entitlement in entitlements {
|
||||
if entitlement.status != "active"
|
||||
|| entitlement.starts_at_unix_secs > now
|
||||
|| entitlement.expires_at_unix_secs <= now
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(items) = entitlement.entitlements_snapshot.as_array() else {
|
||||
continue;
|
||||
};
|
||||
for item in items {
|
||||
if item.get("type").and_then(serde_json::Value::as_str) != Some("membership_group")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(groups) = item
|
||||
.get("grant_user_groups")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for group_id in groups {
|
||||
if let Some(group_id) = group_id.as_str().map(str::trim) {
|
||||
if !group_id.is_empty() {
|
||||
group_ids.insert(group_id.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(group_ids.into_iter().collect())
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_admin_unrestricted_auth_snapshot(snapshot: &mut GatewayAuthApiKeySnapshot) {
|
||||
|
||||
@@ -62,9 +62,9 @@ use aether_data::repository::wallet::{
|
||||
AdminRedeemCodeListQuery, AdminWalletLedgerQuery, AdminWalletListQuery,
|
||||
AdminWalletRefundRequestListQuery, CompleteAdminWalletRefundInput,
|
||||
CreateAdminRedeemCodeBatchInput, CreateAdminRedeemCodeBatchResult,
|
||||
CreateManualWalletRechargeInput, CreateWalletRechargeOrderInput,
|
||||
CreateWalletRechargeOrderOutcome, CreateWalletRefundRequestInput,
|
||||
CreateWalletRefundRequestOutcome, CreditAdminPaymentOrderInput,
|
||||
CreateManualWalletRechargeInput, CreatePlanPurchaseOrderInput, CreatePlanPurchaseOrderOutcome,
|
||||
CreateWalletRechargeOrderInput, CreateWalletRechargeOrderOutcome,
|
||||
CreateWalletRefundRequestInput, CreateWalletRefundRequestOutcome, CreditAdminPaymentOrderInput,
|
||||
DeleteAdminRedeemCodeBatchInput, DisableAdminRedeemCodeBatchInput, DisableAdminRedeemCodeInput,
|
||||
FailAdminWalletRefundInput, ProcessAdminWalletRefundInput, ProcessPaymentCallbackInput,
|
||||
ProcessPaymentCallbackOutcome, RedeemWalletCodeInput, RedeemWalletCodeOutcome,
|
||||
@@ -89,7 +89,9 @@ use aether_data_contracts::repository::background_tasks::{
|
||||
use aether_data_contracts::repository::billing::{
|
||||
AdminBillingCollectorRecord, AdminBillingCollectorWriteInput, AdminBillingMutationOutcome,
|
||||
AdminBillingPresetApplyResult, AdminBillingRuleRecord, AdminBillingRuleWriteInput,
|
||||
BillingReadRepository, StoredBillingModelContext,
|
||||
BillingPlanRecord, BillingPlanWriteInput, BillingReadRepository, PaymentGatewayConfigRecord,
|
||||
PaymentGatewayConfigWriteInput, StoredBillingModelContext, UserDailyQuotaAvailabilityRecord,
|
||||
UserPlanEntitlementRecord,
|
||||
};
|
||||
use aether_data_contracts::repository::candidate_selection::{
|
||||
MinimalCandidateSelectionReadRepository, StoredMinimalCandidateSelectionRow,
|
||||
|
||||
@@ -5,31 +5,34 @@ use super::{
|
||||
AdminBillingRuleWriteInput, AdminPaymentOrderListQuery, AdminRedeemCodeBatchListQuery,
|
||||
AdminRedeemCodeListQuery, AdminWalletLedgerQuery, AdminWalletListQuery,
|
||||
AdminWalletRefundRequestListQuery, AnnouncementListQuery, AuditLogListQuery,
|
||||
BackgroundTaskListQuery, BackgroundTaskSummary, CompleteAdminWalletRefundInput,
|
||||
CreateAdminRedeemCodeBatchInput, CreateAdminRedeemCodeBatchResult, CreateAnnouncementRecord,
|
||||
CreateManualWalletRechargeInput, CreateWalletRechargeOrderInput,
|
||||
BackgroundTaskListQuery, BackgroundTaskSummary, BillingPlanRecord, BillingPlanWriteInput,
|
||||
CompleteAdminWalletRefundInput, CreateAdminRedeemCodeBatchInput,
|
||||
CreateAdminRedeemCodeBatchResult, CreateAnnouncementRecord, CreateManualWalletRechargeInput,
|
||||
CreatePlanPurchaseOrderInput, CreatePlanPurchaseOrderOutcome, CreateWalletRechargeOrderInput,
|
||||
CreateWalletRechargeOrderOutcome, CreateWalletRefundRequestInput,
|
||||
CreateWalletRefundRequestOutcome, CreditAdminPaymentOrderInput, DataLayerError,
|
||||
DatabaseMaintenanceSummary, DecisionTrace, DeleteAdminRedeemCodeBatchInput,
|
||||
DisableAdminRedeemCodeBatchInput, DisableAdminRedeemCodeInput, FailAdminWalletRefundInput,
|
||||
GatewayDataState, GatewayProviderTransportSnapshot, LocalVideoTaskReadResponse,
|
||||
ProcessAdminWalletRefundInput, ProcessPaymentCallbackInput, ProcessPaymentCallbackOutcome,
|
||||
RedeemWalletCodeInput, RedeemWalletCodeOutcome, RequestAuditBundle, RequestCandidateTrace,
|
||||
StoredAdminAuditLogPage, StoredAdminPaymentCallbackPage, StoredAdminPaymentOrder,
|
||||
StoredAdminPaymentOrderPage, StoredAdminRedeemCodeBatch, StoredAdminRedeemCodeBatchPage,
|
||||
StoredAdminRedeemCodePage, StoredAdminWalletLedgerPage, StoredAdminWalletListPage,
|
||||
StoredAdminWalletRefund, StoredAdminWalletRefundPage, StoredAdminWalletRefundRequestPage,
|
||||
StoredAdminWalletTransaction, StoredAdminWalletTransactionPage, StoredAnnouncement,
|
||||
StoredAnnouncementPage, StoredBackgroundTaskEvent, StoredBackgroundTaskRun,
|
||||
StoredBackgroundTaskRunPage, StoredBillingModelContext, StoredProviderQuotaSnapshot,
|
||||
StoredProviderUsageSummary, StoredRequestUsageAudit, StoredSuspiciousActivity,
|
||||
StoredUsageSettlement, StoredUserAuditLogPage, StoredUserAuthRecord, StoredUserExportRow,
|
||||
StoredUserSummary, StoredVideoTask, StoredWalletDailyUsageLedger,
|
||||
StoredWalletDailyUsageLedgerPage, StoredWalletSnapshot, UpdateAnnouncementRecord,
|
||||
UpsertBackgroundTaskEvent, UpsertBackgroundTaskRun, UpsertUsageRecord, UpsertVideoTask,
|
||||
UsageSettlementInput, VideoTaskLookupKey, VideoTaskModelCount, VideoTaskQueryFilter,
|
||||
VideoTaskStatusCount, WalletDailyUsageAggregationInput, WalletDailyUsageAggregationResult,
|
||||
WalletLookupKey, WalletMutationOutcome,
|
||||
PaymentGatewayConfigRecord, PaymentGatewayConfigWriteInput, ProcessAdminWalletRefundInput,
|
||||
ProcessPaymentCallbackInput, ProcessPaymentCallbackOutcome, RedeemWalletCodeInput,
|
||||
RedeemWalletCodeOutcome, RequestAuditBundle, RequestCandidateTrace, StoredAdminAuditLogPage,
|
||||
StoredAdminPaymentCallbackPage, StoredAdminPaymentOrder, StoredAdminPaymentOrderPage,
|
||||
StoredAdminRedeemCodeBatch, StoredAdminRedeemCodeBatchPage, StoredAdminRedeemCodePage,
|
||||
StoredAdminWalletLedgerPage, StoredAdminWalletListPage, StoredAdminWalletRefund,
|
||||
StoredAdminWalletRefundPage, StoredAdminWalletRefundRequestPage, StoredAdminWalletTransaction,
|
||||
StoredAdminWalletTransactionPage, StoredAnnouncement, StoredAnnouncementPage,
|
||||
StoredBackgroundTaskEvent, StoredBackgroundTaskRun, StoredBackgroundTaskRunPage,
|
||||
StoredBillingModelContext, StoredProviderQuotaSnapshot, StoredProviderUsageSummary,
|
||||
StoredRequestUsageAudit, StoredSuspiciousActivity, StoredUsageSettlement,
|
||||
StoredUserAuditLogPage, StoredUserAuthRecord, StoredUserExportRow, StoredUserSummary,
|
||||
StoredVideoTask, StoredWalletDailyUsageLedger, StoredWalletDailyUsageLedgerPage,
|
||||
StoredWalletSnapshot, UpdateAnnouncementRecord, UpsertBackgroundTaskEvent,
|
||||
UpsertBackgroundTaskRun, UpsertUsageRecord, UpsertVideoTask, UsageSettlementInput,
|
||||
UserDailyQuotaAvailabilityRecord, UserPlanEntitlementRecord, VideoTaskLookupKey,
|
||||
VideoTaskModelCount, VideoTaskQueryFilter, VideoTaskStatusCount,
|
||||
WalletDailyUsageAggregationInput, WalletDailyUsageAggregationResult, WalletLookupKey,
|
||||
WalletMutationOutcome,
|
||||
};
|
||||
use aether_data_contracts::repository::usage::{
|
||||
PendingUsageCleanupSummary, ProviderApiKeyWindowUsageRequest,
|
||||
@@ -675,6 +678,16 @@ impl GatewayDataState {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn create_plan_purchase_order(
|
||||
&self,
|
||||
input: CreatePlanPurchaseOrderInput,
|
||||
) -> Result<Option<CreatePlanPurchaseOrderOutcome>, DataLayerError> {
|
||||
match &self.wallet_writer {
|
||||
Some(repository) => repository.create_plan_purchase_order(input).await.map(Some),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn create_wallet_refund_request(
|
||||
&self,
|
||||
input: CreateWalletRefundRequestInput,
|
||||
@@ -1663,6 +1676,108 @@ impl GatewayDataState {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn find_payment_gateway_config(
|
||||
&self,
|
||||
provider: &str,
|
||||
) -> Result<Option<PaymentGatewayConfigRecord>, DataLayerError> {
|
||||
match &self.billing_reader {
|
||||
Some(repository) => repository.find_payment_gateway_config(provider).await,
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn upsert_payment_gateway_config(
|
||||
&self,
|
||||
input: &PaymentGatewayConfigWriteInput,
|
||||
) -> Result<AdminBillingMutationOutcome<PaymentGatewayConfigRecord>, DataLayerError> {
|
||||
match &self.billing_reader {
|
||||
Some(repository) => repository.upsert_payment_gateway_config(input).await,
|
||||
None => Ok(AdminBillingMutationOutcome::Unavailable),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn list_billing_plans(
|
||||
&self,
|
||||
include_disabled: bool,
|
||||
) -> Result<Option<Vec<BillingPlanRecord>>, DataLayerError> {
|
||||
match &self.billing_reader {
|
||||
Some(repository) => repository.list_billing_plans(include_disabled).await,
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn find_billing_plan(
|
||||
&self,
|
||||
plan_id: &str,
|
||||
) -> Result<Option<BillingPlanRecord>, DataLayerError> {
|
||||
match &self.billing_reader {
|
||||
Some(repository) => repository.find_billing_plan(plan_id).await,
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn create_billing_plan(
|
||||
&self,
|
||||
input: &BillingPlanWriteInput,
|
||||
) -> Result<AdminBillingMutationOutcome<BillingPlanRecord>, DataLayerError> {
|
||||
match &self.billing_reader {
|
||||
Some(repository) => repository.create_billing_plan(input).await,
|
||||
None => Ok(AdminBillingMutationOutcome::Unavailable),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn update_billing_plan(
|
||||
&self,
|
||||
plan_id: &str,
|
||||
input: &BillingPlanWriteInput,
|
||||
) -> Result<AdminBillingMutationOutcome<BillingPlanRecord>, DataLayerError> {
|
||||
match &self.billing_reader {
|
||||
Some(repository) => repository.update_billing_plan(plan_id, input).await,
|
||||
None => Ok(AdminBillingMutationOutcome::Unavailable),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn set_billing_plan_enabled(
|
||||
&self,
|
||||
plan_id: &str,
|
||||
enabled: bool,
|
||||
) -> Result<AdminBillingMutationOutcome<BillingPlanRecord>, DataLayerError> {
|
||||
match &self.billing_reader {
|
||||
Some(repository) => repository.set_billing_plan_enabled(plan_id, enabled).await,
|
||||
None => Ok(AdminBillingMutationOutcome::Unavailable),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_billing_plan(
|
||||
&self,
|
||||
plan_id: &str,
|
||||
) -> Result<AdminBillingMutationOutcome<()>, DataLayerError> {
|
||||
match &self.billing_reader {
|
||||
Some(repository) => repository.delete_billing_plan(plan_id).await,
|
||||
None => Ok(AdminBillingMutationOutcome::Unavailable),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn list_user_plan_entitlements(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Option<Vec<UserPlanEntitlementRecord>>, DataLayerError> {
|
||||
match &self.billing_reader {
|
||||
Some(repository) => repository.list_user_plan_entitlements(user_id).await,
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn find_user_daily_quota_availability(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Option<UserDailyQuotaAvailabilityRecord>, DataLayerError> {
|
||||
match &self.billing_reader {
|
||||
Some(repository) => repository.find_user_daily_quota_availability(user_id).await,
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn read_request_candidate_trace(
|
||||
&self,
|
||||
request_id: &str,
|
||||
|
||||
@@ -14,6 +14,7 @@ const ADMIN_BILLING_DATA_UNAVAILABLE_DETAIL: &str = "Admin billing data unavaila
|
||||
|
||||
mod collectors;
|
||||
mod payments;
|
||||
mod plans;
|
||||
mod presets;
|
||||
mod routes;
|
||||
mod rules;
|
||||
@@ -47,6 +48,14 @@ fn build_admin_billing_bad_request_response(detail: impl Into<String>) -> Respon
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn build_admin_billing_conflict_response(detail: impl Into<String>) -> Response<Body> {
|
||||
(
|
||||
http::StatusCode::CONFLICT,
|
||||
Json(json!({ "detail": detail.into() })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn build_admin_billing_read_only_response(detail: &'static str) -> Response<Body> {
|
||||
(
|
||||
http::StatusCode::CONFLICT,
|
||||
@@ -239,7 +248,24 @@ pub(crate) async fn maybe_build_local_admin_billing_response(
|
||||
))
|
||||
|| (request_context.method() == http::Method::PUT
|
||||
&& path.starts_with("/api/admin/billing/collectors/")
|
||||
&& path.matches('/').count() == 5);
|
||||
&& path.matches('/').count() == 5)
|
||||
|| (matches!(
|
||||
request_context.method(),
|
||||
&http::Method::GET | &http::Method::POST
|
||||
) && matches!(
|
||||
path,
|
||||
"/api/admin/billing/plans" | "/api/admin/billing/plans/"
|
||||
))
|
||||
|| (request_context.method() == http::Method::PUT
|
||||
&& path.starts_with("/api/admin/billing/plans/")
|
||||
&& path.matches('/').count() == 5)
|
||||
|| (request_context.method() == http::Method::DELETE
|
||||
&& path.starts_with("/api/admin/billing/plans/")
|
||||
&& path.matches('/').count() == 5)
|
||||
|| (request_context.method() == http::Method::PATCH
|
||||
&& path.starts_with("/api/admin/billing/plans/")
|
||||
&& path.ends_with("/status")
|
||||
&& path.matches('/').count() == 6);
|
||||
|
||||
if !is_billing_route {
|
||||
return Ok(None);
|
||||
@@ -269,6 +295,12 @@ pub(crate) async fn maybe_build_local_admin_billing_response(
|
||||
{
|
||||
return Ok(Some(response));
|
||||
}
|
||||
if let Some(response) =
|
||||
plans::maybe_build_local_admin_billing_plans_response(state, request_context, request_body)
|
||||
.await?
|
||||
{
|
||||
return Ok(Some(response));
|
||||
}
|
||||
|
||||
let _ = decision.route_kind.as_deref();
|
||||
Ok(Some(build_admin_billing_data_unavailable_response()))
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
use super::{
|
||||
build_admin_payments_backend_unavailable_response, build_admin_payments_bad_request_response,
|
||||
};
|
||||
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
||||
use crate::{GatewayError, LocalMutationOutcome};
|
||||
use aether_data_contracts::repository::billing::PaymentGatewayConfigWriteInput;
|
||||
use axum::{
|
||||
body::Body,
|
||||
http,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct EpayGatewayConfigRequest {
|
||||
#[serde(default)]
|
||||
enabled: bool,
|
||||
endpoint_url: String,
|
||||
#[serde(default)]
|
||||
callback_base_url: Option<String>,
|
||||
merchant_id: String,
|
||||
#[serde(default)]
|
||||
merchant_key: Option<String>,
|
||||
#[serde(default = "default_pay_currency")]
|
||||
pay_currency: String,
|
||||
#[serde(default = "default_usd_exchange_rate")]
|
||||
usd_exchange_rate: f64,
|
||||
#[serde(default = "default_min_recharge_usd")]
|
||||
min_recharge_usd: f64,
|
||||
#[serde(default = "default_channels")]
|
||||
channels: serde_json::Value,
|
||||
}
|
||||
|
||||
fn default_pay_currency() -> String {
|
||||
"CNY".to_string()
|
||||
}
|
||||
|
||||
fn default_usd_exchange_rate() -> f64 {
|
||||
7.2
|
||||
}
|
||||
|
||||
fn default_min_recharge_usd() -> f64 {
|
||||
1.0
|
||||
}
|
||||
|
||||
fn default_channels() -> serde_json::Value {
|
||||
json!([
|
||||
{"channel": "alipay", "display_name": "支付宝"},
|
||||
{"channel": "wxpay", "display_name": "微信支付"}
|
||||
])
|
||||
}
|
||||
|
||||
fn normalize_text(value: impl Into<String>, field: &str, max_len: usize) -> Result<String, String> {
|
||||
let value = value.into();
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(format!("{field} must not be empty"));
|
||||
}
|
||||
if trimmed.chars().count() > max_len {
|
||||
return Err(format!("{field} exceeds maximum length {max_len}"));
|
||||
}
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
|
||||
fn normalize_optional_text(
|
||||
value: Option<String>,
|
||||
max_len: usize,
|
||||
) -> Result<Option<String>, String> {
|
||||
let Some(value) = value else {
|
||||
return Ok(None);
|
||||
};
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
if trimmed.chars().count() > max_len {
|
||||
return Err(format!("field exceeds maximum length {max_len}"));
|
||||
}
|
||||
Ok(Some(trimmed.to_string()))
|
||||
}
|
||||
|
||||
fn gateway_config_payload(
|
||||
record: aether_data_contracts::repository::billing::PaymentGatewayConfigRecord,
|
||||
) -> serde_json::Value {
|
||||
json!({
|
||||
"provider": record.provider,
|
||||
"enabled": record.enabled,
|
||||
"endpoint_url": record.endpoint_url,
|
||||
"callback_base_url": record.callback_base_url,
|
||||
"merchant_id": record.merchant_id,
|
||||
"has_secret": record.merchant_key_encrypted.as_deref().is_some_and(|value| !value.trim().is_empty()),
|
||||
"pay_currency": record.pay_currency,
|
||||
"usd_exchange_rate": record.usd_exchange_rate,
|
||||
"min_recharge_usd": record.min_recharge_usd,
|
||||
"channels": record.channels_json,
|
||||
"created_at": record.created_at_unix_secs,
|
||||
"updated_at": record.updated_at_unix_secs,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn maybe_build_local_admin_payment_gateways_response(
|
||||
state: &AdminAppState<'_>,
|
||||
_request_context: &AdminRequestContext<'_>,
|
||||
request_body: Option<&axum::body::Bytes>,
|
||||
route_kind: Option<&str>,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
match route_kind {
|
||||
Some("get_epay_gateway") => {
|
||||
let record = state.app().find_payment_gateway_config("epay").await?;
|
||||
let payload = record.map(gateway_config_payload).unwrap_or_else(
|
||||
|| json!({"provider": "epay", "enabled": false, "has_secret": false}),
|
||||
);
|
||||
Ok(Some(Json(payload).into_response()))
|
||||
}
|
||||
Some("update_epay_gateway") => {
|
||||
let Some(body) = request_body else {
|
||||
return Ok(Some(build_admin_payments_bad_request_response(
|
||||
"缺少请求体",
|
||||
)));
|
||||
};
|
||||
let payload = match serde_json::from_slice::<EpayGatewayConfigRequest>(body) {
|
||||
Ok(value) => value,
|
||||
Err(_) => {
|
||||
return Ok(Some(build_admin_payments_bad_request_response(
|
||||
"输入验证失败",
|
||||
)))
|
||||
}
|
||||
};
|
||||
if !payload.usd_exchange_rate.is_finite() || payload.usd_exchange_rate <= 0.0 {
|
||||
return Ok(Some(build_admin_payments_bad_request_response(
|
||||
"usd_exchange_rate must be positive",
|
||||
)));
|
||||
}
|
||||
if !payload.min_recharge_usd.is_finite() || payload.min_recharge_usd <= 0.0 {
|
||||
return Ok(Some(build_admin_payments_bad_request_response(
|
||||
"min_recharge_usd must be positive",
|
||||
)));
|
||||
}
|
||||
let merchant_key_encrypted = match payload
|
||||
.merchant_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
Some(secret) => match state.encrypt_catalog_secret_with_fallbacks(secret) {
|
||||
Some(value) => Some(value),
|
||||
None => {
|
||||
return Ok(Some(build_admin_payments_backend_unavailable_response(
|
||||
"encryption key is not configured",
|
||||
)))
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
let endpoint_url = match normalize_text(payload.endpoint_url, "endpoint_url", 512) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(Some(build_admin_payments_bad_request_response(detail))),
|
||||
};
|
||||
let callback_base_url = match normalize_optional_text(payload.callback_base_url, 512) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(Some(build_admin_payments_bad_request_response(detail))),
|
||||
};
|
||||
let merchant_id = match normalize_text(payload.merchant_id, "merchant_id", 128) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(Some(build_admin_payments_bad_request_response(detail))),
|
||||
};
|
||||
let pay_currency = match normalize_text(payload.pay_currency, "pay_currency", 16) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(Some(build_admin_payments_bad_request_response(detail))),
|
||||
};
|
||||
let input = PaymentGatewayConfigWriteInput {
|
||||
provider: "epay".to_string(),
|
||||
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: payload.channels,
|
||||
};
|
||||
match state.app().upsert_payment_gateway_config(&input).await? {
|
||||
LocalMutationOutcome::Applied(record) => {
|
||||
Ok(Some(Json(gateway_config_payload(record)).into_response()))
|
||||
}
|
||||
_ => Ok(Some(build_admin_payments_backend_unavailable_response(
|
||||
"payment gateway config backend unavailable",
|
||||
))),
|
||||
}
|
||||
}
|
||||
Some("test_epay_gateway") => {
|
||||
let status = state.app().find_payment_gateway_config("epay").await?;
|
||||
let ok = status
|
||||
.as_ref()
|
||||
.is_some_and(|record| record.enabled && record.merchant_key_encrypted.is_some());
|
||||
Ok(Some(
|
||||
(
|
||||
if ok {
|
||||
http::StatusCode::OK
|
||||
} else {
|
||||
http::StatusCode::BAD_REQUEST
|
||||
},
|
||||
Json(json!({"ok": ok, "provider": "epay"})),
|
||||
)
|
||||
.into_response(),
|
||||
))
|
||||
}
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ use crate::GatewayError;
|
||||
use axum::{body::Body, response::Response};
|
||||
|
||||
mod callbacks;
|
||||
mod gateways;
|
||||
mod orders;
|
||||
#[path = "../../payment/postgres.rs"]
|
||||
mod payment_postgres;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::{
|
||||
build_admin_payments_data_unavailable_response,
|
||||
callbacks::maybe_build_local_admin_payment_callbacks_response,
|
||||
gateways::maybe_build_local_admin_payment_gateways_response,
|
||||
orders::maybe_build_local_admin_payment_orders_response,
|
||||
redeem_codes::maybe_build_local_admin_redeem_codes_response,
|
||||
};
|
||||
@@ -29,6 +30,12 @@ pub(super) async fn maybe_build_local_admin_payments_response(
|
||||
};
|
||||
let is_payments_route = (request_context.method() == http::Method::GET
|
||||
&& path == "/api/admin/payments/orders")
|
||||
|| (matches!(
|
||||
request_context.method(),
|
||||
&http::Method::GET | &http::Method::PUT
|
||||
) && path == "/api/admin/payments/gateways/epay")
|
||||
|| (request_context.method() == http::Method::POST
|
||||
&& path == "/api/admin/payments/gateways/epay/test")
|
||||
|| (request_context.method() == http::Method::GET
|
||||
&& path.starts_with("/api/admin/payments/orders/")
|
||||
&& path.matches('/').count() == 5)
|
||||
@@ -75,6 +82,16 @@ pub(super) async fn maybe_build_local_admin_payments_response(
|
||||
}
|
||||
|
||||
let route_kind = decision.route_kind.as_deref();
|
||||
if let Some(response) = maybe_build_local_admin_payment_gateways_response(
|
||||
state,
|
||||
request_context,
|
||||
request_body,
|
||||
route_kind,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(Some(response));
|
||||
}
|
||||
if let Some(response) = maybe_build_local_admin_payment_orders_response(
|
||||
state,
|
||||
request_context,
|
||||
|
||||
394
apps/aether-gateway/src/handlers/admin/billing/plans.rs
Normal file
394
apps/aether-gateway/src/handlers/admin/billing/plans.rs
Normal file
@@ -0,0 +1,394 @@
|
||||
use super::{
|
||||
build_admin_billing_bad_request_response, build_admin_billing_conflict_response,
|
||||
build_admin_billing_data_unavailable_response, build_admin_billing_not_found_response,
|
||||
};
|
||||
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
||||
use crate::{GatewayError, LocalMutationOutcome};
|
||||
use aether_data_contracts::repository::billing::{BillingPlanRecord, BillingPlanWriteInput};
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
http,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct BillingPlanRequest {
|
||||
title: String,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
price_amount: f64,
|
||||
#[serde(default = "default_price_currency")]
|
||||
price_currency: String,
|
||||
duration_unit: String,
|
||||
duration_value: i64,
|
||||
#[serde(default = "default_enabled")]
|
||||
enabled: bool,
|
||||
#[serde(default)]
|
||||
sort_order: i64,
|
||||
#[serde(default = "default_max_active_per_user")]
|
||||
max_active_per_user: i64,
|
||||
#[serde(default = "default_purchase_limit_scope")]
|
||||
purchase_limit_scope: String,
|
||||
entitlements: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct BillingPlanStatusRequest {
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
fn default_price_currency() -> String {
|
||||
"CNY".to_string()
|
||||
}
|
||||
|
||||
fn default_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_max_active_per_user() -> i64 {
|
||||
1
|
||||
}
|
||||
|
||||
fn default_purchase_limit_scope() -> String {
|
||||
"active_period".to_string()
|
||||
}
|
||||
|
||||
fn normalize_text(value: impl Into<String>, field: &str, max_len: usize) -> Result<String, String> {
|
||||
let value = value.into();
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(format!("{field} must not be empty"));
|
||||
}
|
||||
if trimmed.chars().count() > max_len {
|
||||
return Err(format!("{field} exceeds maximum length {max_len}"));
|
||||
}
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
|
||||
fn normalize_optional_text(
|
||||
value: Option<String>,
|
||||
max_len: usize,
|
||||
) -> Result<Option<String>, String> {
|
||||
let Some(value) = value else {
|
||||
return Ok(None);
|
||||
};
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
if trimmed.chars().count() > max_len {
|
||||
return Err(format!("field exceeds maximum length {max_len}"));
|
||||
}
|
||||
Ok(Some(trimmed.to_string()))
|
||||
}
|
||||
|
||||
fn validate_entitlements(value: &serde_json::Value) -> Result<(), String> {
|
||||
let items = value
|
||||
.as_array()
|
||||
.ok_or_else(|| "entitlements must be an array".to_string())?;
|
||||
if items.is_empty() {
|
||||
return Err("entitlements must not be empty".to_string());
|
||||
}
|
||||
for item in items {
|
||||
let kind = item
|
||||
.get("type")
|
||||
.and_then(|value| value.as_str())
|
||||
.ok_or_else(|| "entitlement.type is required".to_string())?;
|
||||
match kind {
|
||||
"wallet_credit" => {
|
||||
let amount = item
|
||||
.get("amount_usd")
|
||||
.and_then(|value| value.as_f64())
|
||||
.ok_or_else(|| "wallet_credit.amount_usd is required".to_string())?;
|
||||
if !amount.is_finite() || amount <= 0.0 {
|
||||
return Err("wallet_credit.amount_usd must be positive".to_string());
|
||||
}
|
||||
if let Some(bucket) = item.get("balance_bucket") {
|
||||
let bucket = bucket.as_str().ok_or_else(|| {
|
||||
"wallet_credit.balance_bucket must be a string".to_string()
|
||||
})?;
|
||||
if !matches!(bucket, "recharge" | "gift") {
|
||||
return Err(
|
||||
"wallet_credit.balance_bucket must be recharge/gift".to_string()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
"daily_quota" => {
|
||||
let amount = item
|
||||
.get("daily_quota_usd")
|
||||
.and_then(|value| value.as_f64())
|
||||
.ok_or_else(|| "daily_quota.daily_quota_usd is required".to_string())?;
|
||||
if !amount.is_finite() || amount <= 0.0 {
|
||||
return Err("daily_quota.daily_quota_usd must be positive".to_string());
|
||||
}
|
||||
if let Some(reset_timezone) = item.get("reset_timezone") {
|
||||
let reset_timezone = reset_timezone
|
||||
.as_str()
|
||||
.ok_or_else(|| "daily_quota.reset_timezone must be a string".to_string())?
|
||||
.trim();
|
||||
if reset_timezone.is_empty() {
|
||||
return Err("daily_quota.reset_timezone must not be empty".to_string());
|
||||
}
|
||||
reset_timezone.parse::<chrono_tz::Tz>().map_err(|_| {
|
||||
"daily_quota.reset_timezone must be a valid timezone".to_string()
|
||||
})?;
|
||||
}
|
||||
if let Some(carry_over) = item.get("carry_over") {
|
||||
let carry_over = carry_over
|
||||
.as_bool()
|
||||
.ok_or_else(|| "daily_quota.carry_over must be a boolean".to_string())?;
|
||||
if carry_over {
|
||||
return Err("daily_quota.carry_over is not supported".to_string());
|
||||
}
|
||||
}
|
||||
if item
|
||||
.get("allow_wallet_overage")
|
||||
.is_some_and(|value| !value.is_boolean())
|
||||
{
|
||||
return Err("daily_quota.allow_wallet_overage must be a boolean".to_string());
|
||||
}
|
||||
}
|
||||
"membership_group" => {
|
||||
let groups = item
|
||||
.get("grant_user_groups")
|
||||
.and_then(|value| value.as_array())
|
||||
.ok_or_else(|| "membership_group.grant_user_groups is required".to_string())?;
|
||||
if groups.is_empty() {
|
||||
return Err("membership_group.grant_user_groups must not be empty".to_string());
|
||||
}
|
||||
for group in groups {
|
||||
let group = group.as_str().ok_or_else(|| {
|
||||
"membership_group.grant_user_groups must contain strings".to_string()
|
||||
})?;
|
||||
if group.trim().is_empty() {
|
||||
return Err(
|
||||
"membership_group.grant_user_groups must not contain empty values"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => return Err(format!("unsupported entitlement type: {kind}")),
|
||||
}
|
||||
}
|
||||
if !entitlements_include_package_rights(items) {
|
||||
return Err("套餐至少需要包含每日额度或会员分组;钱包充值请使用充值功能".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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")
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_plan_input(payload: BillingPlanRequest) -> Result<BillingPlanWriteInput, String> {
|
||||
if !payload.price_amount.is_finite() || payload.price_amount <= 0.0 {
|
||||
return Err("price_amount must be positive".to_string());
|
||||
}
|
||||
if payload.duration_value <= 0 {
|
||||
return Err("duration_value must be positive".to_string());
|
||||
}
|
||||
if payload.max_active_per_user <= 0 {
|
||||
return Err("max_active_per_user must be positive".to_string());
|
||||
}
|
||||
let duration_unit = normalize_text(payload.duration_unit, "duration_unit", 32)?;
|
||||
if !matches!(duration_unit.as_str(), "day" | "month" | "year" | "custom") {
|
||||
return Err("duration_unit must be day/month/year/custom".to_string());
|
||||
}
|
||||
let purchase_limit_scope =
|
||||
normalize_text(payload.purchase_limit_scope, "purchase_limit_scope", 32)?;
|
||||
if !matches!(
|
||||
purchase_limit_scope.as_str(),
|
||||
"active_period" | "lifetime" | "unlimited"
|
||||
) {
|
||||
return Err("purchase_limit_scope must be active_period/lifetime/unlimited".to_string());
|
||||
}
|
||||
validate_entitlements(&payload.entitlements)?;
|
||||
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)?,
|
||||
duration_unit,
|
||||
duration_value: payload.duration_value,
|
||||
enabled: payload.enabled,
|
||||
sort_order: payload.sort_order,
|
||||
max_active_per_user: payload.max_active_per_user,
|
||||
purchase_limit_scope,
|
||||
entitlements_json: payload.entitlements,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn billing_plan_payload(record: &BillingPlanRecord) -> serde_json::Value {
|
||||
json!({
|
||||
"id": record.id,
|
||||
"title": record.title,
|
||||
"description": record.description,
|
||||
"price_amount": record.price_amount,
|
||||
"price_currency": record.price_currency,
|
||||
"duration_unit": record.duration_unit,
|
||||
"duration_value": record.duration_value,
|
||||
"enabled": record.enabled,
|
||||
"sort_order": record.sort_order,
|
||||
"max_active_per_user": record.max_active_per_user,
|
||||
"purchase_limit_scope": record.purchase_limit_scope,
|
||||
"entitlements": record.entitlements_json,
|
||||
"created_at": record.created_at_unix_secs,
|
||||
"updated_at": record.updated_at_unix_secs,
|
||||
})
|
||||
}
|
||||
|
||||
fn plan_id_from_path(path: &str, suffix: Option<&str>) -> Option<String> {
|
||||
let trimmed = path.trim_end_matches('/');
|
||||
let rest = trimmed.strip_prefix("/api/admin/billing/plans/")?;
|
||||
let id = if let Some(suffix) = suffix {
|
||||
rest.strip_suffix(suffix)?.trim_end_matches('/')
|
||||
} else {
|
||||
rest
|
||||
};
|
||||
if id.is_empty() || id.contains('/') {
|
||||
None
|
||||
} else {
|
||||
Some(id.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn maybe_build_local_admin_billing_plans_response(
|
||||
state: &AdminAppState<'_>,
|
||||
request_context: &AdminRequestContext<'_>,
|
||||
request_body: Option<&Bytes>,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
let path = request_context.path().trim_end_matches('/');
|
||||
match (request_context.method(), path) {
|
||||
(&http::Method::GET, "/api/admin/billing/plans") => {
|
||||
let items = state
|
||||
.app()
|
||||
.list_billing_plans(true)
|
||||
.await?
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(billing_plan_payload)
|
||||
.collect::<Vec<_>>();
|
||||
Ok(Some(
|
||||
Json(json!({"items": items, "total": items.len()})).into_response(),
|
||||
))
|
||||
}
|
||||
(&http::Method::POST, "/api/admin/billing/plans") => {
|
||||
let Some(body) = request_body else {
|
||||
return Ok(Some(build_admin_billing_bad_request_response("缺少请求体")));
|
||||
};
|
||||
let payload = match serde_json::from_slice::<BillingPlanRequest>(body) {
|
||||
Ok(value) => value,
|
||||
Err(_) => {
|
||||
return Ok(Some(build_admin_billing_bad_request_response(
|
||||
"输入验证失败",
|
||||
)))
|
||||
}
|
||||
};
|
||||
let input = match normalize_plan_input(payload) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(Some(build_admin_billing_bad_request_response(detail))),
|
||||
};
|
||||
match state.app().create_billing_plan(&input).await? {
|
||||
LocalMutationOutcome::Applied(record) => {
|
||||
Ok(Some(Json(billing_plan_payload(&record)).into_response()))
|
||||
}
|
||||
_ => Ok(Some(build_admin_billing_data_unavailable_response())),
|
||||
}
|
||||
}
|
||||
_ if request_context.method() == http::Method::PUT
|
||||
&& path.starts_with("/api/admin/billing/plans/") =>
|
||||
{
|
||||
let Some(plan_id) = plan_id_from_path(path, None) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(body) = request_body else {
|
||||
return Ok(Some(build_admin_billing_bad_request_response("缺少请求体")));
|
||||
};
|
||||
let payload = match serde_json::from_slice::<BillingPlanRequest>(body) {
|
||||
Ok(value) => value,
|
||||
Err(_) => {
|
||||
return Ok(Some(build_admin_billing_bad_request_response(
|
||||
"输入验证失败",
|
||||
)))
|
||||
}
|
||||
};
|
||||
let input = match normalize_plan_input(payload) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(Some(build_admin_billing_bad_request_response(detail))),
|
||||
};
|
||||
match state.app().update_billing_plan(&plan_id, &input).await? {
|
||||
LocalMutationOutcome::Applied(record) => {
|
||||
Ok(Some(Json(billing_plan_payload(&record)).into_response()))
|
||||
}
|
||||
LocalMutationOutcome::NotFound => Ok(Some(build_admin_billing_not_found_response(
|
||||
"Billing plan not found",
|
||||
))),
|
||||
_ => Ok(Some(build_admin_billing_data_unavailable_response())),
|
||||
}
|
||||
}
|
||||
_ if request_context.method() == http::Method::PATCH
|
||||
&& path.ends_with("/status")
|
||||
&& path.starts_with("/api/admin/billing/plans/") =>
|
||||
{
|
||||
let Some(plan_id) = plan_id_from_path(path, Some("/status")) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(body) = request_body else {
|
||||
return Ok(Some(build_admin_billing_bad_request_response("缺少请求体")));
|
||||
};
|
||||
let payload = match serde_json::from_slice::<BillingPlanStatusRequest>(body) {
|
||||
Ok(value) => value,
|
||||
Err(_) => {
|
||||
return Ok(Some(build_admin_billing_bad_request_response(
|
||||
"输入验证失败",
|
||||
)))
|
||||
}
|
||||
};
|
||||
match state
|
||||
.app()
|
||||
.set_billing_plan_enabled(&plan_id, payload.enabled)
|
||||
.await?
|
||||
{
|
||||
LocalMutationOutcome::Applied(record) => {
|
||||
Ok(Some(Json(billing_plan_payload(&record)).into_response()))
|
||||
}
|
||||
LocalMutationOutcome::NotFound => Ok(Some(build_admin_billing_not_found_response(
|
||||
"Billing plan not found",
|
||||
))),
|
||||
_ => Ok(Some(build_admin_billing_data_unavailable_response())),
|
||||
}
|
||||
}
|
||||
_ if request_context.method() == http::Method::DELETE
|
||||
&& path.starts_with("/api/admin/billing/plans/") =>
|
||||
{
|
||||
let Some(plan_id) = plan_id_from_path(path, None) else {
|
||||
return Ok(None);
|
||||
};
|
||||
match state.app().delete_billing_plan(&plan_id).await? {
|
||||
LocalMutationOutcome::Applied(()) => {
|
||||
Ok(Some(http::StatusCode::NO_CONTENT.into_response()))
|
||||
}
|
||||
LocalMutationOutcome::NotFound => Ok(Some(build_admin_billing_not_found_response(
|
||||
"Billing plan not found",
|
||||
))),
|
||||
LocalMutationOutcome::Invalid(detail) => {
|
||||
Ok(Some(build_admin_billing_conflict_response(detail)))
|
||||
}
|
||||
LocalMutationOutcome::Unavailable => {
|
||||
Ok(Some(build_admin_billing_data_unavailable_response()))
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::super::shared::{
|
||||
admin_wallet_id_from_suffix_path, admin_wallet_operator_id,
|
||||
build_admin_wallet_not_found_response, build_admin_wallet_summary_payload,
|
||||
build_admin_wallet_not_found_response, build_admin_wallet_summary_payload_with_package,
|
||||
build_admin_wallet_transaction_payload, build_admin_wallets_bad_request_response,
|
||||
build_admin_wallets_data_unavailable_response, normalize_admin_wallet_balance_type,
|
||||
normalize_admin_wallet_description, normalize_admin_wallet_non_zero_amount,
|
||||
@@ -79,7 +79,8 @@ pub(in super::super) async fn build_admin_wallet_adjust_response(
|
||||
};
|
||||
};
|
||||
let owner = resolve_admin_wallet_owner_summary(state, &wallet).await?;
|
||||
let wallet_payload = build_admin_wallet_summary_payload(&wallet, &owner);
|
||||
let wallet_payload =
|
||||
build_admin_wallet_summary_payload_with_package(state, &wallet, &owner).await?;
|
||||
let transaction_payload = build_admin_wallet_transaction_payload(
|
||||
&wallet,
|
||||
&owner,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::super::shared::{
|
||||
admin_wallet_operator_id, admin_wallet_refund_ids_from_suffix_path,
|
||||
build_admin_wallet_not_found_response, build_admin_wallet_refund_not_found_response,
|
||||
build_admin_wallet_refund_payload, build_admin_wallet_summary_payload,
|
||||
build_admin_wallet_refund_payload, build_admin_wallet_summary_payload_with_package,
|
||||
build_admin_wallet_transaction_payload, build_admin_wallets_bad_request_response,
|
||||
build_admin_wallets_data_unavailable_response, normalize_admin_wallet_required_text,
|
||||
resolve_admin_wallet_owner_summary, AdminWalletRefundFailRequest,
|
||||
@@ -62,8 +62,10 @@ pub(in super::super) async fn build_admin_wallet_fail_refund_response(
|
||||
{
|
||||
crate::AdminWalletMutationOutcome::Applied((wallet, refund, transaction)) => {
|
||||
let owner = resolve_admin_wallet_owner_summary(state, &wallet).await?;
|
||||
let wallet_payload =
|
||||
build_admin_wallet_summary_payload_with_package(state, &wallet, &owner).await?;
|
||||
let response = Json(json!({
|
||||
"wallet": build_admin_wallet_summary_payload(&wallet, &owner),
|
||||
"wallet": wallet_payload,
|
||||
"refund": build_admin_wallet_refund_payload(&wallet, &owner, &refund),
|
||||
"transaction": transaction
|
||||
.map(|transaction| {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::super::shared::{
|
||||
admin_wallet_operator_id, admin_wallet_refund_ids_from_suffix_path,
|
||||
build_admin_wallet_not_found_response, build_admin_wallet_refund_not_found_response,
|
||||
build_admin_wallet_refund_payload, build_admin_wallet_summary_payload,
|
||||
build_admin_wallet_refund_payload, build_admin_wallet_summary_payload_with_package,
|
||||
build_admin_wallet_transaction_payload, build_admin_wallets_bad_request_response,
|
||||
build_admin_wallets_data_unavailable_response, resolve_admin_wallet_owner_summary,
|
||||
ADMIN_WALLETS_API_KEY_REFUND_DETAIL,
|
||||
@@ -49,8 +49,10 @@ pub(in super::super) async fn build_admin_wallet_process_refund_response(
|
||||
{
|
||||
crate::AdminWalletMutationOutcome::Applied((wallet, refund, transaction)) => {
|
||||
let owner = resolve_admin_wallet_owner_summary(state, &wallet).await?;
|
||||
let wallet_payload =
|
||||
build_admin_wallet_summary_payload_with_package(state, &wallet, &owner).await?;
|
||||
let response = Json(json!({
|
||||
"wallet": build_admin_wallet_summary_payload(&wallet, &owner),
|
||||
"wallet": wallet_payload,
|
||||
"refund": build_admin_wallet_refund_payload(&wallet, &owner, &refund),
|
||||
"transaction": build_admin_wallet_transaction_payload(
|
||||
&wallet,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::super::shared::{
|
||||
admin_wallet_id_from_suffix_path, admin_wallet_operator_id,
|
||||
build_admin_wallet_not_found_response, build_admin_wallet_payment_order_payload,
|
||||
build_admin_wallet_summary_payload, build_admin_wallets_bad_request_response,
|
||||
build_admin_wallet_summary_payload_with_package, build_admin_wallets_bad_request_response,
|
||||
build_admin_wallets_data_unavailable_response, normalize_admin_wallet_description,
|
||||
normalize_admin_wallet_payment_method, normalize_admin_wallet_positive_amount,
|
||||
resolve_admin_wallet_owner_summary, AdminWalletRechargeRequest,
|
||||
@@ -79,8 +79,10 @@ pub(in super::super) async fn build_admin_wallet_recharge_response(
|
||||
};
|
||||
};
|
||||
let owner = resolve_admin_wallet_owner_summary(state, &wallet).await?;
|
||||
let wallet_payload =
|
||||
build_admin_wallet_summary_payload_with_package(state, &wallet, &owner).await?;
|
||||
let response = Json(json!({
|
||||
"wallet": build_admin_wallet_summary_payload(&wallet, &owner),
|
||||
"wallet": wallet_payload,
|
||||
"payment_order": build_admin_wallet_payment_order_payload(
|
||||
payment_order.id,
|
||||
payment_order.order_no,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::super::shared::{
|
||||
admin_wallet_id_from_detail_path, build_admin_wallet_not_found_response,
|
||||
build_admin_wallet_summary_payload, build_admin_wallets_bad_request_response,
|
||||
build_admin_wallet_summary_payload_with_package, build_admin_wallets_bad_request_response,
|
||||
resolve_admin_wallet_owner_summary,
|
||||
};
|
||||
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
||||
@@ -29,7 +29,8 @@ pub(in super::super) async fn build_admin_wallet_detail_response(
|
||||
};
|
||||
|
||||
let owner = resolve_admin_wallet_owner_summary(state, &wallet).await?;
|
||||
let mut payload = build_admin_wallet_summary_payload(&wallet, &owner);
|
||||
let mut payload =
|
||||
build_admin_wallet_summary_payload_with_package(state, &wallet, &owner).await?;
|
||||
if let Some(object) = payload.as_object_mut() {
|
||||
object.insert("pending_refund_count".to_string(), serde_json::Value::Null);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::super::shared::{
|
||||
build_admin_wallets_bad_request_response, parse_admin_wallets_limit,
|
||||
parse_admin_wallets_offset, parse_admin_wallets_owner_type_filter,
|
||||
build_admin_wallets_bad_request_response, enrich_admin_wallet_package_summary,
|
||||
parse_admin_wallets_limit, parse_admin_wallets_offset, parse_admin_wallets_owner_type_filter,
|
||||
wallet_owner_summary_from_fields,
|
||||
};
|
||||
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
||||
@@ -32,38 +32,48 @@ pub(in super::super) async fn build_admin_wallet_list_response(
|
||||
let (wallets, total) = state
|
||||
.list_admin_wallets(status.as_deref(), owner_type.as_deref(), limit, offset)
|
||||
.await?;
|
||||
let items = wallets
|
||||
.into_iter()
|
||||
.map(|wallet| {
|
||||
let owner = wallet_owner_summary_from_fields(
|
||||
wallet.user_id.as_deref(),
|
||||
wallet.user_name.clone(),
|
||||
wallet.api_key_id.as_deref(),
|
||||
wallet.api_key_name.clone(),
|
||||
);
|
||||
json!({
|
||||
"id": wallet.id,
|
||||
"user_id": wallet.user_id,
|
||||
"api_key_id": wallet.api_key_id,
|
||||
"owner_type": owner.owner_type,
|
||||
"owner_name": owner.owner_name,
|
||||
"balance": wallet.balance + wallet.gift_balance,
|
||||
"recharge_balance": wallet.balance,
|
||||
"gift_balance": wallet.gift_balance,
|
||||
"refundable_balance": wallet.balance,
|
||||
"currency": wallet.currency,
|
||||
"status": wallet.status,
|
||||
"limit_mode": wallet.limit_mode.clone(),
|
||||
"unlimited": wallet.limit_mode.eq_ignore_ascii_case("unlimited"),
|
||||
"total_recharged": wallet.total_recharged,
|
||||
"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),
|
||||
"updated_at": wallet.updated_at_unix_secs.and_then(unix_secs_to_rfc3339),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut items = Vec::with_capacity(wallets.len());
|
||||
for wallet in wallets {
|
||||
let owner = wallet_owner_summary_from_fields(
|
||||
wallet.user_id.as_deref(),
|
||||
wallet.user_name.clone(),
|
||||
wallet.api_key_id.as_deref(),
|
||||
wallet.api_key_name.clone(),
|
||||
);
|
||||
let user_id = wallet.user_id.clone();
|
||||
let wallet_balance = wallet.balance + wallet.gift_balance;
|
||||
let unlimited = wallet.limit_mode.eq_ignore_ascii_case("unlimited");
|
||||
let mut payload = json!({
|
||||
"id": wallet.id,
|
||||
"user_id": wallet.user_id,
|
||||
"api_key_id": wallet.api_key_id,
|
||||
"owner_type": owner.owner_type,
|
||||
"owner_name": owner.owner_name,
|
||||
"balance": wallet_balance,
|
||||
"recharge_balance": wallet.balance,
|
||||
"gift_balance": wallet.gift_balance,
|
||||
"refundable_balance": wallet.balance,
|
||||
"currency": wallet.currency,
|
||||
"status": wallet.status,
|
||||
"limit_mode": wallet.limit_mode.clone(),
|
||||
"unlimited": unlimited,
|
||||
"total_recharged": wallet.total_recharged,
|
||||
"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),
|
||||
"updated_at": wallet.updated_at_unix_secs.and_then(unix_secs_to_rfc3339),
|
||||
});
|
||||
enrich_admin_wallet_package_summary(
|
||||
state,
|
||||
&mut payload,
|
||||
user_id.as_deref(),
|
||||
wallet_balance,
|
||||
unlimited,
|
||||
)
|
||||
.await?;
|
||||
items.push(payload);
|
||||
}
|
||||
|
||||
Ok(Json(json!({
|
||||
"items": items,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::super::shared::{
|
||||
admin_wallet_id_from_suffix_path, build_admin_wallet_not_found_response,
|
||||
build_admin_wallet_refund_payload, build_admin_wallet_summary_payload,
|
||||
build_admin_wallet_refund_payload, build_admin_wallet_summary_payload_with_package,
|
||||
build_admin_wallets_bad_request_response, parse_admin_wallets_limit,
|
||||
parse_admin_wallets_offset, resolve_admin_wallet_owner_summary,
|
||||
ADMIN_WALLETS_API_KEY_REFUND_DETAIL,
|
||||
@@ -46,7 +46,8 @@ pub(in super::super) async fn build_admin_wallet_refunds_response(
|
||||
}
|
||||
|
||||
let owner = resolve_admin_wallet_owner_summary(state, &wallet).await?;
|
||||
let wallet_payload = build_admin_wallet_summary_payload(&wallet, &owner);
|
||||
let wallet_payload =
|
||||
build_admin_wallet_summary_payload_with_package(state, &wallet, &owner).await?;
|
||||
let (refunds, total) = state
|
||||
.list_admin_wallet_refunds(&wallet.id, limit, offset)
|
||||
.await?;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::super::shared::{
|
||||
admin_wallet_id_from_suffix_path, build_admin_wallet_not_found_response,
|
||||
build_admin_wallet_summary_payload, build_admin_wallets_bad_request_response,
|
||||
build_admin_wallet_summary_payload_with_package, build_admin_wallets_bad_request_response,
|
||||
parse_admin_wallets_limit, parse_admin_wallets_offset, resolve_admin_wallet_owner_summary,
|
||||
};
|
||||
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
||||
@@ -39,7 +39,8 @@ pub(in super::super) async fn build_admin_wallet_transactions_response(
|
||||
return Ok(build_admin_wallet_not_found_response());
|
||||
};
|
||||
let owner = resolve_admin_wallet_owner_summary(state, &wallet).await?;
|
||||
let wallet_payload = build_admin_wallet_summary_payload(&wallet, &owner);
|
||||
let wallet_payload =
|
||||
build_admin_wallet_summary_payload_with_package(state, &wallet, &owner).await?;
|
||||
|
||||
let (transactions, total) = state
|
||||
.list_admin_wallet_transactions(&wallet.id, limit, offset)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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;
|
||||
|
||||
@@ -160,6 +161,79 @@ pub(in super::super) fn build_admin_wallet_summary_payload(
|
||||
})
|
||||
}
|
||||
|
||||
pub(in super::super) async fn build_admin_wallet_summary_payload_with_package(
|
||||
state: &AdminAppState<'_>,
|
||||
wallet: &aether_data::repository::wallet::StoredWalletSnapshot,
|
||||
owner: &AdminWalletOwnerSummary,
|
||||
) -> Result<serde_json::Value, GatewayError> {
|
||||
let mut payload = build_admin_wallet_summary_payload(wallet, owner);
|
||||
enrich_admin_wallet_package_summary(
|
||||
state,
|
||||
&mut payload,
|
||||
wallet.user_id.as_deref(),
|
||||
wallet.balance + wallet.gift_balance,
|
||||
wallet.limit_mode.eq_ignore_ascii_case("unlimited"),
|
||||
)
|
||||
.await?;
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
pub(in super::super) async fn enrich_admin_wallet_package_summary(
|
||||
state: &AdminAppState<'_>,
|
||||
payload: &mut serde_json::Value,
|
||||
user_id: Option<&str>,
|
||||
wallet_balance: f64,
|
||||
unlimited: bool,
|
||||
) -> Result<(), GatewayError> {
|
||||
let daily_quota = match user_id {
|
||||
Some(user_id) if !user_id.trim().is_empty() => {
|
||||
state
|
||||
.app()
|
||||
.find_user_daily_quota_availability(user_id)
|
||||
.await?
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let (has_active_daily_quota, total_quota_usd, used_usd, remaining_usd, allow_wallet_overage) =
|
||||
daily_quota
|
||||
.map(|quota| {
|
||||
(
|
||||
quota.has_active_daily_quota,
|
||||
quota.total_quota_usd,
|
||||
quota.used_usd,
|
||||
quota.remaining_usd,
|
||||
quota.allow_wallet_overage,
|
||||
)
|
||||
})
|
||||
.unwrap_or((false, 0.0, 0.0, 0.0, false));
|
||||
let package_balance = if has_active_daily_quota {
|
||||
remaining_usd.max(0.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
payload["daily_quota"] = json!({
|
||||
"has_active": has_active_daily_quota,
|
||||
"total_usd": round_to(total_quota_usd.max(0.0), 6),
|
||||
"used_usd": round_to(used_usd.max(0.0), 6),
|
||||
"remaining_usd": round_to(package_balance, 6),
|
||||
"allow_wallet_overage": allow_wallet_overage,
|
||||
});
|
||||
payload["package_balance"] = json!(round_to(package_balance, 6));
|
||||
payload["wallet_balance"] = json!(round_to(wallet_balance.max(0.0), 6));
|
||||
payload["total_available_balance"] = if unlimited {
|
||||
serde_json::Value::Null
|
||||
} else {
|
||||
json!(round_to((wallet_balance + package_balance).max(0.0), 6))
|
||||
};
|
||||
payload["deduction_order"] = json!([
|
||||
"package_daily_quota",
|
||||
"wallet_recharge_balance",
|
||||
"wallet_gift_balance"
|
||||
]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(in super::super) fn build_admin_wallet_refund_payload(
|
||||
wallet: &aether_data::repository::wallet::StoredWalletSnapshot,
|
||||
owner: &AdminWalletOwnerSummary,
|
||||
|
||||
347
apps/aether-gateway/src/handlers/admin/users/billing.rs
Normal file
347
apps/aether-gateway/src/handlers/admin/users/billing.rs
Normal file
@@ -0,0 +1,347 @@
|
||||
use super::{build_admin_users_bad_request_response, build_admin_users_data_unavailable_response};
|
||||
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
||||
use crate::handlers::admin::shared::{attach_admin_audit_response, unix_secs_to_rfc3339};
|
||||
use crate::handlers::shared::unix_ms_to_rfc3339;
|
||||
use crate::GatewayError;
|
||||
use aether_data_contracts::repository::billing::{BillingPlanRecord, UserPlanEntitlementRecord};
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
http,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AdminGrantUserPlanRequest {
|
||||
plan_id: String,
|
||||
#[serde(default)]
|
||||
reason: Option<String>,
|
||||
}
|
||||
|
||||
fn admin_user_id_from_billing_path(request_path: &str, suffix: &str) -> Option<String> {
|
||||
let trimmed = request_path.trim_end_matches('/');
|
||||
let rest = trimmed.strip_prefix("/api/admin/users/")?;
|
||||
let user_id = rest.strip_suffix(suffix)?.trim_end_matches('/');
|
||||
if user_id.is_empty() || user_id.contains('/') {
|
||||
None
|
||||
} else {
|
||||
Some(user_id.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn admin_user_billing_operator_id(request_context: &AdminRequestContext<'_>) -> Option<String> {
|
||||
request_context
|
||||
.decision()
|
||||
.and_then(|decision| decision.admin_principal.as_ref())
|
||||
.map(|principal| principal.user_id.clone())
|
||||
}
|
||||
|
||||
fn normalize_admin_grant_reason(value: Option<String>) -> Result<Option<String>, String> {
|
||||
let Some(value) = value else {
|
||||
return Ok(None);
|
||||
};
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
if value.chars().count() > 512 {
|
||||
return Err("reason exceeds maximum length 512".to_string());
|
||||
}
|
||||
Ok(Some(value.to_string()))
|
||||
}
|
||||
|
||||
fn admin_plan_grant_order_no(now: chrono::DateTime<Utc>) -> String {
|
||||
format!(
|
||||
"pg_{}_{}",
|
||||
now.format("%Y%m%d%H%M%S%6f"),
|
||||
&Uuid::new_v4().simple().to_string()[..12]
|
||||
)
|
||||
}
|
||||
|
||||
fn billing_plan_payload(record: &BillingPlanRecord) -> serde_json::Value {
|
||||
json!({
|
||||
"id": record.id,
|
||||
"title": record.title,
|
||||
"description": record.description,
|
||||
"price_amount": record.price_amount,
|
||||
"price_currency": record.price_currency,
|
||||
"duration_unit": record.duration_unit,
|
||||
"duration_value": record.duration_value,
|
||||
"enabled": record.enabled,
|
||||
"sort_order": record.sort_order,
|
||||
"max_active_per_user": record.max_active_per_user,
|
||||
"purchase_limit_scope": record.purchase_limit_scope,
|
||||
"entitlements": record.entitlements_json,
|
||||
"created_at": record.created_at_unix_secs,
|
||||
"updated_at": record.updated_at_unix_secs,
|
||||
})
|
||||
}
|
||||
|
||||
fn billing_plan_snapshot(record: &BillingPlanRecord) -> serde_json::Value {
|
||||
json!({
|
||||
"id": record.id,
|
||||
"title": record.title,
|
||||
"description": record.description,
|
||||
"price_amount": record.price_amount,
|
||||
"price_currency": record.price_currency,
|
||||
"duration_unit": record.duration_unit,
|
||||
"duration_value": record.duration_value,
|
||||
"max_active_per_user": record.max_active_per_user,
|
||||
"purchase_limit_scope": record.purchase_limit_scope,
|
||||
"entitlements": record.entitlements_json,
|
||||
})
|
||||
}
|
||||
|
||||
fn plan_has_package_rights(record: &BillingPlanRecord) -> bool {
|
||||
record.entitlements_json.as_array().is_some_and(|items| {
|
||||
items.iter().any(|item| {
|
||||
matches!(
|
||||
item.get("type").and_then(|value| value.as_str()),
|
||||
Some("daily_quota" | "membership_group")
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn admin_payment_order_payload(record: &crate::AdminWalletPaymentOrderRecord) -> serde_json::Value {
|
||||
json!({
|
||||
"id": record.id,
|
||||
"order_no": record.order_no,
|
||||
"wallet_id": record.wallet_id,
|
||||
"user_id": record.user_id,
|
||||
"amount_usd": record.amount_usd,
|
||||
"pay_amount": record.pay_amount,
|
||||
"pay_currency": record.pay_currency,
|
||||
"exchange_rate": record.exchange_rate,
|
||||
"refunded_amount_usd": record.refunded_amount_usd,
|
||||
"refundable_amount_usd": record.refundable_amount_usd,
|
||||
"payment_method": record.payment_method,
|
||||
"gateway_order_id": record.gateway_order_id,
|
||||
"gateway_response": record.gateway_response,
|
||||
"status": record.status,
|
||||
"order_kind": "plan_purchase",
|
||||
"created_at": unix_ms_to_rfc3339(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),
|
||||
})
|
||||
}
|
||||
|
||||
fn entitlement_payload(
|
||||
record: &UserPlanEntitlementRecord,
|
||||
plan: Option<&BillingPlanRecord>,
|
||||
now_unix_secs: u64,
|
||||
) -> serde_json::Value {
|
||||
json!({
|
||||
"id": record.id,
|
||||
"user_id": record.user_id,
|
||||
"plan_id": record.plan_id,
|
||||
"payment_order_id": record.payment_order_id,
|
||||
"status": record.status,
|
||||
"starts_at": unix_secs_to_rfc3339(record.starts_at_unix_secs),
|
||||
"expires_at": unix_secs_to_rfc3339(record.expires_at_unix_secs),
|
||||
"entitlements": record.entitlements_snapshot,
|
||||
"active": record.status == "active"
|
||||
&& record.starts_at_unix_secs <= now_unix_secs
|
||||
&& record.expires_at_unix_secs > now_unix_secs,
|
||||
"plan_title": plan.map(|plan| plan.title.clone()),
|
||||
"plan": plan.map(billing_plan_payload),
|
||||
"created_at": unix_secs_to_rfc3339(record.created_at_unix_secs),
|
||||
"updated_at": unix_secs_to_rfc3339(record.updated_at_unix_secs),
|
||||
})
|
||||
}
|
||||
|
||||
async fn load_admin_user_entitlements_payload(
|
||||
state: &AdminAppState<'_>,
|
||||
user_id: &str,
|
||||
) -> Result<Option<serde_json::Value>, GatewayError> {
|
||||
let entitlements = match state.app().list_user_plan_entitlements(user_id).await? {
|
||||
Some(value) => value,
|
||||
None => return Ok(None),
|
||||
};
|
||||
let plans = state
|
||||
.app()
|
||||
.list_billing_plans(true)
|
||||
.await?
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|plan| (plan.id.clone(), plan))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let now = Utc::now().timestamp().max(0) as u64;
|
||||
let items = entitlements
|
||||
.iter()
|
||||
.map(|record| entitlement_payload(record, plans.get(&record.plan_id), now))
|
||||
.collect::<Vec<_>>();
|
||||
Ok(Some(json!({"items": items, "total": items.len()})))
|
||||
}
|
||||
|
||||
pub(in super::super) async fn build_admin_list_user_billing_entitlements_response(
|
||||
state: &AdminAppState<'_>,
|
||||
request_context: &AdminRequestContext<'_>,
|
||||
) -> Result<Response<Body>, GatewayError> {
|
||||
let Some(user_id) =
|
||||
admin_user_id_from_billing_path(request_context.path(), "/billing/entitlements")
|
||||
else {
|
||||
return Ok(build_admin_users_bad_request_response("缺少 user_id"));
|
||||
};
|
||||
if state.find_user_auth_by_id(&user_id).await?.is_none() {
|
||||
return Ok((
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": "用户不存在" })),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
match load_admin_user_entitlements_payload(state, &user_id).await? {
|
||||
Some(payload) => Ok(Json(payload).into_response()),
|
||||
None => Ok(build_admin_users_data_unavailable_response()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(in super::super) async fn build_admin_grant_user_billing_plan_response(
|
||||
state: &AdminAppState<'_>,
|
||||
request_context: &AdminRequestContext<'_>,
|
||||
request_body: Option<&Bytes>,
|
||||
) -> Result<Response<Body>, GatewayError> {
|
||||
let Some(user_id) =
|
||||
admin_user_id_from_billing_path(request_context.path(), "/billing/grant-plan")
|
||||
else {
|
||||
return Ok(build_admin_users_bad_request_response("缺少 user_id"));
|
||||
};
|
||||
if state.find_user_auth_by_id(&user_id).await?.is_none() {
|
||||
return Ok((
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": "用户不存在" })),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
let Some(body) = request_body else {
|
||||
return Ok(build_admin_users_bad_request_response("缺少请求体"));
|
||||
};
|
||||
let payload = match serde_json::from_slice::<AdminGrantUserPlanRequest>(body) {
|
||||
Ok(value) => value,
|
||||
Err(_) => return Ok(build_admin_users_bad_request_response("输入验证失败")),
|
||||
};
|
||||
let plan_id = payload.plan_id.trim();
|
||||
if plan_id.is_empty() {
|
||||
return Ok(build_admin_users_bad_request_response("plan_id 不能为空"));
|
||||
}
|
||||
let reason = match normalize_admin_grant_reason(payload.reason) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(build_admin_users_bad_request_response(detail)),
|
||||
};
|
||||
let Some(plan) = state.app().find_billing_plan(plan_id).await? else {
|
||||
return Ok((
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": "套餐不存在" })),
|
||||
)
|
||||
.into_response());
|
||||
};
|
||||
if !plan_has_package_rights(&plan) {
|
||||
return Ok(build_admin_users_bad_request_response(
|
||||
"余额包已移除,请使用钱包充值功能",
|
||||
));
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
let order_no = admin_plan_grant_order_no(now);
|
||||
let operator_id = admin_user_billing_operator_id(request_context);
|
||||
let gateway_response = json!({
|
||||
"source": "admin_grant",
|
||||
"operator_id": operator_id.as_deref(),
|
||||
"reason": reason,
|
||||
"granted_at": now.to_rfc3339(),
|
||||
});
|
||||
let outcome = match state
|
||||
.app()
|
||||
.create_plan_purchase_order(
|
||||
aether_data::repository::wallet::CreatePlanPurchaseOrderInput {
|
||||
preferred_wallet_id: None,
|
||||
user_id: user_id.clone(),
|
||||
amount_usd: 0.0,
|
||||
pay_amount: 0.0,
|
||||
pay_currency: plan.price_currency.clone(),
|
||||
exchange_rate: 1.0,
|
||||
payment_method: "admin_grant".to_string(),
|
||||
payment_provider: Some("admin".to_string()),
|
||||
payment_channel: Some("manual".to_string()),
|
||||
gateway_order_id: order_no.clone(),
|
||||
gateway_response,
|
||||
order_no: order_no.clone(),
|
||||
product_id: plan.id.clone(),
|
||||
product_snapshot: billing_plan_snapshot(&plan),
|
||||
expires_at_unix_secs: (now + chrono::Duration::minutes(30)).timestamp().max(0)
|
||||
as u64,
|
||||
},
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Some(value) => value,
|
||||
None => return Ok(build_admin_users_data_unavailable_response()),
|
||||
};
|
||||
let order = match outcome {
|
||||
aether_data::repository::wallet::CreatePlanPurchaseOrderOutcome::Created(order) => order,
|
||||
aether_data::repository::wallet::CreatePlanPurchaseOrderOutcome::WalletInactive => {
|
||||
return Ok(build_admin_users_bad_request_response(
|
||||
"wallet is not active",
|
||||
));
|
||||
}
|
||||
aether_data::repository::wallet::CreatePlanPurchaseOrderOutcome::ActivePlanLimitReached => {
|
||||
return Ok((
|
||||
http::StatusCode::CONFLICT,
|
||||
Json(json!({ "detail": "套餐购买限制已达到上限" })),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
};
|
||||
|
||||
let credit_result = state
|
||||
.admin_credit_payment_order(
|
||||
&order.id,
|
||||
Some(&order_no),
|
||||
Some(0.0),
|
||||
Some(&plan.price_currency),
|
||||
Some(1.0),
|
||||
Some(json!({ "admin_grant": true })),
|
||||
operator_id.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
let (credited_order, credited) = match credit_result {
|
||||
crate::AdminWalletMutationOutcome::Applied(value) => value,
|
||||
crate::AdminWalletMutationOutcome::NotFound => {
|
||||
return Ok(build_admin_users_data_unavailable_response());
|
||||
}
|
||||
crate::AdminWalletMutationOutcome::Invalid(detail) => {
|
||||
return Ok((
|
||||
http::StatusCode::CONFLICT,
|
||||
Json(json!({ "detail": detail })),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
crate::AdminWalletMutationOutcome::Unavailable => {
|
||||
return Ok(build_admin_users_data_unavailable_response());
|
||||
}
|
||||
};
|
||||
let entitlements = match load_admin_user_entitlements_payload(state, &user_id).await? {
|
||||
Some(value) => value,
|
||||
None => return Ok(build_admin_users_data_unavailable_response()),
|
||||
};
|
||||
Ok(attach_admin_audit_response(
|
||||
Json(json!({
|
||||
"order": admin_payment_order_payload(&credited_order),
|
||||
"credited": credited,
|
||||
"items": entitlements["items"].clone(),
|
||||
"entitlements": entitlements["items"].clone(),
|
||||
"total": entitlements["total"].clone(),
|
||||
}))
|
||||
.into_response(),
|
||||
"admin_user_plan_granted",
|
||||
"grant_user_billing_plan",
|
||||
"user",
|
||||
&user_id,
|
||||
))
|
||||
}
|
||||
@@ -4,6 +4,7 @@ const ADMIN_USERS_DATA_UNAVAILABLE_DETAIL: &str = "Admin user management data un
|
||||
|
||||
mod api_keys;
|
||||
mod batch;
|
||||
mod billing;
|
||||
mod groups;
|
||||
mod lifecycle;
|
||||
mod route_seam;
|
||||
@@ -24,6 +25,10 @@ pub(crate) use self::api_keys::{
|
||||
use self::batch::{
|
||||
build_admin_resolve_user_selection_response, build_admin_user_batch_action_response,
|
||||
};
|
||||
use self::billing::{
|
||||
build_admin_grant_user_billing_plan_response,
|
||||
build_admin_list_user_billing_entitlements_response,
|
||||
};
|
||||
use self::groups::{
|
||||
build_admin_create_user_group_response, build_admin_delete_user_group_response,
|
||||
build_admin_list_user_group_members_response, build_admin_list_user_groups_response,
|
||||
|
||||
@@ -3,7 +3,8 @@ use super::{
|
||||
build_admin_create_user_response, build_admin_delete_user_api_key_response,
|
||||
build_admin_delete_user_group_response, build_admin_delete_user_response,
|
||||
build_admin_delete_user_session_response, build_admin_delete_user_sessions_response,
|
||||
build_admin_get_user_response, build_admin_list_user_api_keys_response,
|
||||
build_admin_get_user_response, build_admin_grant_user_billing_plan_response,
|
||||
build_admin_list_user_api_keys_response, build_admin_list_user_billing_entitlements_response,
|
||||
build_admin_list_user_group_members_response, build_admin_list_user_groups_response,
|
||||
build_admin_list_user_sessions_response, build_admin_list_users_response,
|
||||
build_admin_replace_user_group_members_response, build_admin_resolve_user_selection_response,
|
||||
@@ -49,6 +50,14 @@ fn is_admin_users_route(request_context: &AdminRequestContext<'_>) -> bool {
|
||||
| "/api/admin/users/batch-action"
|
||||
| "/api/admin/users/batch-action/"
|
||||
))
|
||||
|| (request_context.method() == http::Method::GET
|
||||
&& path.starts_with("/api/admin/users/")
|
||||
&& path.ends_with("/billing/entitlements")
|
||||
&& path.matches('/').count() == 6)
|
||||
|| (request_context.method() == http::Method::POST
|
||||
&& path.starts_with("/api/admin/users/")
|
||||
&& path.ends_with("/billing/grant-plan")
|
||||
&& path.matches('/').count() == 6)
|
||||
|| ((request_context.method() == http::Method::GET
|
||||
|| request_context.method() == http::Method::PUT
|
||||
|| request_context.method() == http::Method::DELETE)
|
||||
@@ -139,6 +148,13 @@ pub(super) async fn maybe_build_local_admin_users_routes_response(
|
||||
Some("batch_action_users") => Ok(Some(
|
||||
build_admin_user_batch_action_response(state, request_context, request_body).await?,
|
||||
)),
|
||||
Some("list_user_billing_entitlements") => Ok(Some(
|
||||
build_admin_list_user_billing_entitlements_response(state, request_context).await?,
|
||||
)),
|
||||
Some("grant_user_billing_plan") => Ok(Some(
|
||||
build_admin_grant_user_billing_plan_response(state, request_context, request_body)
|
||||
.await?,
|
||||
)),
|
||||
Some("get_user") => Ok(Some(
|
||||
build_admin_get_user_response(state, request_context).await?,
|
||||
)),
|
||||
|
||||
@@ -140,10 +140,10 @@ pub(super) fn build_admin_users_read_only_response(detail: &'static str) -> Resp
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub(super) fn build_admin_users_bad_request_response(detail: &'static str) -> Response<Body> {
|
||||
pub(super) fn build_admin_users_bad_request_response(detail: impl Into<String>) -> Response<Body> {
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": detail })),
|
||||
Json(json!({ "detail": detail.into() })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
mod support_announcements;
|
||||
#[path = "support/auth.rs"]
|
||||
mod support_auth;
|
||||
#[path = "support/billing.rs"]
|
||||
mod support_billing;
|
||||
#[path = "support/dashboard.rs"]
|
||||
mod support_dashboard;
|
||||
#[path = "support/install.rs"]
|
||||
@@ -62,6 +64,7 @@ use self::support_auth::{
|
||||
build_auth_error_response, build_auth_json_response, build_auth_registration_settings_payload,
|
||||
build_auth_settings_payload, extract_client_device_id, maybe_build_local_auth_response,
|
||||
};
|
||||
use self::support_billing::maybe_build_local_billing_response;
|
||||
use self::support_dashboard::maybe_build_local_dashboard_response;
|
||||
use self::support_install::{
|
||||
handle_users_me_api_key_install_session_create, maybe_build_local_install_response,
|
||||
@@ -147,6 +150,15 @@ pub(crate) async fn maybe_build_local_public_support_response(
|
||||
return Some(build_unhandled_public_support_response(request_context));
|
||||
}
|
||||
|
||||
if decision.route_family.as_deref() == Some("billing") {
|
||||
if let Some(response) =
|
||||
maybe_build_local_billing_response(state, request_context, headers, request_body).await
|
||||
{
|
||||
return Some(response);
|
||||
}
|
||||
return Some(build_unhandled_public_support_response(request_context));
|
||||
}
|
||||
|
||||
if decision.route_family.as_deref() == Some("users_me") {
|
||||
return maybe_build_local_users_me_response(state, request_context, headers, request_body)
|
||||
.await;
|
||||
|
||||
451
apps/aether-gateway/src/handlers/public/support/billing.rs
Normal file
451
apps/aether-gateway/src/handlers/public/support/billing.rs
Normal file
@@ -0,0 +1,451 @@
|
||||
use super::support_payment::payment_epay::{
|
||||
build_epay_checkout_url, epay_callback_base_url, load_epay_config, resolve_epay_channel,
|
||||
EpayCheckoutInput,
|
||||
};
|
||||
use super::{
|
||||
build_auth_error_response, build_auth_json_response, resolve_authenticated_local_user,
|
||||
sanitize_wallet_gateway_response, unix_secs_to_rfc3339, AppState, GatewayPublicRequestContext,
|
||||
};
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
http,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
const BILLING_STORAGE_UNAVAILABLE_DETAIL: &str = "套餐后端暂不可用";
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
struct BillingPlanCheckoutRequest {
|
||||
#[serde(default)]
|
||||
payment_method: Option<String>,
|
||||
#[serde(default)]
|
||||
payment_provider: Option<String>,
|
||||
#[serde(default)]
|
||||
payment_channel: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct NormalizedBillingPlanCheckoutRequest {
|
||||
payment_method: String,
|
||||
payment_provider: String,
|
||||
payment_channel: Option<String>,
|
||||
}
|
||||
|
||||
fn billing_storage_unavailable_response() -> Response<Body> {
|
||||
build_auth_error_response(
|
||||
http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
BILLING_STORAGE_UNAVAILABLE_DETAIL,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
fn normalize_optional_checkout_string(value: Option<String>, max_len: usize) -> Option<String> {
|
||||
value
|
||||
.map(|value| value.trim().to_ascii_lowercase())
|
||||
.filter(|value| !value.is_empty() && value.chars().count() <= max_len)
|
||||
}
|
||||
|
||||
fn normalize_checkout_request(
|
||||
payload: BillingPlanCheckoutRequest,
|
||||
) -> Result<NormalizedBillingPlanCheckoutRequest, &'static str> {
|
||||
let payment_provider = normalize_optional_checkout_string(payload.payment_provider, 30)
|
||||
.or_else(|| normalize_optional_checkout_string(payload.payment_method.clone(), 30))
|
||||
.unwrap_or_else(|| "epay".to_string());
|
||||
if payment_provider != "epay" {
|
||||
return Err("unsupported payment_provider");
|
||||
}
|
||||
let payment_method = normalize_optional_checkout_string(payload.payment_method, 30)
|
||||
.unwrap_or_else(|| "epay".to_string());
|
||||
let payment_channel = normalize_optional_checkout_string(payload.payment_channel, 30)
|
||||
.or_else(|| (payment_method != "epay").then_some(payment_method.clone()));
|
||||
Ok(NormalizedBillingPlanCheckoutRequest {
|
||||
payment_method: "epay".to_string(),
|
||||
payment_provider,
|
||||
payment_channel,
|
||||
})
|
||||
}
|
||||
|
||||
fn plan_id_from_checkout_path(path: &str) -> Option<String> {
|
||||
let trimmed = path.trim_end_matches('/');
|
||||
let rest = trimmed.strip_prefix("/api/billing/plans/")?;
|
||||
let plan_id = rest.strip_suffix("/checkout")?.trim_matches('/');
|
||||
if plan_id.is_empty() || plan_id.contains('/') {
|
||||
None
|
||||
} else {
|
||||
Some(plan_id.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn billing_order_no(now: chrono::DateTime<chrono::Utc>) -> String {
|
||||
format!(
|
||||
"pp_{}_{}",
|
||||
now.format("%Y%m%d%H%M%S%6f"),
|
||||
&Uuid::new_v4().simple().to_string()[..12]
|
||||
)
|
||||
}
|
||||
|
||||
fn billing_plan_payload(
|
||||
record: &aether_data_contracts::repository::billing::BillingPlanRecord,
|
||||
) -> serde_json::Value {
|
||||
json!({
|
||||
"id": record.id,
|
||||
"title": record.title,
|
||||
"description": record.description,
|
||||
"price_amount": record.price_amount,
|
||||
"price_currency": record.price_currency,
|
||||
"duration_unit": record.duration_unit,
|
||||
"duration_value": record.duration_value,
|
||||
"enabled": record.enabled,
|
||||
"sort_order": record.sort_order,
|
||||
"max_active_per_user": record.max_active_per_user,
|
||||
"purchase_limit_scope": record.purchase_limit_scope,
|
||||
"entitlements": record.entitlements_json,
|
||||
"created_at": record.created_at_unix_secs,
|
||||
"updated_at": record.updated_at_unix_secs,
|
||||
})
|
||||
}
|
||||
|
||||
fn billing_plan_snapshot(
|
||||
record: &aether_data_contracts::repository::billing::BillingPlanRecord,
|
||||
) -> serde_json::Value {
|
||||
json!({
|
||||
"id": record.id,
|
||||
"title": record.title,
|
||||
"description": record.description,
|
||||
"price_amount": record.price_amount,
|
||||
"price_currency": record.price_currency,
|
||||
"duration_unit": record.duration_unit,
|
||||
"duration_value": record.duration_value,
|
||||
"max_active_per_user": record.max_active_per_user,
|
||||
"purchase_limit_scope": record.purchase_limit_scope,
|
||||
"entitlements": record.entitlements_json,
|
||||
})
|
||||
}
|
||||
|
||||
fn plan_has_package_rights(
|
||||
record: &aether_data_contracts::repository::billing::BillingPlanRecord,
|
||||
) -> bool {
|
||||
record.entitlements_json.as_array().is_some_and(|items| {
|
||||
items.iter().any(|item| {
|
||||
matches!(
|
||||
item.get("type").and_then(|value| value.as_str()),
|
||||
Some("daily_quota" | "membership_group")
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn payment_order_payload(
|
||||
record: &aether_data::repository::wallet::StoredAdminPaymentOrder,
|
||||
plan: &aether_data_contracts::repository::billing::BillingPlanRecord,
|
||||
) -> serde_json::Value {
|
||||
json!({
|
||||
"id": record.id,
|
||||
"order_no": record.order_no,
|
||||
"wallet_id": record.wallet_id,
|
||||
"user_id": record.user_id,
|
||||
"amount_usd": record.amount_usd,
|
||||
"pay_amount": record.pay_amount,
|
||||
"pay_currency": record.pay_currency,
|
||||
"exchange_rate": record.exchange_rate,
|
||||
"payment_method": record.payment_method,
|
||||
"gateway_order_id": record.gateway_order_id,
|
||||
"gateway_response": sanitize_wallet_gateway_response(record.gateway_response.clone()),
|
||||
"status": record.status,
|
||||
"order_kind": "plan_purchase",
|
||||
"product_id": plan.id,
|
||||
"product": billing_plan_payload(plan),
|
||||
"created_at": unix_secs_to_rfc3339(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),
|
||||
})
|
||||
}
|
||||
|
||||
fn entitlement_payload(
|
||||
record: &aether_data_contracts::repository::billing::UserPlanEntitlementRecord,
|
||||
) -> serde_json::Value {
|
||||
json!({
|
||||
"id": record.id,
|
||||
"user_id": record.user_id,
|
||||
"plan_id": record.plan_id,
|
||||
"payment_order_id": record.payment_order_id,
|
||||
"status": record.status,
|
||||
"starts_at": unix_secs_to_rfc3339(record.starts_at_unix_secs),
|
||||
"expires_at": unix_secs_to_rfc3339(record.expires_at_unix_secs),
|
||||
"entitlements": record.entitlements_snapshot,
|
||||
"created_at": unix_secs_to_rfc3339(record.created_at_unix_secs),
|
||||
"updated_at": unix_secs_to_rfc3339(record.updated_at_unix_secs),
|
||||
})
|
||||
}
|
||||
|
||||
fn compute_plan_payment_amounts(
|
||||
plan: &aether_data_contracts::repository::billing::BillingPlanRecord,
|
||||
pay_currency: &str,
|
||||
usd_exchange_rate: f64,
|
||||
) -> Result<(f64, f64), &'static str> {
|
||||
if !plan.price_amount.is_finite() || plan.price_amount <= 0.0 || usd_exchange_rate <= 0.0 {
|
||||
return Err("套餐价格配置无效");
|
||||
}
|
||||
if plan.price_currency.eq_ignore_ascii_case(pay_currency) {
|
||||
let amount_usd =
|
||||
(plan.price_amount / usd_exchange_rate * 100_000_000.0).round() / 100_000_000.0;
|
||||
let pay_amount = (plan.price_amount * 100.0).round() / 100.0;
|
||||
return Ok((amount_usd, pay_amount));
|
||||
}
|
||||
if plan.price_currency.eq_ignore_ascii_case("USD") {
|
||||
let amount_usd = (plan.price_amount * 100_000_000.0).round() / 100_000_000.0;
|
||||
let pay_amount = (plan.price_amount * usd_exchange_rate * 100.0).round() / 100.0;
|
||||
return Ok((amount_usd, pay_amount));
|
||||
}
|
||||
Err("套餐币种与支付网关币种不匹配")
|
||||
}
|
||||
|
||||
pub(super) async fn handle_billing_plans_list(state: &AppState) -> Response<Body> {
|
||||
let plans = match state.list_billing_plans(false).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 plan lookup failed: {err:?}"),
|
||||
false,
|
||||
)
|
||||
}
|
||||
};
|
||||
let items = plans
|
||||
.iter()
|
||||
.filter(|plan| plan_has_package_rights(plan))
|
||||
.map(billing_plan_payload)
|
||||
.collect::<Vec<_>>();
|
||||
Json(json!({"items": items, "total": items.len()})).into_response()
|
||||
}
|
||||
|
||||
pub(super) async fn handle_billing_entitlements(
|
||||
state: &AppState,
|
||||
request_context: &GatewayPublicRequestContext,
|
||||
headers: &http::HeaderMap,
|
||||
) -> Response<Body> {
|
||||
let auth = match resolve_authenticated_local_user(state, request_context, headers).await {
|
||||
Ok(value) => value,
|
||||
Err(response) => return response,
|
||||
};
|
||||
let entitlements = match state.list_user_plan_entitlements(&auth.user.id).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 entitlement lookup failed: {err:?}"),
|
||||
false,
|
||||
)
|
||||
}
|
||||
};
|
||||
let now = Utc::now().timestamp().max(0) as u64;
|
||||
let items = entitlements
|
||||
.iter()
|
||||
.map(|record| {
|
||||
let mut payload = entitlement_payload(record);
|
||||
payload["active"] = json!(
|
||||
record.status == "active"
|
||||
&& record.starts_at_unix_secs <= now
|
||||
&& record.expires_at_unix_secs > now
|
||||
);
|
||||
payload
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Json(json!({"items": items, "total": items.len()})).into_response()
|
||||
}
|
||||
|
||||
pub(super) async fn handle_billing_plan_checkout(
|
||||
state: &AppState,
|
||||
request_context: &GatewayPublicRequestContext,
|
||||
headers: &http::HeaderMap,
|
||||
request_body: Option<&Bytes>,
|
||||
) -> Response<Body> {
|
||||
let auth = match resolve_authenticated_local_user(state, request_context, headers).await {
|
||||
Ok(value) => value,
|
||||
Err(response) => return response,
|
||||
};
|
||||
let Some(plan_id) = plan_id_from_checkout_path(&request_context.request_path) else {
|
||||
return build_auth_error_response(http::StatusCode::BAD_REQUEST, "缺少套餐ID", false);
|
||||
};
|
||||
let payload = match request_body {
|
||||
Some(body) if !body.is_empty() => {
|
||||
match serde_json::from_slice::<BillingPlanCheckoutRequest>(body) {
|
||||
Ok(value) => value,
|
||||
Err(_) => {
|
||||
return build_auth_error_response(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
"输入验证失败",
|
||||
false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => BillingPlanCheckoutRequest::default(),
|
||||
};
|
||||
let checkout_request = match normalize_checkout_request(payload) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => {
|
||||
return build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false)
|
||||
}
|
||||
};
|
||||
|
||||
let plan = match state.find_billing_plan(&plan_id).await {
|
||||
Ok(Some(value)) if value.enabled => value,
|
||||
Ok(Some(_)) => {
|
||||
return build_auth_error_response(http::StatusCode::BAD_REQUEST, "套餐已下架", false)
|
||||
}
|
||||
Ok(None) => {
|
||||
return build_auth_error_response(http::StatusCode::NOT_FOUND, "套餐不存在", false)
|
||||
}
|
||||
Err(err) => {
|
||||
return build_auth_error_response(
|
||||
http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("billing plan lookup failed: {err:?}"),
|
||||
false,
|
||||
)
|
||||
}
|
||||
};
|
||||
if !plan_has_package_rights(&plan) {
|
||||
return build_auth_error_response(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
"余额包已移除,请使用钱包充值功能",
|
||||
false,
|
||||
);
|
||||
}
|
||||
let config = match load_epay_config(state).await {
|
||||
Ok(value) => value,
|
||||
Err(detail) => {
|
||||
return build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false)
|
||||
}
|
||||
};
|
||||
let payment_channel =
|
||||
match resolve_epay_channel(&config, checkout_request.payment_channel.as_deref()) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => {
|
||||
return build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false);
|
||||
}
|
||||
};
|
||||
let (amount_usd, pay_amount) =
|
||||
match compute_plan_payment_amounts(&plan, &config.pay_currency, config.usd_exchange_rate) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => {
|
||||
return build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false)
|
||||
}
|
||||
};
|
||||
let Some(callback_base_url) = epay_callback_base_url(
|
||||
config.callback_base_url.as_deref(),
|
||||
headers,
|
||||
request_context,
|
||||
) else {
|
||||
return build_auth_error_response(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
"epay callback_base_url is required",
|
||||
false,
|
||||
);
|
||||
};
|
||||
let now = Utc::now();
|
||||
let order_no = billing_order_no(now);
|
||||
let expires_at = now + chrono::Duration::minutes(30);
|
||||
let checkout = build_epay_checkout_url(
|
||||
&config,
|
||||
&EpayCheckoutInput {
|
||||
order_no: order_no.clone(),
|
||||
channel: payment_channel.clone(),
|
||||
subject: plan.title.clone(),
|
||||
pay_amount,
|
||||
notify_url: format!("{callback_base_url}/api/payment/epay/notify"),
|
||||
return_url: format!("{callback_base_url}/api/payment/epay/return"),
|
||||
},
|
||||
);
|
||||
let outcome = match state
|
||||
.create_plan_purchase_order(
|
||||
aether_data::repository::wallet::CreatePlanPurchaseOrderInput {
|
||||
preferred_wallet_id: None,
|
||||
user_id: auth.user.id.clone(),
|
||||
amount_usd,
|
||||
pay_amount,
|
||||
pay_currency: config.pay_currency.clone(),
|
||||
exchange_rate: config.usd_exchange_rate,
|
||||
payment_method: checkout_request.payment_method,
|
||||
payment_provider: Some(checkout_request.payment_provider),
|
||||
payment_channel: Some(payment_channel),
|
||||
gateway_order_id: order_no.clone(),
|
||||
gateway_response: checkout.clone(),
|
||||
order_no,
|
||||
product_id: plan.id.clone(),
|
||||
product_snapshot: billing_plan_snapshot(&plan),
|
||||
expires_at_unix_secs: expires_at.timestamp().max(0) as u64,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(value)) => value,
|
||||
Ok(None) => return billing_storage_unavailable_response(),
|
||||
Err(err) => {
|
||||
return build_auth_error_response(
|
||||
http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("billing checkout create failed: {err:?}"),
|
||||
false,
|
||||
)
|
||||
}
|
||||
};
|
||||
let order = match outcome {
|
||||
aether_data::repository::wallet::CreatePlanPurchaseOrderOutcome::Created(order) => {
|
||||
payment_order_payload(&order, &plan)
|
||||
}
|
||||
aether_data::repository::wallet::CreatePlanPurchaseOrderOutcome::WalletInactive => {
|
||||
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(
|
||||
state: &AppState,
|
||||
request_context: &GatewayPublicRequestContext,
|
||||
headers: &http::HeaderMap,
|
||||
request_body: Option<&Bytes>,
|
||||
) -> Option<Response<Body>> {
|
||||
let decision = request_context.control_decision.as_ref()?;
|
||||
if decision.route_family.as_deref() != Some("billing") {
|
||||
return None;
|
||||
}
|
||||
match decision.route_kind.as_deref() {
|
||||
Some("plans") if request_context.request_path == "/api/billing/plans" => {
|
||||
Some(handle_billing_plans_list(state).await)
|
||||
}
|
||||
Some("plan_checkout") => {
|
||||
Some(handle_billing_plan_checkout(state, request_context, headers, request_body).await)
|
||||
}
|
||||
Some("entitlements") if request_context.request_path == "/api/billing/entitlements" => {
|
||||
Some(handle_billing_entitlements(state, request_context, headers).await)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
use axum::{body::Body, http, response::Response};
|
||||
|
||||
pub(super) use super::{build_auth_error_response, AppState, GatewayPublicRequestContext};
|
||||
pub(super) use super::{
|
||||
build_auth_error_response, build_auth_json_response, AppState, GatewayPublicRequestContext,
|
||||
};
|
||||
|
||||
#[path = "payment/epay.rs"]
|
||||
pub(super) mod payment_epay;
|
||||
#[path = "payment/gateway.rs"]
|
||||
pub(super) mod payment_gateway;
|
||||
#[path = "payment/repository.rs"]
|
||||
|
||||
528
apps/aether-gateway/src/handlers/public/support/payment/epay.rs
Normal file
528
apps/aether-gateway/src/handlers/public/support/payment/epay.rs
Normal file
@@ -0,0 +1,528 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use axum::{body::Body, http, response::Response};
|
||||
use md5::{Digest, Md5};
|
||||
use serde_json::json;
|
||||
|
||||
use super::{payment_shared::payment_callback_payload_hash, AppState, GatewayPublicRequestContext};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct EpayMerchantConfig {
|
||||
pub(crate) endpoint_url: String,
|
||||
pub(crate) callback_base_url: Option<String>,
|
||||
pub(crate) merchant_id: String,
|
||||
pub(crate) merchant_key: String,
|
||||
pub(crate) pay_currency: String,
|
||||
pub(crate) usd_exchange_rate: f64,
|
||||
pub(crate) min_recharge_usd: f64,
|
||||
pub(crate) channels: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct EpayChannelConfig {
|
||||
pub(crate) channel: String,
|
||||
pub(crate) display_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct EpayCheckoutInput {
|
||||
pub(crate) order_no: String,
|
||||
pub(crate) channel: String,
|
||||
pub(crate) subject: String,
|
||||
pub(crate) pay_amount: f64,
|
||||
pub(crate) notify_url: String,
|
||||
pub(crate) return_url: String,
|
||||
}
|
||||
|
||||
pub(crate) fn configured_epay_channels(config: &EpayMerchantConfig) -> Vec<EpayChannelConfig> {
|
||||
let Some(channels) = config.channels.as_array() else {
|
||||
return Vec::new();
|
||||
};
|
||||
channels
|
||||
.iter()
|
||||
.filter_map(|channel| {
|
||||
let channel_id = channel
|
||||
.get("channel")
|
||||
.or_else(|| channel.get("type"))
|
||||
.and_then(|value| value.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
let display_name = channel
|
||||
.get("display_name")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(channel_id)
|
||||
.to_string();
|
||||
Some(EpayChannelConfig {
|
||||
channel: channel_id.to_string(),
|
||||
display_name,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_epay_channel(
|
||||
config: &EpayMerchantConfig,
|
||||
requested_channel: Option<&str>,
|
||||
) -> Result<String, &'static str> {
|
||||
let channels = configured_epay_channels(config);
|
||||
if channels.is_empty() {
|
||||
return Err("支付网关未配置可用通道");
|
||||
}
|
||||
let requested_channel = requested_channel
|
||||
.map(|value| value.trim().to_ascii_lowercase())
|
||||
.filter(|value| !value.is_empty());
|
||||
if let Some(requested_channel) = requested_channel {
|
||||
if let Some(channel) = channels
|
||||
.iter()
|
||||
.find(|channel| channel.channel.eq_ignore_ascii_case(&requested_channel))
|
||||
{
|
||||
return Ok(channel.channel.clone());
|
||||
}
|
||||
return Err("支付通道未配置或已停用");
|
||||
}
|
||||
Ok(channels[0].channel.clone())
|
||||
}
|
||||
|
||||
pub(crate) fn epay_sign(params: &BTreeMap<String, String>, merchant_key: &str) -> String {
|
||||
let canonical = params
|
||||
.iter()
|
||||
.filter(|(key, value)| {
|
||||
key.as_str() != "sign" && key.as_str() != "sign_type" && !value.trim().is_empty()
|
||||
})
|
||||
.map(|(key, value)| format!("{key}={value}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&");
|
||||
let mut hasher = Md5::new();
|
||||
hasher.update(canonical.as_bytes());
|
||||
hasher.update(merchant_key.as_bytes());
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
pub(crate) fn epay_signature_valid(params: &BTreeMap<String, String>, merchant_key: &str) -> bool {
|
||||
let Some(sign) = params.get("sign") else {
|
||||
return false;
|
||||
};
|
||||
epay_sign(params, merchant_key).eq_ignore_ascii_case(sign.trim())
|
||||
}
|
||||
|
||||
fn epay_submit_url(endpoint_url: &str) -> String {
|
||||
let trimmed = endpoint_url.trim();
|
||||
if trimmed.is_empty() {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
let Ok(mut url) = url::Url::parse(trimmed) else {
|
||||
return trimmed.trim_end_matches('/').to_string();
|
||||
};
|
||||
let path = url.path();
|
||||
if path.is_empty() || path == "/" {
|
||||
url.set_path("submit.php");
|
||||
}
|
||||
url.to_string()
|
||||
}
|
||||
|
||||
fn normalize_epay_base_url(value: &str) -> Option<String> {
|
||||
let trimmed = value.trim().trim_end_matches('/');
|
||||
let parsed = url::Url::parse(trimmed).ok()?;
|
||||
if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
|
||||
return None;
|
||||
}
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
|
||||
fn forwarded_header_first(value: String) -> Option<String> {
|
||||
value
|
||||
.split(',')
|
||||
.next()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
pub(crate) fn epay_callback_base_url(
|
||||
configured: Option<&str>,
|
||||
headers: &http::HeaderMap,
|
||||
request_context: &GatewayPublicRequestContext,
|
||||
) -> Option<String> {
|
||||
if let Some(value) = configured.and_then(normalize_epay_base_url) {
|
||||
return Some(value);
|
||||
}
|
||||
|
||||
if let Some(value) = std::env::var("AETHER_PUBLIC_BASE_URL")
|
||||
.ok()
|
||||
.or_else(|| std::env::var("PUBLIC_BASE_URL").ok())
|
||||
.and_then(|value| normalize_epay_base_url(&value))
|
||||
{
|
||||
return Some(value);
|
||||
}
|
||||
|
||||
let host = crate::headers::header_value_str(headers, crate::constants::FORWARDED_HOST_HEADER)
|
||||
.and_then(forwarded_header_first)
|
||||
.or_else(|| request_context.host_header.clone())
|
||||
.map(|value| value.trim().trim_end_matches('/').to_string())
|
||||
.filter(|value| {
|
||||
!value.is_empty()
|
||||
&& !value.contains('/')
|
||||
&& !value.contains('\\')
|
||||
&& !value.contains('@')
|
||||
&& !value.contains(char::is_whitespace)
|
||||
})?;
|
||||
let proto = crate::headers::header_value_str(headers, crate::constants::FORWARDED_PROTO_HEADER)
|
||||
.and_then(forwarded_header_first)
|
||||
.map(|value| value.trim().trim_end_matches(':').to_ascii_lowercase())
|
||||
.filter(|value| value == "http" || value == "https")
|
||||
.unwrap_or_else(|| "http".to_string());
|
||||
normalize_epay_base_url(&format!("{proto}://{host}"))
|
||||
}
|
||||
|
||||
pub(crate) fn build_epay_checkout_url(
|
||||
config: &EpayMerchantConfig,
|
||||
input: &EpayCheckoutInput,
|
||||
) -> serde_json::Value {
|
||||
let money = format!("{:.2}", input.pay_amount);
|
||||
let mut params = BTreeMap::new();
|
||||
params.insert("pid".to_string(), config.merchant_id.clone());
|
||||
params.insert("type".to_string(), input.channel.clone());
|
||||
params.insert("out_trade_no".to_string(), input.order_no.clone());
|
||||
params.insert("notify_url".to_string(), input.notify_url.clone());
|
||||
params.insert("return_url".to_string(), input.return_url.clone());
|
||||
params.insert("name".to_string(), input.subject.clone());
|
||||
params.insert("money".to_string(), money.clone());
|
||||
params.insert("sign_type".to_string(), "MD5".to_string());
|
||||
let sign = epay_sign(¶ms, &config.merchant_key);
|
||||
params.insert("sign".to_string(), sign);
|
||||
|
||||
let payment_url = epay_submit_url(&config.endpoint_url);
|
||||
let payment_params = params
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), serde_json::Value::String(value.clone())))
|
||||
.collect::<serde_json::Map<_, _>>();
|
||||
json!({
|
||||
"gateway": "epay",
|
||||
"display_name": "易支付",
|
||||
"gateway_order_id": input.order_no,
|
||||
"payment_url": payment_url,
|
||||
"submit_method": "POST",
|
||||
"payment_params": serde_json::Value::Object(payment_params),
|
||||
"qr_code": serde_json::Value::Null,
|
||||
"pay_amount": input.pay_amount,
|
||||
"pay_currency": config.pay_currency,
|
||||
"payment_channel": input.channel,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn parse_epay_params(
|
||||
query: Option<&str>,
|
||||
body: Option<&axum::body::Bytes>,
|
||||
) -> BTreeMap<String, String> {
|
||||
let raw = body
|
||||
.filter(|bytes| !bytes.is_empty())
|
||||
.and_then(|bytes| std::str::from_utf8(bytes).ok())
|
||||
.or(query)
|
||||
.unwrap_or("");
|
||||
url::form_urlencoded::parse(raw.as_bytes())
|
||||
.map(|(key, value)| (key.into_owned(), value.into_owned()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) async fn load_epay_config(state: &AppState) -> Result<EpayMerchantConfig, String> {
|
||||
let Some(record) = state
|
||||
.find_payment_gateway_config("epay")
|
||||
.await
|
||||
.map_err(|err| format!("epay config lookup failed: {err:?}"))?
|
||||
else {
|
||||
return Err("epay is not configured".to_string());
|
||||
};
|
||||
if !record.enabled {
|
||||
return Err("epay is disabled".to_string());
|
||||
}
|
||||
let Some(encrypted_key) = record.merchant_key_encrypted.as_deref() else {
|
||||
return Err("epay merchant key is missing".to_string());
|
||||
};
|
||||
let Some(merchant_key) = crate::handlers::shared::decrypt_catalog_secret_with_fallbacks(
|
||||
state.encryption_key(),
|
||||
encrypted_key,
|
||||
) else {
|
||||
return Err("epay merchant key decrypt failed".to_string());
|
||||
};
|
||||
Ok(EpayMerchantConfig {
|
||||
endpoint_url: record.endpoint_url,
|
||||
callback_base_url: record.callback_base_url,
|
||||
merchant_id: record.merchant_id,
|
||||
merchant_key,
|
||||
pay_currency: record.pay_currency,
|
||||
usd_exchange_rate: record.usd_exchange_rate,
|
||||
min_recharge_usd: record.min_recharge_usd,
|
||||
channels: record.channels_json,
|
||||
})
|
||||
}
|
||||
|
||||
fn epay_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("epay plain response should build")
|
||||
}
|
||||
|
||||
fn epay_redirect(location: String) -> Response<Body> {
|
||||
Response::builder()
|
||||
.status(http::StatusCode::FOUND)
|
||||
.header(http::header::LOCATION, location)
|
||||
.body(Body::empty())
|
||||
.expect("epay redirect response should build")
|
||||
}
|
||||
|
||||
fn epay_return_location(params: &BTreeMap<String, String>, signature_valid: bool) -> String {
|
||||
let order_no = params.get("out_trade_no").map(String::as_str).unwrap_or("");
|
||||
let base = if order_no.starts_with("pp_") {
|
||||
"/dashboard/billing"
|
||||
} else {
|
||||
"/dashboard/wallet"
|
||||
};
|
||||
let payment_status = if signature_valid
|
||||
&& params.get("trade_status").map(String::as_str) == Some("TRADE_SUCCESS")
|
||||
{
|
||||
"success"
|
||||
} else {
|
||||
"pending"
|
||||
};
|
||||
let mut serializer = url::form_urlencoded::Serializer::new(String::new());
|
||||
serializer.append_pair("payment_provider", "epay");
|
||||
serializer.append_pair("payment_status", payment_status);
|
||||
if !order_no.is_empty() {
|
||||
serializer.append_pair("order_no", order_no);
|
||||
}
|
||||
if let Some(trade_no) = params
|
||||
.get("trade_no")
|
||||
.map(String::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
serializer.append_pair("trade_no", trade_no);
|
||||
}
|
||||
format!("{base}?{}", serializer.finish())
|
||||
}
|
||||
|
||||
pub(super) async fn handle_epay_notify(
|
||||
state: &AppState,
|
||||
request_context: &GatewayPublicRequestContext,
|
||||
request_body: Option<&axum::body::Bytes>,
|
||||
) -> Response<Body> {
|
||||
let config = match load_epay_config(state).await {
|
||||
Ok(value) => value,
|
||||
Err(_) => return epay_plain(http::StatusCode::OK, "fail"),
|
||||
};
|
||||
let params = parse_epay_params(
|
||||
request_context.request_query_string.as_deref(),
|
||||
request_body,
|
||||
);
|
||||
if !epay_signature_valid(¶ms, &config.merchant_key) {
|
||||
return epay_plain(http::StatusCode::OK, "fail");
|
||||
}
|
||||
if params.get("trade_status").map(String::as_str) != Some("TRADE_SUCCESS") {
|
||||
return epay_plain(http::StatusCode::OK, "fail");
|
||||
}
|
||||
let Some(order_no) = params.get("out_trade_no").cloned() else {
|
||||
return epay_plain(http::StatusCode::OK, "fail");
|
||||
};
|
||||
let Some(pay_amount) = params
|
||||
.get("money")
|
||||
.and_then(|value| value.parse::<f64>().ok())
|
||||
else {
|
||||
return epay_plain(http::StatusCode::OK, "fail");
|
||||
};
|
||||
let channel = params
|
||||
.get("type")
|
||||
.map(|value| value.trim().to_ascii_lowercase())
|
||||
.filter(|value| !value.is_empty());
|
||||
let payload = serde_json::to_value(¶ms).unwrap_or_else(|_| json!({}));
|
||||
let payload_hash = match payment_callback_payload_hash(&payload) {
|
||||
Ok(value) => value,
|
||||
Err(_) => return epay_plain(http::StatusCode::OK, "fail"),
|
||||
};
|
||||
let callback_key = params
|
||||
.get("trade_no")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| format!("epay:{order_no}:{payload_hash}"));
|
||||
let amount_usd = if config.usd_exchange_rate > 0.0 {
|
||||
pay_amount / config.usd_exchange_rate
|
||||
} else {
|
||||
pay_amount
|
||||
};
|
||||
|
||||
let outcome = state
|
||||
.process_payment_callback(
|
||||
aether_data::repository::wallet::ProcessPaymentCallbackInput {
|
||||
payment_method: "epay".to_string(),
|
||||
payment_provider: Some("epay".to_string()),
|
||||
payment_channel: channel,
|
||||
callback_key,
|
||||
order_no: Some(order_no),
|
||||
gateway_order_id: params.get("trade_no").cloned(),
|
||||
amount_usd,
|
||||
pay_amount: Some(pay_amount),
|
||||
pay_currency: Some(config.pay_currency),
|
||||
exchange_rate: Some(config.usd_exchange_rate),
|
||||
payload_hash,
|
||||
payload,
|
||||
signature_valid: true,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
match outcome {
|
||||
Ok(Some(aether_data::repository::wallet::ProcessPaymentCallbackOutcome::Applied {
|
||||
..
|
||||
}))
|
||||
| Ok(Some(
|
||||
aether_data::repository::wallet::ProcessPaymentCallbackOutcome::AlreadyCredited {
|
||||
..
|
||||
},
|
||||
))
|
||||
| Ok(Some(
|
||||
aether_data::repository::wallet::ProcessPaymentCallbackOutcome::DuplicateProcessed {
|
||||
..
|
||||
},
|
||||
)) => epay_plain(http::StatusCode::OK, "success"),
|
||||
_ => epay_plain(http::StatusCode::OK, "fail"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn handle_epay_return(
|
||||
state: &AppState,
|
||||
request_context: &GatewayPublicRequestContext,
|
||||
request_body: Option<&axum::body::Bytes>,
|
||||
) -> Response<Body> {
|
||||
let params = parse_epay_params(
|
||||
request_context.request_query_string.as_deref(),
|
||||
request_body,
|
||||
);
|
||||
let signature_valid = load_epay_config(state)
|
||||
.await
|
||||
.ok()
|
||||
.is_some_and(|config| epay_signature_valid(¶ms, &config.merchant_key));
|
||||
epay_redirect(epay_return_location(¶ms, signature_valid))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
build_epay_checkout_url, configured_epay_channels, epay_sign, epay_signature_valid,
|
||||
resolve_epay_channel, EpayCheckoutInput, EpayMerchantConfig,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[test]
|
||||
fn epay_sign_excludes_sign_type_sign_and_empty_values() {
|
||||
let mut params = BTreeMap::new();
|
||||
params.insert("pid".to_string(), "1001".to_string());
|
||||
params.insert("out_trade_no".to_string(), "po_1".to_string());
|
||||
params.insert("money".to_string(), "10.00".to_string());
|
||||
params.insert("empty".to_string(), "".to_string());
|
||||
params.insert("sign_type".to_string(), "MD5".to_string());
|
||||
let sign = epay_sign(¶ms, "secret");
|
||||
params.insert("sign".to_string(), sign.clone());
|
||||
assert!(epay_signature_valid(¶ms, "secret"));
|
||||
assert!(!epay_signature_valid(¶ms, "wrong"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_epay_channels_do_not_invent_defaults() {
|
||||
let mut config = test_epay_config(json!([
|
||||
{"channel": " Alipay ", "display_name": "支付宝"},
|
||||
{"type": "wxpay", "display_name": ""},
|
||||
{"display_name": "缺少通道值"}
|
||||
]));
|
||||
|
||||
let channels = configured_epay_channels(&config);
|
||||
assert_eq!(channels.len(), 2);
|
||||
assert_eq!(channels[0].channel, "Alipay");
|
||||
assert_eq!(channels[0].display_name, "支付宝");
|
||||
assert_eq!(channels[1].channel, "wxpay");
|
||||
assert_eq!(channels[1].display_name, "wxpay");
|
||||
assert_eq!(
|
||||
resolve_epay_channel(&config, None),
|
||||
Ok("Alipay".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_epay_channel(&config, Some("WXPAY")),
|
||||
Ok("wxpay".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_epay_channel(&config, Some("manual")),
|
||||
Err("支付通道未配置或已停用")
|
||||
);
|
||||
|
||||
config.channels = json!([]);
|
||||
assert!(configured_epay_channels(&config).is_empty());
|
||||
assert_eq!(
|
||||
resolve_epay_channel(&config, None),
|
||||
Err("支付网关未配置可用通道")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn epay_checkout_uses_post_form_payload_and_submit_endpoint() {
|
||||
let mut config = test_epay_config(json!([]));
|
||||
config.endpoint_url = "https://pay.example.com/".to_string();
|
||||
|
||||
let checkout = build_epay_checkout_url(
|
||||
&config,
|
||||
&EpayCheckoutInput {
|
||||
order_no: "po_test".to_string(),
|
||||
channel: "alipay".to_string(),
|
||||
subject: "钱包充值".to_string(),
|
||||
pay_amount: 10.0,
|
||||
notify_url: "https://aether.example.com/api/payment/epay/notify".to_string(),
|
||||
return_url: "https://aether.example.com/api/payment/epay/return".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
checkout["payment_url"],
|
||||
"https://pay.example.com/submit.php"
|
||||
);
|
||||
assert_eq!(checkout["submit_method"], "POST");
|
||||
assert_eq!(checkout["payment_params"]["pid"], "1000");
|
||||
assert_eq!(checkout["payment_params"]["type"], "alipay");
|
||||
assert_eq!(checkout["payment_params"]["out_trade_no"], "po_test");
|
||||
assert_eq!(checkout["payment_params"]["money"], "10.00");
|
||||
assert_eq!(checkout["payment_params"]["sign_type"], "MD5");
|
||||
assert!(checkout["payment_params"]["sign"]
|
||||
.as_str()
|
||||
.is_some_and(|value| !value.is_empty()));
|
||||
|
||||
config.endpoint_url = "https://pay.example.com/submit.php".to_string();
|
||||
let checkout = build_epay_checkout_url(
|
||||
&config,
|
||||
&EpayCheckoutInput {
|
||||
order_no: format!("po_{}", Utc::now().timestamp()),
|
||||
channel: "wxpay".to_string(),
|
||||
subject: "钱包充值".to_string(),
|
||||
pay_amount: 1.0,
|
||||
notify_url: "https://aether.example.com/api/payment/epay/notify".to_string(),
|
||||
return_url: "https://aether.example.com/api/payment/epay/return".to_string(),
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
checkout["payment_url"],
|
||||
"https://pay.example.com/submit.php"
|
||||
);
|
||||
}
|
||||
|
||||
fn test_epay_config(channels: serde_json::Value) -> EpayMerchantConfig {
|
||||
EpayMerchantConfig {
|
||||
endpoint_url: "https://pay.example.com/submit.php".to_string(),
|
||||
callback_base_url: Some("https://aether.example.com".to_string()),
|
||||
merchant_id: "1000".to_string(),
|
||||
merchant_key: "secret".to_string(),
|
||||
pay_currency: "CNY".to_string(),
|
||||
usd_exchange_rate: 7.2,
|
||||
min_recharge_usd: 1.0,
|
||||
channels,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,8 @@ pub(super) async fn handle_payment_callback_with_wallet_repository(
|
||||
.process_payment_callback(
|
||||
aether_data::repository::wallet::ProcessPaymentCallbackInput {
|
||||
payment_method: payment_method.to_string(),
|
||||
payment_provider: None,
|
||||
payment_channel: None,
|
||||
callback_key: payload.callback_key.clone(),
|
||||
order_no: payload.order_no.clone(),
|
||||
gateway_order_id: payload.gateway_order_id.clone(),
|
||||
|
||||
@@ -7,7 +7,8 @@ use super::payment_shared::{
|
||||
};
|
||||
use super::{
|
||||
build_auth_error_response, build_payment_callback_storage_unavailable_response,
|
||||
handle_payment_callback_with_wallet_repository, AppState, GatewayPublicRequestContext,
|
||||
handle_payment_callback_with_wallet_repository, payment_epay, AppState,
|
||||
GatewayPublicRequestContext,
|
||||
};
|
||||
|
||||
pub(super) async fn maybe_build_local_payment_callback_route_response(
|
||||
@@ -17,9 +18,19 @@ pub(super) async fn maybe_build_local_payment_callback_route_response(
|
||||
request_body: Option<&axum::body::Bytes>,
|
||||
) -> Option<Response<Body>> {
|
||||
let decision = request_context.control_decision.as_ref()?;
|
||||
if decision.route_family.as_deref() != Some("payment_callback")
|
||||
|| decision.route_kind.as_deref() != Some("callback")
|
||||
{
|
||||
if decision.route_family.as_deref() != Some("payment_callback") {
|
||||
return None;
|
||||
}
|
||||
|
||||
if decision.route_kind.as_deref() == Some("epay_notify") {
|
||||
return Some(payment_epay::handle_epay_notify(state, request_context, request_body).await);
|
||||
}
|
||||
|
||||
if decision.route_kind.as_deref() == Some("epay_return") {
|
||||
return Some(payment_epay::handle_epay_return(state, request_context, request_body).await);
|
||||
}
|
||||
|
||||
if decision.route_kind.as_deref() != Some("callback") {
|
||||
return None;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ use self::reads::{
|
||||
pub(crate) use self::recharge::sanitize_wallet_gateway_response;
|
||||
use self::recharge::{
|
||||
handle_wallet_create_recharge, handle_wallet_recharge_detail, handle_wallet_recharge_list,
|
||||
wallet_recharge_detail_path_matches,
|
||||
handle_wallet_recharge_options, wallet_recharge_detail_path_matches,
|
||||
};
|
||||
use self::redeem::handle_wallet_redeem;
|
||||
use self::refunds::{
|
||||
@@ -53,8 +53,13 @@ const WALLET_SAFE_GATEWAY_RESPONSE_KEYS: &[&str] = &[
|
||||
"display_name",
|
||||
"gateway_order_id",
|
||||
"payment_url",
|
||||
"payment_params",
|
||||
"submit_method",
|
||||
"qr_code",
|
||||
"expires_at",
|
||||
"pay_amount",
|
||||
"pay_currency",
|
||||
"payment_channel",
|
||||
"manual_credit",
|
||||
];
|
||||
|
||||
@@ -154,6 +159,12 @@ pub(super) async fn maybe_build_local_wallet_response(
|
||||
);
|
||||
}
|
||||
|
||||
if decision.route_kind.as_deref() == Some("recharge_options")
|
||||
&& request_context.request_path == "/api/wallet/recharge/options"
|
||||
{
|
||||
return Some(handle_wallet_recharge_options(state, request_context, headers).await);
|
||||
}
|
||||
|
||||
if decision.route_kind.as_deref() == Some("redeem")
|
||||
&& request_context.request_path == "/api/wallet/redeem"
|
||||
{
|
||||
|
||||
@@ -49,6 +49,64 @@ fn build_wallet_balance_payload(
|
||||
payload
|
||||
}
|
||||
|
||||
async fn build_wallet_balance_payload_for_user(
|
||||
state: &AppState,
|
||||
user_id: &str,
|
||||
wallet: Option<&aether_data::repository::wallet::StoredWalletSnapshot>,
|
||||
) -> serde_json::Value {
|
||||
let mut payload = build_wallet_balance_payload(wallet);
|
||||
let wallet_balance = wallet
|
||||
.map(|value| value.balance + value.gift_balance)
|
||||
.unwrap_or(0.0);
|
||||
let daily_quota = state
|
||||
.find_user_daily_quota_availability(user_id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let (has_active_daily_quota, total_quota_usd, used_usd, remaining_usd, allow_wallet_overage) =
|
||||
daily_quota
|
||||
.map(|quota| {
|
||||
(
|
||||
quota.has_active_daily_quota,
|
||||
quota.total_quota_usd,
|
||||
quota.used_usd,
|
||||
quota.remaining_usd,
|
||||
quota.allow_wallet_overage,
|
||||
)
|
||||
})
|
||||
.unwrap_or((false, 0.0, 0.0, 0.0, false));
|
||||
let package_balance = if has_active_daily_quota {
|
||||
remaining_usd.max(0.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let unlimited = payload
|
||||
.get("unlimited")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
|
||||
payload["daily_quota"] = json!({
|
||||
"has_active": has_active_daily_quota,
|
||||
"total_usd": round_to(total_quota_usd.max(0.0), 6),
|
||||
"used_usd": round_to(used_usd.max(0.0), 6),
|
||||
"remaining_usd": round_to(package_balance, 6),
|
||||
"allow_wallet_overage": allow_wallet_overage,
|
||||
});
|
||||
payload["package_balance"] = json!(round_to(package_balance, 6));
|
||||
payload["wallet_balance"] = json!(round_to(wallet_balance.max(0.0), 6));
|
||||
payload["total_available_balance"] = if unlimited {
|
||||
serde_json::Value::Null
|
||||
} else {
|
||||
json!(round_to((wallet_balance + package_balance).max(0.0), 6))
|
||||
};
|
||||
payload["deduction_order"] = json!([
|
||||
"package_daily_quota",
|
||||
"wallet_recharge_balance",
|
||||
"wallet_gift_balance"
|
||||
]);
|
||||
payload
|
||||
}
|
||||
|
||||
pub(super) fn parse_wallet_limit(query: Option<&str>) -> Result<usize, String> {
|
||||
match query_param_value(query, "limit") {
|
||||
Some(value) => {
|
||||
@@ -173,7 +231,7 @@ pub(super) async fn handle_wallet_balance(
|
||||
.flatten();
|
||||
build_auth_json_response(
|
||||
http::StatusCode::OK,
|
||||
build_wallet_balance_payload(wallet.as_ref()),
|
||||
build_wallet_balance_payload_for_user(state, &auth.user.id, wallet.as_ref()).await,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
use super::super::support_payment::payment_epay::{
|
||||
build_epay_checkout_url, configured_epay_channels, epay_callback_base_url, load_epay_config,
|
||||
resolve_epay_channel, EpayCheckoutInput,
|
||||
};
|
||||
use super::super::support_payment::payment_gateway::{
|
||||
CreateCheckoutSessionInput, PaymentGatewayRegistry,
|
||||
};
|
||||
@@ -23,6 +27,10 @@ struct WalletCreateRechargeRequest {
|
||||
amount_usd: f64,
|
||||
payment_method: String,
|
||||
#[serde(default)]
|
||||
payment_provider: Option<String>,
|
||||
#[serde(default)]
|
||||
payment_channel: Option<String>,
|
||||
#[serde(default)]
|
||||
pay_amount: Option<f64>,
|
||||
#[serde(default)]
|
||||
pay_currency: Option<String>,
|
||||
@@ -34,6 +42,8 @@ struct WalletCreateRechargeRequest {
|
||||
struct NormalizedWalletCreateRechargeRequest {
|
||||
amount_usd: f64,
|
||||
payment_method: String,
|
||||
payment_provider: Option<String>,
|
||||
payment_channel: Option<String>,
|
||||
pay_amount: Option<f64>,
|
||||
pay_currency: Option<String>,
|
||||
exchange_rate: Option<f64>,
|
||||
@@ -49,6 +59,10 @@ fn normalize_wallet_create_recharge_request(
|
||||
if payment_method.is_empty() || payment_method.chars().count() > 30 {
|
||||
return Err("输入验证失败");
|
||||
}
|
||||
let payment_provider = wallet_normalize_optional_string_field(payload.payment_provider, 30)?
|
||||
.map(|value| value.to_ascii_lowercase());
|
||||
let payment_channel = wallet_normalize_optional_string_field(payload.payment_channel, 30)?
|
||||
.map(|value| value.to_ascii_lowercase());
|
||||
if matches!(payload.pay_amount, Some(value) if !value.is_finite() || value <= 0.0) {
|
||||
return Err("输入验证失败");
|
||||
}
|
||||
@@ -63,6 +77,8 @@ fn normalize_wallet_create_recharge_request(
|
||||
Ok(NormalizedWalletCreateRechargeRequest {
|
||||
amount_usd: payload.amount_usd,
|
||||
payment_method,
|
||||
payment_provider,
|
||||
payment_channel,
|
||||
pay_amount: payload.pay_amount,
|
||||
pay_currency,
|
||||
exchange_rate: payload.exchange_rate,
|
||||
@@ -300,73 +316,137 @@ pub(super) async fn handle_wallet_create_recharge(
|
||||
let now = Utc::now();
|
||||
let order_no = wallet_build_order_no(now);
|
||||
let expires_at = now + chrono::Duration::minutes(30);
|
||||
let Some(adapter) = PaymentGatewayRegistry::get(&payload.payment_method) else {
|
||||
return build_auth_error_response(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
format!("unsupported payment_method: {}", payload.payment_method),
|
||||
false,
|
||||
);
|
||||
};
|
||||
let checkout = match adapter.create_checkout_session(&CreateCheckoutSessionInput {
|
||||
order_no: order_no.clone(),
|
||||
amount_usd: payload.amount_usd,
|
||||
expires_at,
|
||||
}) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => {
|
||||
return build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false);
|
||||
}
|
||||
};
|
||||
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: payload.pay_amount,
|
||||
pay_currency: payload.pay_currency.clone(),
|
||||
exchange_rate: payload.exchange_rate,
|
||||
payment_method: payload.payment_method.clone(),
|
||||
gateway_order_id: checkout.gateway_order_id,
|
||||
gateway_response: checkout.gateway_response.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 => {
|
||||
let uses_epay =
|
||||
payload.payment_provider.as_deref() == Some("epay") || payload.payment_method == "epay";
|
||||
if uses_epay {
|
||||
let config = match load_epay_config(state).await {
|
||||
Ok(value) => value,
|
||||
Err(detail) => {
|
||||
return build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false);
|
||||
}
|
||||
};
|
||||
if payload.amount_usd < config.min_recharge_usd {
|
||||
return build_auth_error_response(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
"wallet is not active",
|
||||
"充值金额低于支付网关最小金额",
|
||||
false,
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
build_auth_json_response(
|
||||
http::StatusCode::OK,
|
||||
json!({
|
||||
"order": order_payload,
|
||||
"payment_instructions": sanitize_wallet_gateway_response(Some(checkout.gateway_response)),
|
||||
}),
|
||||
None,
|
||||
let requested_channel = payload.payment_channel.as_deref().or_else(|| {
|
||||
(payload.payment_method != "epay").then_some(payload.payment_method.as_str())
|
||||
});
|
||||
let payment_channel = match resolve_epay_channel(&config, requested_channel) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => {
|
||||
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 Some(callback_base_url) = epay_callback_base_url(
|
||||
config.callback_base_url.as_deref(),
|
||||
headers,
|
||||
request_context,
|
||||
) else {
|
||||
return build_auth_error_response(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
"epay callback_base_url is required",
|
||||
false,
|
||||
);
|
||||
};
|
||||
let checkout = build_epay_checkout_url(
|
||||
&config,
|
||||
&EpayCheckoutInput {
|
||||
order_no: order_no.clone(),
|
||||
channel: payment_channel.clone(),
|
||||
subject: "钱包充值".to_string(),
|
||||
pay_amount,
|
||||
notify_url: format!("{callback_base_url}/api/payment/epay/notify"),
|
||||
return_url: format!("{callback_base_url}/api/payment/epay/return"),
|
||||
},
|
||||
);
|
||||
let 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(config.pay_currency.clone()),
|
||||
exchange_rate: Some(config.usd_exchange_rate),
|
||||
payment_method: "epay".to_string(),
|
||||
payment_provider: Some("epay".to_string()),
|
||||
payment_channel: Some(payment_channel),
|
||||
gateway_order_id: order_no.clone(),
|
||||
gateway_response: checkout.clone(),
|
||||
order_no,
|
||||
expires_at_unix_secs: expires_at.timestamp().max(0) as u64,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(value)) => value,
|
||||
Ok(None) => return build_wallet_recharge_storage_unavailable_response(),
|
||||
Err(err) => {
|
||||
return build_auth_error_response(
|
||||
http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("wallet recharge create failed: {err:?}"),
|
||||
false,
|
||||
)
|
||||
}
|
||||
};
|
||||
let order_payload = match outcome {
|
||||
aether_data::repository::wallet::CreateWalletRechargeOrderOutcome::Created(order) => {
|
||||
wallet_payment_order_payload_from_record(&order)
|
||||
}
|
||||
aether_data::repository::wallet::CreateWalletRechargeOrderOutcome::WalletInactive => {
|
||||
return build_auth_error_response(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
"wallet is not active",
|
||||
false,
|
||||
)
|
||||
}
|
||||
};
|
||||
return build_auth_json_response(
|
||||
http::StatusCode::OK,
|
||||
json!({
|
||||
"order": order_payload,
|
||||
"payment_instructions": sanitize_wallet_gateway_response(Some(checkout)),
|
||||
}),
|
||||
None,
|
||||
);
|
||||
}
|
||||
build_auth_error_response(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
format!("unsupported payment_method: {}", payload.payment_method),
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) async fn handle_wallet_recharge_options(
|
||||
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 methods = Vec::new();
|
||||
if let Ok(config) = load_epay_config(state).await {
|
||||
for channel in configured_epay_channels(&config) {
|
||||
methods.push(json!({
|
||||
"payment_method": "epay",
|
||||
"payment_provider": "epay",
|
||||
"payment_channel": channel.channel,
|
||||
"display_name": channel.display_name,
|
||||
"pay_currency": config.pay_currency,
|
||||
"usd_exchange_rate": config.usd_exchange_rate,
|
||||
"min_recharge_usd": config.min_recharge_usd,
|
||||
}));
|
||||
}
|
||||
}
|
||||
build_auth_json_response(http::StatusCode::OK, json!({ "items": methods }), None)
|
||||
}
|
||||
|
||||
pub(super) async fn handle_wallet_recharge_list(
|
||||
state: &AppState,
|
||||
request_context: &GatewayPublicRequestContext,
|
||||
|
||||
@@ -304,6 +304,10 @@ pub(crate) fn admin_proxy_local_requires_buffered_body(
|
||||
| (Some("billing_manage"), http::Method::PUT, Some("update_rule"))
|
||||
| (Some("billing_manage"), http::Method::POST, Some("create_collector"))
|
||||
| (Some("billing_manage"), http::Method::PUT, Some("update_collector"))
|
||||
| (Some("billing_manage"), http::Method::POST, Some("create_plan"))
|
||||
| (Some("billing_manage"), http::Method::PUT, Some("update_plan"))
|
||||
| (Some("billing_manage"), http::Method::PATCH, Some("set_plan_status"))
|
||||
| (Some("payments_manage"), http::Method::PUT, Some("update_epay_gateway"))
|
||||
| (Some("payments_manage"), http::Method::POST, Some("credit_order"))
|
||||
| (Some("payments_manage"), http::Method::POST, Some("create_redeem_code_batch"))
|
||||
| (Some("payments_manage"), http::Method::POST, Some("delete_redeem_code_batch"))
|
||||
@@ -324,6 +328,7 @@ pub(crate) fn admin_proxy_local_requires_buffered_body(
|
||||
| (Some("users_manage"), http::Method::POST, Some("create_user"))
|
||||
| (Some("users_manage"), http::Method::POST, Some("resolve_user_selection"))
|
||||
| (Some("users_manage"), http::Method::POST, Some("batch_action_users"))
|
||||
| (Some("users_manage"), http::Method::POST, Some("grant_user_billing_plan"))
|
||||
| (Some("users_manage"), http::Method::PUT, Some("update_user"))
|
||||
| (Some("users_manage"), http::Method::POST, Some("create_user_group"))
|
||||
| (Some("users_manage"), http::Method::PUT, Some("update_user_group"))
|
||||
@@ -459,11 +464,12 @@ pub(crate) fn public_support_local_requires_buffered_body(
|
||||
Some("wallet"),
|
||||
http::Method::POST,
|
||||
Some("create_refund" | "create_recharge_order" | "redeem"),
|
||||
) | (
|
||||
Some("payment_callback"),
|
||||
http::Method::POST,
|
||||
Some("callback"),
|
||||
)
|
||||
) | (Some("billing"), http::Method::POST, Some("plan_checkout"),)
|
||||
| (
|
||||
Some("payment_callback"),
|
||||
http::Method::POST,
|
||||
Some("callback" | "epay_notify" | "epay_return"),
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,4 +6,6 @@ pub(crate) use aether_data::repository::wallet::{
|
||||
pub(crate) use aether_data_contracts::repository::billing::{
|
||||
AdminBillingCollectorRecord, AdminBillingCollectorWriteInput, AdminBillingMutationOutcome,
|
||||
AdminBillingPresetApplyResult, AdminBillingRuleRecord, AdminBillingRuleWriteInput,
|
||||
BillingPlanRecord, BillingPlanWriteInput, PaymentGatewayConfigRecord,
|
||||
PaymentGatewayConfigWriteInput, UserDailyQuotaAvailabilityRecord, UserPlanEntitlementRecord,
|
||||
};
|
||||
|
||||
@@ -21,7 +21,9 @@ pub(crate) use self::admin_types::{
|
||||
AdminBillingCollectorRecord, AdminBillingCollectorWriteInput, AdminBillingMutationOutcome,
|
||||
AdminBillingPresetApplyResult, AdminBillingRuleRecord, AdminBillingRuleWriteInput,
|
||||
AdminPaymentCallbackRecord, AdminSecurityBlacklistEntry, AdminWalletPaymentOrderRecord,
|
||||
AdminWalletRefundRecord, AdminWalletTransactionRecord,
|
||||
AdminWalletRefundRecord, AdminWalletTransactionRecord, BillingPlanRecord,
|
||||
BillingPlanWriteInput, PaymentGatewayConfigRecord, PaymentGatewayConfigWriteInput,
|
||||
UserDailyQuotaAvailabilityRecord, UserPlanEntitlementRecord,
|
||||
};
|
||||
pub use self::app::AppState;
|
||||
pub(crate) use self::cache::{
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use super::{
|
||||
AdminBillingCollectorRecord, AdminBillingCollectorWriteInput, AdminBillingMutationOutcome,
|
||||
AdminBillingPresetApplyResult, AdminBillingRuleRecord, AdminBillingRuleWriteInput, AppState,
|
||||
GatewayError, LocalMutationOutcome,
|
||||
BillingPlanRecord, BillingPlanWriteInput, GatewayError, LocalMutationOutcome,
|
||||
PaymentGatewayConfigRecord, PaymentGatewayConfigWriteInput, UserDailyQuotaAvailabilityRecord,
|
||||
UserPlanEntitlementRecord,
|
||||
};
|
||||
|
||||
fn data_error(err: impl ToString) -> GatewayError {
|
||||
@@ -405,4 +407,111 @@ impl AppState {
|
||||
.map(local_mutation_outcome)
|
||||
.map_err(data_error)
|
||||
}
|
||||
|
||||
pub(crate) async fn find_payment_gateway_config(
|
||||
&self,
|
||||
provider: &str,
|
||||
) -> Result<Option<PaymentGatewayConfigRecord>, GatewayError> {
|
||||
self.data
|
||||
.find_payment_gateway_config(provider)
|
||||
.await
|
||||
.map_err(data_error)
|
||||
}
|
||||
|
||||
pub(crate) async fn upsert_payment_gateway_config(
|
||||
&self,
|
||||
input: &PaymentGatewayConfigWriteInput,
|
||||
) -> Result<LocalMutationOutcome<PaymentGatewayConfigRecord>, GatewayError> {
|
||||
self.data
|
||||
.upsert_payment_gateway_config(input)
|
||||
.await
|
||||
.map(local_mutation_outcome)
|
||||
.map_err(data_error)
|
||||
}
|
||||
|
||||
pub(crate) async fn list_billing_plans(
|
||||
&self,
|
||||
include_disabled: bool,
|
||||
) -> Result<Option<Vec<BillingPlanRecord>>, GatewayError> {
|
||||
self.data
|
||||
.list_billing_plans(include_disabled)
|
||||
.await
|
||||
.map_err(data_error)
|
||||
}
|
||||
|
||||
pub(crate) async fn find_billing_plan(
|
||||
&self,
|
||||
plan_id: &str,
|
||||
) -> Result<Option<BillingPlanRecord>, GatewayError> {
|
||||
self.data
|
||||
.find_billing_plan(plan_id)
|
||||
.await
|
||||
.map_err(data_error)
|
||||
}
|
||||
|
||||
pub(crate) async fn create_billing_plan(
|
||||
&self,
|
||||
input: &BillingPlanWriteInput,
|
||||
) -> Result<LocalMutationOutcome<BillingPlanRecord>, GatewayError> {
|
||||
self.data
|
||||
.create_billing_plan(input)
|
||||
.await
|
||||
.map(local_mutation_outcome)
|
||||
.map_err(data_error)
|
||||
}
|
||||
|
||||
pub(crate) async fn update_billing_plan(
|
||||
&self,
|
||||
plan_id: &str,
|
||||
input: &BillingPlanWriteInput,
|
||||
) -> Result<LocalMutationOutcome<BillingPlanRecord>, GatewayError> {
|
||||
self.data
|
||||
.update_billing_plan(plan_id, input)
|
||||
.await
|
||||
.map(local_mutation_outcome)
|
||||
.map_err(data_error)
|
||||
}
|
||||
|
||||
pub(crate) async fn set_billing_plan_enabled(
|
||||
&self,
|
||||
plan_id: &str,
|
||||
enabled: bool,
|
||||
) -> Result<LocalMutationOutcome<BillingPlanRecord>, GatewayError> {
|
||||
self.data
|
||||
.set_billing_plan_enabled(plan_id, enabled)
|
||||
.await
|
||||
.map(local_mutation_outcome)
|
||||
.map_err(data_error)
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_billing_plan(
|
||||
&self,
|
||||
plan_id: &str,
|
||||
) -> Result<LocalMutationOutcome<()>, GatewayError> {
|
||||
self.data
|
||||
.delete_billing_plan(plan_id)
|
||||
.await
|
||||
.map(local_mutation_outcome)
|
||||
.map_err(data_error)
|
||||
}
|
||||
|
||||
pub(crate) async fn list_user_plan_entitlements(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Option<Vec<UserPlanEntitlementRecord>>, GatewayError> {
|
||||
self.data
|
||||
.list_user_plan_entitlements(user_id)
|
||||
.await
|
||||
.map_err(data_error)
|
||||
}
|
||||
|
||||
pub(crate) async fn find_user_daily_quota_availability(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Option<UserDailyQuotaAvailabilityRecord>, GatewayError> {
|
||||
self.data
|
||||
.find_user_daily_quota_availability(user_id)
|
||||
.await
|
||||
.map_err(data_error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use super::super::{
|
||||
AdminBillingCollectorRecord, AdminBillingCollectorWriteInput, AdminBillingMutationOutcome,
|
||||
AdminBillingPresetApplyResult, AdminBillingRuleRecord, AdminBillingRuleWriteInput, AppState,
|
||||
GatewayError, LocalMutationOutcome,
|
||||
BillingPlanRecord, BillingPlanWriteInput, GatewayError, LocalMutationOutcome,
|
||||
PaymentGatewayConfigRecord, PaymentGatewayConfigWriteInput, UserDailyQuotaAvailabilityRecord,
|
||||
UserPlanEntitlementRecord,
|
||||
};
|
||||
|
||||
mod admin;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use aether_data::repository::wallet::{
|
||||
AdjustWalletBalanceInput, CompleteAdminWalletRefundInput, CreateManualWalletRechargeInput,
|
||||
CreateWalletRechargeOrderInput, CreateWalletRechargeOrderOutcome,
|
||||
CreateWalletRefundRequestInput, CreateWalletRefundRequestOutcome, CreditAdminPaymentOrderInput,
|
||||
FailAdminWalletRefundInput, ProcessAdminWalletRefundInput, ProcessPaymentCallbackInput,
|
||||
ProcessPaymentCallbackOutcome, WalletMutationOutcome,
|
||||
CreatePlanPurchaseOrderInput, CreatePlanPurchaseOrderOutcome, CreateWalletRechargeOrderInput,
|
||||
CreateWalletRechargeOrderOutcome, CreateWalletRefundRequestInput,
|
||||
CreateWalletRefundRequestOutcome, CreditAdminPaymentOrderInput, FailAdminWalletRefundInput,
|
||||
ProcessAdminWalletRefundInput, ProcessPaymentCallbackInput, ProcessPaymentCallbackOutcome,
|
||||
WalletMutationOutcome,
|
||||
};
|
||||
|
||||
use crate::{AppState, GatewayError};
|
||||
@@ -19,6 +20,16 @@ impl AppState {
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn create_plan_purchase_order(
|
||||
&self,
|
||||
input: CreatePlanPurchaseOrderInput,
|
||||
) -> Result<Option<CreatePlanPurchaseOrderOutcome>, GatewayError> {
|
||||
self.data
|
||||
.create_plan_purchase_order(input)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn create_wallet_refund_request(
|
||||
&self,
|
||||
input: CreateWalletRefundRequestInput,
|
||||
|
||||
@@ -7,6 +7,8 @@ use crate::control::GatewayLocalAuthRejection;
|
||||
use crate::data::auth::GatewayAuthApiKeySnapshot;
|
||||
use crate::{AppState, GatewayError};
|
||||
|
||||
const DAILY_QUOTA_EPSILON_USD: f64 = 0.000_000_01;
|
||||
|
||||
pub(crate) async fn resolve_wallet_auth_gate(
|
||||
state: &AppState,
|
||||
auth_snapshot: &GatewayAuthApiKeySnapshot,
|
||||
@@ -27,11 +29,29 @@ pub(crate) async fn resolve_wallet_auth_gate(
|
||||
auth_snapshot.api_key_is_standalone,
|
||||
);
|
||||
|
||||
Ok(Some(match wallet.as_ref() {
|
||||
let decision = match wallet.as_ref() {
|
||||
Some(wallet) => map_wallet_snapshot(wallet).access_decision(is_admin),
|
||||
None if is_admin => WalletAccessDecision::allowed(None),
|
||||
None => WalletAccessDecision::wallet_unavailable(None),
|
||||
}))
|
||||
};
|
||||
if !auth_snapshot.api_key_is_standalone {
|
||||
if let Some(quota) = state
|
||||
.find_user_daily_quota_availability(&auth_snapshot.user_id)
|
||||
.await?
|
||||
.filter(|quota| quota.has_active_daily_quota)
|
||||
{
|
||||
let has_remaining_quota = quota.remaining_usd > DAILY_QUOTA_EPSILON_USD;
|
||||
if decision.failure == Some(WalletAccessFailure::BalanceDenied) && has_remaining_quota {
|
||||
return Ok(Some(WalletAccessDecision::allowed(Some(
|
||||
quota.remaining_usd,
|
||||
))));
|
||||
}
|
||||
if decision.failure.is_none() && !quota.allow_wallet_overage && !has_remaining_quota {
|
||||
return Ok(Some(WalletAccessDecision::balance_denied(Some(0.0))));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Some(decision))
|
||||
}
|
||||
|
||||
pub(crate) fn local_rejection_from_wallet_access(
|
||||
|
||||
Reference in New Issue
Block a user