mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-05 00:47:48 +08:00
Merge remote-tracking branch 'entropy-xu/payment-billing-plans'
# Conflicts: # crates/aether-data/src/lifecycle/bootstrap/postgres.rs # crates/aether-data/src/lifecycle/migrate/tests.rs # frontend/src/views/admin/Users.vue
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user