Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions apps/aether-gateway/src/control/route/public_support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,20 @@ pub(super) fn classify_public_support_route(
"user:billing",
false,
))
} else if method == http::Method::POST
&& has_single_nested_suffix_after_prefix(
normalized_path,
"/api/billing/entitlements/",
"daily-quota-reset",
)
{
Some(classified(
"public_support",
"billing",
"daily_quota_reset",
"user:billing",
false,
))
} else if method == http::Method::POST
&& has_single_nested_suffix_after_prefix(normalized_path, "/api/billing/plans/", "checkout")
{
Expand Down
6 changes: 6 additions & 0 deletions apps/aether-gateway/src/control/tests/public_support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,12 @@ fn classifies_billing_plan_routes_as_public_support_routes() {
"entitlements",
"user:billing",
),
(
http::Method::POST,
"/api/billing/entitlements/entitlement-1/daily-quota-reset",
"daily_quota_reset",
"user:billing",
),
] {
let uri: Uri = uri.parse().expect("uri should parse");
let decision =
Expand Down
22 changes: 22 additions & 0 deletions apps/aether-gateway/src/data/state/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1920,6 +1920,28 @@ impl GatewayDataState {
}
}

pub(crate) async fn reset_user_daily_quota(
&self,
user_id: &str,
entitlement_id: &str,
min_remaining_secs: u64,
penalty_secs: u64,
) -> Result<AdminBillingMutationOutcome<UserPlanEntitlementRecord>, DataLayerError> {
match &self.billing_reader {
Some(repository) => {
repository
.reset_user_daily_quota(
user_id,
entitlement_id,
min_remaining_secs,
penalty_secs,
)
.await
}
None => Ok(AdminBillingMutationOutcome::Unavailable),
}
}

