mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat: 扩展 Rust gateway 全功能模块,新增 billing/crypto/wallet crate 及完整数据层
- 新增 aether-billing、aether-crypto、aether-wallet 独立 crate - aether-data 扩展 repository 层:announcements、auth_modules、billing、 candidate_selection、gemini_file_mappings、global_models、management_tokens、 oauth_providers、proxy_nodes、quota、users、wallet 等模块 - aether-gateway 新增 api/auth/billing/control/middleware/scheduler/usage/ video_tasks/hooks/maintenance/model_fetch/provider_transport 等功能模块 - 重构 executor decision 和 gateway state 为模块目录结构 - 新增 gateway router、frontdoor 路由层及对应测试 - Python 侧 API 路由重构,新增 compat/support 模块 - 前端 Logo 组件更新及 Provider 管理页面调整
This commit is contained in:
10
crates/aether-wallet/Cargo.toml
Normal file
10
crates/aether-wallet/Cargo.toml
Normal file
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "aether-wallet"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
|
||||
180
crates/aether-wallet/src/access.rs
Normal file
180
crates/aether-wallet/src/access.rs
Normal file
@@ -0,0 +1,180 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum WalletLimitMode {
|
||||
Finite,
|
||||
Unlimited,
|
||||
}
|
||||
|
||||
impl WalletLimitMode {
|
||||
pub fn parse(value: &str) -> Self {
|
||||
if value.trim().eq_ignore_ascii_case("unlimited") {
|
||||
Self::Unlimited
|
||||
} else {
|
||||
Self::Finite
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum WalletStatus {
|
||||
Active,
|
||||
Inactive,
|
||||
}
|
||||
|
||||
impl WalletStatus {
|
||||
pub fn parse(value: &str) -> Self {
|
||||
if value.trim().eq_ignore_ascii_case("active") {
|
||||
Self::Active
|
||||
} else {
|
||||
Self::Inactive
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct WalletSnapshot {
|
||||
pub wallet_id: String,
|
||||
pub user_id: Option<String>,
|
||||
pub api_key_id: Option<String>,
|
||||
pub recharge_balance: f64,
|
||||
pub gift_balance: f64,
|
||||
pub limit_mode: WalletLimitMode,
|
||||
pub currency: String,
|
||||
pub status: WalletStatus,
|
||||
}
|
||||
|
||||
impl WalletSnapshot {
|
||||
pub fn spendable_balance(&self) -> f64 {
|
||||
quantize_money(self.recharge_balance + self.gift_balance)
|
||||
}
|
||||
|
||||
pub fn refundable_balance(&self) -> f64 {
|
||||
quantize_money(self.recharge_balance)
|
||||
}
|
||||
|
||||
pub fn balance_snapshot(&self) -> Option<f64> {
|
||||
if self.recharge_balance < 0.0 {
|
||||
return Some(quantize_money(self.recharge_balance));
|
||||
}
|
||||
match self.limit_mode {
|
||||
WalletLimitMode::Unlimited => None,
|
||||
WalletLimitMode::Finite => Some(self.spendable_balance()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn access_decision(&self, is_admin: bool) -> WalletAccessDecision {
|
||||
if is_admin {
|
||||
return WalletAccessDecision::allowed(None);
|
||||
}
|
||||
if self.status != WalletStatus::Active {
|
||||
return WalletAccessDecision::wallet_unavailable(self.balance_snapshot());
|
||||
}
|
||||
if self.recharge_balance < 0.0 {
|
||||
return WalletAccessDecision::balance_denied(Some(quantize_money(
|
||||
self.recharge_balance,
|
||||
)));
|
||||
}
|
||||
if self.limit_mode == WalletLimitMode::Unlimited {
|
||||
return WalletAccessDecision::allowed(None);
|
||||
}
|
||||
let remaining = self.spendable_balance();
|
||||
if remaining <= 0.0 {
|
||||
return WalletAccessDecision::balance_denied(Some(remaining));
|
||||
}
|
||||
WalletAccessDecision::allowed(Some(remaining))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum WalletAccessFailure {
|
||||
WalletUnavailable,
|
||||
BalanceDenied,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct WalletAccessDecision {
|
||||
pub allowed: bool,
|
||||
pub remaining: Option<f64>,
|
||||
pub failure: Option<WalletAccessFailure>,
|
||||
}
|
||||
|
||||
impl WalletAccessDecision {
|
||||
pub fn allowed(remaining: Option<f64>) -> Self {
|
||||
Self {
|
||||
allowed: true,
|
||||
remaining,
|
||||
failure: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn wallet_unavailable(remaining: Option<f64>) -> Self {
|
||||
Self {
|
||||
allowed: false,
|
||||
remaining,
|
||||
failure: Some(WalletAccessFailure::WalletUnavailable),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn balance_denied(remaining: Option<f64>) -> Self {
|
||||
Self {
|
||||
allowed: false,
|
||||
remaining,
|
||||
failure: Some(WalletAccessFailure::BalanceDenied),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn quantize_money(value: f64) -> f64 {
|
||||
(value * 100_000_000.0).round() / 100_000_000.0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{WalletAccessFailure, WalletLimitMode, WalletSnapshot, WalletStatus};
|
||||
|
||||
fn wallet_snapshot(limit_mode: WalletLimitMode, recharge: f64, gift: f64) -> WalletSnapshot {
|
||||
WalletSnapshot {
|
||||
wallet_id: "wallet-1".to_string(),
|
||||
user_id: Some("user-1".to_string()),
|
||||
api_key_id: None,
|
||||
recharge_balance: recharge,
|
||||
gift_balance: gift,
|
||||
limit_mode,
|
||||
currency: "USD".to_string(),
|
||||
status: WalletStatus::Active,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finite_wallet_denies_empty_balance() {
|
||||
let decision = wallet_snapshot(WalletLimitMode::Finite, 0.0, 0.0).access_decision(false);
|
||||
assert!(!decision.allowed);
|
||||
assert_eq!(decision.failure, Some(WalletAccessFailure::BalanceDenied));
|
||||
assert_eq!(decision.remaining, Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlimited_wallet_ignores_balance() {
|
||||
let decision =
|
||||
wallet_snapshot(WalletLimitMode::Unlimited, -10.0, 0.0).access_decision(false);
|
||||
assert!(!decision.allowed);
|
||||
assert_eq!(decision.failure, Some(WalletAccessFailure::BalanceDenied));
|
||||
|
||||
let decision = wallet_snapshot(WalletLimitMode::Unlimited, 0.0, 0.0).access_decision(false);
|
||||
assert!(decision.allowed);
|
||||
assert_eq!(decision.remaining, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inactive_wallet_is_unavailable() {
|
||||
let mut wallet = wallet_snapshot(WalletLimitMode::Finite, 10.0, 2.0);
|
||||
wallet.status = WalletStatus::Inactive;
|
||||
let decision = wallet.access_decision(false);
|
||||
assert!(!decision.allowed);
|
||||
assert_eq!(
|
||||
decision.failure,
|
||||
Some(WalletAccessFailure::WalletUnavailable)
|
||||
);
|
||||
}
|
||||
}
|
||||
8
crates/aether-wallet/src/lib.rs
Normal file
8
crates/aether-wallet/src/lib.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
mod access;
|
||||
mod quota;
|
||||
|
||||
pub use access::{
|
||||
quantize_money, WalletAccessDecision, WalletAccessFailure, WalletLimitMode, WalletSnapshot,
|
||||
WalletStatus,
|
||||
};
|
||||
pub use quota::{ProviderBillingType, ProviderQuotaSnapshot};
|
||||
82
crates/aether-wallet/src/quota.rs
Normal file
82
crates/aether-wallet/src/quota.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::quantize_money;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ProviderBillingType {
|
||||
MonthlyQuota,
|
||||
PayAsYouGo,
|
||||
FreeTier,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl ProviderBillingType {
|
||||
pub fn parse(value: &str) -> Self {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"monthly_quota" => Self::MonthlyQuota,
|
||||
"pay_as_you_go" => Self::PayAsYouGo,
|
||||
"free_tier" => Self::FreeTier,
|
||||
_ => Self::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ProviderQuotaSnapshot {
|
||||
pub provider_id: String,
|
||||
pub billing_type: ProviderBillingType,
|
||||
pub monthly_quota_usd: Option<f64>,
|
||||
pub monthly_used_usd: f64,
|
||||
pub quota_reset_day: Option<u64>,
|
||||
pub quota_last_reset_at_unix_secs: Option<u64>,
|
||||
pub quota_expires_at_unix_secs: Option<u64>,
|
||||
pub is_active: bool,
|
||||
}
|
||||
|
||||
impl ProviderQuotaSnapshot {
|
||||
pub fn remaining_quota_usd(&self) -> Option<f64> {
|
||||
self.monthly_quota_usd
|
||||
.map(|quota| quantize_money(quota - self.monthly_used_usd))
|
||||
}
|
||||
|
||||
pub fn is_expired(&self, now_unix_secs: u64) -> bool {
|
||||
self.quota_expires_at_unix_secs
|
||||
.is_some_and(|expires_at| expires_at <= now_unix_secs)
|
||||
}
|
||||
|
||||
pub fn should_reset(&self, now_unix_secs: u64) -> bool {
|
||||
if self.billing_type != ProviderBillingType::MonthlyQuota || !self.is_active {
|
||||
return false;
|
||||
}
|
||||
let Some(reset_day) = self.quota_reset_day.filter(|value| *value > 0) else {
|
||||
return false;
|
||||
};
|
||||
let Some(last_reset) = self.quota_last_reset_at_unix_secs else {
|
||||
return true;
|
||||
};
|
||||
now_unix_secs.saturating_sub(last_reset) >= reset_day.saturating_mul(24 * 60 * 60)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{ProviderBillingType, ProviderQuotaSnapshot};
|
||||
|
||||
#[test]
|
||||
fn monthly_quota_resets_after_period() {
|
||||
let snapshot = ProviderQuotaSnapshot {
|
||||
provider_id: "provider-1".to_string(),
|
||||
billing_type: ProviderBillingType::MonthlyQuota,
|
||||
monthly_quota_usd: Some(20.0),
|
||||
monthly_used_usd: 5.0,
|
||||
quota_reset_day: Some(7),
|
||||
quota_last_reset_at_unix_secs: Some(1_000),
|
||||
quota_expires_at_unix_secs: None,
|
||||
is_active: true,
|
||||
};
|
||||
|
||||
assert!(!snapshot.should_reset(1_000 + 6 * 24 * 60 * 60));
|
||||
assert!(snapshot.should_reset(1_000 + 7 * 24 * 60 * 60));
|
||||
assert_eq!(snapshot.remaining_quota_usd(), Some(15.0));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user