diff --git a/apps/aether-gateway/src/control/route/public_support.rs b/apps/aether-gateway/src/control/route/public_support.rs index 438b5c0f9..2b1dc4b4e 100644 --- a/apps/aether-gateway/src/control/route/public_support.rs +++ b/apps/aether-gateway/src/control/route/public_support.rs @@ -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") { diff --git a/apps/aether-gateway/src/control/tests/public_support.rs b/apps/aether-gateway/src/control/tests/public_support.rs index 0ed2b9e53..936d26d6b 100644 --- a/apps/aether-gateway/src/control/tests/public_support.rs +++ b/apps/aether-gateway/src/control/tests/public_support.rs @@ -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 = diff --git a/apps/aether-gateway/src/data/state/runtime.rs b/apps/aether-gateway/src/data/state/runtime.rs index 23aedc87f..2d71ec3b6 100644 --- a/apps/aether-gateway/src/data/state/runtime.rs +++ b/apps/aether-gateway/src/data/state/runtime.rs @@ -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, 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, diff --git a/apps/aether-gateway/src/handlers/admin/billing/plans.rs b/apps/aether-gateway/src/handlers/admin/billing/plans.rs index 710bec985..a810a73c3 100644 --- a/apps/aether-gateway/src/handlers/admin/billing/plans.rs +++ b/apps/aether-gateway/src/handlers/admin/billing/plans.rs @@ -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 @@ -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 diff --git a/apps/aether-gateway/src/handlers/public/support/billing.rs b/apps/aether-gateway/src/handlers/public/support/billing.rs index c84f5366d..02b5802ce 100644 --- a/apps/aether-gateway/src/handlers/public/support/billing.rs +++ b/apps/aether-gateway/src/handlers/public/support/billing.rs @@ -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, @@ -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 { @@ -88,6 +91,17 @@ fn plan_id_from_checkout_path(path: &str) -> Option { } } +fn entitlement_id_from_daily_quota_reset_path(path: &str) -> Option { + 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) -> String { format!( "pp_{}_{}", @@ -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, @@ -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 { + 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, @@ -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, } } diff --git a/apps/aether-gateway/src/handlers/shared/normalize.rs b/apps/aether-gateway/src/handlers/shared/normalize.rs index 07349b320..cc64a1d2b 100644 --- a/apps/aether-gateway/src/handlers/shared/normalize.rs +++ b/apps/aether-gateway/src/handlers/shared/normalize.rs @@ -54,6 +54,7 @@ pub(crate) fn normalize_feature_settings(value: Option) -> Result 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) @@ -397,6 +398,49 @@ fn normalize_notification_push_service_feature_object( Ok(()) } +fn normalize_billing_source_feature_settings( + settings: &mut Map, +) -> 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) -> 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::{ @@ -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( diff --git a/apps/aether-gateway/src/state/runtime/billing/admin.rs b/apps/aether-gateway/src/state/runtime/billing/admin.rs index 3823d3f1c..864b09e2a 100644 --- a/apps/aether-gateway/src/state/runtime/billing/admin.rs +++ b/apps/aether-gateway/src/state/runtime/billing/admin.rs @@ -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, GatewayError> { + self.data + .reset_user_daily_quota(user_id, entitlement_id, min_remaining_secs, penalty_secs) + .await + .map_err(data_error) + } } diff --git a/apps/aether-gateway/src/wallet_runtime/access.rs b/apps/aether-gateway/src/wallet_runtime/access.rs index e116e3ab4..59828190a 100644 --- a/apps/aether-gateway/src/wallet_runtime/access.rs +++ b/apps/aether-gateway/src/wallet_runtime/access.rs @@ -2,6 +2,7 @@ use aether_data::repository::wallet::StoredWalletSnapshot; use aether_wallet::{ WalletAccessDecision, WalletAccessFailure, WalletLimitMode, WalletSnapshot, WalletStatus, }; +use serde_json::Value; use crate::control::GatewayLocalAuthRejection; use crate::data::auth::GatewayAuthApiKeySnapshot; @@ -9,6 +10,13 @@ use crate::{AppState, GatewayError}; const DAILY_QUOTA_EPSILON_USD: f64 = 0.000_000_01; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ApiKeyBillingSourceMode { + Auto, + Wallet, + Package, +} + pub(crate) async fn resolve_wallet_auth_gate( state: &AppState, auth_snapshot: &GatewayAuthApiKeySnapshot, @@ -17,6 +25,15 @@ pub(crate) async fn resolve_wallet_auth_gate( return Ok(None); } + let billing_source_mode = resolve_api_key_billing_source_mode(state, auth_snapshot).await?; + if !auth_snapshot.api_key_is_standalone + && billing_source_mode == ApiKeyBillingSourceMode::Package + { + return Ok(Some( + resolve_package_billing_source_decision(state, &auth_snapshot.user_id).await?, + )); + } + let wallet = state .read_wallet_snapshot_for_auth( &auth_snapshot.user_id, @@ -29,7 +46,8 @@ pub(crate) async fn resolve_wallet_auth_gate( Some(wallet) => map_wallet_snapshot(wallet).access_decision(false), None => WalletAccessDecision::wallet_unavailable(None), }; - if !auth_snapshot.api_key_is_standalone { + if !auth_snapshot.api_key_is_standalone && billing_source_mode == ApiKeyBillingSourceMode::Auto + { if let Some(quota) = state .find_user_daily_quota_availability(&auth_snapshot.user_id) .await? @@ -49,6 +67,65 @@ pub(crate) async fn resolve_wallet_auth_gate( Ok(Some(decision)) } +async fn resolve_api_key_billing_source_mode( + state: &AppState, + auth_snapshot: &GatewayAuthApiKeySnapshot, +) -> Result { + if auth_snapshot.api_key_is_standalone { + return Ok(ApiKeyBillingSourceMode::Auto); + } + let feature_settings = state + .read_auth_api_key_feature_settings( + &auth_snapshot.user_id, + &auth_snapshot.api_key_id, + auth_snapshot.api_key_is_standalone, + ) + .await?; + Ok(api_key_billing_source_mode_from_feature_settings( + feature_settings.as_ref(), + )) +} + +async fn resolve_package_billing_source_decision( + state: &AppState, + user_id: &str, +) -> Result { + let quota = state + .find_user_daily_quota_availability(user_id) + .await? + .filter(|quota| quota.has_active_daily_quota); + let Some(quota) = quota else { + return Ok(WalletAccessDecision::balance_denied(Some(0.0))); + }; + if quota.remaining_usd > DAILY_QUOTA_EPSILON_USD { + return Ok(WalletAccessDecision::allowed(Some(quota.remaining_usd))); + } + Ok(WalletAccessDecision::balance_denied(Some( + quota.remaining_usd.max(0.0), + ))) +} + +fn api_key_billing_source_mode_from_feature_settings( + feature_settings: Option<&Value>, +) -> ApiKeyBillingSourceMode { + let Some(settings) = feature_settings.and_then(Value::as_object) else { + return ApiKeyBillingSourceMode::Auto; + }; + let Some(billing_source) = settings.get("billing_source").and_then(Value::as_object) else { + return ApiKeyBillingSourceMode::Auto; + }; + match billing_source + .get("mode") + .and_then(Value::as_str) + .map(|value| value.trim().to_ascii_lowercase()) + .as_deref() + { + Some("wallet") => ApiKeyBillingSourceMode::Wallet, + Some("package") => ApiKeyBillingSourceMode::Package, + _ => ApiKeyBillingSourceMode::Auto, + } +} + pub(crate) fn local_rejection_from_wallet_access( decision: &WalletAccessDecision, ) -> Option { @@ -82,6 +159,12 @@ fn map_wallet_snapshot(snapshot: &StoredWalletSnapshot) -> WalletSnapshot { mod tests { use std::sync::Arc; + use aether_data::repository::auth::{ + InMemoryAuthApiKeySnapshotRepository, StoredAuthApiKeyExportRecord, + }; + use aether_data::repository::candidate_selection::InMemoryMinimalCandidateSelectionReadRepository; + use aether_data::repository::candidates::InMemoryRequestCandidateRepository; + use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository; use aether_data::repository::usage::InMemoryUsageReadRepository; use aether_data::repository::wallet::{InMemoryWalletRepository, StoredWalletSnapshot}; use aether_data_contracts::repository::billing::{ @@ -90,6 +173,7 @@ mod tests { use aether_data_contracts::DataLayerError; use aether_wallet::{WalletAccessFailure, WalletLimitMode, WalletSnapshot, WalletStatus}; use async_trait::async_trait; + use serde_json::json; use super::{ local_rejection_from_wallet_access, map_wallet_snapshot, resolve_wallet_auth_gate, @@ -232,6 +316,63 @@ mod tests { assert_eq!(decision.remaining, Some(4.0)); } + #[tokio::test] + async fn wallet_billing_source_ignores_remaining_quota() { + let state = state_with_wallet_quota_and_billing_source( + empty_user_wallet(), + Some(quota_availability(10.0, 4.0, false)), + "wallet", + ); + let auth_snapshot = ordinary_user_api_key_snapshot(); + + let decision = resolve_wallet_auth_gate(&state, &auth_snapshot) + .await + .expect("wallet gate should resolve") + .expect("wallet gate should return a decision"); + + assert!(!decision.allowed); + assert_eq!(decision.failure, Some(WalletAccessFailure::BalanceDenied)); + assert_eq!(decision.remaining, Some(0.0)); + } + + #[tokio::test] + async fn package_billing_source_allows_remaining_quota_with_empty_wallet() { + let state = state_with_wallet_quota_and_billing_source( + empty_user_wallet(), + Some(quota_availability(10.0, 4.0, false)), + "package", + ); + let auth_snapshot = ordinary_user_api_key_snapshot(); + + let decision = resolve_wallet_auth_gate(&state, &auth_snapshot) + .await + .expect("wallet gate should resolve") + .expect("wallet gate should return a decision"); + + assert!(decision.allowed); + assert_eq!(decision.failure, None); + assert_eq!(decision.remaining, Some(4.0)); + } + + #[tokio::test] + async fn package_billing_source_denies_exhausted_quota_even_with_wallet_balance() { + let state = state_with_wallet_quota_and_billing_source( + funded_user_wallet(10.0), + Some(quota_availability(10.0, 0.0, true)), + "package", + ); + let auth_snapshot = ordinary_user_api_key_snapshot(); + + let decision = resolve_wallet_auth_gate(&state, &auth_snapshot) + .await + .expect("wallet gate should resolve") + .expect("wallet gate should return a decision"); + + assert!(!decision.allowed); + assert_eq!(decision.failure, Some(WalletAccessFailure::BalanceDenied)); + assert_eq!(decision.remaining, Some(0.0)); + } + fn state_with_wallet_and_quota( wallet: StoredWalletSnapshot, quota: Option, @@ -250,12 +391,48 @@ mod tests { .with_data_state_for_tests(data) } + fn state_with_wallet_quota_and_billing_source( + wallet: StoredWalletSnapshot, + quota: Option, + mode: &str, + ) -> AppState { + let usage_repository = Arc::new(InMemoryUsageReadRepository::default()); + let billing_repository: Arc = + Arc::new(FixedQuotaBillingReadRepository { quota }); + let wallet_repository = Arc::new(InMemoryWalletRepository::seed(vec![wallet])); + let auth_repository = Arc::new( + InMemoryAuthApiKeySnapshotRepository::default().with_export_records(vec![ + auth_export_record(json!({ + "billing_source": {"mode": mode} + })), + ]), + ); + let data = + GatewayDataState::with_auth_candidate_selection_provider_catalog_request_candidates_usage_billing_and_wallet_for_tests( + auth_repository, + Arc::new(InMemoryMinimalCandidateSelectionReadRepository::default()), + Arc::new(InMemoryProviderCatalogReadRepository::default()), + Arc::new(InMemoryRequestCandidateRepository::default()), + usage_repository, + billing_repository, + wallet_repository, + "test-encryption-key", + ); + AppState::new() + .expect("state should build") + .with_data_state_for_tests(data) + } + fn empty_user_wallet() -> StoredWalletSnapshot { + funded_user_wallet(0.0) + } + + fn funded_user_wallet(balance: f64) -> StoredWalletSnapshot { StoredWalletSnapshot::new( "wallet-user-1".to_string(), Some("user-1".to_string()), None, - 0.0, + balance, 0.0, "finite".to_string(), "USD".to_string(), @@ -269,6 +446,31 @@ mod tests { .expect("wallet should build") } + fn auth_export_record(feature_settings: serde_json::Value) -> StoredAuthApiKeyExportRecord { + StoredAuthApiKeyExportRecord::new( + "user-1".to_string(), + "api-key-1".to_string(), + "hash-api-key-1".to_string(), + None, + Some("user-key".to_string()), + None, + None, + None, + Some(0), + None, + None, + true, + None, + false, + 0, + 0, + 0.0, + false, + ) + .expect("auth export record should build") + .with_feature_settings(Some(feature_settings)) + } + fn quota_availability( total_quota_usd: f64, remaining_usd: f64, diff --git a/crates/aether-admin/src/provider/ops/actions.rs b/crates/aether-admin/src/provider/ops/actions.rs index cb451a3a5..c1ffe189e 100644 --- a/crates/aether-admin/src/provider/ops/actions.rs +++ b/crates/aether-admin/src/provider/ops/actions.rs @@ -372,6 +372,7 @@ fn yescode_balance_extra(combined_data: &Map) -> Map) -> Map Option { mod tests { use super::{ attach_balance_checkin_outcome, parse_query_balance_payload, parse_sub2api_balance_payload, - ProviderOpsCheckinOutcome, + parse_yescode_combined_balance_payload, ProviderOpsCheckinOutcome, }; use serde_json::json; @@ -540,6 +545,33 @@ mod tests { assert_eq!(payload["extra"]["active_subscriptions"], json!(2)); } + #[test] + fn yescode_parser_keeps_subscription_balance_breakdown() { + let payload = parse_yescode_combined_balance_payload( + &json!({ "currency": "USD" }) + .as_object() + .cloned() + .expect("config"), + &json!({ + "pay_as_you_go_balance": 4.0, + "subscription_balance": 10.0, + "weekly_limit": 12.0, + "weekly_spent_balance": 3.0, + "subscription_plan": { + "daily_balance": 10.0 + } + }) + .as_object() + .cloned() + .expect("combined data"), + ); + + assert_eq!(payload["total_available"], json!(13.0)); + assert_eq!(payload["extra"]["pay_as_you_go_balance"], json!(4.0)); + assert_eq!(payload["extra"]["subscription_balance"], json!(10.0)); + assert_eq!(payload["extra"]["subscription_available"], json!(9.0)); + } + #[test] fn cubence_parser_reads_wrapped_dashboard_overview() { let payload = parse_query_balance_payload( diff --git a/crates/aether-data-contracts/src/repository/billing/types.rs b/crates/aether-data-contracts/src/repository/billing/types.rs index 2ec998899..e464e2949 100644 --- a/crates/aether-data-contracts/src/repository/billing/types.rs +++ b/crates/aether-data-contracts/src/repository/billing/types.rs @@ -443,4 +443,15 @@ pub trait BillingReadRepository: Send + Sync { let _ = user_id; Ok(None) } + + async fn reset_user_daily_quota( + &self, + user_id: &str, + entitlement_id: &str, + min_remaining_secs: u64, + penalty_secs: u64, + ) -> Result, crate::DataLayerError> { + let _ = (user_id, entitlement_id, min_remaining_secs, penalty_secs); + Ok(AdminBillingMutationOutcome::Unavailable) + } } diff --git a/crates/aether-data/src/repository/billing/memory.rs b/crates/aether-data/src/repository/billing/memory.rs index 8f07894df..c9f77618f 100644 --- a/crates/aether-data/src/repository/billing/memory.rs +++ b/crates/aether-data/src/repository/billing/memory.rs @@ -103,9 +103,27 @@ fn daily_quota_availability_from_entitlements( if !daily_quota_usd.is_finite() || daily_quota_usd <= 0.0 { continue; } + let quota_multiplier = if item + .get("carry_over") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + { + item.get("carry_over_limit_multiplier") + .and_then(serde_json::Value::as_f64) + .unwrap_or_else(|| { + item.get("carry_over_days") + .and_then(serde_json::Value::as_u64) + .map(|days| days.saturating_add(1) as f64) + .unwrap_or(2.0) + }) + .clamp(1.0, 31.0) + } else { + 1.0 + }; + let quota_usd = daily_quota_usd * quota_multiplier; has_active_daily_quota = true; - total_quota_usd += daily_quota_usd; - remaining_usd += daily_quota_usd; + total_quota_usd += quota_usd; + remaining_usd += quota_usd; allow_wallet_overage &= item .get("allow_wallet_overage") .and_then(serde_json::Value::as_bool) @@ -121,6 +139,32 @@ fn daily_quota_availability_from_entitlements( } } +fn entitlement_has_self_service_daily_quota_reset(entitlement: &UserPlanEntitlementRecord) -> bool { + entitlement + .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 entitlement_has_daily_quota(entitlement: &UserPlanEntitlementRecord) -> bool { + entitlement + .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") + }) + }) +} + #[async_trait] impl BillingReadRepository for InMemoryBillingReadRepository { async fn find_model_context( @@ -384,6 +428,50 @@ impl BillingReadRepository for InMemoryBillingReadRepository { now, ))) } + + async fn reset_user_daily_quota( + &self, + user_id: &str, + entitlement_id: &str, + min_remaining_secs: u64, + penalty_secs: u64, + ) -> Result, DataLayerError> { + let mut entitlements = self + .entitlements_by_id + .write() + .expect("billing repository lock"); + let Some(record) = entitlements.get_mut(entitlement_id) else { + return Ok(AdminBillingMutationOutcome::NotFound); + }; + let now = current_unix_secs(); + if record.user_id != user_id + || record.status != "active" + || record.starts_at_unix_secs > now + || record.expires_at_unix_secs <= now + { + return Ok(AdminBillingMutationOutcome::Invalid( + "权益未生效或已过期".to_string(), + )); + } + if record.expires_at_unix_secs.saturating_sub(now) < min_remaining_secs { + return Ok(AdminBillingMutationOutcome::Invalid( + "剩余有效期不足 72 小时,不能自助重置".to_string(), + )); + } + if !entitlement_has_daily_quota(record) { + return Ok(AdminBillingMutationOutcome::Invalid( + "权益不包含每日额度".to_string(), + )); + } + if !entitlement_has_self_service_daily_quota_reset(record) { + return Ok(AdminBillingMutationOutcome::Invalid( + "该套餐未开放自助重置每日额度".to_string(), + )); + } + record.expires_at_unix_secs = record.expires_at_unix_secs.saturating_sub(penalty_secs); + record.updated_at_unix_secs = now; + Ok(AdminBillingMutationOutcome::Applied(record.clone())) + } } fn find_context_by_provider_model_name( diff --git a/crates/aether-data/src/repository/billing/mysql.rs b/crates/aether-data/src/repository/billing/mysql.rs index 458bb80d9..a9e27b15c 100644 --- a/crates/aether-data/src/repository/billing/mysql.rs +++ b/crates/aether-data/src/repository/billing/mysql.rs @@ -925,7 +925,7 @@ ORDER BY expires_at ASC, created_at ASC let now_unix_secs = current_unix_secs_i64(); let rows = sqlx::query( r#" -SELECT id, entitlements_snapshot +SELECT id, starts_at, entitlements_snapshot FROM user_plan_entitlements WHERE user_id = ? AND status = 'active' @@ -944,10 +944,13 @@ ORDER BY expires_at ASC, created_at ASC, id ASC let mut grants = Vec::new(); for row in rows { let entitlement_id: String = row.try_get("id").map_sql_err()?; + let starts_at_unix_secs = + row.try_get::("starts_at").map_sql_err()?.max(0) as u64; let entitlements = parse_json(row.try_get("entitlements_snapshot").ok().flatten())? .unwrap_or_else(|| serde_json::json!([])); grants.extend(daily_quota_grants_from_entitlement( &entitlement_id, + starts_at_unix_secs, &entitlements, now, )?); @@ -959,22 +962,25 @@ ORDER BY expires_at ASC, created_at ASC, id ASC let mut allow_wallet_overage = true; for grant in &grants { allow_wallet_overage &= grant.allow_wallet_overage; - let used = sqlx::query_scalar::<_, f64>( - r#" + let mut used = 0.0; + for usage_date in &grant.usage_dates { + used += sqlx::query_scalar::<_, f64>( + r#" SELECT COALESCE(SUM(amount_usd), 0) FROM entitlement_usage_ledgers WHERE user_entitlement_id = ? AND usage_date = ? "#, - ) - .bind(&grant.entitlement_id) - .bind(&grant.usage_date) - .fetch_one(&self.pool) - .await - .map_sql_err()?; - total_quota_usd += grant.daily_quota_usd; - used_usd += used.min(grant.daily_quota_usd).max(0.0); - remaining_usd += (grant.daily_quota_usd - used).max(0.0); + ) + .bind(&grant.entitlement_id) + .bind(usage_date) + .fetch_one(&self.pool) + .await + .map_sql_err()?; + } + total_quota_usd += grant.effective_limit_usd; + used_usd += used.min(grant.effective_limit_usd).max(0.0); + remaining_usd += (grant.effective_limit_usd - used).max(0.0); } let has_active_daily_quota = !grants.is_empty(); Ok(Some(UserDailyQuotaAvailabilityRecord { @@ -985,6 +991,168 @@ WHERE user_entitlement_id = ? allow_wallet_overage: has_active_daily_quota && allow_wallet_overage, })) } + + async fn reset_user_daily_quota( + &self, + user_id: &str, + entitlement_id: &str, + min_remaining_secs: u64, + penalty_secs: u64, + ) -> Result, DataLayerError> { + let min_remaining_secs = i64::try_from(min_remaining_secs) + .map_err(|_| DataLayerError::InvalidInput("min remaining overflow".to_string()))?; + let penalty_secs = i64::try_from(penalty_secs) + .map_err(|_| DataLayerError::InvalidInput("penalty overflow".to_string()))?; + let now_unix_secs = current_unix_secs_i64(); + let now = now_unix_secs.max(0) as u64; + let mut tx = self.pool.begin().await.map_sql_err()?; + let row = sqlx::query( + r#" +SELECT + id, user_id, plan_id, payment_order_id, status, + starts_at AS starts_at_unix_secs, expires_at AS expires_at_unix_secs, + entitlements_snapshot, created_at AS created_at_unix_secs, + updated_at AS updated_at_unix_secs +FROM user_plan_entitlements +WHERE id = ? + AND user_id = ? +LIMIT 1 + "#, + ) + .bind(entitlement_id) + .bind(user_id) + .fetch_optional(&mut *tx) + .await + .map_sql_err()?; + let Some(row) = row else { + return Ok(AdminBillingMutationOutcome::NotFound); + }; + let record = map_user_plan_entitlement_mysql(&row)?; + if record.status != "active" + || record.starts_at_unix_secs > now + || record.expires_at_unix_secs <= now + { + return Ok(AdminBillingMutationOutcome::Invalid( + "权益未生效或已过期".to_string(), + )); + } + if record.expires_at_unix_secs.saturating_sub(now) < min_remaining_secs as u64 { + return Ok(AdminBillingMutationOutcome::Invalid( + "剩余有效期不足 72 小时,不能自助重置".to_string(), + )); + } + if !entitlement_has_daily_quota(&record.entitlements_snapshot) { + return Ok(AdminBillingMutationOutcome::Invalid( + "权益不包含每日额度".to_string(), + )); + } + if !entitlement_allows_self_service_daily_quota_reset(&record.entitlements_snapshot) { + return Ok(AdminBillingMutationOutcome::Invalid( + "该套餐未开放自助重置每日额度".to_string(), + )); + } + let grants = daily_quota_grants_from_entitlement( + &record.id, + record.starts_at_unix_secs, + &record.entitlements_snapshot, + chrono::Utc::now(), + )?; + if grants.is_empty() { + return Ok(AdminBillingMutationOutcome::Invalid( + "权益不包含可用每日额度".to_string(), + )); + } + for grant in &grants { + let current_used = sqlx::query_scalar::<_, f64>( + r#" +SELECT COALESCE(SUM(amount_usd), 0) +FROM entitlement_usage_ledgers +WHERE user_entitlement_id = ? + AND usage_date = ? + "#, + ) + .bind(&grant.entitlement_id) + .bind(&grant.usage_date) + .fetch_one(&mut *tx) + .await + .map_sql_err()?; + let used_to_reset = current_used.max(0.0); + if used_to_reset <= 0.000_000_01 { + continue; + } + let balance_before = (grant.effective_limit_usd - current_used).max(0.0); + let balance_after = (balance_before + used_to_reset).min(grant.effective_limit_usd); + sqlx::query( + r#" +INSERT INTO entitlement_usage_ledgers ( + id, user_entitlement_id, user_id, request_id, amount_usd, + balance_before, balance_after, usage_date, created_at +) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + "#, + ) + .bind(uuid::Uuid::new_v4().to_string()) + .bind(&grant.entitlement_id) + .bind(user_id) + .bind(format!( + "self_service_daily_quota_reset:{}", + uuid::Uuid::new_v4() + )) + .bind(-used_to_reset) + .bind(balance_before) + .bind(balance_after) + .bind(&grant.usage_date) + .bind(now_unix_secs) + .execute(&mut *tx) + .await + .map_sql_err()?; + } + let next_expires_at = (record.expires_at_unix_secs as i64 - penalty_secs).max(0); + let result = sqlx::query( + r#" +UPDATE user_plan_entitlements +SET expires_at = ?, + updated_at = ? +WHERE id = ? + AND user_id = ? + "#, + ) + .bind(next_expires_at) + .bind(now_unix_secs) + .bind(entitlement_id) + .bind(user_id) + .execute(&mut *tx) + .await + .map_sql_err()?; + if result.rows_affected() == 0 { + return Ok(AdminBillingMutationOutcome::NotFound); + } + let row = sqlx::query( + r#" +SELECT + id, user_id, plan_id, payment_order_id, status, + starts_at AS starts_at_unix_secs, expires_at AS expires_at_unix_secs, + entitlements_snapshot, created_at AS created_at_unix_secs, + updated_at AS updated_at_unix_secs +FROM user_plan_entitlements +WHERE id = ? +LIMIT 1 + "#, + ) + .bind(entitlement_id) + .fetch_optional(&mut *tx) + .await + .map_sql_err()?; + tx.commit().await.map_sql_err()?; + match row + .as_ref() + .map(map_user_plan_entitlement_mysql) + .transpose()? + { + Some(record) => Ok(AdminBillingMutationOutcome::Applied(record)), + None => Ok(AdminBillingMutationOutcome::NotFound), + } + } } const BILLING_PLAN_INSERT_MYSQL: &str = r#" @@ -1157,26 +1325,44 @@ fn json_to_string(value: &serde_json::Value) -> Result { #[derive(Debug)] struct DailyQuotaGrant { entitlement_id: String, - daily_quota_usd: f64, usage_date: String, + usage_dates: Vec, allow_wallet_overage: bool, + effective_limit_usd: f64, } -fn daily_quota_usage_date( +fn daily_quota_usage_dates( reset_timezone: Option<&str>, + starts_at_unix_secs: u64, now: chrono::DateTime, -) -> Result { + carry_over_days: u64, +) -> Result, DataLayerError> { let timezone = reset_timezone .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("Asia/Shanghai") .parse::() .map_err(|err| DataLayerError::InvalidInput(format!("invalid reset_timezone: {err}")))?; - Ok(now.with_timezone(&timezone).date_naive().to_string()) + let current_date = now.with_timezone(&timezone).date_naive(); + let starts_at = chrono::DateTime::from_timestamp(starts_at_unix_secs as i64, 0) + .unwrap_or(now) + .with_timezone(&timezone) + .date_naive(); + let first_date = (current_date - chrono::Duration::days(carry_over_days as i64)).max(starts_at); + let mut dates = Vec::new(); + let mut date = first_date; + while date <= current_date { + dates.push(date.to_string()); + date = date + .succ_opt() + .ok_or_else(|| DataLayerError::InvalidInput("daily quota date overflow".to_string()))?; + } + Ok(dates) } fn daily_quota_grants_from_entitlement( entitlement_id: &str, + starts_at_unix_secs: u64, entitlements: &serde_json::Value, now: chrono::DateTime, ) -> Result, DataLayerError> { @@ -1195,23 +1381,70 @@ fn daily_quota_grants_from_entitlement( if !daily_quota_usd.is_finite() || daily_quota_usd <= 0.0 { continue; } + let carry_over = item + .get("carry_over") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let carry_over_days = if carry_over { + item.get("carry_over_days") + .and_then(serde_json::Value::as_u64) + .unwrap_or(1) + .clamp(1, 30) + } else { + 0 + }; + let usage_dates = daily_quota_usage_dates( + item.get("reset_timezone") + .and_then(serde_json::Value::as_str), + starts_at_unix_secs, + now, + carry_over_days, + )?; + let usage_date = usage_dates + .last() + .cloned() + .unwrap_or_else(|| now.date_naive().to_string()); + let window_limit_usd = daily_quota_usd * usage_dates.len() as f64; + let multiplier_limit_usd = daily_quota_usd + * item + .get("carry_over_limit_multiplier") + .and_then(serde_json::Value::as_f64) + .unwrap_or(usage_dates.len() as f64) + .clamp(1.0, usage_dates.len() as f64); grants.push(DailyQuotaGrant { entitlement_id: entitlement_id.to_string(), - daily_quota_usd, - usage_date: daily_quota_usage_date( - item.get("reset_timezone") - .and_then(serde_json::Value::as_str), - now, - )?, + usage_date, + usage_dates, allow_wallet_overage: item .get("allow_wallet_overage") .and_then(serde_json::Value::as_bool) .unwrap_or(false), + effective_limit_usd: window_limit_usd.min(multiplier_limit_usd), }); } Ok(grants) } +fn entitlement_has_daily_quota(entitlements: &serde_json::Value) -> bool { + entitlements.as_array().is_some_and(|items| { + items + .iter() + .any(|item| item.get("type").and_then(serde_json::Value::as_str) == Some("daily_quota")) + }) +} + +fn entitlement_allows_self_service_daily_quota_reset(entitlements: &serde_json::Value) -> bool { + entitlements.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 read_count_mysql(row: &MySqlRow) -> Result { Ok(row.try_get::("total").map_sql_err()?.max(0) as u64) } diff --git a/crates/aether-data/src/repository/billing/postgres.rs b/crates/aether-data/src/repository/billing/postgres.rs index defc45eff..49531bded 100644 --- a/crates/aether-data/src/repository/billing/postgres.rs +++ b/crates/aether-data/src/repository/billing/postgres.rs @@ -1003,7 +1003,7 @@ ORDER BY expires_at ASC, created_at ASC ) -> Result, DataLayerError> { let rows = sqlx::query( r#" -SELECT id, entitlements_snapshot +SELECT id, CAST(EXTRACT(EPOCH FROM starts_at) AS BIGINT) AS starts_at_unix_secs, entitlements_snapshot FROM user_plan_entitlements WHERE user_id = $1 AND status = 'active' @@ -1020,10 +1020,15 @@ ORDER BY expires_at ASC, created_at ASC, id ASC let mut grants = Vec::new(); for row in rows { let entitlement_id: String = row.try_get("id").map_postgres_err()?; + let starts_at_unix_secs = row + .try_get::("starts_at_unix_secs") + .map_postgres_err()? + .max(0) as u64; let entitlements: serde_json::Value = row.try_get("entitlements_snapshot").map_postgres_err()?; grants.extend(daily_quota_grants_from_entitlement( &entitlement_id, + starts_at_unix_secs, &entitlements, now, )?); @@ -1035,23 +1040,26 @@ ORDER BY expires_at ASC, created_at ASC, id ASC let mut allow_wallet_overage = true; for grant in &grants { allow_wallet_overage &= grant.allow_wallet_overage; - let used = sqlx::query_scalar::<_, Option>( - r#" + let mut used = 0.0; + for usage_date in &grant.usage_dates { + used += sqlx::query_scalar::<_, Option>( + r#" SELECT CAST(COALESCE(SUM(amount_usd), 0) AS DOUBLE PRECISION) FROM entitlement_usage_ledgers WHERE user_entitlement_id = $1 AND usage_date = $2 "#, - ) - .bind(&grant.entitlement_id) - .bind(&grant.usage_date) - .fetch_one(&self.pool) - .await - .map_postgres_err()? - .unwrap_or(0.0); - total_quota_usd += grant.daily_quota_usd; - used_usd += used.min(grant.daily_quota_usd).max(0.0); - remaining_usd += (grant.daily_quota_usd - used).max(0.0); + ) + .bind(&grant.entitlement_id) + .bind(usage_date) + .fetch_one(&self.pool) + .await + .map_postgres_err()? + .unwrap_or(0.0); + } + total_quota_usd += grant.effective_limit_usd; + used_usd += used.min(grant.effective_limit_usd).max(0.0); + remaining_usd += (grant.effective_limit_usd - used).max(0.0); } let has_active_daily_quota = !grants.is_empty(); Ok(Some(UserDailyQuotaAvailabilityRecord { @@ -1062,6 +1070,180 @@ WHERE user_entitlement_id = $1 allow_wallet_overage: has_active_daily_quota && allow_wallet_overage, })) } + + async fn reset_user_daily_quota( + &self, + user_id: &str, + entitlement_id: &str, + min_remaining_secs: u64, + penalty_secs: u64, + ) -> Result, DataLayerError> { + let min_remaining_secs = i64::try_from(min_remaining_secs) + .map_err(|_| DataLayerError::InvalidInput("min remaining overflow".to_string()))?; + let penalty_secs = i64::try_from(penalty_secs) + .map_err(|_| DataLayerError::InvalidInput("penalty overflow".to_string()))?; + let now = chrono::Utc::now(); + let now_unix_secs = now.timestamp().max(0); + let mut tx = self.pool.begin().await.map_postgres_err()?; + let row = sqlx::query( + r#" +SELECT + id, user_id, plan_id, payment_order_id, status, + CAST(EXTRACT(EPOCH FROM starts_at) AS BIGINT) AS starts_at_unix_secs, + CAST(EXTRACT(EPOCH FROM expires_at) AS BIGINT) AS expires_at_unix_secs, + entitlements_snapshot, + CAST(EXTRACT(EPOCH FROM created_at) AS BIGINT) AS created_at_unix_secs, + CAST(EXTRACT(EPOCH FROM updated_at) AS BIGINT) AS updated_at_unix_secs +FROM user_plan_entitlements +WHERE id = $1 + AND user_id = $2 +LIMIT 1 + "#, + ) + .bind(entitlement_id) + .bind(user_id) + .fetch_optional(&mut *tx) + .await + .map_postgres_err()?; + let Some(row) = row else { + return Ok(AdminBillingMutationOutcome::NotFound); + }; + let record = map_user_plan_entitlement_row(&row)?; + if record.status != "active" + || record.starts_at_unix_secs > now_unix_secs as u64 + || record.expires_at_unix_secs <= now_unix_secs as u64 + { + return Ok(AdminBillingMutationOutcome::Invalid( + "权益未生效或已过期".to_string(), + )); + } + if record + .expires_at_unix_secs + .saturating_sub(now_unix_secs as u64) + < min_remaining_secs as u64 + { + return Ok(AdminBillingMutationOutcome::Invalid( + "剩余有效期不足 72 小时,不能自助重置".to_string(), + )); + } + if !entitlement_has_daily_quota(&record.entitlements_snapshot) { + return Ok(AdminBillingMutationOutcome::Invalid( + "权益不包含每日额度".to_string(), + )); + } + if !entitlement_allows_self_service_daily_quota_reset(&record.entitlements_snapshot) { + return Ok(AdminBillingMutationOutcome::Invalid( + "该套餐未开放自助重置每日额度".to_string(), + )); + } + let grants = daily_quota_grants_from_entitlement( + &record.id, + record.starts_at_unix_secs, + &record.entitlements_snapshot, + now, + )?; + if grants.is_empty() { + return Ok(AdminBillingMutationOutcome::Invalid( + "权益不包含可用每日额度".to_string(), + )); + } + for grant in &grants { + let current_used = sqlx::query_scalar::<_, Option>( + r#" +SELECT CAST(COALESCE(SUM(amount_usd), 0) AS DOUBLE PRECISION) +FROM entitlement_usage_ledgers +WHERE user_entitlement_id = $1 + AND usage_date = $2 + "#, + ) + .bind(&grant.entitlement_id) + .bind(&grant.usage_date) + .fetch_one(&mut *tx) + .await + .map_postgres_err()? + .unwrap_or(0.0); + let used_to_reset = current_used.max(0.0); + if used_to_reset <= 0.000_000_01 { + continue; + } + let balance_before = (grant.effective_limit_usd - current_used).max(0.0); + let balance_after = (balance_before + used_to_reset).min(grant.effective_limit_usd); + sqlx::query( + r#" +INSERT INTO entitlement_usage_ledgers ( + id, user_entitlement_id, user_id, request_id, amount_usd, + balance_before, balance_after, usage_date, created_at +) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + "#, + ) + .bind(uuid::Uuid::new_v4().to_string()) + .bind(&grant.entitlement_id) + .bind(user_id) + .bind(format!( + "self_service_daily_quota_reset:{}", + uuid::Uuid::new_v4() + )) + .bind(-used_to_reset) + .bind(balance_before) + .bind(balance_after) + .bind(&grant.usage_date) + .bind(now_unix_secs) + .execute(&mut *tx) + .await + .map_postgres_err()?; + } + let next_expires_at = chrono::DateTime::::from_timestamp( + (record.expires_at_unix_secs as i64 - penalty_secs).max(0), + 0, + ) + .unwrap_or(now); + let result = sqlx::query( + r#" +UPDATE user_plan_entitlements +SET expires_at = $1, + updated_at = NOW() +WHERE id = $2 + AND user_id = $3 + "#, + ) + .bind(next_expires_at) + .bind(entitlement_id) + .bind(user_id) + .execute(&mut *tx) + .await + .map_postgres_err()?; + if result.rows_affected() == 0 { + return Ok(AdminBillingMutationOutcome::NotFound); + } + let row = sqlx::query( + r#" +SELECT + id, user_id, plan_id, payment_order_id, status, + CAST(EXTRACT(EPOCH FROM starts_at) AS BIGINT) AS starts_at_unix_secs, + CAST(EXTRACT(EPOCH FROM expires_at) AS BIGINT) AS expires_at_unix_secs, + entitlements_snapshot, + CAST(EXTRACT(EPOCH FROM created_at) AS BIGINT) AS created_at_unix_secs, + CAST(EXTRACT(EPOCH FROM updated_at) AS BIGINT) AS updated_at_unix_secs +FROM user_plan_entitlements +WHERE id = $1 +LIMIT 1 + "#, + ) + .bind(entitlement_id) + .fetch_optional(&mut *tx) + .await + .map_postgres_err()?; + tx.commit().await.map_postgres_err()?; + match row + .as_ref() + .map(map_user_plan_entitlement_row) + .transpose()? + { + Some(record) => Ok(AdminBillingMutationOutcome::Applied(record)), + None => Ok(AdminBillingMutationOutcome::NotFound), + } + } } const BILLING_PLAN_INSERT_RETURNING_SQL: &str = r#" @@ -1137,26 +1319,44 @@ fn read_count(row: sqlx::postgres::PgRow) -> Result { #[derive(Debug)] struct DailyQuotaGrant { entitlement_id: String, - daily_quota_usd: f64, usage_date: String, + usage_dates: Vec, allow_wallet_overage: bool, + effective_limit_usd: f64, } -fn daily_quota_usage_date( +fn daily_quota_usage_dates( reset_timezone: Option<&str>, + starts_at_unix_secs: u64, now: chrono::DateTime, -) -> Result { + carry_over_days: u64, +) -> Result, DataLayerError> { let timezone = reset_timezone .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("Asia/Shanghai") .parse::() .map_err(|err| DataLayerError::InvalidInput(format!("invalid reset_timezone: {err}")))?; - Ok(now.with_timezone(&timezone).date_naive().to_string()) + let current_date = now.with_timezone(&timezone).date_naive(); + let starts_at = chrono::DateTime::from_timestamp(starts_at_unix_secs as i64, 0) + .unwrap_or(now) + .with_timezone(&timezone) + .date_naive(); + let first_date = (current_date - chrono::Duration::days(carry_over_days as i64)).max(starts_at); + let mut dates = Vec::new(); + let mut date = first_date; + while date <= current_date { + dates.push(date.to_string()); + date = date + .succ_opt() + .ok_or_else(|| DataLayerError::InvalidInput("daily quota date overflow".to_string()))?; + } + Ok(dates) } fn daily_quota_grants_from_entitlement( entitlement_id: &str, + starts_at_unix_secs: u64, entitlements: &serde_json::Value, now: chrono::DateTime, ) -> Result, DataLayerError> { @@ -1175,23 +1375,70 @@ fn daily_quota_grants_from_entitlement( if !daily_quota_usd.is_finite() || daily_quota_usd <= 0.0 { continue; } + let carry_over = item + .get("carry_over") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let carry_over_days = if carry_over { + item.get("carry_over_days") + .and_then(serde_json::Value::as_u64) + .unwrap_or(1) + .clamp(1, 30) + } else { + 0 + }; + let usage_dates = daily_quota_usage_dates( + item.get("reset_timezone") + .and_then(serde_json::Value::as_str), + starts_at_unix_secs, + now, + carry_over_days, + )?; + let usage_date = usage_dates + .last() + .cloned() + .unwrap_or_else(|| now.date_naive().to_string()); + let window_limit_usd = daily_quota_usd * usage_dates.len() as f64; + let multiplier_limit_usd = daily_quota_usd + * item + .get("carry_over_limit_multiplier") + .and_then(serde_json::Value::as_f64) + .unwrap_or(usage_dates.len() as f64) + .clamp(1.0, usage_dates.len() as f64); grants.push(DailyQuotaGrant { entitlement_id: entitlement_id.to_string(), - daily_quota_usd, - usage_date: daily_quota_usage_date( - item.get("reset_timezone") - .and_then(serde_json::Value::as_str), - now, - )?, + usage_date, + usage_dates, allow_wallet_overage: item .get("allow_wallet_overage") .and_then(serde_json::Value::as_bool) .unwrap_or(false), + effective_limit_usd: window_limit_usd.min(multiplier_limit_usd), }); } Ok(grants) } +fn entitlement_has_daily_quota(entitlements: &serde_json::Value) -> bool { + entitlements.as_array().is_some_and(|items| { + items + .iter() + .any(|item| item.get("type").and_then(serde_json::Value::as_str) == Some("daily_quota")) + }) +} + +fn entitlement_allows_self_service_daily_quota_reset(entitlements: &serde_json::Value) -> bool { + entitlements.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 map_payment_gateway_config_row( row: &sqlx::postgres::PgRow, ) -> Result { diff --git a/crates/aether-data/src/repository/billing/sqlite.rs b/crates/aether-data/src/repository/billing/sqlite.rs index 57e5d9cfa..8fa4f125e 100644 --- a/crates/aether-data/src/repository/billing/sqlite.rs +++ b/crates/aether-data/src/repository/billing/sqlite.rs @@ -925,7 +925,7 @@ ORDER BY expires_at ASC, created_at ASC let now_unix_secs = current_unix_secs_i64(); let rows = sqlx::query( r#" -SELECT id, entitlements_snapshot +SELECT id, starts_at, entitlements_snapshot FROM user_plan_entitlements WHERE user_id = ? AND status = 'active' @@ -944,10 +944,13 @@ ORDER BY expires_at ASC, created_at ASC, id ASC let mut grants = Vec::new(); for row in rows { let entitlement_id: String = row.try_get("id").map_sql_err()?; + let starts_at_unix_secs = + row.try_get::("starts_at").map_sql_err()?.max(0) as u64; let entitlements = parse_json(row.try_get("entitlements_snapshot").ok().flatten())? .unwrap_or_else(|| serde_json::json!([])); grants.extend(daily_quota_grants_from_entitlement( &entitlement_id, + starts_at_unix_secs, &entitlements, now, )?); @@ -959,22 +962,25 @@ ORDER BY expires_at ASC, created_at ASC, id ASC let mut allow_wallet_overage = true; for grant in &grants { allow_wallet_overage &= grant.allow_wallet_overage; - let used = sqlx::query_scalar::<_, f64>( - r#" + let mut used = 0.0; + for usage_date in &grant.usage_dates { + used += sqlx::query_scalar::<_, f64>( + r#" SELECT CAST(COALESCE(SUM(amount_usd), 0) AS REAL) FROM entitlement_usage_ledgers WHERE user_entitlement_id = ? AND usage_date = ? "#, - ) - .bind(&grant.entitlement_id) - .bind(&grant.usage_date) - .fetch_one(&self.pool) - .await - .map_sql_err()?; - total_quota_usd += grant.daily_quota_usd; - used_usd += used.min(grant.daily_quota_usd).max(0.0); - remaining_usd += (grant.daily_quota_usd - used).max(0.0); + ) + .bind(&grant.entitlement_id) + .bind(usage_date) + .fetch_one(&self.pool) + .await + .map_sql_err()?; + } + total_quota_usd += grant.effective_limit_usd; + used_usd += used.min(grant.effective_limit_usd).max(0.0); + remaining_usd += (grant.effective_limit_usd - used).max(0.0); } let has_active_daily_quota = !grants.is_empty(); Ok(Some(UserDailyQuotaAvailabilityRecord { @@ -985,6 +991,169 @@ WHERE user_entitlement_id = ? allow_wallet_overage: has_active_daily_quota && allow_wallet_overage, })) } + + async fn reset_user_daily_quota( + &self, + user_id: &str, + entitlement_id: &str, + min_remaining_secs: u64, + penalty_secs: u64, + ) -> Result, DataLayerError> { + let min_remaining_secs = i64::try_from(min_remaining_secs) + .map_err(|_| DataLayerError::InvalidInput("min remaining overflow".to_string()))?; + let penalty_secs = i64::try_from(penalty_secs) + .map_err(|_| DataLayerError::InvalidInput("penalty overflow".to_string()))?; + let now_unix_secs = current_unix_secs_i64(); + let now = now_unix_secs.max(0) as u64; + let mut tx = self.pool.begin().await.map_sql_err()?; + let row = sqlx::query( + r#" +SELECT + id, user_id, plan_id, payment_order_id, status, + starts_at AS starts_at_unix_secs, expires_at AS expires_at_unix_secs, + entitlements_snapshot, created_at AS created_at_unix_secs, + updated_at AS updated_at_unix_secs +FROM user_plan_entitlements +WHERE id = ? + AND user_id = ? +LIMIT 1 + "#, + ) + .bind(entitlement_id) + .bind(user_id) + .fetch_optional(&mut *tx) + .await + .map_sql_err()?; + let Some(row) = row else { + return Ok(AdminBillingMutationOutcome::NotFound); + }; + let record = map_user_plan_entitlement_sqlite(&row)?; + if record.status != "active" + || record.starts_at_unix_secs > now + || record.expires_at_unix_secs <= now + { + return Ok(AdminBillingMutationOutcome::Invalid( + "权益未生效或已过期".to_string(), + )); + } + if record.expires_at_unix_secs.saturating_sub(now) < min_remaining_secs as u64 { + return Ok(AdminBillingMutationOutcome::Invalid( + "剩余有效期不足 72 小时,不能自助重置".to_string(), + )); + } + if !entitlement_has_daily_quota(&record.entitlements_snapshot) { + return Ok(AdminBillingMutationOutcome::Invalid( + "权益不包含每日额度".to_string(), + )); + } + if !entitlement_allows_self_service_daily_quota_reset(&record.entitlements_snapshot) { + return Ok(AdminBillingMutationOutcome::Invalid( + "该套餐未开放自助重置每日额度".to_string(), + )); + } + + let grants = daily_quota_grants_from_entitlement( + &record.id, + record.starts_at_unix_secs, + &record.entitlements_snapshot, + chrono::Utc::now(), + )?; + if grants.is_empty() { + return Ok(AdminBillingMutationOutcome::Invalid( + "权益不包含可用每日额度".to_string(), + )); + } + for grant in &grants { + let current_used = sqlx::query_scalar::<_, f64>( + r#" +SELECT CAST(COALESCE(SUM(amount_usd), 0) AS REAL) +FROM entitlement_usage_ledgers +WHERE user_entitlement_id = ? + AND usage_date = ? + "#, + ) + .bind(&grant.entitlement_id) + .bind(&grant.usage_date) + .fetch_one(&mut *tx) + .await + .map_sql_err()?; + let used_to_reset = current_used.max(0.0); + if used_to_reset <= 0.000_000_01 { + continue; + } + let balance_before = (grant.effective_limit_usd - current_used).max(0.0); + let balance_after = (balance_before + used_to_reset).min(grant.effective_limit_usd); + sqlx::query( + r#" +INSERT INTO entitlement_usage_ledgers ( + id, user_entitlement_id, user_id, request_id, amount_usd, + balance_before, balance_after, usage_date, created_at +) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + "#, + ) + .bind(uuid::Uuid::new_v4().to_string()) + .bind(&grant.entitlement_id) + .bind(user_id) + .bind(format!( + "self_service_daily_quota_reset:{}", + uuid::Uuid::new_v4() + )) + .bind(-used_to_reset) + .bind(balance_before) + .bind(balance_after) + .bind(&grant.usage_date) + .bind(now_unix_secs) + .execute(&mut *tx) + .await + .map_sql_err()?; + } + let next_expires_at = (record.expires_at_unix_secs as i64 - penalty_secs).max(0); + let result = sqlx::query( + r#" +UPDATE user_plan_entitlements +SET expires_at = ?, + updated_at = ? +WHERE id = ? + AND user_id = ? + "#, + ) + .bind(next_expires_at) + .bind(now_unix_secs) + .bind(entitlement_id) + .bind(user_id) + .execute(&mut *tx) + .await + .map_sql_err()?; + if result.rows_affected() == 0 { + return Ok(AdminBillingMutationOutcome::NotFound); + } + let row = sqlx::query( + r#" +SELECT + id, user_id, plan_id, payment_order_id, status, + starts_at AS starts_at_unix_secs, expires_at AS expires_at_unix_secs, + entitlements_snapshot, created_at AS created_at_unix_secs, + updated_at AS updated_at_unix_secs +FROM user_plan_entitlements +WHERE id = ? +LIMIT 1 + "#, + ) + .bind(entitlement_id) + .fetch_optional(&mut *tx) + .await + .map_sql_err()?; + tx.commit().await.map_sql_err()?; + match row + .as_ref() + .map(map_user_plan_entitlement_sqlite) + .transpose()? + { + Some(record) => Ok(AdminBillingMutationOutcome::Applied(record)), + None => Ok(AdminBillingMutationOutcome::NotFound), + } + } } const BILLING_PLAN_INSERT_SQLITE: &str = r#" @@ -1151,26 +1320,44 @@ fn json_to_string(value: &serde_json::Value) -> Result { #[derive(Debug)] struct DailyQuotaGrant { entitlement_id: String, - daily_quota_usd: f64, usage_date: String, + usage_dates: Vec, allow_wallet_overage: bool, + effective_limit_usd: f64, } -fn daily_quota_usage_date( +fn daily_quota_usage_dates( reset_timezone: Option<&str>, + starts_at_unix_secs: u64, now: chrono::DateTime, -) -> Result { + carry_over_days: u64, +) -> Result, DataLayerError> { let timezone = reset_timezone .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("Asia/Shanghai") .parse::() .map_err(|err| DataLayerError::InvalidInput(format!("invalid reset_timezone: {err}")))?; - Ok(now.with_timezone(&timezone).date_naive().to_string()) + let current_date = now.with_timezone(&timezone).date_naive(); + let starts_at = chrono::DateTime::from_timestamp(starts_at_unix_secs as i64, 0) + .unwrap_or(now) + .with_timezone(&timezone) + .date_naive(); + let first_date = (current_date - chrono::Duration::days(carry_over_days as i64)).max(starts_at); + let mut dates = Vec::new(); + let mut date = first_date; + while date <= current_date { + dates.push(date.to_string()); + date = date + .succ_opt() + .ok_or_else(|| DataLayerError::InvalidInput("daily quota date overflow".to_string()))?; + } + Ok(dates) } fn daily_quota_grants_from_entitlement( entitlement_id: &str, + starts_at_unix_secs: u64, entitlements: &serde_json::Value, now: chrono::DateTime, ) -> Result, DataLayerError> { @@ -1189,23 +1376,70 @@ fn daily_quota_grants_from_entitlement( if !daily_quota_usd.is_finite() || daily_quota_usd <= 0.0 { continue; } + let carry_over = item + .get("carry_over") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let carry_over_days = if carry_over { + item.get("carry_over_days") + .and_then(serde_json::Value::as_u64) + .unwrap_or(1) + .clamp(1, 30) + } else { + 0 + }; + let usage_dates = daily_quota_usage_dates( + item.get("reset_timezone") + .and_then(serde_json::Value::as_str), + starts_at_unix_secs, + now, + carry_over_days, + )?; + let usage_date = usage_dates + .last() + .cloned() + .unwrap_or_else(|| now.date_naive().to_string()); + let window_limit_usd = daily_quota_usd * usage_dates.len() as f64; + let multiplier_limit_usd = daily_quota_usd + * item + .get("carry_over_limit_multiplier") + .and_then(serde_json::Value::as_f64) + .unwrap_or(usage_dates.len() as f64) + .clamp(1.0, usage_dates.len() as f64); grants.push(DailyQuotaGrant { entitlement_id: entitlement_id.to_string(), - daily_quota_usd, - usage_date: daily_quota_usage_date( - item.get("reset_timezone") - .and_then(serde_json::Value::as_str), - now, - )?, + usage_date, + usage_dates, allow_wallet_overage: item .get("allow_wallet_overage") .and_then(serde_json::Value::as_bool) .unwrap_or(false), + effective_limit_usd: window_limit_usd.min(multiplier_limit_usd), }); } Ok(grants) } +fn entitlement_has_daily_quota(entitlements: &serde_json::Value) -> bool { + entitlements.as_array().is_some_and(|items| { + items + .iter() + .any(|item| item.get("type").and_then(serde_json::Value::as_str) == Some("daily_quota")) + }) +} + +fn entitlement_allows_self_service_daily_quota_reset(entitlements: &serde_json::Value) -> bool { + entitlements.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 read_count_sqlite(row: &SqliteRow) -> Result { Ok(row.try_get::("total").map_sql_err()?.max(0) as u64) } @@ -1399,13 +1633,48 @@ fn parse_required_json(raw: String) -> Result mod tests { use serde_json::json; - use super::SqliteBillingReadRepository; + use super::{daily_quota_grants_from_entitlement, SqliteBillingReadRepository}; use crate::lifecycle::migrate::run_sqlite_migrations; use crate::repository::billing::{ AdminBillingCollectorWriteInput, AdminBillingMutationOutcome, AdminBillingRuleWriteInput, BillingPlanWriteInput, BillingReadRepository, }; + #[test] + fn daily_quota_grants_apply_carry_over_window_and_multiplier() { + let now = chrono::DateTime::parse_from_rfc3339("2026-05-29T04:00:00Z") + .expect("now should parse") + .with_timezone(&chrono::Utc); + let starts_at_unix_secs = chrono::DateTime::parse_from_rfc3339("2026-05-27T00:00:00+08:00") + .expect("starts_at should parse") + .timestamp() + .max(0) as u64; + let entitlements = json!([{ + "type": "daily_quota", + "daily_quota_usd": 10.0, + "reset_timezone": "Asia/Shanghai", + "carry_over": true, + "carry_over_days": 5, + "carry_over_limit_multiplier": 2.5, + "allow_wallet_overage": false + }]); + + let grants = daily_quota_grants_from_entitlement( + "entitlement-carry", + starts_at_unix_secs, + &entitlements, + now, + ) + .expect("daily quota grant should build"); + + assert_eq!(grants.len(), 1); + assert_eq!( + grants[0].usage_dates, + vec!["2026-05-27", "2026-05-28", "2026-05-29"] + ); + assert_eq!(grants[0].effective_limit_usd, 25.0); + } + #[tokio::test] async fn sqlite_repository_reads_billing_model_context() { let pool = sqlx::sqlite::SqlitePoolOptions::new() @@ -1646,6 +1915,182 @@ VALUES ('order-1', 'order-no-1', 'wallet-1', 0, 'epay', 'plan_purchase', .is_some()); } + #[tokio::test] + async fn sqlite_repository_resets_self_service_daily_quota_usage() { + let pool = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("sqlite pool should connect"); + run_sqlite_migrations(&pool) + .await + .expect("sqlite migrations should run"); + + let now = chrono::Utc::now(); + let now_unix_secs = now.timestamp().max(0); + let today = now.date_naive().to_string(); + let starts_at = (now - chrono::Duration::hours(2)).timestamp().max(0); + let expires_at = (now + chrono::Duration::hours(100)).timestamp().max(0); + + sqlx::query( + r#" +INSERT INTO users (id, username, email, auth_source, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, ?) + "#, + ) + .bind("user-1") + .bind("user-1") + .bind("user-1@example.com") + .bind("local") + .bind(now_unix_secs) + .bind(now_unix_secs) + .execute(&pool) + .await + .expect("user should seed"); + + sqlx::query( + r#" +INSERT INTO billing_plans ( + id, title, description, price_amount, price_currency, duration_unit, + duration_value, enabled, sort_order, max_active_per_user, purchase_limit_scope, + entitlements_json, created_at, updated_at +) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + "#, + ) + .bind("plan-1") + .bind("Daily") + .bind(Option::::None) + .bind(10.0) + .bind("CNY") + .bind("month") + .bind(1_i64) + .bind(true) + .bind(0_i64) + .bind(1_i64) + .bind("active_period") + .bind( + json!([{ + "type": "daily_quota", + "daily_quota_usd": 10.0, + "reset_timezone": "UTC", + "self_service_daily_quota_reset": true + }]) + .to_string(), + ) + .bind(now_unix_secs) + .bind(now_unix_secs) + .execute(&pool) + .await + .expect("plan should seed"); + + sqlx::query( + r#" +INSERT INTO payment_orders ( + id, order_no, wallet_id, user_id, amount_usd, payment_method, order_kind, + product_id, fulfillment_status, status, created_at +) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + "#, + ) + .bind("order-1") + .bind("order-no-1") + .bind("wallet-1") + .bind("user-1") + .bind(10.0) + .bind("epay") + .bind("plan_purchase") + .bind("plan-1") + .bind("fulfilled") + .bind("paid") + .bind(now_unix_secs) + .execute(&pool) + .await + .expect("payment order should seed"); + + sqlx::query( + r#" +INSERT INTO user_plan_entitlements ( + id, user_id, plan_id, payment_order_id, status, starts_at, expires_at, + entitlements_snapshot, created_at, updated_at +) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + "#, + ) + .bind("entitlement-1") + .bind("user-1") + .bind("plan-1") + .bind("order-1") + .bind("active") + .bind(starts_at) + .bind(expires_at) + .bind( + json!([{ + "type": "daily_quota", + "daily_quota_usd": 10.0, + "reset_timezone": "UTC", + "self_service_daily_quota_reset": true + }]) + .to_string(), + ) + .bind(now_unix_secs) + .bind(now_unix_secs) + .execute(&pool) + .await + .expect("entitlement should seed"); + + sqlx::query( + r#" +INSERT INTO entitlement_usage_ledgers ( + id, user_entitlement_id, user_id, request_id, amount_usd, + balance_before, balance_after, usage_date, created_at +) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + "#, + ) + .bind("ledger-1") + .bind("entitlement-1") + .bind("user-1") + .bind("usage-1") + .bind(4.0) + .bind(10.0) + .bind(6.0) + .bind(&today) + .bind(now_unix_secs) + .execute(&pool) + .await + .expect("usage ledger should seed"); + + let repository = SqliteBillingReadRepository::new(pool.clone()); + let result = repository + .reset_user_daily_quota("user-1", "entitlement-1", 72 * 60 * 60, 24 * 60 * 60) + .await + .expect("reset should run"); + let updated = match result { + AdminBillingMutationOutcome::Applied(record) => record, + other => panic!("unexpected reset outcome: {other:?}"), + }; + assert_eq!( + updated.expires_at_unix_secs, + (expires_at as u64).saturating_sub(24 * 60 * 60) + ); + + let used = sqlx::query_scalar::<_, f64>( + r#" +SELECT CAST(COALESCE(SUM(amount_usd), 0) AS REAL) +FROM entitlement_usage_ledgers +WHERE user_entitlement_id = ? + AND usage_date = ? + "#, + ) + .bind("entitlement-1") + .bind(&today) + .fetch_one(&pool) + .await + .expect("usage sum should query"); + assert!((used - 0.0).abs() < 0.000_001); + } + async fn seed_billing_context(pool: &sqlx::SqlitePool) { sqlx::query( r#" diff --git a/crates/aether-data/src/repository/settlement/mysql.rs b/crates/aether-data/src/repository/settlement/mysql.rs index 3c215c2df..67f8091b9 100644 --- a/crates/aether-data/src/repository/settlement/mysql.rs +++ b/crates/aether-data/src/repository/settlement/mysql.rs @@ -146,26 +146,44 @@ struct DailyQuotaDebitResult { #[derive(Debug)] struct DailyQuotaGrant { entitlement_id: String, - daily_quota_usd: f64, usage_date: String, + usage_dates: Vec, allow_wallet_overage: bool, + effective_limit_usd: f64, } -fn daily_quota_usage_date( +fn daily_quota_usage_dates( reset_timezone: Option<&str>, + starts_at_unix_secs: u64, now: chrono::DateTime, -) -> Result { + carry_over_days: u64, +) -> Result, DataLayerError> { let timezone = reset_timezone .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("Asia/Shanghai") .parse::() .map_err(|err| DataLayerError::InvalidInput(format!("invalid reset_timezone: {err}")))?; - Ok(now.with_timezone(&timezone).date_naive().to_string()) + let current_date = now.with_timezone(&timezone).date_naive(); + let starts_at = chrono::DateTime::from_timestamp(starts_at_unix_secs as i64, 0) + .unwrap_or(now) + .with_timezone(&timezone) + .date_naive(); + let first_date = (current_date - chrono::Duration::days(carry_over_days as i64)).max(starts_at); + let mut dates = Vec::new(); + let mut date = first_date; + while date <= current_date { + dates.push(date.to_string()); + date = date + .succ_opt() + .ok_or_else(|| DataLayerError::InvalidInput("daily quota date overflow".to_string()))?; + } + Ok(dates) } fn daily_quota_grants_from_entitlement( entitlement_id: &str, + starts_at_unix_secs: u64, entitlements: &serde_json::Value, now: chrono::DateTime, ) -> Result, DataLayerError> { @@ -184,18 +202,45 @@ fn daily_quota_grants_from_entitlement( if !daily_quota_usd.is_finite() || daily_quota_usd <= 0.0 { continue; } + let carry_over = item + .get("carry_over") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let carry_over_days = if carry_over { + item.get("carry_over_days") + .and_then(serde_json::Value::as_u64) + .unwrap_or(1) + .clamp(1, 30) + } else { + 0 + }; + let usage_dates = daily_quota_usage_dates( + item.get("reset_timezone") + .and_then(serde_json::Value::as_str), + starts_at_unix_secs, + now, + carry_over_days, + )?; + let usage_date = usage_dates + .last() + .cloned() + .unwrap_or_else(|| now.date_naive().to_string()); + let window_limit_usd = daily_quota_usd * usage_dates.len() as f64; + let multiplier_limit_usd = daily_quota_usd + * item + .get("carry_over_limit_multiplier") + .and_then(serde_json::Value::as_f64) + .unwrap_or(usage_dates.len() as f64) + .clamp(1.0, usage_dates.len() as f64); grants.push(DailyQuotaGrant { entitlement_id: entitlement_id.to_string(), - daily_quota_usd, - usage_date: daily_quota_usage_date( - item.get("reset_timezone") - .and_then(serde_json::Value::as_str), - now, - )?, + usage_date, + usage_dates, allow_wallet_overage: item .get("allow_wallet_overage") .and_then(serde_json::Value::as_bool) .unwrap_or(false), + effective_limit_usd: window_limit_usd.min(multiplier_limit_usd), }); } Ok(grants) @@ -215,7 +260,7 @@ async fn consume_daily_quota_mysql( } let rows = sqlx::query( r#" -SELECT id, entitlements_snapshot +SELECT id, starts_at, entitlements_snapshot FROM user_plan_entitlements WHERE user_id = ? AND status = 'active' @@ -235,6 +280,7 @@ FOR UPDATE let mut grants = Vec::new(); for row in rows { let entitlement_id: String = row.try_get("id").map_sql_err()?; + let starts_at_unix_secs = row.try_get::("starts_at").map_sql_err()?.max(0) as u64; let entitlements_raw: String = row.try_get("entitlements_snapshot").map_sql_err()?; let entitlements = serde_json::from_str::(&entitlements_raw).map_err(|err| { @@ -244,6 +290,7 @@ FOR UPDATE })?; grants.extend(daily_quota_grants_from_entitlement( &entitlement_id, + starts_at_unix_secs, &entitlements, now, )?); @@ -257,20 +304,23 @@ FOR UPDATE let mut allow_wallet_overage = true; for grant in grants { allow_wallet_overage &= grant.allow_wallet_overage; - let used = sqlx::query_scalar::<_, f64>( - r#" + let mut used = 0.0; + for usage_date in &grant.usage_dates { + used += sqlx::query_scalar::<_, f64>( + r#" SELECT COALESCE(SUM(amount_usd), 0) FROM entitlement_usage_ledgers WHERE user_entitlement_id = ? AND usage_date = ? "#, - ) - .bind(&grant.entitlement_id) - .bind(&grant.usage_date) - .fetch_one(&mut **tx) - .await - .map_sql_err()?; - let remaining = (grant.daily_quota_usd - used).max(0.0); + ) + .bind(&grant.entitlement_id) + .bind(usage_date) + .fetch_one(&mut **tx) + .await + .map_sql_err()?; + } + let remaining = (grant.effective_limit_usd - used).max(0.0); total_remaining += remaining; grants_with_remaining.push((grant, remaining)); } diff --git a/crates/aether-data/src/repository/settlement/postgres.rs b/crates/aether-data/src/repository/settlement/postgres.rs index e5764b987..a68bd8496 100644 --- a/crates/aether-data/src/repository/settlement/postgres.rs +++ b/crates/aether-data/src/repository/settlement/postgres.rs @@ -250,26 +250,44 @@ struct DailyQuotaDebitResult { #[derive(Debug)] struct DailyQuotaGrant { entitlement_id: String, - daily_quota_usd: f64, usage_date: String, + usage_dates: Vec, allow_wallet_overage: bool, + effective_limit_usd: f64, } -fn daily_quota_usage_date( +fn daily_quota_usage_dates( reset_timezone: Option<&str>, + starts_at_unix_secs: u64, now: chrono::DateTime, -) -> Result { + carry_over_days: u64, +) -> Result, DataLayerError> { let timezone = reset_timezone .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("Asia/Shanghai") .parse::() .map_err(|err| DataLayerError::InvalidInput(format!("invalid reset_timezone: {err}")))?; - Ok(now.with_timezone(&timezone).date_naive().to_string()) + let current_date = now.with_timezone(&timezone).date_naive(); + let starts_at = chrono::DateTime::from_timestamp(starts_at_unix_secs as i64, 0) + .unwrap_or(now) + .with_timezone(&timezone) + .date_naive(); + let first_date = (current_date - chrono::Duration::days(carry_over_days as i64)).max(starts_at); + let mut dates = Vec::new(); + let mut date = first_date; + while date <= current_date { + dates.push(date.to_string()); + date = date + .succ_opt() + .ok_or_else(|| DataLayerError::InvalidInput("daily quota date overflow".to_string()))?; + } + Ok(dates) } fn daily_quota_grants_from_entitlement( entitlement_id: &str, + starts_at_unix_secs: u64, entitlements: &serde_json::Value, now: chrono::DateTime, ) -> Result, DataLayerError> { @@ -288,19 +306,45 @@ fn daily_quota_grants_from_entitlement( if !daily_quota_usd.is_finite() || daily_quota_usd <= 0.0 { continue; } - let usage_date = daily_quota_usage_date( + let carry_over = item + .get("carry_over") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let carry_over_days = if carry_over { + item.get("carry_over_days") + .and_then(serde_json::Value::as_u64) + .unwrap_or(1) + .clamp(1, 30) + } else { + 0 + }; + let usage_dates = daily_quota_usage_dates( item.get("reset_timezone") .and_then(serde_json::Value::as_str), + starts_at_unix_secs, now, + carry_over_days, )?; + let usage_date = usage_dates + .last() + .cloned() + .unwrap_or_else(|| now.date_naive().to_string()); + let window_limit_usd = daily_quota_usd * usage_dates.len() as f64; + let multiplier_limit_usd = daily_quota_usd + * item + .get("carry_over_limit_multiplier") + .and_then(serde_json::Value::as_f64) + .unwrap_or(usage_dates.len() as f64) + .clamp(1.0, usage_dates.len() as f64); grants.push(DailyQuotaGrant { entitlement_id: entitlement_id.to_string(), - daily_quota_usd, usage_date, + usage_dates, allow_wallet_overage: item .get("allow_wallet_overage") .and_then(serde_json::Value::as_bool) .unwrap_or(false), + effective_limit_usd: window_limit_usd.min(multiplier_limit_usd), }); } Ok(grants) @@ -320,7 +364,7 @@ async fn consume_daily_quota_postgres( let now = chrono::Utc::now(); let entitlement_rows = sqlx::query( r#" -SELECT id, entitlements_snapshot +SELECT id, CAST(EXTRACT(EPOCH FROM starts_at) AS BIGINT) AS starts_at_unix_secs, entitlements_snapshot FROM user_plan_entitlements WHERE user_id = $1 AND status = 'active' @@ -337,10 +381,15 @@ FOR UPDATE let mut grants = Vec::new(); for row in entitlement_rows { let entitlement_id: String = row.try_get("id").map_postgres_err()?; + let starts_at_unix_secs = row + .try_get::("starts_at_unix_secs") + .map_postgres_err()? + .max(0) as u64; let entitlements: serde_json::Value = row.try_get("entitlements_snapshot").map_postgres_err()?; grants.extend(daily_quota_grants_from_entitlement( &entitlement_id, + starts_at_unix_secs, &entitlements, now, )?); @@ -354,21 +403,24 @@ FOR UPDATE let mut allow_wallet_overage = true; for grant in grants { allow_wallet_overage &= grant.allow_wallet_overage; - let used = sqlx::query_scalar::<_, Option>( - r#" + let mut used = 0.0; + for usage_date in &grant.usage_dates { + used += sqlx::query_scalar::<_, Option>( + r#" SELECT CAST(COALESCE(SUM(amount_usd), 0) AS DOUBLE PRECISION) FROM entitlement_usage_ledgers WHERE user_entitlement_id = $1 AND usage_date = $2 "#, - ) - .bind(&grant.entitlement_id) - .bind(&grant.usage_date) - .fetch_one(&mut **tx) - .await - .map_postgres_err()? - .unwrap_or(0.0); - let remaining = (grant.daily_quota_usd - used).max(0.0); + ) + .bind(&grant.entitlement_id) + .bind(usage_date) + .fetch_one(&mut **tx) + .await + .map_postgres_err()? + .unwrap_or(0.0); + } + let remaining = (grant.effective_limit_usd - used).max(0.0); total_remaining += remaining; grants_with_remaining.push((grant, remaining)); } diff --git a/crates/aether-data/src/repository/settlement/sqlite.rs b/crates/aether-data/src/repository/settlement/sqlite.rs index abd371663..d963cf012 100644 --- a/crates/aether-data/src/repository/settlement/sqlite.rs +++ b/crates/aether-data/src/repository/settlement/sqlite.rs @@ -160,26 +160,44 @@ struct DailyQuotaDebitResult { #[derive(Debug)] struct DailyQuotaGrant { entitlement_id: String, - daily_quota_usd: f64, usage_date: String, + usage_dates: Vec, allow_wallet_overage: bool, + effective_limit_usd: f64, } -fn daily_quota_usage_date( +fn daily_quota_usage_dates( reset_timezone: Option<&str>, + starts_at_unix_secs: u64, now: chrono::DateTime, -) -> Result { + carry_over_days: u64, +) -> Result, DataLayerError> { let timezone = reset_timezone .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("Asia/Shanghai") .parse::() .map_err(|err| DataLayerError::InvalidInput(format!("invalid reset_timezone: {err}")))?; - Ok(now.with_timezone(&timezone).date_naive().to_string()) + let current_date = now.with_timezone(&timezone).date_naive(); + let starts_at = chrono::DateTime::from_timestamp(starts_at_unix_secs as i64, 0) + .unwrap_or(now) + .with_timezone(&timezone) + .date_naive(); + let first_date = (current_date - chrono::Duration::days(carry_over_days as i64)).max(starts_at); + let mut dates = Vec::new(); + let mut date = first_date; + while date <= current_date { + dates.push(date.to_string()); + date = date + .succ_opt() + .ok_or_else(|| DataLayerError::InvalidInput("daily quota date overflow".to_string()))?; + } + Ok(dates) } fn daily_quota_grants_from_entitlement( entitlement_id: &str, + starts_at_unix_secs: u64, entitlements: &serde_json::Value, now: chrono::DateTime, ) -> Result, DataLayerError> { @@ -198,18 +216,45 @@ fn daily_quota_grants_from_entitlement( if !daily_quota_usd.is_finite() || daily_quota_usd <= 0.0 { continue; } + let carry_over = item + .get("carry_over") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let carry_over_days = if carry_over { + item.get("carry_over_days") + .and_then(serde_json::Value::as_u64) + .unwrap_or(1) + .clamp(1, 30) + } else { + 0 + }; + let usage_dates = daily_quota_usage_dates( + item.get("reset_timezone") + .and_then(serde_json::Value::as_str), + starts_at_unix_secs, + now, + carry_over_days, + )?; + let usage_date = usage_dates + .last() + .cloned() + .unwrap_or_else(|| now.date_naive().to_string()); + let window_limit_usd = daily_quota_usd * usage_dates.len() as f64; + let multiplier_limit_usd = daily_quota_usd + * item + .get("carry_over_limit_multiplier") + .and_then(serde_json::Value::as_f64) + .unwrap_or(usage_dates.len() as f64) + .clamp(1.0, usage_dates.len() as f64); grants.push(DailyQuotaGrant { entitlement_id: entitlement_id.to_string(), - daily_quota_usd, - usage_date: daily_quota_usage_date( - item.get("reset_timezone") - .and_then(serde_json::Value::as_str), - now, - )?, + usage_date, + usage_dates, allow_wallet_overage: item .get("allow_wallet_overage") .and_then(serde_json::Value::as_bool) .unwrap_or(false), + effective_limit_usd: window_limit_usd.min(multiplier_limit_usd), }); } Ok(grants) @@ -229,7 +274,7 @@ async fn consume_daily_quota_sqlite( } let rows = sqlx::query( r#" -SELECT id, entitlements_snapshot +SELECT id, starts_at, entitlements_snapshot FROM user_plan_entitlements WHERE user_id = ? AND status = 'active' @@ -248,6 +293,7 @@ ORDER BY expires_at ASC, created_at ASC, id ASC let mut grants = Vec::new(); for row in rows { let entitlement_id: String = row.try_get("id").map_sql_err()?; + let starts_at_unix_secs = row.try_get::("starts_at").map_sql_err()?.max(0) as u64; let entitlements_raw: String = row.try_get("entitlements_snapshot").map_sql_err()?; let entitlements = serde_json::from_str::(&entitlements_raw).map_err(|err| { @@ -257,6 +303,7 @@ ORDER BY expires_at ASC, created_at ASC, id ASC })?; grants.extend(daily_quota_grants_from_entitlement( &entitlement_id, + starts_at_unix_secs, &entitlements, now, )?); @@ -270,20 +317,23 @@ ORDER BY expires_at ASC, created_at ASC, id ASC let mut allow_wallet_overage = true; for grant in grants { allow_wallet_overage &= grant.allow_wallet_overage; - let used = sqlx::query_scalar::<_, f64>( - r#" + let mut used = 0.0; + for usage_date in &grant.usage_dates { + used += sqlx::query_scalar::<_, f64>( + r#" SELECT CAST(COALESCE(SUM(amount_usd), 0) AS REAL) FROM entitlement_usage_ledgers WHERE user_entitlement_id = ? AND usage_date = ? "#, - ) - .bind(&grant.entitlement_id) - .bind(&grant.usage_date) - .fetch_one(&mut **tx) - .await - .map_sql_err()?; - let remaining = (grant.daily_quota_usd - used).max(0.0); + ) + .bind(&grant.entitlement_id) + .bind(usage_date) + .fetch_one(&mut **tx) + .await + .map_sql_err()?; + } + let remaining = (grant.effective_limit_usd - used).max(0.0); total_remaining += remaining; grants_with_remaining.push((grant, remaining)); } @@ -692,11 +742,47 @@ WHERE id = ? #[cfg(test)] mod tests { - use super::SqliteSettlementRepository; + use super::{daily_quota_grants_from_entitlement, SqliteSettlementRepository}; use crate::lifecycle::migrate::run_sqlite_migrations; use crate::repository::settlement::{SettlementWriteRepository, UsageSettlementInput}; use sqlx::Row; + #[test] + fn daily_quota_grants_record_new_usage_on_current_day_with_carry_over() { + let now = chrono::DateTime::parse_from_rfc3339("2026-05-29T04:00:00Z") + .expect("now should parse") + .with_timezone(&chrono::Utc); + let starts_at_unix_secs = chrono::DateTime::parse_from_rfc3339("2026-05-27T00:00:00+08:00") + .expect("starts_at should parse") + .timestamp() + .max(0) as u64; + let entitlements = serde_json::json!([{ + "type": "daily_quota", + "daily_quota_usd": 10.0, + "reset_timezone": "Asia/Shanghai", + "carry_over": true, + "carry_over_days": 2, + "carry_over_limit_multiplier": 2.0, + "allow_wallet_overage": false + }]); + + let grants = daily_quota_grants_from_entitlement( + "entitlement-carry", + starts_at_unix_secs, + &entitlements, + now, + ) + .expect("daily quota grant should build"); + + assert_eq!(grants.len(), 1); + assert_eq!( + grants[0].usage_dates, + vec!["2026-05-27", "2026-05-28", "2026-05-29"] + ); + assert_eq!(grants[0].usage_date, "2026-05-29"); + assert_eq!(grants[0].effective_limit_usd, 20.0); + } + #[tokio::test] async fn sqlite_repository_settles_usage_once() { let pool = sqlx::sqlite::SqlitePoolOptions::new() diff --git a/frontend/src/api/billing.ts b/frontend/src/api/billing.ts index 002c890c4..373a33f3b 100644 --- a/frontend/src/api/billing.ts +++ b/frontend/src/api/billing.ts @@ -64,7 +64,10 @@ export interface DailyQuotaEntitlement { daily_quota_usd: number reset_timezone?: string carry_over?: boolean + carry_over_days?: number + carry_over_limit_multiplier?: number allow_wallet_overage?: boolean + self_service_daily_quota_reset?: boolean } export interface MembershipGroupEntitlement { @@ -256,4 +259,12 @@ export const billingApi = { const response = await apiClient.get('/api/billing/entitlements') return response.data }, + + async resetDailyQuota(entitlementId: string): Promise { + const response = await apiClient.post( + `/api/billing/entitlements/${entitlementId}/daily-quota-reset`, + {} + ) + return response.data + }, } diff --git a/frontend/src/api/providerOps.ts b/frontend/src/api/providerOps.ts index 2df16528e..518fe783b 100644 --- a/frontend/src/api/providerOps.ts +++ b/frontend/src/api/providerOps.ts @@ -87,6 +87,13 @@ export interface BalanceInfo { expires_at: string | null currency: string extra: Record & { + balance?: number | string | null + normal_balance?: number | string | null + pay_as_you_go_balance?: number | string | null + subscription_balance?: number | string | null + subscription_available?: number | string | null + points?: number | string | null + charity_balance?: number | string | null // Anyrouter 签到信息 checkin_success?: boolean | null // true=成功, false=失败, null=已签到/跳过 checkin_message?: string diff --git a/frontend/src/components/common/HelpPopover.vue b/frontend/src/components/common/HelpPopover.vue new file mode 100644 index 000000000..c1151a606 --- /dev/null +++ b/frontend/src/components/common/HelpPopover.vue @@ -0,0 +1,48 @@ + + + diff --git a/frontend/src/components/common/index.ts b/frontend/src/components/common/index.ts index 03d6e7bee..fb6462739 100644 --- a/frontend/src/components/common/index.ts +++ b/frontend/src/components/common/index.ts @@ -10,5 +10,6 @@ export { default as LoadingState } from './LoadingState.vue' export { default as StripePaymentDialog } from './StripePaymentDialog.vue' // 表单组件 +export { default as HelpPopover } from './HelpPopover.vue' export { default as MultiSelect } from './MultiSelect.vue' export { default as TimeRangePicker } from './TimeRangePicker.vue' diff --git a/frontend/src/components/ui/pagination.vue b/frontend/src/components/ui/pagination.vue index f843a2d60..6b8a86fff 100644 --- a/frontend/src/components/ui/pagination.vue +++ b/frontend/src/components/ui/pagination.vue @@ -105,6 +105,9 @@ const jumpPageInput = ref('') const totalPages = computed(() => Math.ceil(props.total / props.pageSize)) const recordRange = computed(() => { + if (props.total <= 0) { + return { start: 0, end: 0 } + } const start = (props.current - 1) * props.pageSize + 1 const end = Math.min(props.current * props.pageSize, props.total) return { start, end } diff --git a/frontend/src/features/providers/components/ProviderBalanceCell.vue b/frontend/src/features/providers/components/ProviderBalanceCell.vue index 16da6307b..0c6fe122a 100644 --- a/frontend/src/features/providers/components/ProviderBalanceCell.vue +++ b/frontend/src/features/providers/components/ProviderBalanceCell.vue @@ -10,31 +10,29 @@
- - +
+ + {{ formatBalanceDisplay(getProviderBalance(provider.id)) }} +
boolean getProviderBalance: (providerId: string) => { available: number | null; currency: string } | null - getProviderBalanceBreakdown: (providerId: string) => { balance: number; points: number; currency: string } | null + getProviderBalanceBreakdown: (providerId: string) => ProviderBalanceBreakdown | null getProviderBalanceError: (providerId: string) => { status: string; message: string } | null getProviderCheckin: (providerId: string) => { success: boolean | null; message: string } | null getProviderCookieExpired: (providerId: string) => { expired: boolean; message: string } | null @@ -147,4 +146,9 @@ defineProps<{ formatResetCountdown: (resetsAt: number) => string getQuotaUsedColorClass: (provider: ProviderWithEndpointsSummary) => string }>() + +function formatBalanceAmount(amount: number, currency: string): string { + const symbol = currency === 'USD' ? '$' : `${currency} ` + return `${symbol}${amount.toFixed(2)}` +} diff --git a/frontend/src/features/providers/components/ProviderMobileCard.vue b/frontend/src/features/providers/components/ProviderMobileCard.vue index 1bc7f5aad..3eb4791f4 100644 --- a/frontend/src/features/providers/components/ProviderMobileCard.vue +++ b/frontend/src/features/providers/components/ProviderMobileCard.vue @@ -134,29 +134,43 @@ 加载中... - - 余额 {{ formatBalanceDisplay(getProviderBalance(provider.id)) }} - - 签到 Cookie 已失效 - - 已签到 - 签到失败 - + +
+ 余额 {{ formatBalanceDisplay(getProviderBalance(provider.id)) }} +
+
+ + 签到 Cookie 已失效 + + 已签到 + 签到失败 +
+
boolean getProviderBalance: (providerId: string) => { available: number | null; currency: string } | null + getProviderBalanceBreakdown: (providerId: string) => ProviderBalanceBreakdown | null getProviderBalanceError: (providerId: string) => { status: string; message: string } | null getProviderCheckin: (providerId: string) => { success: boolean | null; message: string } | null getProviderCookieExpired: (providerId: string) => { expired: boolean; message: string } | null @@ -254,6 +270,11 @@ const props = defineProps<{ getQuotaUsedColorClass: (provider: ProviderWithEndpointsSummary) => string }>() +function formatBalanceAmount(amount: number, currency: string): string { + const symbol = currency === 'USD' ? '$' : `${currency} ` + return `${symbol}${amount.toFixed(2)}` +} + const emit = defineEmits<{ 'viewDetail': [providerId: string] 'editProvider': [provider: ProviderWithEndpointsSummary] diff --git a/frontend/src/features/providers/components/ProviderTableRow.vue b/frontend/src/features/providers/components/ProviderTableRow.vue index 4264a5b6c..dc83be88f 100644 --- a/frontend/src/features/providers/components/ProviderTableRow.vue +++ b/frontend/src/features/providers/components/ProviderTableRow.vue @@ -211,6 +211,7 @@ import TableCell from '@/components/ui/table-cell.vue' import ProviderBalanceCell from './ProviderBalanceCell.vue' import { type ProviderWithEndpointsSummary, formatApiFormatShort } from '@/api/endpoints' import { sortEndpoints, isEndpointAvailable, getEndpointDotColor, getEndpointTooltip } from '@/features/providers/composables/useEndpointStatus' +import type { ProviderBalanceBreakdown } from '@/features/providers/composables/useProviderBalance' import type { BalanceExtraItem } from '@/features/providers/auth-templates' const props = defineProps<{ @@ -219,7 +220,7 @@ const props = defineProps<{ // Balance functions isBalanceLoading: (providerId: string) => boolean getProviderBalance: (providerId: string) => { available: number | null; currency: string } | null - getProviderBalanceBreakdown: (providerId: string) => { balance: number; points: number; currency: string } | null + getProviderBalanceBreakdown: (providerId: string) => ProviderBalanceBreakdown | null getProviderBalanceError: (providerId: string) => { status: string; message: string } | null getProviderCheckin: (providerId: string) => { success: boolean | null; message: string } | null getProviderCookieExpired: (providerId: string) => { expired: boolean; message: string } | null diff --git a/frontend/src/features/providers/composables/useProviderBalance.ts b/frontend/src/features/providers/composables/useProviderBalance.ts index e6ca31f1c..724325fc8 100644 --- a/frontend/src/features/providers/composables/useProviderBalance.ts +++ b/frontend/src/features/providers/composables/useProviderBalance.ts @@ -9,6 +9,17 @@ const MAX_BALANCE_RETRIES = 2 const PENDING_BALANCE_RETRY_BASE_DELAY_MS = 12_000 const PENDING_BALANCE_RETRY_MAX_DELAY_MS = 60_000 +export interface ProviderBalanceLine { + key: string + label: string + amount: number +} + +export interface ProviderBalanceBreakdown { + currency: string + lines: ProviderBalanceLine[] +} + export function useProviderBalance() { // 余额数据缓存 {providerId: ActionResultResponse} const balanceCache = ref>({}) @@ -150,6 +161,17 @@ export function useProviderBalance() { return true } + function numericExtra(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return value + } + if (typeof value === 'string') { + const parsed = Number(value.trim()) + return Number.isFinite(parsed) ? parsed : null + } + return null + } + // 获取 provider 的余额显示 function getProviderBalance(providerId: string): { available: number | null; currency: string } | null { const result = balanceCache.value[providerId] @@ -166,21 +188,47 @@ export function useProviderBalance() { } } - // 获取 provider 余额明细(balance + points 分开显示) - function getProviderBalanceBreakdown(providerId: string): { balance: number; points: number; currency: string } | null { + // 获取 provider 余额明细(余额 / 订阅 / 积分分开显示) + function getProviderBalanceBreakdown(providerId: string): ProviderBalanceBreakdown | null { const result = balanceCache.value[providerId] if (!result || (result.status !== 'success' && result.status !== 'auth_expired') || !result.data) { return null } const data = result.data as Record - const extra = data.extra - if (!extra || extra.balance === undefined || extra.points === undefined) { + const extra = data.extra as Record | undefined + if (!extra || typeof extra !== 'object') { + return null + } + const lines: ProviderBalanceLine[] = [] + const balance = numericExtra(extra.pay_as_you_go_balance) + ?? numericExtra(extra.normal_balance) + ?? numericExtra(extra.balance) + const subscriptionBalance = numericExtra(extra.subscription_balance) + ?? numericExtra(extra.subscription_available) + const points = numericExtra(extra.points) + const charityBalance = numericExtra(extra.charity_balance) + + if (balance !== null) { + lines.push({ key: 'balance', label: '余额', amount: balance }) + } + if (subscriptionBalance !== null) { + lines.push({ key: 'subscription', label: '订阅', amount: subscriptionBalance }) + } + if (points !== null) { + lines.push({ key: 'points', label: '积分', amount: points }) + } + if (charityBalance !== null) { + lines.push({ key: 'charity', label: '公益', amount: charityBalance }) + } + if (lines.length === 0) { return null } + const currency = typeof data.currency === 'string' && data.currency.trim() + ? data.currency + : 'USD' return { - balance: extra.balance, - points: extra.points, - currency: data.currency || 'USD', + currency, + lines, } } diff --git a/frontend/src/features/wallet/utils/__tests__/opsOverview.spec.ts b/frontend/src/features/wallet/utils/__tests__/opsOverview.spec.ts new file mode 100644 index 000000000..2214979bf --- /dev/null +++ b/frontend/src/features/wallet/utils/__tests__/opsOverview.spec.ts @@ -0,0 +1,225 @@ +import { describe, expect, it } from 'vitest' + +import { buildWalletOpsOverview } from '../opsOverview' +import type { AdminWallet } from '@/api/admin-wallets' +import type { RedeemCodeBatch } from '@/api/admin-payments' +import type { PaymentOrder, RefundRequest } from '@/api/wallet' + +function wallet(overrides: Partial = {}): AdminWallet { + return { + id: 'wallet-1', + user_id: 'user-1', + api_key_id: null, + owner_type: 'user', + owner_name: 'Alice', + balance: 100, + recharge_balance: 80, + gift_balance: 20, + refundable_balance: 80, + currency: 'USD', + status: 'active', + total_recharged: 120, + total_consumed: 10, + total_refunded: 0, + total_adjusted: 0, + created_at: '2026-05-29T00:00:00Z', + updated_at: '2026-05-29T00:00:00Z', + wallet_balance: 100, + package_balance: 10, + total_available_balance: 110, + daily_quota: { + has_active: true, + total_usd: 50, + used_usd: 20, + remaining_usd: 30, + allow_wallet_overage: true, + }, + ...overrides, + } +} + +function order(overrides: Partial = {}): PaymentOrder { + return { + id: 'order-1', + order_no: 'po_1', + wallet_id: 'wallet-1', + user_id: 'user-1', + amount_usd: 10, + pay_amount: 10, + pay_currency: 'USD', + exchange_rate: 1, + refunded_amount_usd: 0, + refundable_amount_usd: 0, + payment_method: 'stripe', + payment_provider: 'stripe', + payment_channel: 'card', + order_kind: 'wallet_recharge', + product_id: null, + product_snapshot: null, + fulfillment_status: 'pending', + fulfillment_error: null, + gateway_order_id: 'gw-1', + gateway_response: null, + status: 'pending', + created_at: '2026-05-29T00:00:00Z', + paid_at: null, + credited_at: null, + expires_at: '2026-05-29T00:20:00Z', + ...overrides, + } +} + +function refund(overrides: Partial = {}): RefundRequest { + return { + id: 'refund-1', + refund_no: 'rf_1', + payment_order_id: 'order-1', + source_type: 'payment_order', + source_id: 'order-1', + refund_mode: 'offline_payout', + amount_usd: 1, + status: 'pending_approval', + reason: null, + failure_reason: null, + gateway_refund_id: null, + payout_method: null, + payout_reference: null, + payout_proof: null, + created_at: '2026-05-29T00:00:00Z', + updated_at: '2026-05-29T00:00:00Z', + processed_at: null, + completed_at: null, + ...overrides, + } +} + +function redeemBatch(overrides: Partial = {}): RedeemCodeBatch { + return { + id: 'batch-1', + name: 'Campaign', + amount_usd: 1, + currency: 'USD', + balance_bucket: 'recharge', + total_count: 10, + redeemed_count: 3, + active_count: 7, + status: 'active', + description: null, + created_by: null, + expires_at: '2026-06-01T00:00:00Z', + created_at: '2026-05-29T00:00:00Z', + updated_at: '2026-05-29T00:00:00Z', + ...overrides, + } +} + +describe('wallet ops overview', () => { + it('aggregates wallet, order, refund, and redeem-code operating metrics', () => { + const overview = buildWalletOpsOverview({ + nowMs: Date.parse('2026-05-29T00:00:00Z'), + pendingExpiryWarningWindowMinutes: 15, + wallets: [ + wallet(), + wallet({ + id: 'wallet-2', + user_id: null, + api_key_id: 'key-1', + owner_type: 'api_key', + status: 'suspended', + balance: 5, + recharge_balance: 5, + gift_balance: 0, + package_balance: 0, + total_available_balance: 5, + daily_quota: null, + }), + ], + orders: [ + order({ + id: 'pending-expiring', + order_no: 'po_expiring', + amount_usd: 12, + expires_at: '2026-05-29T00:05:00Z', + }), + order({ + id: 'pending-missing-expiry', + order_no: 'po_missing', + amount_usd: 7, + expires_at: null, + }), + order({ id: 'paid-order', status: 'paid', amount_usd: 9 }), + order({ id: 'credited-order', status: 'credited', amount_usd: 20 }), + order({ id: 'expired-order', status: 'expired', amount_usd: 5 }), + ], + refunds: [ + refund({ amount_usd: 3, status: 'pending_approval' }), + refund({ id: 'refund-2', amount_usd: 2, status: 'approved' }), + refund({ id: 'refund-3', amount_usd: 4, status: 'processing' }), + refund({ id: 'refund-4', amount_usd: 6, status: 'succeeded' }), + ], + redeemBatches: [ + redeemBatch(), + redeemBatch({ + id: 'batch-2', + status: 'disabled', + amount_usd: 2, + total_count: 5, + redeemed_count: 1, + active_count: 0, + expires_at: null, + }), + redeemBatch({ + id: 'batch-3', + amount_usd: 5, + total_count: 10, + redeemed_count: 0, + active_count: 10, + expires_at: '2026-05-28T00:00:00Z', + }), + ], + }) + + expect(overview.walletCount).toBe(2) + expect(overview.activeWalletCount).toBe(1) + expect(overview.totalAvailableBalance).toBe(115) + expect(overview.totalPackageBalance).toBe(10) + expect(overview.activeDailyQuotaWalletCount).toBe(1) + expect(overview.dailyQuotaRemainingUsd).toBe(30) + + expect(overview.pendingOrderCount).toBe(2) + expect(overview.pendingOrderAmountUsd).toBe(19) + expect(overview.paidOrderCount).toBe(1) + expect(overview.creditedOrderAmountUsd).toBe(20) + expect(overview.expiredOrderCount).toBe(1) + expect(overview.pendingOrderAlertCount).toBe(2) + expect(overview.pendingOrderMissingExpiryCount).toBe(1) + expect(overview.pendingOrderExpiringSoonCount).toBe(1) + + expect(overview.pendingRefundCount).toBe(2) + expect(overview.processingRefundCount).toBe(1) + expect(overview.pendingRefundAmountUsd).toBe(5) + expect(overview.completedRefundAmountUsd).toBe(6) + + expect(overview.redeemBatchCount).toBe(3) + expect(overview.activeRedeemBatchCount).toBe(1) + expect(overview.expiringRedeemBatchCount).toBe(1) + expect(overview.activeRedeemCodeCount).toBe(7) + expect(overview.redeemedRedeemCodeCount).toBe(4) + expect(overview.redeemStockValueUsd).toBe(7) + expect(overview.redeemRedeemedValueUsd).toBe(5) + }) + + it('returns empty metrics for empty input', () => { + const overview = buildWalletOpsOverview({ + wallets: [], + orders: [], + refunds: [], + redeemBatches: [], + nowMs: Date.parse('2026-05-29T00:00:00Z'), + }) + + expect(overview.walletCount).toBe(0) + expect(overview.pendingOrderWarnings).toEqual([]) + expect(overview.redeemStockValueUsd).toBe(0) + }) +}) diff --git a/frontend/src/features/wallet/utils/opsOverview.ts b/frontend/src/features/wallet/utils/opsOverview.ts new file mode 100644 index 000000000..07126b652 --- /dev/null +++ b/frontend/src/features/wallet/utils/opsOverview.ts @@ -0,0 +1,249 @@ +import type { AdminWallet } from '@/api/admin-wallets' +import type { RedeemCodeBatch } from '@/api/admin-payments' +import type { PaymentOrder, RefundRequest } from '@/api/wallet' + +export type PendingOrderWarningReason = 'missing_expiry' | 'expiring_soon' + +export interface PendingOrderWarning { + id: string + order_no: string + amount_usd: number + payment_method: string + order_kind: string + created_at: string + expires_at: string | null + reason: PendingOrderWarningReason +} + +export interface WalletOpsOverviewInput { + wallets: AdminWallet[] + orders: PaymentOrder[] + refunds: RefundRequest[] + redeemBatches: RedeemCodeBatch[] + nowMs?: number + pendingExpiryWarningWindowMinutes?: number +} + +export interface WalletOpsOverview { + walletCount: number + activeWalletCount: number + userWalletCount: number + apiKeyWalletCount: number + totalAvailableBalance: number + totalRechargeBalance: number + totalGiftBalance: number + totalPackageBalance: number + activeDailyQuotaWalletCount: number + dailyQuotaTotalUsd: number + dailyQuotaUsedUsd: number + dailyQuotaRemainingUsd: number + pendingOrderCount: number + pendingOrderAmountUsd: number + paidOrderCount: number + paidOrderAmountUsd: number + creditedOrderCount: number + creditedOrderAmountUsd: number + expiredOrderCount: number + pendingOrderMissingExpiryCount: number + pendingOrderExpiringSoonCount: number + pendingOrderAlertCount: number + pendingRefundCount: number + processingRefundCount: number + pendingRefundAmountUsd: number + completedRefundAmountUsd: number + redeemBatchCount: number + activeRedeemBatchCount: number + expiringRedeemBatchCount: number + redeemCodeCount: number + activeRedeemCodeCount: number + redeemedRedeemCodeCount: number + disabledRedeemCodeCount: number + redeemStockValueUsd: number + redeemRedeemedValueUsd: number + pendingOrderWarnings: PendingOrderWarning[] +} + +const DEFAULT_PENDING_EXPIRY_WARNING_WINDOW_MINUTES = 15 + +function asFiniteNumber(value: unknown): number { + const parsed = Number(value ?? 0) + return Number.isFinite(parsed) ? parsed : 0 +} + +function parseDateMs(value: string | null | undefined): number | null { + if (!value) return null + const parsed = Date.parse(value) + return Number.isFinite(parsed) ? parsed : null +} + +function sumBy(items: T[], selector: (item: T) => number): number { + return items.reduce((total, item) => total + asFiniteNumber(selector(item)), 0) +} + +function isPendingOrder(order: PaymentOrder): boolean { + return order.status === 'pending' +} + +function isPaidOrder(order: PaymentOrder): boolean { + return order.status === 'paid' +} + +function isCreditedOrder(order: PaymentOrder): boolean { + return order.status === 'credited' +} + +function isExpiredOrder(order: PaymentOrder): boolean { + return order.status === 'expired' +} + +function isPendingRefund(refund: RefundRequest): boolean { + return refund.status === 'pending_approval' || refund.status === 'approved' +} + +function isProcessingRefund(refund: RefundRequest): boolean { + return refund.status === 'processing' +} + +function isCompletedRefund(refund: RefundRequest): boolean { + return refund.status === 'succeeded' +} + +function isActiveRedeemBatch(batch: RedeemCodeBatch, nowMs: number): boolean { + if (batch.status !== 'active') return false + const expiresAtMs = parseDateMs(batch.expires_at) + return expiresAtMs === null || expiresAtMs > nowMs +} + +function isExpiringSoonRedeemBatch(batch: RedeemCodeBatch, nowMs: number): boolean { + if (batch.status !== 'active') return false + const expiresAtMs = parseDateMs(batch.expires_at) + if (expiresAtMs === null) return false + return expiresAtMs > nowMs && expiresAtMs <= nowMs + 7 * 24 * 60 * 60 * 1000 +} + +export function buildWalletOpsOverview(input: WalletOpsOverviewInput): WalletOpsOverview { + const nowMs = input.nowMs ?? Date.now() + const pendingExpiryWindowMs = Math.max( + 1, + input.pendingExpiryWarningWindowMinutes ?? DEFAULT_PENDING_EXPIRY_WARNING_WINDOW_MINUTES, + ) * 60 * 1000 + + const wallets = input.wallets || [] + const orders = input.orders || [] + const refunds = input.refunds || [] + const redeemBatches = input.redeemBatches || [] + + const walletCount = wallets.length + const activeWalletCount = wallets.filter(wallet => wallet.status === 'active').length + const userWalletCount = wallets.filter(wallet => wallet.owner_type === 'user').length + const apiKeyWalletCount = wallets.filter(wallet => wallet.owner_type === 'api_key').length + const totalAvailableBalance = sumBy(wallets, wallet => wallet.total_available_balance ?? wallet.balance ?? 0) + const totalRechargeBalance = sumBy(wallets, wallet => wallet.recharge_balance ?? 0) + const totalGiftBalance = sumBy(wallets, wallet => wallet.gift_balance ?? 0) + const totalPackageBalance = sumBy(wallets, wallet => wallet.package_balance ?? 0) + const activeDailyQuotaWalletCount = wallets.filter(wallet => wallet.daily_quota?.has_active).length + const dailyQuotaTotalUsd = sumBy(wallets, wallet => wallet.daily_quota?.total_usd ?? 0) + const dailyQuotaUsedUsd = sumBy(wallets, wallet => wallet.daily_quota?.used_usd ?? 0) + const dailyQuotaRemainingUsd = sumBy(wallets, wallet => wallet.daily_quota?.remaining_usd ?? 0) + + const pendingOrders = orders.filter(isPendingOrder) + const paidOrders = orders.filter(isPaidOrder) + const creditedOrders = orders.filter(isCreditedOrder) + const expiredOrders = orders.filter(isExpiredOrder) + + const pendingOrderWarnings = pendingOrders.flatMap((order) => { + const expiresAtMs = parseDateMs(order.expires_at) + if (expiresAtMs === null) { + return [{ + id: order.id, + order_no: order.order_no, + amount_usd: asFiniteNumber(order.amount_usd), + payment_method: order.payment_method, + order_kind: order.order_kind || 'unknown', + created_at: order.created_at, + expires_at: null, + reason: 'missing_expiry' as const, + }] + } + if (expiresAtMs <= nowMs + pendingExpiryWindowMs) { + return [{ + id: order.id, + order_no: order.order_no, + amount_usd: asFiniteNumber(order.amount_usd), + payment_method: order.payment_method, + order_kind: order.order_kind || 'unknown', + created_at: order.created_at, + expires_at: order.expires_at ?? null, + reason: 'expiring_soon' as const, + }] + } + return [] + }) + + const pendingOrderCount = pendingOrders.length + const pendingOrderAmountUsd = sumBy(pendingOrders, order => order.amount_usd) + const paidOrderCount = paidOrders.length + const paidOrderAmountUsd = sumBy(paidOrders, order => order.amount_usd) + const creditedOrderCount = creditedOrders.length + const creditedOrderAmountUsd = sumBy(creditedOrders, order => order.amount_usd) + const expiredOrderCount = expiredOrders.length + const pendingOrderMissingExpiryCount = pendingOrderWarnings.filter(warning => warning.reason === 'missing_expiry').length + const pendingOrderExpiringSoonCount = pendingOrderWarnings.filter(warning => warning.reason === 'expiring_soon').length + const pendingOrderAlertCount = pendingOrderWarnings.length + + const pendingRefunds = refunds.filter(isPendingRefund) + const processingRefunds = refunds.filter(isProcessingRefund) + const completedRefunds = refunds.filter(isCompletedRefund) + const pendingRefundCount = pendingRefunds.length + const processingRefundCount = processingRefunds.length + const pendingRefundAmountUsd = sumBy(pendingRefunds, refund => refund.amount_usd) + const completedRefundAmountUsd = sumBy(completedRefunds, refund => refund.amount_usd) + + const activeRedeemBatches = redeemBatches.filter(batch => isActiveRedeemBatch(batch, nowMs)) + const expiringRedeemBatches = redeemBatches.filter(batch => isExpiringSoonRedeemBatch(batch, nowMs)) + const redeemCodeCount = redeemBatches.reduce((total, batch) => total + Math.max(0, Math.round(asFiniteNumber(batch.total_count))), 0) + const activeRedeemCodeCount = activeRedeemBatches.reduce((total, batch) => total + Math.max(0, Math.round(asFiniteNumber(batch.active_count))), 0) + const redeemedRedeemCodeCount = redeemBatches.reduce((total, batch) => total + Math.max(0, Math.round(asFiniteNumber(batch.redeemed_count))), 0) + const disabledRedeemCodeCount = Math.max(0, redeemCodeCount - activeRedeemCodeCount - redeemedRedeemCodeCount) + const redeemStockValueUsd = sumBy(activeRedeemBatches, batch => asFiniteNumber(batch.amount_usd) * asFiniteNumber(batch.active_count)) + const redeemRedeemedValueUsd = sumBy(redeemBatches, batch => asFiniteNumber(batch.amount_usd) * asFiniteNumber(batch.redeemed_count)) + + return { + walletCount, + activeWalletCount, + userWalletCount, + apiKeyWalletCount, + totalAvailableBalance, + totalRechargeBalance, + totalGiftBalance, + totalPackageBalance, + activeDailyQuotaWalletCount, + dailyQuotaTotalUsd, + dailyQuotaUsedUsd, + dailyQuotaRemainingUsd, + pendingOrderCount, + pendingOrderAmountUsd, + paidOrderCount, + paidOrderAmountUsd, + creditedOrderCount, + creditedOrderAmountUsd, + expiredOrderCount, + pendingOrderMissingExpiryCount, + pendingOrderExpiringSoonCount, + pendingOrderAlertCount, + pendingRefundCount, + processingRefundCount, + pendingRefundAmountUsd, + completedRefundAmountUsd, + redeemBatchCount: redeemBatches.length, + activeRedeemBatchCount: activeRedeemBatches.length, + expiringRedeemBatchCount: expiringRedeemBatches.length, + redeemCodeCount, + activeRedeemCodeCount, + redeemedRedeemCodeCount, + disabledRedeemCodeCount, + redeemStockValueUsd, + redeemRedeemedValueUsd, + pendingOrderWarnings, + } +} diff --git a/frontend/src/utils/featureSettings.ts b/frontend/src/utils/featureSettings.ts index 92f3f8087..cbc4a45ba 100644 --- a/frontend/src/utils/featureSettings.ts +++ b/frontend/src/utils/featureSettings.ts @@ -7,6 +7,12 @@ export interface NotificationPushServiceFeatureSettings { enabled: boolean } +export type BillingSourceMode = 'auto' | 'wallet' | 'package' + +export interface BillingSourceFeatureSettings { + mode: BillingSourceMode +} + export type FeatureSettingsMap = Record const DEFAULT_CHAT_PII_REDACTION_FEATURE_SETTINGS: ChatPiiRedactionFeatureSettings = { @@ -18,6 +24,10 @@ const DEFAULT_NOTIFICATION_PUSH_SERVICE_FEATURE_SETTINGS: NotificationPushServic enabled: false, } +const DEFAULT_BILLING_SOURCE_FEATURE_SETTINGS: BillingSourceFeatureSettings = { + mode: 'auto', +} + function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value) } @@ -84,3 +94,39 @@ export function mergeNotificationPushServiceFeatureSettings( } return Object.keys(settings).length > 0 ? settings : null } + +export function readBillingSourceFeatureSettings( + featureSettings: unknown, +): BillingSourceFeatureSettings { + const feature = isRecord(featureSettings) + ? featureSettings.billing_source + : null + if (!isRecord(feature)) { + return { ...DEFAULT_BILLING_SOURCE_FEATURE_SETTINGS } + } + const rawMode = typeof feature.mode === 'string' + ? feature.mode.trim().toLowerCase() + : '' + if (rawMode === 'wallet' || rawMode === 'package') { + return { mode: rawMode } + } + return { ...DEFAULT_BILLING_SOURCE_FEATURE_SETTINGS } +} + +export function mergeBillingSourceFeatureSettings( + featureSettings: unknown, + billingSource: BillingSourceFeatureSettings, +): FeatureSettingsMap | null { + const settings: FeatureSettingsMap = isRecord(featureSettings) + ? { ...featureSettings } + : {} + + if (billingSource.mode === 'auto') { + delete settings.billing_source + } else { + settings.billing_source = { + mode: billingSource.mode, + } + } + return Object.keys(settings).length > 0 ? settings : null +} diff --git a/frontend/src/views/admin/BillingPlansManagement.vue b/frontend/src/views/admin/BillingPlansManagement.vue index 93cc7b0e6..344b4d894 100644 --- a/frontend/src/views/admin/BillingPlansManagement.vue +++ b/frontend/src/views/admin/BillingPlansManagement.vue @@ -253,32 +253,18 @@
- +
+ + + 用户端购买页和订单快照里显示的套餐名称。 + +
- +
+ + + 设置用户实际支付的套餐价格。 + +
- +
+ + + 简短描述套餐包含的权益,建议控制在一两句话内。 + +