pub(crate) async fn read_request_candidate_trace(
&self,
request_id: &str,
Expand Down
39 changes: 36 additions & 3 deletions apps/aether-gateway/src/handlers/admin/billing/plans.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,11 +138,36 @@ fn validate_entitlements(value: &serde_json::Value) -> Result<(), String> {
})?;
}
if let Some(carry_over) = item.get("carry_over") {
let carry_over = 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());
}
let carry_over_days = if let Some(carry_over_days) = item.get("carry_over_days") {
let carry_over_days = carry_over_days.as_u64().ok_or_else(|| {
"daily_quota.carry_over_days must be an integer".to_string()
})?;
if !(1..=30).contains(&carry_over_days) {
return Err(
"daily_quota.carry_over_days must be between 1 and 30".to_string()
);
}
carry_over_days
} else {
1
};
if let Some(limit_multiplier) = item.get("carry_over_limit_multiplier") {
let limit_multiplier = limit_multiplier.as_f64().ok_or_else(|| {
"daily_quota.carry_over_limit_multiplier must be a number".to_string()
})?;
let max_multiplier = carry_over_days as f64 + 1.0;
if !limit_multiplier.is_finite()
|| limit_multiplier < 1.0
|| limit_multiplier > max_multiplier
{
return Err(
"daily_quota.carry_over_limit_multiplier must be between 1 and carry_over_days+1"
.to_string(),
);
}
}
if item
Expand All @@ -151,6 +176,14 @@ fn validate_entitlements(value: &serde_json::Value) -> Result<(), String> {
{
return Err("daily_quota.allow_wallet_overage must be a boolean".to_string());
}
if item
.get("self_service_daily_quota_reset")
.is_some_and(|value| !value.is_boolean())
{
return Err(
"daily_quota.self_service_daily_quota_reset must be a boolean".to_string(),
);
}
}
"membership_group" => {
let groups = item
Expand Down
116 changes: 116 additions & 0 deletions apps/aether-gateway/src/handlers/public/support/billing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use crate::handlers::shared::{
create_alipay_direct_checkout, create_stripe_direct_checkout, create_wxpay_direct_checkout,
direct_payment_client_ip, DirectPaymentCheckoutInput,
};
use aether_data_contracts::repository::billing::AdminBillingMutationOutcome;
use axum::{
body::{Body, Bytes},
http,
Expand All @@ -22,6 +23,8 @@ use serde_json::{json, Value};
use uuid::Uuid;

const BILLING_STORAGE_UNAVAILABLE_DETAIL: &str = "套餐后端暂不可用";
const DAILY_QUOTA_RESET_MIN_REMAINING_SECS: u64 = 72 * 60 * 60;
const DAILY_QUOTA_RESET_PENALTY_SECS: u64 = 24 * 60 * 60;

#[derive(Debug, Deserialize, Default)]
struct BillingPlanCheckoutRequest {
Expand Down Expand Up @@ -88,6 +91,17 @@ fn plan_id_from_checkout_path(path: &str) -> Option<String> {
}
}

fn entitlement_id_from_daily_quota_reset_path(path: &str) -> Option<String> {
let trimmed = path.trim_end_matches('/');
let rest = trimmed.strip_prefix("/api/billing/entitlements/")?;
let entitlement_id = rest.strip_suffix("/daily-quota-reset")?.trim_matches('/');
if entitlement_id.is_empty() || entitlement_id.contains('/') {
None
} else {
Some(entitlement_id.to_string())
}
}

fn billing_order_no(now: chrono::DateTime<chrono::Utc>) -> String {
format!(
"pp_{}_{}",
Expand Down Expand Up @@ -191,6 +205,23 @@ fn entitlement_payload(
})
}

fn entitlement_has_self_service_daily_quota_reset(
record: &aether_data_contracts::repository::billing::UserPlanEntitlementRecord,
) -> bool {
record
.entitlements_snapshot
.as_array()
.is_some_and(|items| {
items.iter().any(|item| {
item.get("type").and_then(serde_json::Value::as_str) == Some("daily_quota")
&& item
.get("self_service_daily_quota_reset")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
})
})
}

fn compute_plan_payment_amounts(
plan: &aether_data_contracts::repository::billing::BillingPlanRecord,
pay_currency: &str,
Expand Down Expand Up @@ -269,6 +300,88 @@ pub(super) async fn handle_billing_entitlements(
Json(json!({"items": items, "total": items.len()})).into_response()
}

pub(super) async fn handle_billing_daily_quota_reset(
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 Some(entitlement_id) =
entitlement_id_from_daily_quota_reset_path(&request_context.request_path)
else {
return build_auth_error_response(http::StatusCode::BAD_REQUEST, "缺少权益ID", false);
};

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 Some(entitlement) = entitlements
.iter()
.find(|record| record.id == entitlement_id)
.cloned()
else {
return build_auth_error_response(http::StatusCode::NOT_FOUND, "权益不存在", false);
};
let now = Utc::now().timestamp().max(0) as u64;
if entitlement.status != "active"
|| entitlement.starts_at_unix_secs > now
|| entitlement.expires_at_unix_secs <= now
{
return build_auth_error_response(http::StatusCode::CONFLICT, "权益未生效或已过期", false);
}
if !entitlement_has_self_service_daily_quota_reset(&entitlement) {
return build_auth_error_response(
http::StatusCode::FORBIDDEN,
"该套餐未开放自助重置每日额度",
false,
);
}
if entitlement.expires_at_unix_secs.saturating_sub(now) < DAILY_QUOTA_RESET_MIN_REMAINING_SECS {
return build_auth_error_response(
http::StatusCode::CONFLICT,
"剩余有效期不足 72 小时,不能自助重置",
false,
);
}

match state
.reset_user_daily_quota(
&auth.user.id,
&entitlement.id,
DAILY_QUOTA_RESET_MIN_REMAINING_SECS,
DAILY_QUOTA_RESET_PENALTY_SECS,
)
.await
{
Ok(AdminBillingMutationOutcome::Applied(record)) => {
build_auth_json_response(http::StatusCode::OK, entitlement_payload(&record), None)
}
Ok(AdminBillingMutationOutcome::NotFound) => {
build_auth_error_response(http::StatusCode::NOT_FOUND, "权益不存在", false)
}
Ok(AdminBillingMutationOutcome::Invalid(detail)) => {
build_auth_error_response(http::StatusCode::CONFLICT, detail, false)
}
Ok(AdminBillingMutationOutcome::Unavailable) => billing_storage_unavailable_response(),
Err(err) => build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("daily quota reset failed: {err:?}"),
false,
),
}
}

pub(super) async fn handle_billing_plan_checkout(
state: &AppState,
request_context: &GatewayPublicRequestContext,
Expand Down Expand Up @@ -640,6 +753,9 @@ pub(super) async fn maybe_build_local_billing_response(
Some("entitlements") if request_context.request_path == "/api/billing/entitlements" => {
Some(handle_billing_entitlements(state, request_context, headers).await)
}
Some("daily_quota_reset") => {
Some(handle_billing_daily_quota_reset(state, request_context, headers).await)
}
_ => None,
}
}
65 changes: 65 additions & 0 deletions apps/aether-gateway/src/handlers/shared/normalize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ pub(crate) fn normalize_feature_settings(value: Option<Value>) -> Result<Option<
Value::Null => Ok(None),
Value::Object(ref mut settings) => {
normalize_chat_pii_redaction_feature_settings(settings)?;
normalize_billing_source_feature_settings(settings)?;
normalize_notification_push_service_feature_settings(settings)?;
if settings.is_empty() {
Ok(None)
Expand Down Expand Up @@ -397,6 +398,49 @@ fn normalize_notification_push_service_feature_object(
Ok(())
}

fn normalize_billing_source_feature_settings(
settings: &mut Map<String, Value>,
) -> Result<(), String> {
let Some(value) = settings.get_mut("billing_source") else {
return Ok(());
};
match value {
Value::Null => {
settings.remove("billing_source");
Ok(())
}
Value::Object(feature) => {
normalize_billing_source_feature_object(feature)?;
if feature.is_empty() {
settings.remove("billing_source");
}
Ok(())
}
_ => Err("billing_source 必须是对象".to_string()),
}
}

fn normalize_billing_source_feature_object(feature: &mut Map<String, Value>) -> Result<(), String> {
let Some(value) = feature.get("mode") else {
return Ok(());
};
let Some(mode) = value.as_str() else {
return Err("billing_source.mode 必须是字符串".to_string());
};
let normalized = mode.trim().to_lowercase();
match normalized.as_str() {
"auto" => {
feature.remove("mode");
Ok(())
}
"wallet" | "package" => {
feature.insert("mode".to_string(), Value::String(normalized));
Ok(())
}
_ => Err("billing_source.mode 必须是 auto、wallet 或 package".to_string()),
}
}

#[cfg(test)]
mod tests {
use super::{
Expand Down Expand Up @@ -446,6 +490,27 @@ mod tests {
);
}

#[test]
fn normalize_feature_settings_accepts_billing_source_mode() {
let normalized = normalize_feature_settings(Some(json!({
"billing_source": {"mode": " wallet "}
})))
.expect("feature settings should normalize")
.expect("feature settings should remain set");

assert_eq!(normalized["billing_source"]["mode"], json!("wallet"));
}

#[test]
fn normalize_feature_settings_removes_auto_billing_source_mode() {
let normalized = normalize_feature_settings(Some(json!({
"billing_source": {"mode": "auto"}
})))
.expect("feature settings should normalize");

assert!(normalized.is_none());
}

#[test]
fn user_self_feature_update_preserves_notification_push_permission() {
let normalized = normalize_user_self_feature_settings_update(
Expand Down
13 changes: 13 additions & 0 deletions apps/aether-gateway/src/state/runtime/billing/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -514,4 +514,17 @@ impl AppState {
.await
.map_err(data_error)
}

pub(crate) async fn reset_user_daily_quota(
&self,
user_id: &str,
entitlement_id: &str,
min_remaining_secs: u64,
penalty_secs: u64,
) -> Result<AdminBillingMutationOutcome<UserPlanEntitlementRecord>, GatewayError> {
self.data
.reset_user_daily_quota(user_id, entitlement_id, min_remaining_secs, penalty_secs)
.await
.map_err(data_error)
}
}
Loading
Loading