refactor: 拆分 gateway 单体为独立 crate,新增 systemd 部署方案

将 gateway 内部的 model-fetch、provider-transport、scheduler-core、
usage-runtime、video-tasks-core 模块提取为独立 crate;重构 gateway
内部模块结构(state/router/cache/data/query 等);移除大量遗留模块
文件;新增 systemd 二进制部署骨架及相关文档;更新前端 usage 相关
API 和组件。
This commit is contained in:
fawney19
2026-04-05 20:23:16 +08:00
parent cbc811f6ce
commit 763ff03a7b
777 changed files with 42659 additions and 21469 deletions

View File

@@ -0,0 +1,9 @@
pub(crate) use aether_data::repository::billing::{
AdminBillingCollectorRecord, AdminBillingCollectorWriteInput, AdminBillingPresetApplyResult,
AdminBillingRuleRecord, AdminBillingRuleWriteInput,
};
pub(crate) use aether_data::repository::system::AdminSecurityBlacklistEntry;
pub(crate) use aether_data::repository::wallet::{
AdminPaymentCallbackRecord, AdminWalletPaymentOrderRecord, AdminWalletRefundRecord,
AdminWalletTransactionRecord,
};

View File

@@ -0,0 +1,114 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use aether_runtime::{ConcurrencyGate, DistributedConcurrencyGate};
use super::super::async_task::{VideoTaskPollerConfig, VideoTaskService};
use super::super::fallback_metrics;
use super::super::cache::{
AuthApiKeyLastUsedCache, AuthContextCache, DirectPlanBypassCache, SchedulerAffinityCache,
};
use super::super::data::GatewayDataState;
use super::super::rate_limit::FrontdoorUserRpmLimiter;
use super::super::{provider_transport, usage};
use super::{
AdminBillingCollectorRecord, AdminBillingRuleRecord, AdminPaymentCallbackRecord,
AdminWalletPaymentOrderRecord, AdminWalletRefundRecord, AdminWalletTransactionRecord,
CachedProviderTransportSnapshot, FrontdoorCorsConfig, LocalExecutionRuntimeMissDiagnostic,
LocalProviderDeleteTaskState, ProviderTransportSnapshotCacheKey,
};
#[derive(Debug, Clone)]
pub struct AppState {
#[cfg(test)]
pub(crate) execution_runtime_override_base_url: Option<String>,
pub(crate) data: Arc<GatewayDataState>,
pub(crate) usage_runtime: Arc<usage::UsageRuntime>,
pub(crate) video_tasks: Arc<VideoTaskService>,
pub(crate) video_task_poller: Option<VideoTaskPollerConfig>,
pub(crate) request_gate: Option<Arc<ConcurrencyGate>>,
pub(crate) distributed_request_gate: Option<Arc<DistributedConcurrencyGate>>,
pub(crate) client: reqwest::Client,
pub(crate) auth_context_cache: Arc<AuthContextCache>,
pub(crate) auth_api_key_last_used_cache: Arc<AuthApiKeyLastUsedCache>,
pub(crate) oauth_refresh: Arc<provider_transport::LocalOAuthRefreshCoordinator>,
pub(crate) direct_plan_bypass_cache: Arc<DirectPlanBypassCache>,
pub(crate) scheduler_affinity_cache: Arc<SchedulerAffinityCache>,
pub(crate) fallback_metrics: Arc<fallback_metrics::GatewayFallbackMetrics>,
pub(crate) frontdoor_cors: Option<Arc<FrontdoorCorsConfig>>,
pub(crate) frontdoor_user_rpm: Arc<FrontdoorUserRpmLimiter>,
pub(crate) tunnel: crate::tunnel::EmbeddedTunnelState,
pub(crate) provider_transport_snapshot_cache:
Arc<StdMutex<HashMap<ProviderTransportSnapshotCacheKey, CachedProviderTransportSnapshot>>>,
pub(crate) provider_key_rpm_resets: Arc<StdMutex<HashMap<String, u64>>>,
pub(crate) local_execution_runtime_miss_diagnostics:
Arc<StdMutex<HashMap<String, LocalExecutionRuntimeMissDiagnostic>>>,
pub(crate) admin_monitoring_error_stats_reset_at: Arc<StdMutex<Option<u64>>>,
pub(crate) provider_delete_tasks:
Arc<StdMutex<HashMap<String, LocalProviderDeleteTaskState>>>,
#[cfg(test)]
pub(crate) provider_oauth_state_store:
Option<Arc<StdMutex<HashMap<String, String>>>>,
#[cfg(test)]
pub(crate) provider_oauth_device_session_store:
Option<Arc<StdMutex<HashMap<String, String>>>>,
#[cfg(test)]
pub(crate) provider_oauth_batch_task_store:
Option<Arc<StdMutex<HashMap<String, String>>>>,
#[cfg(test)]
pub(crate) auth_session_store: Option<
Arc<
StdMutex<HashMap<String, crate::data::state::StoredUserSessionRecord>>,
>,
>,
#[cfg(test)]
pub(crate) auth_email_verification_store:
Option<Arc<StdMutex<HashMap<String, String>>>>,
#[cfg(test)]
pub(crate) auth_email_delivery_store: Option<Arc<StdMutex<Vec<serde_json::Value>>>>,
#[cfg(test)]
pub(crate) auth_user_store: Option<
Arc<StdMutex<HashMap<String, aether_data::repository::users::StoredUserAuthRecord>>>,
>,
#[cfg(test)]
pub(crate) auth_user_model_capability_store:
Option<Arc<StdMutex<HashMap<String, serde_json::Value>>>>,
#[cfg(test)]
pub(crate) auth_wallet_store: Option<
Arc<StdMutex<HashMap<String, aether_data::repository::wallet::StoredWalletSnapshot>>>,
>,
#[cfg(test)]
pub(crate) admin_wallet_payment_order_store:
Option<Arc<StdMutex<HashMap<String, AdminWalletPaymentOrderRecord>>>>,
#[cfg(test)]
pub(crate) admin_payment_callback_store:
Option<Arc<StdMutex<HashMap<String, AdminPaymentCallbackRecord>>>>,
#[cfg(test)]
pub(crate) admin_wallet_transaction_store:
Option<Arc<StdMutex<HashMap<String, AdminWalletTransactionRecord>>>>,
#[cfg(test)]
pub(crate) admin_wallet_refund_store:
Option<Arc<StdMutex<HashMap<String, AdminWalletRefundRecord>>>>,
#[cfg(test)]
pub(crate) admin_billing_rule_store:
Option<Arc<StdMutex<HashMap<String, AdminBillingRuleRecord>>>>,
#[cfg(test)]
pub(crate) admin_billing_collector_store:
Option<Arc<StdMutex<HashMap<String, AdminBillingCollectorRecord>>>>,
#[cfg(test)]
pub(crate) admin_security_blacklist_store:
Option<Arc<StdMutex<HashMap<String, String>>>>,
#[cfg(test)]
pub(crate) admin_security_whitelist_store:
Option<Arc<StdMutex<std::collections::BTreeSet<String>>>>,
#[cfg(test)]
pub(crate) admin_monitoring_cache_affinity_store:
Option<Arc<StdMutex<HashMap<String, String>>>>,
#[cfg(test)]
pub(crate) admin_monitoring_redis_key_store:
Option<Arc<StdMutex<HashMap<String, String>>>>,
#[cfg(test)]
pub(crate) provider_oauth_token_url_overrides:
Arc<StdMutex<HashMap<String, String>>>,
}

View File

@@ -0,0 +1,14 @@
use std::time::Duration;
use super::super::provider_transport;
pub(crate) const AUTH_API_KEY_LAST_USED_TTL: Duration = Duration::from_secs(60);
pub(crate) const AUTH_API_KEY_LAST_USED_MAX_ENTRIES: usize = 10_000;
pub(crate) const PROVIDER_TRANSPORT_SNAPSHOT_CACHE_TTL: Duration = Duration::from_secs(1);
pub(crate) const PROVIDER_TRANSPORT_SNAPSHOT_CACHE_MAX_ENTRIES: usize = 1_024;
#[derive(Debug, Clone)]
pub(crate) struct CachedProviderTransportSnapshot {
pub(crate) loaded_at: std::time::Instant,
pub(crate) snapshot: provider_transport::GatewayProviderTransportSnapshot,
}

View File

@@ -0,0 +1,725 @@
use super::{AppState, GatewayError, LocalMutationOutcome, LocalProviderDeleteTaskState};
impl AppState {
pub fn has_provider_catalog_data_reader(&self) -> bool {
self.data.has_provider_catalog_reader()
}
pub(crate) fn has_provider_catalog_data_writer(&self) -> bool {
self.data.has_provider_catalog_writer()
}
pub(crate) fn has_global_model_data_reader(&self) -> bool {
self.data.has_global_model_reader()
}
pub(crate) fn has_global_model_data_writer(&self) -> bool {
self.data.has_global_model_writer()
}
pub(crate) fn has_minimal_candidate_selection_reader(&self) -> bool {
self.data.has_minimal_candidate_selection_reader()
}
pub(crate) fn has_management_token_reader(&self) -> bool {
self.data.has_management_token_reader()
}
pub(crate) fn has_management_token_writer(&self) -> bool {
self.data.has_management_token_writer()
}
pub(crate) async fn list_provider_catalog_providers(
&self,
active_only: bool,
) -> Result<
Vec<aether_data::repository::provider_catalog::StoredProviderCatalogProvider>,
GatewayError,
> {
self.data
.list_provider_catalog_providers(active_only)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn list_provider_catalog_endpoints_by_provider_ids(
&self,
provider_ids: &[String],
) -> Result<
Vec<aether_data::repository::provider_catalog::StoredProviderCatalogEndpoint>,
GatewayError,
> {
self.data
.list_provider_catalog_endpoints_by_provider_ids(provider_ids)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn list_public_global_models(
&self,
query: &aether_data::repository::global_models::PublicGlobalModelQuery,
) -> Result<aether_data::repository::global_models::StoredPublicGlobalModelPage, GatewayError>
{
self.data
.list_public_global_models(query)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn list_management_tokens(
&self,
query: &aether_data::repository::management_tokens::ManagementTokenListQuery,
) -> Result<
aether_data::repository::management_tokens::StoredManagementTokenListPage,
GatewayError,
> {
self.data
.list_management_tokens(query)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn get_management_token_with_user(
&self,
token_id: &str,
) -> Result<
Option<aether_data::repository::management_tokens::StoredManagementTokenWithUser>,
GatewayError,
> {
self.data
.get_management_token_with_user(token_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn create_management_token(
&self,
record: &aether_data::repository::management_tokens::CreateManagementTokenRecord,
) -> Result<
LocalMutationOutcome<aether_data::repository::management_tokens::StoredManagementToken>,
GatewayError,
> {
self.data
.create_management_token(record)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn update_management_token(
&self,
record: &aether_data::repository::management_tokens::UpdateManagementTokenRecord,
) -> Result<
LocalMutationOutcome<aether_data::repository::management_tokens::StoredManagementToken>,
GatewayError,
> {
self.data
.update_management_token(record)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn delete_management_token(
&self,
token_id: &str,
) -> Result<bool, GatewayError> {
self.data
.delete_management_token(token_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn set_management_token_active(
&self,
token_id: &str,
is_active: bool,
) -> Result<
Option<aether_data::repository::management_tokens::StoredManagementToken>,
GatewayError,
> {
self.data
.set_management_token_active(token_id, is_active)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn regenerate_management_token_secret(
&self,
mutation: &aether_data::repository::management_tokens::RegenerateManagementTokenSecret,
) -> Result<
LocalMutationOutcome<aether_data::repository::management_tokens::StoredManagementToken>,
GatewayError,
> {
self.data
.regenerate_management_token_secret(mutation)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn get_public_global_model_by_name(
&self,
model_name: &str,
) -> Result<Option<aether_data::repository::global_models::StoredPublicGlobalModel>, GatewayError>
{
self.data
.get_public_global_model_by_name(model_name)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn list_public_catalog_models(
&self,
query: &aether_data::repository::global_models::PublicCatalogModelListQuery,
) -> Result<Vec<aether_data::repository::global_models::StoredPublicCatalogModel>, GatewayError>
{
self.data
.list_public_catalog_models(query)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn search_public_catalog_models(
&self,
query: &aether_data::repository::global_models::PublicCatalogModelSearchQuery,
) -> Result<Vec<aether_data::repository::global_models::StoredPublicCatalogModel>, GatewayError>
{
self.data
.search_public_catalog_models(query)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn list_admin_provider_models(
&self,
query: &aether_data::repository::global_models::AdminProviderModelListQuery,
) -> Result<Vec<aether_data::repository::global_models::StoredAdminProviderModel>, GatewayError>
{
self.data
.list_admin_provider_models(query)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn list_admin_global_models(
&self,
query: &aether_data::repository::global_models::AdminGlobalModelListQuery,
) -> Result<aether_data::repository::global_models::StoredAdminGlobalModelPage, GatewayError>
{
self.data
.list_admin_global_models(query)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn get_admin_provider_model(
&self,
provider_id: &str,
model_id: &str,
) -> Result<
Option<aether_data::repository::global_models::StoredAdminProviderModel>,
GatewayError,
> {
self.data
.get_admin_provider_model(provider_id, model_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn list_admin_provider_available_source_models(
&self,
provider_id: &str,
) -> Result<Vec<aether_data::repository::global_models::StoredAdminProviderModel>, GatewayError>
{
self.data
.list_admin_provider_available_source_models(provider_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn get_admin_global_model_by_id(
&self,
global_model_id: &str,
) -> Result<Option<aether_data::repository::global_models::StoredAdminGlobalModel>, GatewayError>
{
self.data
.get_admin_global_model_by_id(global_model_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn get_admin_global_model_by_name(
&self,
model_name: &str,
) -> Result<Option<aether_data::repository::global_models::StoredAdminGlobalModel>, GatewayError>
{
self.data
.get_admin_global_model_by_name(model_name)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn list_admin_provider_models_by_global_model_id(
&self,
global_model_id: &str,
) -> Result<Vec<aether_data::repository::global_models::StoredAdminProviderModel>, GatewayError>
{
self.data
.list_admin_provider_models_by_global_model_id(global_model_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn create_admin_provider_model(
&self,
record: &aether_data::repository::global_models::UpsertAdminProviderModelRecord,
) -> Result<
Option<aether_data::repository::global_models::StoredAdminProviderModel>,
GatewayError,
> {
self.data
.create_admin_provider_model(record)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn update_admin_provider_model(
&self,
record: &aether_data::repository::global_models::UpsertAdminProviderModelRecord,
) -> Result<
Option<aether_data::repository::global_models::StoredAdminProviderModel>,
GatewayError,
> {
self.data
.update_admin_provider_model(record)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn delete_admin_provider_model(
&self,
provider_id: &str,
model_id: &str,
) -> Result<bool, GatewayError> {
self.data
.delete_admin_provider_model(provider_id, model_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn create_admin_global_model(
&self,
record: &aether_data::repository::global_models::CreateAdminGlobalModelRecord,
) -> Result<Option<aether_data::repository::global_models::StoredAdminGlobalModel>, GatewayError>
{
self.data
.create_admin_global_model(record)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn update_admin_global_model(
&self,
record: &aether_data::repository::global_models::UpdateAdminGlobalModelRecord,
) -> Result<Option<aether_data::repository::global_models::StoredAdminGlobalModel>, GatewayError>
{
self.data
.update_admin_global_model(record)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn delete_admin_global_model(
&self,
global_model_id: &str,
) -> Result<bool, GatewayError> {
self.data
.delete_admin_global_model(global_model_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn list_provider_model_stats(
&self,
provider_ids: &[String],
) -> Result<Vec<aether_data::repository::global_models::StoredProviderModelStats>, GatewayError>
{
self.data
.list_provider_model_stats(provider_ids)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn list_active_global_model_ids_by_provider_ids(
&self,
provider_ids: &[String],
) -> Result<
Vec<aether_data::repository::global_models::StoredProviderActiveGlobalModel>,
GatewayError,
> {
self.data
.list_active_global_model_ids_by_provider_ids(provider_ids)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn list_finalized_request_candidates_by_endpoint_ids_since(
&self,
endpoint_ids: &[String],
since_unix_secs: u64,
limit: usize,
) -> Result<Vec<aether_data::repository::candidates::StoredRequestCandidate>, GatewayError>
{
self.data
.list_finalized_request_candidates_by_endpoint_ids_since(
endpoint_ids,
since_unix_secs,
limit,
)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn count_finalized_request_candidate_statuses_by_endpoint_ids_since(
&self,
endpoint_ids: &[String],
since_unix_secs: u64,
) -> Result<Vec<aether_data::repository::candidates::PublicHealthStatusCount>, GatewayError>
{
self.data
.count_finalized_request_candidate_statuses_by_endpoint_ids_since(
endpoint_ids,
since_unix_secs,
)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn aggregate_finalized_request_candidate_timeline_by_endpoint_ids_since(
&self,
endpoint_ids: &[String],
since_unix_secs: u64,
until_unix_secs: u64,
segments: u32,
) -> Result<Vec<aether_data::repository::candidates::PublicHealthTimelineBucket>, GatewayError>
{
self.data
.aggregate_finalized_request_candidate_timeline_by_endpoint_ids_since(
endpoint_ids,
since_unix_secs,
until_unix_secs,
segments,
)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn list_provider_catalog_keys_by_provider_ids(
&self,
provider_ids: &[String],
) -> Result<
Vec<aether_data::repository::provider_catalog::StoredProviderCatalogKey>,
GatewayError,
> {
self.data
.list_provider_catalog_keys_by_provider_ids(provider_ids)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn list_provider_catalog_keys_by_ids(
&self,
key_ids: &[String],
) -> Result<
Vec<aether_data::repository::provider_catalog::StoredProviderCatalogKey>,
GatewayError,
> {
self.data
.list_provider_catalog_keys_by_ids(key_ids)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn list_provider_catalog_key_page(
&self,
query: &aether_data::repository::provider_catalog::ProviderCatalogKeyListQuery,
) -> Result<aether_data::repository::provider_catalog::StoredProviderCatalogKeyPage, GatewayError>
{
self.data
.list_provider_catalog_key_page(query)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn list_provider_catalog_key_stats_by_provider_ids(
&self,
provider_ids: &[String],
) -> Result<
Vec<aether_data::repository::provider_catalog::StoredProviderCatalogKeyStats>,
GatewayError,
> {
self.data
.list_provider_catalog_key_stats_by_provider_ids(provider_ids)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn create_provider_catalog_key(
&self,
key: &aether_data::repository::provider_catalog::StoredProviderCatalogKey,
) -> Result<
Option<aether_data::repository::provider_catalog::StoredProviderCatalogKey>,
GatewayError,
> {
let created = self
.data
.create_provider_catalog_key(key)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
if created.is_some() {
self.clear_provider_transport_snapshot_cache();
}
Ok(created)
}
pub(crate) async fn create_provider_catalog_provider(
&self,
provider: &aether_data::repository::provider_catalog::StoredProviderCatalogProvider,
shift_existing_priorities_from: Option<i32>,
) -> Result<
Option<aether_data::repository::provider_catalog::StoredProviderCatalogProvider>,
GatewayError,
> {
let created = self
.data
.create_provider_catalog_provider(provider, shift_existing_priorities_from)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
if created.is_some() {
self.clear_provider_transport_snapshot_cache();
}
Ok(created)
}
pub(crate) async fn update_provider_catalog_provider(
&self,
provider: &aether_data::repository::provider_catalog::StoredProviderCatalogProvider,
) -> Result<
Option<aether_data::repository::provider_catalog::StoredProviderCatalogProvider>,
GatewayError,
> {
let updated = self
.data
.update_provider_catalog_provider(provider)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
if updated.is_some() {
self.clear_provider_transport_snapshot_cache();
}
Ok(updated)
}
pub(crate) async fn delete_provider_catalog_provider(
&self,
provider_id: &str,
) -> Result<bool, GatewayError> {
let deleted = self
.data
.delete_provider_catalog_provider(provider_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
if deleted {
self.clear_provider_transport_snapshot_cache();
}
Ok(deleted)
}
pub(crate) async fn cleanup_deleted_provider_catalog_refs(
&self,
provider_id: &str,
endpoint_ids: &[String],
key_ids: &[String],
) -> Result<(), GatewayError> {
self.data
.cleanup_deleted_provider_catalog_refs(provider_id, endpoint_ids, key_ids)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
if !endpoint_ids.is_empty() || !key_ids.is_empty() {
self.clear_provider_transport_snapshot_cache();
}
Ok(())
}
pub(crate) async fn create_provider_catalog_endpoint(
&self,
endpoint: &aether_data::repository::provider_catalog::StoredProviderCatalogEndpoint,
) -> Result<
Option<aether_data::repository::provider_catalog::StoredProviderCatalogEndpoint>,
GatewayError,
> {
let created = self
.data
.create_provider_catalog_endpoint(endpoint)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
if created.is_some() {
self.clear_provider_transport_snapshot_cache();
}
Ok(created)
}
pub(crate) async fn update_provider_catalog_endpoint(
&self,
endpoint: &aether_data::repository::provider_catalog::StoredProviderCatalogEndpoint,
) -> Result<
Option<aether_data::repository::provider_catalog::StoredProviderCatalogEndpoint>,
GatewayError,
> {
let updated = self
.data
.update_provider_catalog_endpoint(endpoint)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
if updated.is_some() {
self.clear_provider_transport_snapshot_cache();
}
Ok(updated)
}
pub(crate) async fn delete_provider_catalog_endpoint(
&self,
endpoint_id: &str,
) -> Result<bool, GatewayError> {
let deleted = self
.data
.delete_provider_catalog_endpoint(endpoint_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
if deleted {
self.clear_provider_transport_snapshot_cache();
}
Ok(deleted)
}
pub(crate) async fn update_provider_catalog_key(
&self,
key: &aether_data::repository::provider_catalog::StoredProviderCatalogKey,
) -> Result<
Option<aether_data::repository::provider_catalog::StoredProviderCatalogKey>,
GatewayError,
> {
let updated = self
.data
.update_provider_catalog_key(key)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
if updated.is_some() {
self.clear_provider_transport_snapshot_cache();
}
Ok(updated)
}
pub(crate) async fn delete_provider_catalog_key(
&self,
key_id: &str,
) -> Result<bool, GatewayError> {
let deleted = self
.data
.delete_provider_catalog_key(key_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
if deleted {
self.clear_provider_transport_snapshot_cache();
}
Ok(deleted)
}
pub(crate) async fn clear_provider_catalog_key_oauth_invalid_marker(
&self,
key_id: &str,
) -> Result<bool, GatewayError> {
self.data
.clear_provider_catalog_key_oauth_invalid_marker(key_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) fn put_provider_delete_task(&self, task: LocalProviderDeleteTaskState) {
let mut tasks = self
.provider_delete_tasks
.lock()
.expect("provider delete tasks cache should lock");
tasks.insert(task.task_id.clone(), task);
}
pub(crate) fn get_provider_delete_task(
&self,
task_id: &str,
) -> Option<LocalProviderDeleteTaskState> {
let tasks = self
.provider_delete_tasks
.lock()
.expect("provider delete tasks cache should lock");
tasks.get(task_id).cloned()
}
pub(crate) async fn read_provider_catalog_providers_by_ids(
&self,
provider_ids: &[String],
) -> Result<
Vec<aether_data::repository::provider_catalog::StoredProviderCatalogProvider>,
GatewayError,
> {
self.data
.list_provider_catalog_providers_by_ids(provider_ids)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn read_provider_catalog_endpoints_by_ids(
&self,
endpoint_ids: &[String],
) -> Result<
Vec<aether_data::repository::provider_catalog::StoredProviderCatalogEndpoint>,
GatewayError,
> {
self.data
.list_provider_catalog_endpoints_by_ids(endpoint_ids)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn read_provider_catalog_keys_by_ids(
&self,
key_ids: &[String],
) -> Result<
Vec<aether_data::repository::provider_catalog::StoredProviderCatalogKey>,
GatewayError,
> {
self.data
.list_provider_catalog_keys_by_ids(key_ids)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn update_provider_catalog_key_health_state(
&self,
key_id: &str,
is_active: bool,
health_by_format: Option<&serde_json::Value>,
circuit_breaker_by_format: Option<&serde_json::Value>,
) -> Result<bool, GatewayError> {
let updated = self
.data
.update_provider_catalog_key_health_state(
key_id,
is_active,
health_by_format,
circuit_breaker_by_format,
)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
if updated {
self.clear_provider_transport_snapshot_cache();
}
Ok(updated)
}
}

View File

@@ -0,0 +1,637 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use std::time::Duration;
use aether_data::repository::proxy_nodes::{
ProxyNodeHeartbeatMutation, ProxyNodeTunnelStatusMutation, StoredProxyNode,
StoredProxyNodeEvent,
};
use aether_http::{build_http_client, HttpClientConfig};
use aether_runtime::{
service_up_sample, AdmissionPermit, ConcurrencyGate, ConcurrencySnapshot,
DistributedConcurrencyError, DistributedConcurrencyGate, DistributedConcurrencySnapshot,
MetricKind, MetricLabel, MetricSample,
};
use tokio::task::JoinHandle;
use super::{AppState, FrontdoorCorsConfig, LocalExecutionRuntimeMissDiagnostic};
use super::super::async_task::{
spawn_video_task_poller, VideoTaskPollerConfig, VideoTaskService, VideoTaskTruthSourceMode,
};
use super::super::fallback_metrics;
use super::super::fallback_metrics::{GatewayFallbackMetricKind, GatewayFallbackReason};
use super::super::cache::{
AuthApiKeyLastUsedCache, AuthContextCache, DirectPlanBypassCache, SchedulerAffinityCache,
SchedulerAffinityTarget,
};
use super::super::data::{GatewayDataConfig, GatewayDataState};
use super::super::model_fetch::spawn_model_fetch_worker;
use super::super::rate_limit::{FrontdoorUserRpmConfig, FrontdoorUserRpmLimiter};
use super::super::router::RequestAdmissionError;
use super::super::{control::GatewayControlDecision, error::GatewayError};
use super::super::{provider_transport, scheduler, usage};
use crate::maintenance::spawn_audit_cleanup_worker;
use crate::maintenance::spawn_db_maintenance_worker;
use crate::maintenance::spawn_gemini_file_mapping_cleanup_worker;
use crate::maintenance::spawn_pending_cleanup_worker;
use crate::maintenance::spawn_pool_monitor_worker;
use crate::maintenance::spawn_provider_checkin_worker;
use crate::maintenance::spawn_request_candidate_cleanup_worker;
use crate::maintenance::spawn_stats_aggregation_worker;
use crate::maintenance::spawn_stats_hourly_aggregation_worker;
use crate::maintenance::spawn_usage_cleanup_worker;
use crate::maintenance::spawn_wallet_daily_usage_aggregation_worker;
impl AppState {
pub(crate) fn replace_data_state(&mut self, data: Arc<GatewayDataState>) {
self.clear_provider_transport_snapshot_cache();
self.tunnel = crate::tunnel::EmbeddedTunnelState::with_data(Arc::clone(&data));
self.data = data;
}
pub fn force_close_all_tunnel_proxies(&self) -> usize {
self.tunnel.request_close_all_proxies()
}
pub fn new() -> Result<Self, reqwest::Error> {
Self::build(None)
}
#[cfg(test)]
pub(crate) fn with_execution_runtime_override_base_url(
mut self,
execution_runtime_override_base_url: impl Into<String>,
) -> Self {
self.execution_runtime_override_base_url = Some(
execution_runtime_override_base_url
.into()
.trim_end_matches('/')
.to_string(),
)
.filter(|value| !value.is_empty());
self
}
fn build(execution_runtime_override_base_url: Option<String>) -> Result<Self, reqwest::Error> {
let data = Arc::new(GatewayDataState::disabled());
let client = build_http_client(&HttpClientConfig {
connect_timeout_ms: Some(10_000),
request_timeout_ms: Some(300_000),
http2_adaptive_window: true,
..HttpClientConfig::default()
})?;
Ok(Self {
#[cfg(test)]
execution_runtime_override_base_url: execution_runtime_override_base_url
.map(|value| value.trim_end_matches('/').to_string())
.filter(|value| !value.is_empty()),
data: Arc::clone(&data),
usage_runtime: Arc::new(usage::UsageRuntime::disabled()),
video_tasks: Arc::new(VideoTaskService::new(
VideoTaskTruthSourceMode::PythonSyncReport,
)),
video_task_poller: None,
request_gate: None,
distributed_request_gate: None,
client,
auth_context_cache: Arc::new(AuthContextCache::default()),
auth_api_key_last_used_cache: Arc::new(AuthApiKeyLastUsedCache::default()),
oauth_refresh: Arc::new(provider_transport::LocalOAuthRefreshCoordinator::new()),
direct_plan_bypass_cache: Arc::new(DirectPlanBypassCache::default()),
scheduler_affinity_cache: Arc::new(SchedulerAffinityCache::default()),
fallback_metrics: Arc::new(fallback_metrics::GatewayFallbackMetrics::default()),
frontdoor_cors: None,
frontdoor_user_rpm: Arc::new(FrontdoorUserRpmLimiter::new(
FrontdoorUserRpmConfig::default(),
)),
tunnel: crate::tunnel::EmbeddedTunnelState::with_data(data),
provider_transport_snapshot_cache: Arc::new(StdMutex::new(HashMap::new())),
provider_key_rpm_resets: Arc::new(StdMutex::new(HashMap::new())),
local_execution_runtime_miss_diagnostics: Arc::new(StdMutex::new(HashMap::new())),
admin_monitoring_error_stats_reset_at: Arc::new(StdMutex::new(None)),
provider_delete_tasks: Arc::new(StdMutex::new(HashMap::new())),
#[cfg(test)]
provider_oauth_state_store: None,
#[cfg(test)]
provider_oauth_device_session_store: Some(Arc::new(StdMutex::new(HashMap::new()))),
#[cfg(test)]
provider_oauth_batch_task_store: Some(Arc::new(StdMutex::new(HashMap::new()))),
#[cfg(test)]
auth_session_store: Some(Arc::new(StdMutex::new(HashMap::new()))),
#[cfg(test)]
auth_email_verification_store: Some(Arc::new(StdMutex::new(HashMap::new()))),
#[cfg(test)]
auth_email_delivery_store: Some(Arc::new(StdMutex::new(Vec::new()))),
#[cfg(test)]
auth_user_store: Some(Arc::new(StdMutex::new(HashMap::new()))),
#[cfg(test)]
auth_user_model_capability_store: Some(Arc::new(StdMutex::new(HashMap::new()))),
#[cfg(test)]
auth_wallet_store: Some(Arc::new(StdMutex::new(HashMap::new()))),
#[cfg(test)]
admin_wallet_payment_order_store: Some(Arc::new(StdMutex::new(HashMap::new()))),
#[cfg(test)]
admin_payment_callback_store: Some(Arc::new(StdMutex::new(HashMap::new()))),
#[cfg(test)]
admin_wallet_transaction_store: Some(Arc::new(StdMutex::new(HashMap::new()))),
#[cfg(test)]
admin_wallet_refund_store: Some(Arc::new(StdMutex::new(HashMap::new()))),
#[cfg(test)]
admin_billing_rule_store: Some(Arc::new(StdMutex::new(HashMap::new()))),
#[cfg(test)]
admin_billing_collector_store: Some(Arc::new(StdMutex::new(HashMap::new()))),
#[cfg(test)]
admin_security_blacklist_store: Some(Arc::new(StdMutex::new(HashMap::new()))),
#[cfg(test)]
admin_security_whitelist_store: Some(Arc::new(StdMutex::new(
std::collections::BTreeSet::new(),
))),
#[cfg(test)]
admin_monitoring_cache_affinity_store: Some(Arc::new(StdMutex::new(HashMap::new()))),
#[cfg(test)]
admin_monitoring_redis_key_store: Some(Arc::new(StdMutex::new(HashMap::new()))),
#[cfg(test)]
provider_oauth_token_url_overrides: Arc::new(StdMutex::new(HashMap::new())),
})
}
pub const fn execution_runtime_configured(&self) -> bool {
true
}
#[cfg(test)]
pub(crate) fn execution_runtime_override_base_url(&self) -> Option<&str> {
self.execution_runtime_override_base_url.as_deref()
}
pub fn with_data_config(
mut self,
config: GatewayDataConfig,
) -> Result<Self, aether_data::DataLayerError> {
self.replace_data_state(Arc::new(GatewayDataState::from_config(config)?));
Ok(self)
}
pub fn with_tunnel_identity(
mut self,
instance_id: impl Into<String>,
relay_base_url: Option<impl Into<String>>,
) -> Self {
self.tunnel = crate::tunnel::EmbeddedTunnelState::with_data_and_identity(
Arc::clone(&self.data),
instance_id,
relay_base_url,
90,
);
self
}
pub fn with_video_task_truth_source_mode(mut self, mode: VideoTaskTruthSourceMode) -> Self {
self.video_tasks = Arc::new(self.video_tasks.with_truth_source_mode(mode));
self
}
pub fn with_usage_runtime_config(
mut self,
config: usage::UsageRuntimeConfig,
) -> Result<Self, aether_data::DataLayerError> {
self.usage_runtime = Arc::new(usage::UsageRuntime::new(config)?);
Ok(self)
}
pub async fn run_postgres_migrations(&self) -> Result<bool, sqlx::migrate::MigrateError> {
let Some(pool) = self.postgres_pool() else {
return Ok(false);
};
aether_data::migrate::run_migrations(&pool).await?;
Ok(true)
}
pub fn with_video_task_poller_config(mut self, interval: Duration, batch_size: usize) -> Self {
self.video_task_poller = Some(VideoTaskPollerConfig {
interval,
batch_size: batch_size.max(1),
});
self
}
pub fn with_request_concurrency_limit(mut self, limit: usize) -> Self {
self.request_gate = Some(Arc::new(ConcurrencyGate::new(
"gateway_requests",
limit.max(1),
)));
self
}
pub fn with_distributed_request_concurrency_gate(
mut self,
gate: DistributedConcurrencyGate,
) -> Self {
self.distributed_request_gate = Some(Arc::new(gate));
self
}
pub fn with_frontdoor_cors_config(mut self, config: FrontdoorCorsConfig) -> Self {
self.frontdoor_cors = Some(Arc::new(config));
self
}
pub fn with_frontdoor_user_rpm_config(mut self, config: FrontdoorUserRpmConfig) -> Self {
self.frontdoor_user_rpm = Arc::new(FrontdoorUserRpmLimiter::new(config));
self
}
pub fn has_data_backends(&self) -> bool {
self.data.has_backends()
}
pub(crate) fn has_auth_api_key_reader(&self) -> bool {
self.data.has_auth_api_key_reader()
}
pub(crate) fn frontdoor_cors(&self) -> Option<Arc<FrontdoorCorsConfig>> {
self.frontdoor_cors.clone()
}
pub(crate) fn frontdoor_user_rpm(&self) -> Arc<FrontdoorUserRpmLimiter> {
Arc::clone(&self.frontdoor_user_rpm)
}
pub(crate) fn mark_provider_key_rpm_reset(&self, key_id: &str, now_unix_secs: u64) {
let mut resets = self
.provider_key_rpm_resets
.lock()
.expect("provider key rpm reset cache should lock");
let min_kept = now_unix_secs.saturating_sub(scheduler::PROVIDER_KEY_RPM_WINDOW_SECS);
resets.retain(|_, reset_at| *reset_at >= min_kept);
resets.insert(key_id.to_string(), now_unix_secs);
}
pub(crate) fn provider_key_rpm_reset_at(
&self,
key_id: &str,
now_unix_secs: u64,
) -> Option<u64> {
let mut resets = self
.provider_key_rpm_resets
.lock()
.expect("provider key rpm reset cache should lock");
let min_kept = now_unix_secs.saturating_sub(scheduler::PROVIDER_KEY_RPM_WINDOW_SECS);
resets.retain(|_, reset_at| *reset_at >= min_kept);
resets.get(key_id).copied()
}
pub(crate) fn admin_monitoring_error_stats_reset_at(&self) -> Option<u64> {
*self
.admin_monitoring_error_stats_reset_at
.lock()
.expect("admin monitoring error stats reset cache should lock")
}
pub(crate) fn mark_admin_monitoring_error_stats_reset(&self, now_unix_secs: u64) {
let mut reset_at = self
.admin_monitoring_error_stats_reset_at
.lock()
.expect("admin monitoring error stats reset cache should lock");
*reset_at = Some(now_unix_secs);
}
pub(crate) async fn read_system_config_json_value(
&self,
key: &str,
) -> Result<Option<serde_json::Value>, GatewayError> {
self.data
.find_system_config_value(key)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn upsert_system_config_json_value(
&self,
key: &str,
value: &serde_json::Value,
description: Option<&str>,
) -> Result<serde_json::Value, GatewayError> {
self.data
.upsert_system_config_value(key, value, description)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn list_system_config_entries(
&self,
) -> Result<Vec<crate::data::state::StoredSystemConfigEntry>, GatewayError>
{
self.data
.list_system_config_entries()
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn upsert_system_config_entry(
&self,
key: &str,
value: &serde_json::Value,
description: Option<&str>,
) -> Result<crate::data::state::StoredSystemConfigEntry, GatewayError> {
self.data
.upsert_system_config_entry(key, value, description)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn delete_system_config_value(&self, key: &str) -> Result<bool, GatewayError> {
self.data
.delete_system_config_value(key)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn read_admin_system_stats(
&self,
) -> Result<aether_data::repository::system::AdminSystemStats, GatewayError> {
self.data
.read_admin_system_stats()
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn find_proxy_node(
&self,
node_id: &str,
) -> Result<Option<StoredProxyNode>, GatewayError> {
self.data
.find_proxy_node(node_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn list_proxy_nodes(&self) -> Result<Vec<StoredProxyNode>, GatewayError> {
self.data
.list_proxy_nodes()
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn list_proxy_node_events(
&self,
node_id: &str,
limit: usize,
) -> Result<Vec<StoredProxyNodeEvent>, GatewayError> {
self.data
.list_proxy_node_events(node_id, limit)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn apply_proxy_node_heartbeat(
&self,
mutation: &ProxyNodeHeartbeatMutation,
) -> Result<Option<StoredProxyNode>, GatewayError> {
self.data
.apply_proxy_node_heartbeat(mutation)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn update_proxy_node_tunnel_status(
&self,
mutation: &ProxyNodeTunnelStatusMutation,
) -> Result<Option<StoredProxyNode>, GatewayError> {
self.data
.update_proxy_node_tunnel_status(mutation)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) fn request_concurrency_snapshot(&self) -> Option<ConcurrencySnapshot> {
self.request_gate.as_ref().map(|gate| gate.snapshot())
}
pub(crate) async fn distributed_request_concurrency_snapshot(
&self,
) -> Result<Option<DistributedConcurrencySnapshot>, DistributedConcurrencyError> {
match self.distributed_request_gate.as_ref() {
Some(gate) => gate.snapshot().await.map(Some),
None => Ok(None),
}
}
pub(crate) async fn metric_samples(&self) -> Vec<MetricSample> {
let mut samples = vec![service_up_sample("aether-gateway")];
if let Some(snapshot) = self.request_concurrency_snapshot() {
samples.extend(snapshot.to_metric_samples("gateway_requests"));
}
if let Some(gate) = self.distributed_request_gate.as_ref() {
match gate.snapshot().await {
Ok(snapshot) => {
samples.extend(snapshot.to_metric_samples("gateway_requests_distributed"));
}
Err(_) => samples.push(
MetricSample::new(
"concurrency_unavailable",
"Whether the distributed concurrency gate is currently unavailable.",
MetricKind::Gauge,
1,
)
.with_labels(vec![MetricLabel::new(
"gate",
"gateway_requests_distributed",
)]),
),
}
}
samples.extend(self.tunnel.metric_samples());
samples.extend(self.fallback_metrics.metric_samples());
samples
}
pub(crate) fn record_fallback_metric(
&self,
kind: GatewayFallbackMetricKind,
decision: Option<&GatewayControlDecision>,
plan_kind: Option<&str>,
execution_path: Option<&str>,
reason: GatewayFallbackReason,
) {
self.fallback_metrics
.record(kind, decision, plan_kind, execution_path, reason);
}
pub(crate) fn clear_local_execution_runtime_miss_diagnostic(&self, trace_id: &str) {
self.local_execution_runtime_miss_diagnostics
.lock()
.expect("local execution runtime miss diagnostics should lock")
.remove(trace_id);
}
pub(crate) fn set_local_execution_runtime_miss_diagnostic(
&self,
trace_id: &str,
diagnostic: LocalExecutionRuntimeMissDiagnostic,
) {
self.local_execution_runtime_miss_diagnostics
.lock()
.expect("local execution runtime miss diagnostics should lock")
.insert(trace_id.to_string(), diagnostic);
}
pub(crate) fn mutate_local_execution_runtime_miss_diagnostic<F>(
&self,
trace_id: &str,
mutate: F,
) where
F: FnOnce(&mut LocalExecutionRuntimeMissDiagnostic),
{
let mut diagnostics = self
.local_execution_runtime_miss_diagnostics
.lock()
.expect("local execution runtime miss diagnostics should lock");
if let Some(diagnostic) = diagnostics.get_mut(trace_id) {
mutate(diagnostic);
}
}
pub(crate) fn take_local_execution_runtime_miss_diagnostic(
&self,
trace_id: &str,
) -> Option<LocalExecutionRuntimeMissDiagnostic> {
self.local_execution_runtime_miss_diagnostics
.lock()
.expect("local execution runtime miss diagnostics should lock")
.remove(trace_id)
}
pub(crate) async fn try_acquire_request_permit(
&self,
) -> Result<Option<AdmissionPermit>, RequestAdmissionError> {
let local = self
.request_gate
.as_ref()
.map(|gate| gate.try_acquire())
.transpose()
.map_err(RequestAdmissionError::Local)?;
let distributed = match self.distributed_request_gate.as_ref() {
Some(gate) => Some(
gate.try_acquire()
.await
.map_err(RequestAdmissionError::Distributed)?,
),
None => None,
};
Ok(AdmissionPermit::from_parts(local, distributed))
}
pub fn has_auth_api_key_data_reader(&self) -> bool {
self.data.has_auth_api_key_reader()
}
pub fn has_gemini_file_mapping_data_reader(&self) -> bool {
self.data.has_gemini_file_mapping_reader()
}
pub fn has_gemini_file_mapping_data_writer(&self) -> bool {
self.data.has_gemini_file_mapping_writer()
}
pub fn has_redis_data_backend(&self) -> bool {
self.data.has_redis_backend()
}
pub(crate) fn redis_kv_runner(&self) -> Option<aether_data::redis::RedisKvRunner> {
self.data.kv_runner()
}
pub(crate) fn postgres_pool(&self) -> Option<aether_data::postgres::PostgresPool> {
self.data.postgres_pool()
}
pub(crate) fn remove_scheduler_affinity_cache_entry(&self, cache_key: &str) -> bool {
self.scheduler_affinity_cache.remove(cache_key).is_some()
}
pub(crate) fn read_scheduler_affinity_target(
&self,
cache_key: &str,
ttl: Duration,
) -> Option<SchedulerAffinityTarget> {
self.scheduler_affinity_cache.get_fresh(cache_key, ttl)
}
pub(crate) fn remember_scheduler_affinity_target(
&self,
cache_key: &str,
target: SchedulerAffinityTarget,
ttl: Duration,
max_entries: usize,
) {
self.scheduler_affinity_cache
.insert(cache_key.to_string(), target, ttl, max_entries);
}
pub fn with_video_task_store_path(
mut self,
path: impl Into<std::path::PathBuf>,
) -> std::io::Result<Self> {
self.video_tasks = Arc::new(VideoTaskService::with_file_store(
self.video_tasks.truth_source_mode(),
path,
)?);
Ok(self)
}
pub fn spawn_background_tasks(&self) -> Vec<JoinHandle<()>> {
let mut tasks = Vec::new();
if let Some(handle) = self.usage_runtime.spawn_worker(self.data.clone()) {
tasks.push(handle);
}
if let Some(handle) =
crate::wallet_runtime::spawn_provider_quota_reset_worker(self.data.clone())
{
tasks.push(handle);
}
if let Some(handle) = spawn_audit_cleanup_worker(self.data.clone()) {
tasks.push(handle);
}
if let Some(handle) = spawn_db_maintenance_worker(self.data.clone()) {
tasks.push(handle);
}
if let Some(handle) = spawn_wallet_daily_usage_aggregation_worker(self.data.clone()) {
tasks.push(handle);
}
if let Some(handle) = spawn_stats_aggregation_worker(self.data.clone()) {
tasks.push(handle);
}
if let Some(handle) = spawn_usage_cleanup_worker(self.data.clone()) {
tasks.push(handle);
}
if let Some(handle) = spawn_pool_monitor_worker(self.data.clone()) {
tasks.push(handle);
}
if let Some(handle) = spawn_stats_hourly_aggregation_worker(self.data.clone()) {
tasks.push(handle);
}
if let Some(handle) = spawn_pending_cleanup_worker(self.data.clone()) {
tasks.push(handle);
}
if let Some(handle) = spawn_provider_checkin_worker(self.clone()) {
tasks.push(handle);
}
if let Some(handle) = spawn_request_candidate_cleanup_worker(self.data.clone()) {
tasks.push(handle);
}
if let Some(handle) = spawn_gemini_file_mapping_cleanup_worker(self.data.clone()) {
tasks.push(handle);
}
if let Some(handle) = spawn_model_fetch_worker(self.clone()) {
tasks.push(handle);
}
if let Some(handle) = spawn_video_task_poller(self.clone()) {
tasks.push(handle);
}
tasks
}
}

View File

@@ -0,0 +1,70 @@
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FrontdoorCorsConfig {
allowed_origins: Vec<String>,
allow_credentials: bool,
}
impl FrontdoorCorsConfig {
pub fn new(allowed_origins: Vec<String>, allow_credentials: bool) -> Option<Self> {
let allowed_origins = allowed_origins
.into_iter()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.collect::<Vec<_>>();
if allowed_origins.is_empty() {
return None;
}
let allow_any_origin = allowed_origins.iter().any(|value| value == "*");
Some(Self {
allowed_origins,
allow_credentials: allow_credentials && !allow_any_origin,
})
}
pub fn from_environment(
environment: &str,
cors_origins: Option<&str>,
allow_credentials: bool,
) -> Option<Self> {
let configured = cors_origins
.unwrap_or_default()
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.collect::<Vec<_>>();
if !configured.is_empty() {
return Self::new(configured, allow_credentials);
}
if environment.eq_ignore_ascii_case("development") {
return Self::new(
vec![
"http://localhost:3000".to_string(),
"http://localhost:5173".to_string(),
"http://127.0.0.1:3000".to_string(),
"http://127.0.0.1:5173".to_string(),
],
allow_credentials,
);
}
None
}
pub(crate) fn allows_origin(&self, origin: &str) -> bool {
self.allowed_origins
.iter()
.any(|value| value == "*" || value == origin)
}
pub(crate) fn allow_any_origin(&self) -> bool {
self.allowed_origins.iter().any(|value| value == "*")
}
pub(crate) fn allow_credentials(&self) -> bool {
self.allow_credentials
}
pub(crate) fn allowed_origins(&self) -> &[String] {
&self.allowed_origins
}
}

View File

@@ -0,0 +1,350 @@
use std::time::Duration;
use aether_contracts::{ExecutionPlan, ExecutionResult, ProxySnapshot};
use aether_data::repository::candidate_selection::StoredMinimalCandidateSelectionRow;
use aether_data::repository::candidates::{StoredRequestCandidate, UpsertRequestCandidateRecord};
use aether_data::repository::global_models::{
AdminGlobalModelListQuery, AdminProviderModelListQuery, StoredAdminGlobalModelPage,
StoredAdminProviderModel, UpsertAdminProviderModelRecord,
};
use aether_data::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
};
use aether_data::repository::quota::StoredProviderQuotaSnapshot;
use aether_data::DataLayerError;
use aether_model_fetch::{
aggregate_models_for_cache, model_fetch_interval_minutes, ModelFetchAssociationStore,
ModelFetchTransportRuntime,
};
use aether_scheduler_core::SchedulerAffinityTarget;
use async_trait::async_trait;
use serde_json::Value;
use tracing::debug;
use super::{AppState, GatewayError};
use crate::model_fetch::ModelFetchRuntimeState;
use crate::provider_transport::{
resolve_transport_proxy_snapshot_with_tunnel_affinity, GatewayProviderTransportSnapshot,
LocalResolvedOAuthRequestAuth,
};
use crate::scheduler::{
GatewayMinimalCandidateSelectionCandidate, SchedulerCandidateSelectionRowSource,
SchedulerRequestCandidateRuntimeState, SchedulerRuntimeState,
};
use crate::{execution_runtime, provider_transport};
#[async_trait]
impl provider_transport::TransportTunnelAffinityLookup for AppState {
async fn lookup_tunnel_attachment_owner(
&self,
node_id: &str,
) -> Result<Option<provider_transport::TransportTunnelAttachmentOwner>, String> {
self.tunnel
.lookup_attachment_owner(self.data.as_ref(), node_id)
.await
.map(|owner| {
owner.map(|owner| provider_transport::TransportTunnelAttachmentOwner {
gateway_instance_id: owner.gateway_instance_id,
relay_base_url: owner.relay_base_url,
observed_at_unix_secs: owner.observed_at_unix_secs,
})
})
}
}
#[async_trait]
impl provider_transport::VideoTaskTransportSnapshotLookup for AppState {
async fn read_video_task_provider_transport_snapshot(
&self,
provider_id: &str,
endpoint_id: &str,
key_id: &str,
) -> Result<Option<GatewayProviderTransportSnapshot>, String> {
self.read_provider_transport_snapshot(provider_id, endpoint_id, key_id)
.await
.map_err(|err| match err {
GatewayError::UpstreamUnavailable { message, .. }
| GatewayError::ControlUnavailable { message, .. }
| GatewayError::Internal(message) => message,
})
}
}
#[async_trait]
impl ModelFetchTransportRuntime for AppState {
async fn resolve_local_oauth_request_auth(
&self,
transport: &GatewayProviderTransportSnapshot,
) -> Result<Option<LocalResolvedOAuthRequestAuth>, String> {
AppState::resolve_local_oauth_request_auth(self, transport)
.await
.map_err(|err| match err {
GatewayError::UpstreamUnavailable { message, .. }
| GatewayError::ControlUnavailable { message, .. }
| GatewayError::Internal(message) => message,
})
}
async fn resolve_model_fetch_proxy(
&self,
transport: &GatewayProviderTransportSnapshot,
) -> Option<ProxySnapshot> {
resolve_transport_proxy_snapshot_with_tunnel_affinity(self, transport).await
}
}
#[async_trait]
impl ModelFetchRuntimeState for AppState {
fn has_provider_catalog_data_reader(&self) -> bool {
AppState::has_provider_catalog_data_reader(self)
}
fn has_provider_catalog_data_writer(&self) -> bool {
AppState::has_provider_catalog_data_writer(self)
}
async fn list_provider_catalog_providers(
&self,
active_only: bool,
) -> Result<Vec<StoredProviderCatalogProvider>, GatewayError> {
AppState::list_provider_catalog_providers(self, active_only).await
}
async fn list_provider_catalog_endpoints_by_provider_ids(
&self,
provider_ids: &[String],
) -> Result<Vec<StoredProviderCatalogEndpoint>, GatewayError> {
AppState::list_provider_catalog_endpoints_by_provider_ids(self, provider_ids).await
}
async fn read_provider_transport_snapshot(
&self,
provider_id: &str,
endpoint_id: &str,
key_id: &str,
) -> Result<Option<GatewayProviderTransportSnapshot>, GatewayError> {
AppState::read_provider_transport_snapshot(self, provider_id, endpoint_id, key_id).await
}
async fn execute_execution_runtime_sync_plan(
&self,
plan: &ExecutionPlan,
) -> Result<ExecutionResult, GatewayError> {
execution_runtime::execute_execution_runtime_sync_plan(self, None, plan).await
}
async fn update_provider_catalog_key(
&self,
key: &StoredProviderCatalogKey,
) -> Result<(), GatewayError> {
AppState::update_provider_catalog_key(self, key).await?;
Ok(())
}
async fn write_upstream_models_cache(
&self,
provider_id: &str,
key_id: &str,
cached_models: &[Value],
) {
let Some(runner) = AppState::redis_kv_runner(self) else {
return;
};
let Ok(serialized) = serde_json::to_string(&aggregate_models_for_cache(cached_models))
else {
return;
};
let cache_key = format!("upstream_models:{provider_id}:{key_id}");
if let Err(err) = runner
.setex(
&cache_key,
&serialized,
Some(model_fetch_interval_minutes().saturating_mul(60)),
)
.await
{
debug!(
provider_id = %provider_id,
key_id = %key_id,
error = %err,
"gateway model fetch cache write failed"
);
}
}
}
#[async_trait]
impl ModelFetchAssociationStore for AppState {
type Error = String;
fn has_global_model_reader(&self) -> bool {
self.data.has_global_model_reader()
}
fn has_global_model_writer(&self) -> bool {
self.data.has_global_model_writer()
}
fn model_fetch_internal_error(&self, message: String) -> Self::Error {
message
}
async fn list_admin_provider_models(
&self,
query: &AdminProviderModelListQuery,
) -> Result<Vec<StoredAdminProviderModel>, Self::Error> {
AppState::list_admin_provider_models(self, query)
.await
.map_err(|err| format!("{err:?}"))
}
async fn list_admin_global_models(
&self,
query: &AdminGlobalModelListQuery,
) -> Result<StoredAdminGlobalModelPage, Self::Error> {
AppState::list_admin_global_models(self, query)
.await
.map_err(|err| format!("{err:?}"))
}
async fn create_admin_provider_model(
&self,
record: &UpsertAdminProviderModelRecord,
) -> Result<Option<StoredAdminProviderModel>, Self::Error> {
AppState::create_admin_provider_model(self, record)
.await
.map_err(|err| format!("{err:?}"))
}
async fn list_provider_catalog_keys_by_provider_ids(
&self,
provider_ids: &[String],
) -> Result<Vec<StoredProviderCatalogKey>, Self::Error> {
AppState::list_provider_catalog_keys_by_provider_ids(self, provider_ids)
.await
.map_err(|err| format!("{err:?}"))
}
async fn delete_admin_provider_model(
&self,
provider_id: &str,
model_id: &str,
) -> Result<bool, Self::Error> {
AppState::delete_admin_provider_model(self, provider_id, model_id)
.await
.map_err(|err| format!("{err:?}"))
}
}
#[async_trait]
impl SchedulerRequestCandidateRuntimeState for AppState {
fn has_request_candidate_data_writer(&self) -> bool {
AppState::has_request_candidate_data_writer(self)
}
async fn read_request_candidates_by_request_id(
&self,
request_id: &str,
) -> Result<Vec<StoredRequestCandidate>, GatewayError> {
AppState::read_request_candidates_by_request_id(self, request_id).await
}
async fn upsert_request_candidate(
&self,
candidate: UpsertRequestCandidateRecord,
) -> Result<Option<StoredRequestCandidate>, GatewayError> {
AppState::upsert_request_candidate(self, candidate).await
}
}
#[async_trait]
impl SchedulerCandidateSelectionRowSource for AppState {
async fn read_minimal_candidate_selection_rows_for_api_format_and_global_model(
&self,
api_format: &str,
global_model_name: &str,
) -> Result<Vec<StoredMinimalCandidateSelectionRow>, DataLayerError> {
self.data
.list_minimal_candidate_selection_rows(api_format, global_model_name)
.await
}
async fn read_minimal_candidate_selection_rows_for_api_format(
&self,
api_format: &str,
) -> Result<Vec<StoredMinimalCandidateSelectionRow>, DataLayerError> {
self.data
.list_minimal_candidate_selection_rows_for_api_format(api_format)
.await
}
}
#[async_trait]
impl SchedulerRuntimeState for AppState {
async fn read_provider_quota_snapshot(
&self,
provider_id: &str,
) -> Result<Option<StoredProviderQuotaSnapshot>, GatewayError> {
AppState::read_provider_quota_snapshot(self, provider_id).await
}
async fn read_provider_catalog_providers_by_ids(
&self,
provider_ids: &[String],
) -> Result<Vec<StoredProviderCatalogProvider>, GatewayError> {
AppState::read_provider_catalog_providers_by_ids(self, provider_ids).await
}
async fn read_provider_catalog_keys_by_ids(
&self,
key_ids: &[String],
) -> Result<Vec<StoredProviderCatalogKey>, GatewayError> {
AppState::read_provider_catalog_keys_by_ids(self, key_ids).await
}
async fn read_recent_request_candidates(
&self,
limit: usize,
) -> Result<Vec<StoredRequestCandidate>, GatewayError> {
AppState::read_recent_request_candidates(self, limit).await
}
async fn read_minimal_candidate_selection(
&self,
api_format: &str,
global_model_name: &str,
require_streaming: bool,
auth_snapshot: Option<&crate::data::auth::GatewayAuthApiKeySnapshot>,
) -> Result<Vec<GatewayMinimalCandidateSelectionCandidate>, GatewayError> {
AppState::read_minimal_candidate_selection(
self,
api_format,
global_model_name,
require_streaming,
auth_snapshot,
)
.await
}
fn provider_key_rpm_reset_at(&self, key_id: &str, now_unix_secs: u64) -> Option<u64> {
AppState::provider_key_rpm_reset_at(self, key_id, now_unix_secs)
}
fn read_cached_scheduler_affinity_target(
&self,
cache_key: &str,
ttl: Duration,
) -> Option<SchedulerAffinityTarget> {
AppState::read_scheduler_affinity_target(self, cache_key, ttl)
}
fn remember_scheduler_affinity_target(
&self,
cache_key: &str,
target: SchedulerAffinityTarget,
ttl: Duration,
max_entries: usize,
) {
AppState::remember_scheduler_affinity_target(self, cache_key, target, ttl, max_entries);
}
}

View File

@@ -0,0 +1,36 @@
use super::error::GatewayError;
use super::data::GatewayDataState;
mod admin_types;
mod app;
mod cache;
mod catalog;
mod core;
mod cors;
mod integrations;
mod oauth;
mod runtime;
#[cfg(test)]
mod testing;
mod types;
mod video;
pub(crate) use self::admin_types::{
AdminBillingCollectorRecord, AdminBillingCollectorWriteInput, AdminBillingPresetApplyResult,
AdminBillingRuleRecord, AdminBillingRuleWriteInput, AdminPaymentCallbackRecord,
AdminSecurityBlacklistEntry, AdminWalletPaymentOrderRecord, AdminWalletRefundRecord,
AdminWalletTransactionRecord,
};
pub use self::app::AppState;
pub(crate) use self::cache::{
CachedProviderTransportSnapshot, AUTH_API_KEY_LAST_USED_MAX_ENTRIES,
AUTH_API_KEY_LAST_USED_TTL, PROVIDER_TRANSPORT_SNAPSHOT_CACHE_MAX_ENTRIES,
PROVIDER_TRANSPORT_SNAPSHOT_CACHE_TTL,
};
pub use self::cors::FrontdoorCorsConfig;
pub(crate) use self::types::{
AdminWalletMutationOutcome, LocalExecutionRuntimeMissDiagnostic, LocalMutationOutcome,
LocalProviderDeleteTaskState,
};
use super::provider_transport::provider_transport_snapshot_looks_refreshed;
pub(crate) use super::provider_transport::ProviderTransportSnapshotCacheKey;

View File

@@ -0,0 +1,570 @@
use super::{
provider_transport_snapshot_looks_refreshed, AppState, CachedProviderTransportSnapshot,
GatewayError, ProviderTransportSnapshotCacheKey, PROVIDER_TRANSPORT_SNAPSHOT_CACHE_MAX_ENTRIES,
PROVIDER_TRANSPORT_SNAPSHOT_CACHE_TTL,
};
use super::super::provider_transport;
use std::time::Duration;
use aether_crypto::encrypt_python_fernet_plaintext;
impl AppState {
pub(crate) fn clear_provider_transport_snapshot_cache(&self) {
self.provider_transport_snapshot_cache
.lock()
.expect("provider transport snapshot cache should lock")
.clear();
}
fn get_cached_provider_transport_snapshot(
&self,
cache_key: &ProviderTransportSnapshotCacheKey,
) -> Option<provider_transport::GatewayProviderTransportSnapshot> {
let mut cache = self
.provider_transport_snapshot_cache
.lock()
.expect("provider transport snapshot cache should lock");
let cached = cache.get(cache_key).cloned()?;
if cached.loaded_at.elapsed() <= PROVIDER_TRANSPORT_SNAPSHOT_CACHE_TTL {
return Some(cached.snapshot);
}
cache.remove(cache_key);
None
}
fn put_cached_provider_transport_snapshot(
&self,
cache_key: ProviderTransportSnapshotCacheKey,
snapshot: provider_transport::GatewayProviderTransportSnapshot,
) {
let mut cache = self
.provider_transport_snapshot_cache
.lock()
.expect("provider transport snapshot cache should lock");
if cache.len() >= PROVIDER_TRANSPORT_SNAPSHOT_CACHE_MAX_ENTRIES {
cache.retain(|_, entry| {
entry.loaded_at.elapsed() <= PROVIDER_TRANSPORT_SNAPSHOT_CACHE_TTL
});
if cache.len() >= PROVIDER_TRANSPORT_SNAPSHOT_CACHE_MAX_ENTRIES {
cache.clear();
}
}
cache.insert(
cache_key,
CachedProviderTransportSnapshot {
loaded_at: std::time::Instant::now(),
snapshot,
},
);
}
async fn read_provider_transport_snapshot_uncached(
&self,
provider_id: &str,
endpoint_id: &str,
key_id: &str,
) -> Result<
Option<crate::provider_transport::GatewayProviderTransportSnapshot>,
GatewayError,
> {
self.data
.read_provider_transport_snapshot(provider_id, endpoint_id, key_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn list_enabled_oauth_module_providers(
&self,
) -> Result<
Vec<aether_data::repository::auth_modules::StoredOAuthProviderModuleConfig>,
GatewayError,
> {
self.data
.list_enabled_oauth_module_providers()
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn get_ldap_module_config(
&self,
) -> Result<Option<aether_data::repository::auth_modules::StoredLdapModuleConfig>, GatewayError>
{
self.data
.get_ldap_module_config()
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn upsert_ldap_module_config(
&self,
config: &aether_data::repository::auth_modules::StoredLdapModuleConfig,
) -> Result<Option<aether_data::repository::auth_modules::StoredLdapModuleConfig>, GatewayError>
{
self.data
.upsert_ldap_module_config(config)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn count_active_local_admin_users_with_valid_password(
&self,
) -> Result<u64, GatewayError> {
self.data
.count_active_local_admin_users_with_valid_password()
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn list_oauth_provider_configs(
&self,
) -> Result<
Vec<aether_data::repository::oauth_providers::StoredOAuthProviderConfig>,
GatewayError,
> {
self.data
.list_oauth_provider_configs()
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn get_oauth_provider_config(
&self,
provider_type: &str,
) -> Result<
Option<aether_data::repository::oauth_providers::StoredOAuthProviderConfig>,
GatewayError,
> {
self.data
.get_oauth_provider_config(provider_type)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn count_locked_users_if_oauth_provider_disabled(
&self,
provider_type: &str,
ldap_exclusive: bool,
) -> Result<usize, GatewayError> {
self.data
.count_locked_users_if_oauth_provider_disabled(provider_type, ldap_exclusive)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn upsert_oauth_provider_config(
&self,
record: &aether_data::repository::oauth_providers::UpsertOAuthProviderConfigRecord,
) -> Result<
Option<aether_data::repository::oauth_providers::StoredOAuthProviderConfig>,
GatewayError,
> {
self.data
.upsert_oauth_provider_config(record)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn delete_oauth_provider_config(
&self,
provider_type: &str,
) -> Result<bool, GatewayError> {
self.data
.delete_oauth_provider_config(provider_type)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) fn encryption_key(&self) -> Option<&str> {
self.data.encryption_key()
}
pub(crate) fn has_auth_module_writer(&self) -> bool {
self.data.has_auth_module_writer()
}
pub(crate) fn provider_oauth_token_url(
&self,
_provider_type: &str,
default_token_url: &str,
) -> String {
#[cfg(test)]
{
if let Some(value) = self
.provider_oauth_token_url_overrides
.lock()
.expect("provider oauth token url overrides should lock")
.get(_provider_type.trim())
.cloned()
{
return value;
}
}
default_token_url.to_string()
}
pub(crate) fn save_provider_oauth_state_for_tests(&self, _key: &str, _value: &str) -> bool {
#[cfg(test)]
{
if let Some(store) = self.provider_oauth_state_store.as_ref() {
store
.lock()
.expect("provider oauth state store should lock")
.insert(_key.to_string(), _value.to_string());
return true;
}
}
false
}
pub(crate) fn take_provider_oauth_state_for_tests(&self, _key: &str) -> Option<String> {
#[cfg(test)]
{
return self.provider_oauth_state_store.as_ref().and_then(|store| {
store
.lock()
.expect("provider oauth state store should lock")
.remove(_key)
});
}
#[allow(unreachable_code)]
None
}
pub(crate) fn save_provider_oauth_device_session_for_tests(
&self,
_key: &str,
_value: &str,
) -> bool {
#[cfg(test)]
{
if let Some(store) = self.provider_oauth_device_session_store.as_ref() {
store
.lock()
.expect("provider oauth device session store should lock")
.insert(_key.to_string(), _value.to_string());
return true;
}
}
false
}
pub(crate) fn load_provider_oauth_device_session_for_tests(
&self,
_key: &str,
) -> Option<String> {
#[cfg(test)]
{
return self
.provider_oauth_device_session_store
.as_ref()
.and_then(|store| {
store
.lock()
.expect("provider oauth device session store should lock")
.get(_key)
.cloned()
});
}
#[allow(unreachable_code)]
None
}
pub(crate) fn save_provider_oauth_batch_task_for_tests(
&self,
_key: &str,
_value: &str,
) -> bool {
#[cfg(test)]
{
if let Some(store) = self.provider_oauth_batch_task_store.as_ref() {
store
.lock()
.expect("provider oauth batch task store should lock")
.insert(_key.to_string(), _value.to_string());
return true;
}
}
false
}
pub(crate) fn load_provider_oauth_batch_task_for_tests(&self, _key: &str) -> Option<String> {
#[cfg(test)]
{
return self
.provider_oauth_batch_task_store
.as_ref()
.and_then(|store| {
store
.lock()
.expect("provider oauth batch task store should lock")
.get(_key)
.cloned()
});
}
#[allow(unreachable_code)]
None
}
pub(crate) async fn read_provider_transport_snapshot(
&self,
provider_id: &str,
endpoint_id: &str,
key_id: &str,
) -> Result<
Option<crate::provider_transport::GatewayProviderTransportSnapshot>,
GatewayError,
> {
let Some(cache_key) =
ProviderTransportSnapshotCacheKey::new(provider_id, endpoint_id, key_id)
else {
return self
.read_provider_transport_snapshot_uncached(provider_id, endpoint_id, key_id)
.await;
};
if let Some(snapshot) = self.get_cached_provider_transport_snapshot(&cache_key) {
return Ok(Some(snapshot));
}
let snapshot = self
.read_provider_transport_snapshot_uncached(provider_id, endpoint_id, key_id)
.await?;
if let Some(snapshot) = snapshot.as_ref() {
self.put_cached_provider_transport_snapshot(cache_key, snapshot.clone());
}
Ok(snapshot)
}
pub(crate) async fn update_provider_catalog_key_oauth_credentials(
&self,
key_id: &str,
encrypted_api_key: &str,
encrypted_auth_config: Option<&str>,
expires_at_unix_secs: Option<u64>,
) -> Result<bool, GatewayError> {
let updated = self
.data
.update_provider_catalog_key_oauth_credentials(
key_id,
encrypted_api_key,
encrypted_auth_config,
expires_at_unix_secs,
)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
if updated {
self.clear_provider_transport_snapshot_cache();
}
Ok(updated)
}
pub(crate) async fn resolve_local_oauth_request_auth(
&self,
transport: &provider_transport::GatewayProviderTransportSnapshot,
) -> Result<Option<provider_transport::LocalResolvedOAuthRequestAuth>, GatewayError> {
let distributed_lock = self.data.oauth_refresh_lock_runner();
let lock_owner = format!("aether-gateway-{}", std::process::id());
let mut current_transport = transport.clone();
for _ in 0..2 {
let resolution = self
.oauth_refresh
.resolve_with_result(
&self.client,
&current_transport,
distributed_lock.as_ref(),
Some(lock_owner.as_str()),
)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
if resolution
.as_ref()
.is_some_and(|resolution| resolution.refresh_in_flight)
{
let Some(reloaded_transport) = self
.wait_for_remote_oauth_refresh(&current_transport)
.await?
else {
continue;
};
current_transport = reloaded_transport;
continue;
}
if let Some(refreshed_entry) = resolution
.as_ref()
.and_then(|resolution| resolution.refreshed_entry.as_ref())
{
if let Err(err) = self
.persist_local_oauth_refresh_entry(&current_transport, refreshed_entry)
.await
{
tracing::warn!(
key_id = %current_transport.key.id,
provider_type = %current_transport.provider.provider_type,
error = ?err,
"gateway local oauth refresh persistence failed"
);
}
}
return Ok(resolution.and_then(|resolution| resolution.auth));
}
Ok(None)
}
pub(crate) async fn force_local_oauth_refresh_entry(
&self,
transport: &provider_transport::GatewayProviderTransportSnapshot,
) -> Result<
Option<provider_transport::CachedOAuthEntry>,
provider_transport::LocalOAuthRefreshError,
> {
let distributed_lock = self.data.oauth_refresh_lock_runner();
let lock_owner = format!("aether-gateway-admin-{}", std::process::id());
let mut current_transport = transport.clone();
current_transport.key.decrypted_api_key = "__placeholder__".to_string();
for _ in 0..2 {
let resolution = self
.oauth_refresh
.resolve_with_result(
&self.client,
&current_transport,
distributed_lock.as_ref(),
Some(lock_owner.as_str()),
)
.await?;
if resolution
.as_ref()
.is_some_and(|resolution| resolution.refresh_in_flight)
{
let Some(reloaded_transport) = self
.wait_for_remote_oauth_refresh(&current_transport)
.await
.map_err(
|err| provider_transport::LocalOAuthRefreshError::InvalidResponse {
provider_type: "gateway",
message: format!("{err:?}"),
},
)?
else {
continue;
};
current_transport = reloaded_transport;
current_transport.key.decrypted_api_key = "__placeholder__".to_string();
continue;
}
if let Some(refreshed_entry) = resolution
.as_ref()
.and_then(|resolution| resolution.refreshed_entry.as_ref())
{
if let Err(err) = self
.persist_local_oauth_refresh_entry(&current_transport, refreshed_entry)
.await
{
tracing::warn!(
key_id = %current_transport.key.id,
provider_type = %current_transport.provider.provider_type,
error = ?err,
"gateway manual oauth refresh persistence failed"
);
}
return Ok(Some(refreshed_entry.clone()));
}
return Ok(None);
}
Ok(None)
}
async fn persist_local_oauth_refresh_entry(
&self,
transport: &provider_transport::GatewayProviderTransportSnapshot,
entry: &provider_transport::CachedOAuthEntry,
) -> Result<(), GatewayError> {
let key_id = transport.key.id.trim();
if key_id.is_empty() {
return Ok(());
}
let Some(encryption_key) = self.data.encryption_key() else {
return Ok(());
};
let access_token = entry
.auth_header_value
.trim()
.strip_prefix("Bearer ")
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
GatewayError::Internal(
"local oauth refresh produced non-bearer auth header".to_string(),
)
})?;
let encrypted_api_key = encrypt_python_fernet_plaintext(encryption_key, access_token)
.map_err(|err| GatewayError::Internal(err.to_string()))?;
let encrypted_auth_config = entry
.metadata
.as_ref()
.map(|value| serde_json::to_string(value))
.transpose()
.map_err(|err| GatewayError::Internal(err.to_string()))?
.map(|value| encrypt_python_fernet_plaintext(encryption_key, value.as_str()))
.transpose()
.map_err(|err| GatewayError::Internal(err.to_string()))?;
self.update_provider_catalog_key_oauth_credentials(
key_id,
encrypted_api_key.as_str(),
encrypted_auth_config.as_deref(),
entry.expires_at_unix_secs,
)
.await?;
Ok(())
}
async fn wait_for_remote_oauth_refresh(
&self,
transport: &provider_transport::GatewayProviderTransportSnapshot,
) -> Result<Option<provider_transport::GatewayProviderTransportSnapshot>, GatewayError> {
if !self.data.has_provider_catalog_reader() {
return Ok(None);
}
for _ in 0..20 {
let Some(reloaded_transport) = self
.read_provider_transport_snapshot_uncached(
&transport.provider.id,
&transport.endpoint.id,
&transport.key.id,
)
.await?
else {
return Ok(None);
};
if provider_transport_snapshot_looks_refreshed(transport, &reloaded_transport) {
return Ok(Some(reloaded_transport));
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
Ok(None)
}
}

View File

@@ -0,0 +1,79 @@
use crate::{AppState, GatewayError};
impl AppState {
pub(crate) async fn list_announcements(
&self,
query: &aether_data::repository::announcements::AnnouncementListQuery,
) -> Result<aether_data::repository::announcements::StoredAnnouncementPage, GatewayError> {
self.data
.list_announcements(query)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn find_announcement_by_id(
&self,
announcement_id: &str,
) -> Result<Option<aether_data::repository::announcements::StoredAnnouncement>, GatewayError>
{
self.data
.find_announcement_by_id(announcement_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn count_unread_active_announcements(
&self,
user_id: &str,
now_unix_secs: u64,
) -> Result<u64, GatewayError> {
self.data
.count_unread_active_announcements(user_id, now_unix_secs)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn create_announcement(
&self,
record: aether_data::repository::announcements::CreateAnnouncementRecord,
) -> Result<Option<aether_data::repository::announcements::StoredAnnouncement>, GatewayError>
{
self.data
.create_announcement(record)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn update_announcement(
&self,
record: aether_data::repository::announcements::UpdateAnnouncementRecord,
) -> Result<Option<aether_data::repository::announcements::StoredAnnouncement>, GatewayError>
{
self.data
.update_announcement(record)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn delete_announcement(
&self,
announcement_id: &str,
) -> Result<bool, GatewayError> {
self.data
.delete_announcement(announcement_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn mark_announcement_as_read(
&self,
user_id: &str,
announcement_id: &str,
read_at_unix_secs: u64,
) -> Result<bool, GatewayError> {
self.data
.mark_announcement_as_read(user_id, announcement_id, read_at_unix_secs)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
}

View File

@@ -0,0 +1,283 @@
use crate::{AppState, GatewayError};
impl AppState {
pub(crate) async fn list_auth_api_key_export_records_by_user_ids(
&self,
user_ids: &[String],
) -> Result<Vec<aether_data::repository::auth::StoredAuthApiKeyExportRecord>, GatewayError>
{
self.data
.list_auth_api_key_export_records_by_user_ids(user_ids)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn list_auth_api_key_export_records_by_ids(
&self,
api_key_ids: &[String],
) -> Result<Vec<aether_data::repository::auth::StoredAuthApiKeyExportRecord>, GatewayError>
{
self.data
.list_auth_api_key_export_records_by_ids(api_key_ids)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn list_auth_api_key_export_standalone_records_page(
&self,
query: &aether_data::repository::auth::StandaloneApiKeyExportListQuery,
) -> Result<Vec<aether_data::repository::auth::StoredAuthApiKeyExportRecord>, GatewayError>
{
self.data
.list_auth_api_key_export_standalone_records_page(query)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn count_auth_api_key_export_standalone_records(
&self,
is_active: Option<bool>,
) -> Result<u64, GatewayError> {
self.data
.count_auth_api_key_export_standalone_records(is_active)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn summarize_auth_api_key_export_records_by_user_ids(
&self,
user_ids: &[String],
now_unix_secs: u64,
) -> Result<aether_data::repository::auth::AuthApiKeyExportSummary, GatewayError> {
self.data
.summarize_auth_api_key_export_records_by_user_ids(user_ids, now_unix_secs)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn summarize_auth_api_key_export_non_standalone_records(
&self,
now_unix_secs: u64,
) -> Result<aether_data::repository::auth::AuthApiKeyExportSummary, GatewayError> {
self.data
.summarize_auth_api_key_export_non_standalone_records(now_unix_secs)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn list_auth_api_key_export_standalone_records(
&self,
) -> Result<Vec<aether_data::repository::auth::StoredAuthApiKeyExportRecord>, GatewayError>
{
self.data
.list_auth_api_key_export_standalone_records()
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn summarize_auth_api_key_export_standalone_records(
&self,
now_unix_secs: u64,
) -> Result<aether_data::repository::auth::AuthApiKeyExportSummary, GatewayError> {
self.data
.summarize_auth_api_key_export_standalone_records(now_unix_secs)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn find_auth_api_key_export_standalone_record_by_id(
&self,
api_key_id: &str,
) -> Result<Option<aether_data::repository::auth::StoredAuthApiKeyExportRecord>, GatewayError>
{
self.data
.find_auth_api_key_export_standalone_record_by_id(api_key_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn list_non_admin_export_users(
&self,
) -> Result<Vec<aether_data::repository::users::StoredUserExportRow>, GatewayError> {
self.data
.list_non_admin_export_users()
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn list_export_users(
&self,
) -> Result<Vec<aether_data::repository::users::StoredUserExportRow>, GatewayError> {
self.data
.list_export_users()
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn summarize_export_users(
&self,
) -> Result<aether_data::repository::users::UserExportSummary, GatewayError> {
self.data
.summarize_export_users()
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn list_export_users_page(
&self,
query: &aether_data::repository::users::UserExportListQuery,
) -> Result<Vec<aether_data::repository::users::StoredUserExportRow>, GatewayError> {
self.data
.list_export_users_page(query)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn find_export_user_by_id(
&self,
user_id: &str,
) -> Result<Option<aether_data::repository::users::StoredUserExportRow>, GatewayError> {
self.data
.find_export_user_by_id(user_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn list_user_auth_by_ids(
&self,
user_ids: &[String],
) -> Result<Vec<aether_data::repository::users::StoredUserAuthRecord>, GatewayError> {
self.data
.list_user_auth_by_ids(user_ids)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn create_user_api_key(
&self,
record: aether_data::repository::auth::CreateUserApiKeyRecord,
) -> Result<Option<aether_data::repository::auth::StoredAuthApiKeyExportRecord>, GatewayError>
{
self.data
.create_user_api_key(record)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn create_standalone_api_key(
&self,
record: aether_data::repository::auth::CreateStandaloneApiKeyRecord,
) -> Result<Option<aether_data::repository::auth::StoredAuthApiKeyExportRecord>, GatewayError>
{
self.data
.create_standalone_api_key(record)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn update_user_api_key_basic(
&self,
record: aether_data::repository::auth::UpdateUserApiKeyBasicRecord,
) -> Result<Option<aether_data::repository::auth::StoredAuthApiKeyExportRecord>, GatewayError>
{
self.data
.update_user_api_key_basic(record)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn update_standalone_api_key_basic(
&self,
record: aether_data::repository::auth::UpdateStandaloneApiKeyBasicRecord,
) -> Result<Option<aether_data::repository::auth::StoredAuthApiKeyExportRecord>, GatewayError>
{
self.data
.update_standalone_api_key_basic(record)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn set_user_api_key_active(
&self,
user_id: &str,
api_key_id: &str,
is_active: bool,
) -> Result<Option<aether_data::repository::auth::StoredAuthApiKeyExportRecord>, GatewayError>
{
self.data
.set_user_api_key_active(user_id, api_key_id, is_active)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn set_standalone_api_key_active(
&self,
api_key_id: &str,
is_active: bool,
) -> Result<Option<aether_data::repository::auth::StoredAuthApiKeyExportRecord>, GatewayError>
{
self.data
.set_standalone_api_key_active(api_key_id, is_active)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn set_user_api_key_locked(
&self,
user_id: &str,
api_key_id: &str,
is_locked: bool,
) -> Result<bool, GatewayError> {
self.data
.set_user_api_key_locked(user_id, api_key_id, is_locked)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn set_user_api_key_allowed_providers(
&self,
user_id: &str,
api_key_id: &str,
allowed_providers: Option<Vec<String>>,
) -> Result<Option<aether_data::repository::auth::StoredAuthApiKeyExportRecord>, GatewayError>
{
self.data
.set_user_api_key_allowed_providers(user_id, api_key_id, allowed_providers)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn set_user_api_key_force_capabilities(
&self,
user_id: &str,
api_key_id: &str,
force_capabilities: Option<serde_json::Value>,
) -> Result<Option<aether_data::repository::auth::StoredAuthApiKeyExportRecord>, GatewayError>
{
self.data
.set_user_api_key_force_capabilities(user_id, api_key_id, force_capabilities)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn delete_user_api_key(
&self,
user_id: &str,
api_key_id: &str,
) -> Result<bool, GatewayError> {
self.data
.delete_user_api_key(user_id, api_key_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn delete_standalone_api_key(
&self,
api_key_id: &str,
) -> Result<bool, GatewayError> {
self.data
.delete_standalone_api_key(api_key_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
}

View File

@@ -0,0 +1,141 @@
use crate::{scheduler, AppState, GatewayError};
use aether_data::repository::audit::RequestAuditBundle;
use aether_data::repository::usage::StoredRequestUsageAudit;
use super::super::{AUTH_API_KEY_LAST_USED_MAX_ENTRIES, AUTH_API_KEY_LAST_USED_TTL};
impl AppState {
pub(crate) async fn read_request_candidate_trace(
&self,
request_id: &str,
attempted_only: bool,
) -> Result<Option<crate::data::candidates::RequestCandidateTrace>, GatewayError>
{
self.data
.read_request_candidate_trace(request_id, attempted_only)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn read_decision_trace(
&self,
request_id: &str,
attempted_only: bool,
) -> Result<Option<crate::data::decision_trace::DecisionTrace>, GatewayError>
{
self.data
.read_decision_trace(request_id, attempted_only)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn read_request_usage_audit(
&self,
request_id: &str,
) -> Result<Option<StoredRequestUsageAudit>, GatewayError> {
self.data
.read_request_usage_audit(request_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn find_request_usage_by_id(
&self,
usage_id: &str,
) -> Result<Option<aether_data::repository::usage::StoredRequestUsageAudit>, GatewayError> {
self.data
.find_request_usage_by_id(usage_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn read_request_audit_bundle(
&self,
request_id: &str,
attempted_only: bool,
now_unix_secs: u64,
) -> Result<Option<RequestAuditBundle>, GatewayError> {
self.data
.read_request_audit_bundle(request_id, attempted_only, now_unix_secs)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn read_auth_api_key_snapshot(
&self,
user_id: &str,
api_key_id: &str,
now_unix_secs: u64,
) -> Result<Option<crate::data::auth::GatewayAuthApiKeySnapshot>, GatewayError>
{
self.data
.read_auth_api_key_snapshot(user_id, api_key_id, now_unix_secs)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn read_auth_api_key_snapshot_by_key_hash(
&self,
key_hash: &str,
now_unix_secs: u64,
) -> Result<Option<crate::data::auth::GatewayAuthApiKeySnapshot>, GatewayError>
{
self.data
.read_auth_api_key_snapshot_by_key_hash(key_hash, now_unix_secs)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn read_auth_api_key_snapshots_by_ids(
&self,
api_key_ids: &[String],
) -> Result<Vec<aether_data::repository::auth::StoredAuthApiKeySnapshot>, GatewayError> {
self.data
.list_auth_api_key_snapshots_by_ids(api_key_ids)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) fn has_auth_api_key_writer(&self) -> bool {
self.data.has_auth_api_key_writer()
}
pub(crate) async fn touch_auth_api_key_last_used_best_effort(&self, api_key_id: &str) {
let api_key_id = api_key_id.trim();
if api_key_id.is_empty() || !self.has_auth_api_key_writer() {
return;
}
if !self.auth_api_key_last_used_cache.should_touch(
api_key_id,
AUTH_API_KEY_LAST_USED_TTL,
AUTH_API_KEY_LAST_USED_MAX_ENTRIES,
) {
return;
}
if let Err(err) = self.data.touch_auth_api_key_last_used(api_key_id).await {
tracing::warn!(
api_key_id = %api_key_id,
error = ?err,
"gateway auth api key last_used_at touch failed"
);
}
}
pub(crate) async fn read_minimal_candidate_selection(
&self,
api_format: &str,
global_model_name: &str,
require_streaming: bool,
auth_snapshot: Option<&crate::data::auth::GatewayAuthApiKeySnapshot>,
) -> Result<Vec<scheduler::GatewayMinimalCandidateSelectionCandidate>, GatewayError> {
scheduler::read_minimal_candidate_selection(
self.data.as_ref(),
api_format,
global_model_name,
require_streaming,
auth_snapshot,
)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
}

View File

@@ -0,0 +1,3 @@
mod sessions;
mod user_lifecycle;
mod user_provisioning;

View File

@@ -0,0 +1,254 @@
use crate::{AppState, GatewayError};
impl AppState {
pub(crate) async fn find_user_session(
&self,
user_id: &str,
session_id: &str,
) -> Result<Option<crate::data::state::StoredUserSessionRecord>, GatewayError>
{
#[cfg(test)]
if let Some(store) = self.auth_session_store.as_ref() {
let key = format!("{user_id}:{session_id}");
return Ok(store
.lock()
.expect("auth session store should lock")
.get(&key)
.cloned());
}
self.data
.find_user_session(user_id, session_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn list_user_sessions(
&self,
user_id: &str,
) -> Result<Vec<crate::data::state::StoredUserSessionRecord>, GatewayError>
{
#[cfg(test)]
if let Some(store) = self.auth_session_store.as_ref() {
let prefix = format!("{user_id}:");
let now = chrono::Utc::now();
let mut sessions = store
.lock()
.expect("auth session store should lock")
.iter()
.filter(|(key, _)| key.starts_with(&prefix))
.map(|(_, session)| session.clone())
.filter(|session| !session.is_revoked() && !session.is_expired(now))
.collect::<Vec<_>>();
sessions.sort_by(|left, right| {
right
.last_seen_at
.cmp(&left.last_seen_at)
.then_with(|| right.created_at.cmp(&left.created_at))
});
return Ok(sessions);
}
self.data
.list_user_sessions(user_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn touch_user_session(
&self,
user_id: &str,
session_id: &str,
touched_at: chrono::DateTime<chrono::Utc>,
ip_address: Option<&str>,
user_agent: Option<&str>,
) -> Result<bool, GatewayError> {
#[cfg(test)]
if let Some(store) = self.auth_session_store.as_ref() {
let key = format!("{user_id}:{session_id}");
let mut guard = store.lock().expect("auth session store should lock");
if let Some(session) = guard.get_mut(&key) {
session.last_seen_at = Some(touched_at);
if let Some(ip_address) = ip_address {
session.ip_address = Some(ip_address.to_string());
}
if let Some(user_agent) = user_agent {
session.user_agent = Some(user_agent.chars().take(1000).collect());
}
session.updated_at = Some(touched_at);
return Ok(true);
}
return Ok(false);
}
self.data
.touch_user_session(user_id, session_id, touched_at, ip_address, user_agent)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn update_user_session_device_label(
&self,
user_id: &str,
session_id: &str,
device_label: &str,
updated_at: chrono::DateTime<chrono::Utc>,
) -> Result<bool, GatewayError> {
#[cfg(test)]
if let Some(store) = self.auth_session_store.as_ref() {
let key = format!("{user_id}:{session_id}");
let mut guard = store.lock().expect("auth session store should lock");
if let Some(session) = guard.get_mut(&key) {
session.device_label = Some(device_label.chars().take(120).collect());
session.updated_at = Some(updated_at);
return Ok(true);
}
return Ok(false);
}
self.data
.update_user_session_device_label(user_id, session_id, device_label, updated_at)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn create_user_session(
&self,
session: crate::data::state::StoredUserSessionRecord,
) -> Result<Option<crate::data::state::StoredUserSessionRecord>, GatewayError>
{
#[cfg(test)]
if let Some(store) = self.auth_session_store.as_ref() {
let now = session
.created_at
.or(session.updated_at)
.or(session.last_seen_at)
.unwrap_or_else(chrono::Utc::now);
let mut guard = store.lock().expect("auth session store should lock");
for existing in guard.values_mut() {
if existing.user_id == session.user_id
&& existing.client_device_id == session.client_device_id
&& !existing.is_revoked()
&& !existing.is_expired(now)
{
existing.revoked_at = Some(now);
existing.revoke_reason = Some("replaced_by_new_login".to_string());
existing.updated_at = Some(now);
}
}
guard.insert(
format!("{}:{}", session.user_id, session.id),
session.clone(),
);
return Ok(Some(session));
}
self.data
.create_user_session(&session)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn rotate_user_session_refresh_token(
&self,
user_id: &str,
session_id: &str,
previous_refresh_token_hash: &str,
next_refresh_token_hash: &str,
rotated_at: chrono::DateTime<chrono::Utc>,
expires_at: chrono::DateTime<chrono::Utc>,
ip_address: Option<&str>,
user_agent: Option<&str>,
) -> Result<bool, GatewayError> {
#[cfg(test)]
if let Some(store) = self.auth_session_store.as_ref() {
let key = format!("{user_id}:{session_id}");
let mut guard = store.lock().expect("auth session store should lock");
if let Some(session) = guard.get_mut(&key) {
session.prev_refresh_token_hash = Some(previous_refresh_token_hash.to_string());
session.refresh_token_hash = next_refresh_token_hash.to_string();
session.rotated_at = Some(rotated_at);
session.expires_at = Some(expires_at);
session.last_seen_at = Some(rotated_at);
if let Some(ip_address) = ip_address {
session.ip_address = Some(ip_address.to_string());
}
if let Some(user_agent) = user_agent {
session.user_agent = Some(user_agent.chars().take(1000).collect());
}
session.updated_at = Some(rotated_at);
return Ok(true);
}
return Ok(false);
}
self.data
.rotate_user_session_refresh_token(
user_id,
session_id,
previous_refresh_token_hash,
next_refresh_token_hash,
rotated_at,
expires_at,
ip_address,
user_agent,
)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn revoke_user_session(
&self,
user_id: &str,
session_id: &str,
revoked_at: chrono::DateTime<chrono::Utc>,
reason: &str,
) -> Result<bool, GatewayError> {
#[cfg(test)]
if let Some(store) = self.auth_session_store.as_ref() {
let key = format!("{user_id}:{session_id}");
let mut guard = store.lock().expect("auth session store should lock");
if let Some(session) = guard.get_mut(&key) {
session.revoked_at = Some(revoked_at);
session.revoke_reason = Some(reason.chars().take(100).collect());
session.updated_at = Some(revoked_at);
return Ok(true);
}
return Ok(false);
}
self.data
.revoke_user_session(user_id, session_id, revoked_at, reason)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn revoke_all_user_sessions(
&self,
user_id: &str,
revoked_at: chrono::DateTime<chrono::Utc>,
reason: &str,
) -> Result<u64, GatewayError> {
#[cfg(test)]
if let Some(store) = self.auth_session_store.as_ref() {
let prefix = format!("{user_id}:");
let mut revoked = 0_u64;
let mut guard = store.lock().expect("auth session store should lock");
for (key, session) in guard.iter_mut() {
if !key.starts_with(&prefix) || session.revoked_at.is_some() {
continue;
}
session.revoked_at = Some(revoked_at);
session.revoke_reason = Some(reason.chars().take(100).collect());
session.updated_at = Some(revoked_at);
revoked += 1;
}
return Ok(revoked);
}
self.data
.revoke_all_user_sessions(user_id, revoked_at, reason)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
}

View File

@@ -0,0 +1,475 @@
use crate::{AppState, GatewayError};
impl AppState {
pub(crate) async fn find_user_auth_by_id(
&self,
user_id: &str,
) -> Result<Option<aether_data::repository::users::StoredUserAuthRecord>, GatewayError> {
#[cfg(test)]
if let Some(store) = self.auth_user_store.as_ref() {
if let Some(user) = store
.lock()
.expect("auth user store should lock")
.get(user_id)
.cloned()
{
return Ok(Some(user));
}
}
self.data
.find_user_auth_by_id(user_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn find_user_auth_by_identifier(
&self,
identifier: &str,
) -> Result<Option<aether_data::repository::users::StoredUserAuthRecord>, GatewayError> {
#[cfg(test)]
if let Some(store) = self.auth_user_store.as_ref() {
let identifier = identifier.trim();
if !identifier.is_empty() {
if let Some(user) = store
.lock()
.expect("auth user store should lock")
.values()
.find(|user| {
user.username == identifier || user.email.as_deref() == Some(identifier)
})
.cloned()
{
return Ok(Some(user));
}
}
}
self.data
.find_user_auth_by_identifier(identifier)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn is_other_user_auth_email_taken(
&self,
email: &str,
user_id: &str,
) -> Result<bool, GatewayError> {
#[cfg(test)]
if let Some(store) = self.auth_user_store.as_ref() {
if store
.lock()
.expect("auth user store should lock")
.values()
.any(|user| user.id != user_id && user.email.as_deref() == Some(email))
{
return Ok(true);
}
}
self.data
.is_other_user_auth_email_taken(email, user_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn is_other_user_auth_username_taken(
&self,
username: &str,
user_id: &str,
) -> Result<bool, GatewayError> {
#[cfg(test)]
if let Some(store) = self.auth_user_store.as_ref() {
if store
.lock()
.expect("auth user store should lock")
.values()
.any(|user| user.id != user_id && user.username == username)
{
return Ok(true);
}
}
self.data
.is_other_user_auth_username_taken(username, user_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn update_local_auth_user_profile(
&self,
user_id: &str,
email: Option<String>,
username: Option<String>,
) -> Result<Option<aether_data::repository::users::StoredUserAuthRecord>, GatewayError> {
#[cfg(test)]
if let Some(store) = self.auth_user_store.as_ref() {
let existing = {
store
.lock()
.expect("auth user store should lock")
.get(user_id)
.cloned()
};
let existing = match existing {
Some(user) => Some(user),
None => self
.data
.find_user_auth_by_id(user_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?,
};
let Some(mut user) = existing else {
return Ok(None);
};
if let Some(email) = email {
user.email = Some(email);
}
if let Some(username) = username {
user.username = username;
}
store
.lock()
.expect("auth user store should lock")
.insert(user.id.clone(), user.clone());
return Ok(Some(user));
}
self.data
.update_local_auth_user_profile(user_id, email, username)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn update_local_auth_user_password_hash(
&self,
user_id: &str,
password_hash: String,
updated_at: chrono::DateTime<chrono::Utc>,
) -> Result<Option<aether_data::repository::users::StoredUserAuthRecord>, GatewayError> {
#[cfg(test)]
if let Some(store) = self.auth_user_store.as_ref() {
let existing = {
store
.lock()
.expect("auth user store should lock")
.get(user_id)
.cloned()
};
let existing = match existing {
Some(user) => Some(user),
None => self
.data
.find_user_auth_by_id(user_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?,
};
let Some(mut user) = existing else {
return Ok(None);
};
user.password_hash = Some(password_hash);
store
.lock()
.expect("auth user store should lock")
.insert(user.id.clone(), user.clone());
return Ok(Some(user));
}
self.data
.update_local_auth_user_password_hash(user_id, password_hash, updated_at)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn create_local_auth_user(
&self,
email: Option<String>,
email_verified: bool,
username: String,
password_hash: String,
) -> Result<Option<aether_data::repository::users::StoredUserAuthRecord>, GatewayError> {
#[cfg(test)]
if let Some(store) = self.auth_user_store.as_ref() {
let now = chrono::Utc::now();
let user = aether_data::repository::users::StoredUserAuthRecord::new(
uuid::Uuid::new_v4().to_string(),
email,
email_verified,
username,
Some(password_hash),
"user".to_string(),
"local".to_string(),
None,
None,
None,
true,
false,
Some(now),
None,
)
.map_err(|err| GatewayError::Internal(err.to_string()))?;
store
.lock()
.expect("auth user store should lock")
.insert(user.id.clone(), user.clone());
return Ok(Some(user));
}
self.data
.create_local_auth_user(email, email_verified, username, password_hash)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn create_local_auth_user_with_settings(
&self,
email: Option<String>,
email_verified: bool,
username: String,
password_hash: String,
role: String,
allowed_providers: Option<Vec<String>>,
allowed_api_formats: Option<Vec<String>>,
allowed_models: Option<Vec<String>>,
rate_limit: Option<i32>,
) -> Result<Option<aether_data::repository::users::StoredUserAuthRecord>, GatewayError> {
#[cfg(test)]
if let Some(store) = self.auth_user_store.as_ref() {
let now = chrono::Utc::now();
let user = aether_data::repository::users::StoredUserAuthRecord::new(
uuid::Uuid::new_v4().to_string(),
email,
email_verified,
username,
Some(password_hash),
role,
"local".to_string(),
allowed_providers.map(serde_json::Value::from),
allowed_api_formats.map(serde_json::Value::from),
allowed_models.map(serde_json::Value::from),
true,
false,
Some(now),
None,
)
.map_err(|err| GatewayError::Internal(err.to_string()))?;
store
.lock()
.expect("auth user store should lock")
.insert(user.id.clone(), user.clone());
let _ = rate_limit;
return Ok(Some(user));
}
self.data
.create_local_auth_user_with_settings(
email,
email_verified,
username,
password_hash,
role,
allowed_providers,
allowed_api_formats,
allowed_models,
rate_limit,
)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn update_local_auth_user_admin_fields(
&self,
user_id: &str,
role: Option<String>,
allowed_providers_present: bool,
allowed_providers: Option<Vec<String>>,
allowed_api_formats_present: bool,
allowed_api_formats: Option<Vec<String>>,
allowed_models_present: bool,
allowed_models: Option<Vec<String>>,
rate_limit: Option<i32>,
is_active: Option<bool>,
) -> Result<Option<aether_data::repository::users::StoredUserAuthRecord>, GatewayError> {
#[cfg(test)]
if let Some(store) = self.auth_user_store.as_ref() {
let mut guard = store.lock().expect("auth user store should lock");
let Some(user) = guard.get_mut(user_id) else {
return Ok(None);
};
if let Some(role) = role {
user.role = role;
}
if allowed_providers_present {
user.allowed_providers = allowed_providers;
}
if allowed_api_formats_present {
user.allowed_api_formats = allowed_api_formats;
}
if allowed_models_present {
user.allowed_models = allowed_models;
}
if let Some(is_active) = is_active {
user.is_active = is_active;
}
let _ = rate_limit;
return Ok(Some(user.clone()));
}
self.data
.update_local_auth_user_admin_fields(
user_id,
role,
allowed_providers_present,
allowed_providers,
allowed_api_formats_present,
allowed_api_formats,
allowed_models_present,
allowed_models,
rate_limit,
is_active,
)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn touch_auth_user_last_login(
&self,
user_id: &str,
logged_in_at: chrono::DateTime<chrono::Utc>,
) -> Result<bool, GatewayError> {
#[cfg(test)]
if let Some(store) = self.auth_user_store.as_ref() {
let mut guard = store.lock().expect("auth user store should lock");
if let Some(user) = guard.get_mut(user_id) {
user.last_login_at = Some(logged_in_at);
return Ok(true);
}
return Ok(false);
}
self.data
.touch_auth_user_last_login(user_id, logged_in_at)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn delete_local_auth_user(&self, user_id: &str) -> Result<bool, GatewayError> {
#[cfg(test)]
if let Some(store) = self.auth_user_store.as_ref() {
let removed = store
.lock()
.expect("auth user store should lock")
.remove(user_id)
.is_some();
if removed {
if let Some(wallet_store) = self.auth_wallet_store.as_ref() {
wallet_store
.lock()
.expect("auth wallet store should lock")
.retain(|_, wallet| wallet.user_id.as_deref() != Some(user_id));
}
if let Some(session_store) = self.auth_session_store.as_ref() {
let prefix = format!("{user_id}:");
session_store
.lock()
.expect("auth session store should lock")
.retain(|key, _| !key.starts_with(&prefix));
}
}
return Ok(removed);
}
self.data
.delete_local_auth_user(user_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn register_local_auth_user(
&self,
email: Option<String>,
email_verified: bool,
username: String,
password_hash: String,
initial_gift_usd: f64,
unlimited: bool,
) -> Result<
Option<(
aether_data::repository::users::StoredUserAuthRecord,
aether_data::repository::wallet::StoredWalletSnapshot,
)>,
GatewayError,
> {
#[cfg(test)]
if let (Some(user_store), Some(wallet_store)) = (
self.auth_user_store.as_ref(),
self.auth_wallet_store.as_ref(),
) {
let now = chrono::Utc::now();
let user = aether_data::repository::users::StoredUserAuthRecord::new(
uuid::Uuid::new_v4().to_string(),
email,
email_verified,
username,
Some(password_hash),
"user".to_string(),
"local".to_string(),
None,
None,
None,
true,
false,
Some(now),
None,
)
.map_err(|err| GatewayError::Internal(err.to_string()))?;
let gift_balance = if unlimited {
0.0
} else {
initial_gift_usd.max(0.0)
};
let wallet = aether_data::repository::wallet::StoredWalletSnapshot::new(
uuid::Uuid::new_v4().to_string(),
Some(user.id.clone()),
None,
0.0,
gift_balance,
if unlimited {
"unlimited".to_string()
} else {
"finite".to_string()
},
"USD".to_string(),
"active".to_string(),
0.0,
0.0,
0.0,
gift_balance,
now.timestamp(),
)
.map_err(|err| GatewayError::Internal(err.to_string()))?;
user_store
.lock()
.expect("auth user store should lock")
.insert(user.id.clone(), user.clone());
wallet_store
.lock()
.expect("auth wallet store should lock")
.insert(wallet.id.clone(), wallet.clone());
return Ok(Some((user, wallet)));
}
self.data
.register_local_auth_user(
email,
email_verified,
username,
password_hash,
initial_gift_usd,
unlimited,
)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
}

View File

@@ -0,0 +1,271 @@
use crate::{AppState, GatewayError};
impl AppState {
pub(crate) async fn read_user_model_capability_settings(
&self,
user_id: &str,
) -> Result<Option<serde_json::Value>, GatewayError> {
#[cfg(test)]
if let Some(store) = self.auth_user_model_capability_store.as_ref() {
if let Some(settings) = store
.lock()
.expect("auth user model capability store should lock")
.get(user_id)
.cloned()
{
return Ok(Some(settings));
}
}
let users = self.list_non_admin_export_users().await?;
Ok(users
.into_iter()
.find(|user| user.id == user_id)
.and_then(|user| user.model_capability_settings))
}
pub(crate) async fn update_user_model_capability_settings(
&self,
user_id: &str,
settings: Option<serde_json::Value>,
) -> Result<Option<serde_json::Value>, GatewayError> {
#[cfg(test)]
if let Some(store) = self.auth_user_model_capability_store.as_ref() {
let mut guard = store
.lock()
.expect("auth user model capability store should lock");
match settings {
Some(value) => {
guard.insert(user_id.to_string(), value.clone());
return Ok(Some(value));
}
None => {
guard.remove(user_id);
return Ok(None);
}
}
}
self.data
.update_user_model_capability_settings(user_id, settings)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn find_active_provider_name(
&self,
provider_id: &str,
) -> Result<Option<String>, GatewayError> {
self.data
.find_active_provider_name(provider_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn get_or_create_ldap_auth_user(
&self,
email: String,
username: String,
ldap_dn: Option<String>,
ldap_username: Option<String>,
logged_in_at: chrono::DateTime<chrono::Utc>,
initial_gift_usd: f64,
unlimited: bool,
) -> Result<Option<aether_data::repository::users::StoredUserAuthRecord>, GatewayError> {
#[cfg(test)]
if let (Some(user_store), Some(wallet_store)) = (
self.auth_user_store.as_ref(),
self.auth_wallet_store.as_ref(),
) {
let mut users = user_store.lock().expect("auth user store should lock");
let existing_id = users
.values()
.find(|user| {
user.email.as_deref() == Some(email.as_str())
|| user.username == username
|| ldap_username
.as_deref()
.is_some_and(|value| user.username == value)
})
.map(|user| user.id.clone());
if let Some(existing_id) = existing_id {
let Some(user) = users.get_mut(&existing_id) else {
return Ok(None);
};
if user.is_deleted || !user.is_active {
return Ok(None);
}
if !user.auth_source.eq_ignore_ascii_case("ldap") {
return Ok(None);
}
user.email = Some(email);
user.email_verified = true;
user.last_login_at = Some(logged_in_at);
return Ok(Some(user.clone()));
}
let base_username = ldap_username
.as_deref()
.filter(|value| !value.trim().is_empty())
.unwrap_or(username.as_str())
.trim()
.to_string();
let mut candidate_username = base_username.clone();
while users
.values()
.any(|user| user.username == candidate_username)
{
let suffix = uuid::Uuid::new_v4().simple().to_string();
candidate_username = format!(
"{}_ldap_{}{}",
base_username,
logged_in_at.timestamp(),
&suffix[..4]
);
}
let user = aether_data::repository::users::StoredUserAuthRecord::new(
uuid::Uuid::new_v4().to_string(),
Some(email),
true,
candidate_username,
None,
"user".to_string(),
"ldap".to_string(),
None,
None,
None,
true,
false,
Some(logged_in_at),
Some(logged_in_at),
)
.map_err(|err| GatewayError::Internal(err.to_string()))?;
users.insert(user.id.clone(), user.clone());
drop(users);
let gift_balance = if unlimited {
0.0
} else {
initial_gift_usd.max(0.0)
};
let wallet = aether_data::repository::wallet::StoredWalletSnapshot::new(
uuid::Uuid::new_v4().to_string(),
Some(user.id.clone()),
None,
0.0,
gift_balance,
if unlimited {
"unlimited".to_string()
} else {
"finite".to_string()
},
"USD".to_string(),
"active".to_string(),
0.0,
0.0,
0.0,
gift_balance,
logged_in_at.timestamp(),
)
.map_err(|err| GatewayError::Internal(err.to_string()))?;
wallet_store
.lock()
.expect("auth wallet store should lock")
.insert(wallet.id.clone(), wallet);
let _ = ldap_dn;
return Ok(Some(user));
}
self.data
.get_or_create_ldap_auth_user(
email,
username,
ldap_dn,
ldap_username,
logged_in_at,
initial_gift_usd,
unlimited,
)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn initialize_auth_user_wallet(
&self,
user_id: &str,
initial_gift_usd: f64,
unlimited: bool,
) -> Result<Option<aether_data::repository::wallet::StoredWalletSnapshot>, GatewayError> {
#[cfg(test)]
if let Some(store) = self.auth_wallet_store.as_ref() {
let gift_balance = if unlimited {
0.0
} else {
initial_gift_usd.max(0.0)
};
let now_unix_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let wallet = aether_data::repository::wallet::StoredWalletSnapshot::new(
uuid::Uuid::new_v4().to_string(),
Some(user_id.to_string()),
None,
0.0,
gift_balance,
if unlimited {
"unlimited".to_string()
} else {
"finite".to_string()
},
"USD".to_string(),
"active".to_string(),
0.0,
0.0,
0.0,
gift_balance,
now_unix_secs,
)
.map_err(|err| GatewayError::Internal(err.to_string()))?;
store
.lock()
.expect("auth wallet store should lock")
.insert(wallet.id.clone(), wallet.clone());
return Ok(Some(wallet));
}
self.data
.initialize_auth_user_wallet(user_id, initial_gift_usd, unlimited)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn update_auth_user_wallet_limit_mode(
&self,
user_id: &str,
limit_mode: &str,
) -> Result<Option<aether_data::repository::wallet::StoredWalletSnapshot>, GatewayError> {
#[cfg(test)]
if let Some(store) = self.auth_wallet_store.as_ref() {
let mut guard = store.lock().expect("auth wallet store should lock");
let Some((wallet_id, wallet)) = guard
.iter_mut()
.find(|(_, wallet)| wallet.user_id.as_deref() == Some(user_id))
else {
return Ok(None);
};
let _ = wallet_id;
wallet.limit_mode = limit_mode.to_string();
wallet.updated_at_unix_secs = chrono::Utc::now().timestamp().max(0) as u64;
return Ok(Some(wallet.clone()));
}
self.data
.update_auth_user_wallet_limit_mode(user_id, limit_mode)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
}

View File

@@ -0,0 +1,397 @@
use super::{
AdminBillingCollectorRecord, AdminBillingCollectorWriteInput, AdminBillingPresetApplyResult,
AdminBillingRuleRecord, AdminBillingRuleWriteInput, AppState, GatewayError,
LocalMutationOutcome,
};
use crate::query::billing as billing_query;
impl AppState {
pub(crate) async fn admin_billing_enabled_default_value_exists(
&self,
api_format: &str,
task_type: &str,
dimension_name: &str,
existing_id: Option<&str>,
) -> Result<bool, GatewayError> {
#[cfg(test)]
if let Some(store) = self.admin_billing_collector_store.as_ref() {
let exists = store
.lock()
.expect("admin billing collector store should lock")
.values()
.any(|collector| {
collector.api_format == api_format
&& collector.task_type == task_type
&& collector.dimension_name == dimension_name
&& collector.is_enabled
&& collector.default_value.is_some()
&& existing_id.is_none_or(|value| collector.id != value)
});
return Ok(exists);
}
let Some(pool) = self.postgres_pool() else {
return Ok(false);
};
billing_query::admin_billing_enabled_default_value_exists(
&pool,
api_format,
task_type,
dimension_name,
existing_id,
)
.await
}
pub(crate) async fn create_admin_billing_rule(
&self,
input: &AdminBillingRuleWriteInput,
) -> Result<LocalMutationOutcome<AdminBillingRuleRecord>, GatewayError> {
#[cfg(test)]
if let Some(store) = self.admin_billing_rule_store.as_ref() {
let now_unix_secs = chrono::Utc::now().timestamp().max(0) as u64;
let record = AdminBillingRuleRecord {
id: uuid::Uuid::new_v4().to_string(),
name: input.name.clone(),
task_type: input.task_type.clone(),
global_model_id: input.global_model_id.clone(),
model_id: input.model_id.clone(),
expression: input.expression.clone(),
variables: input.variables.clone(),
dimension_mappings: input.dimension_mappings.clone(),
is_enabled: input.is_enabled,
created_at_unix_secs: now_unix_secs,
updated_at_unix_secs: now_unix_secs,
};
store
.lock()
.expect("admin billing rule store should lock")
.insert(record.id.clone(), record.clone());
return Ok(LocalMutationOutcome::Applied(record));
}
let Some(pool) = self.postgres_pool() else {
return Ok(LocalMutationOutcome::Unavailable);
};
billing_query::create_admin_billing_rule(&pool, input).await
}
pub(crate) async fn list_admin_billing_rules(
&self,
task_type: Option<&str>,
is_enabled: Option<bool>,
page: u32,
page_size: u32,
) -> Result<Option<(Vec<AdminBillingRuleRecord>, u64)>, GatewayError> {
#[cfg(test)]
if let Some(store) = self.admin_billing_rule_store.as_ref() {
let mut items = store
.lock()
.expect("admin billing rule store should lock")
.values()
.filter(|record| {
task_type.is_none_or(|expected| record.task_type == expected)
&& is_enabled.is_none_or(|expected| record.is_enabled == expected)
})
.cloned()
.collect::<Vec<_>>();
items.sort_by(|left, right| {
right
.updated_at_unix_secs
.cmp(&left.updated_at_unix_secs)
.then_with(|| right.id.cmp(&left.id))
});
let total = items.len() as u64;
let offset = (page.saturating_sub(1) as usize) * (page_size as usize);
let items = items
.into_iter()
.skip(offset)
.take(page_size as usize)
.collect::<Vec<_>>();
return Ok(Some((items, total)));
}
let Some(pool) = self.postgres_pool() else {
return Ok(None);
};
let (items, total) =
billing_query::list_admin_billing_rules(&pool, task_type, is_enabled, page, page_size)
.await?;
Ok(Some((items, total)))
}
pub(crate) async fn read_admin_billing_rule(
&self,
rule_id: &str,
) -> Result<Option<AdminBillingRuleRecord>, GatewayError> {
#[cfg(test)]
if let Some(store) = self.admin_billing_rule_store.as_ref() {
return Ok(store
.lock()
.expect("admin billing rule store should lock")
.get(rule_id)
.cloned());
}
let Some(pool) = self.postgres_pool() else {
return Ok(None);
};
billing_query::find_admin_billing_rule(&pool, rule_id).await
}
pub(crate) async fn update_admin_billing_rule(
&self,
rule_id: &str,
input: &AdminBillingRuleWriteInput,
) -> Result<LocalMutationOutcome<AdminBillingRuleRecord>, GatewayError> {
#[cfg(test)]
if let Some(store) = self.admin_billing_rule_store.as_ref() {
let mut guard = store.lock().expect("admin billing rule store should lock");
let Some(record) = guard.get_mut(rule_id) else {
return Ok(LocalMutationOutcome::NotFound);
};
record.name = input.name.clone();
record.task_type = input.task_type.clone();
record.global_model_id = input.global_model_id.clone();
record.model_id = input.model_id.clone();
record.expression = input.expression.clone();
record.variables = input.variables.clone();
record.dimension_mappings = input.dimension_mappings.clone();
record.is_enabled = input.is_enabled;
record.updated_at_unix_secs = chrono::Utc::now().timestamp().max(0) as u64;
return Ok(LocalMutationOutcome::Applied(record.clone()));
}
let Some(pool) = self.postgres_pool() else {
return Ok(LocalMutationOutcome::Unavailable);
};
billing_query::update_admin_billing_rule(&pool, rule_id, input).await
}
pub(crate) async fn create_admin_billing_collector(
&self,
input: &AdminBillingCollectorWriteInput,
) -> Result<LocalMutationOutcome<AdminBillingCollectorRecord>, GatewayError> {
#[cfg(test)]
if let Some(store) = self.admin_billing_collector_store.as_ref() {
let now_unix_secs = chrono::Utc::now().timestamp().max(0) as u64;
let record = AdminBillingCollectorRecord {
id: uuid::Uuid::new_v4().to_string(),
api_format: input.api_format.clone(),
task_type: input.task_type.clone(),
dimension_name: input.dimension_name.clone(),
source_type: input.source_type.clone(),
source_path: input.source_path.clone(),
value_type: input.value_type.clone(),
transform_expression: input.transform_expression.clone(),
default_value: input.default_value.clone(),
priority: input.priority,
is_enabled: input.is_enabled,
created_at_unix_secs: now_unix_secs,
updated_at_unix_secs: now_unix_secs,
};
store
.lock()
.expect("admin billing collector store should lock")
.insert(record.id.clone(), record.clone());
return Ok(LocalMutationOutcome::Applied(record));
}
let Some(pool) = self.postgres_pool() else {
return Ok(LocalMutationOutcome::Unavailable);
};
billing_query::create_admin_billing_collector(&pool, input).await
}
pub(crate) async fn list_admin_billing_collectors(
&self,
api_format: Option<&str>,
task_type: Option<&str>,
dimension_name: Option<&str>,
is_enabled: Option<bool>,
page: u32,
page_size: u32,
) -> Result<Option<(Vec<AdminBillingCollectorRecord>, u64)>, GatewayError> {
#[cfg(test)]
if let Some(store) = self.admin_billing_collector_store.as_ref() {
let mut items = store
.lock()
.expect("admin billing collector store should lock")
.values()
.filter(|record| {
api_format.is_none_or(|expected| record.api_format == expected)
&& task_type.is_none_or(|expected| record.task_type == expected)
&& dimension_name.is_none_or(|expected| record.dimension_name == expected)
&& is_enabled.is_none_or(|expected| record.is_enabled == expected)
})
.cloned()
.collect::<Vec<_>>();
items.sort_by(|left, right| {
right
.updated_at_unix_secs
.cmp(&left.updated_at_unix_secs)
.then_with(|| right.priority.cmp(&left.priority))
.then_with(|| right.id.cmp(&left.id))
});
let total = items.len() as u64;
let offset = (page.saturating_sub(1) as usize) * (page_size as usize);
let items = items
.into_iter()
.skip(offset)
.take(page_size as usize)
.collect::<Vec<_>>();
return Ok(Some((items, total)));
}
let Some(pool) = self.postgres_pool() else {
return Ok(None);
};
let (items, total) = billing_query::list_admin_billing_collectors(
&pool,
api_format,
task_type,
dimension_name,
is_enabled,
page,
page_size,
)
.await?;
Ok(Some((items, total)))
}
pub(crate) async fn read_admin_billing_collector(
&self,
collector_id: &str,
) -> Result<Option<AdminBillingCollectorRecord>, GatewayError> {
#[cfg(test)]
if let Some(store) = self.admin_billing_collector_store.as_ref() {
return Ok(store
.lock()
.expect("admin billing collector store should lock")
.get(collector_id)
.cloned());
}
let Some(pool) = self.postgres_pool() else {
return Ok(None);
};
billing_query::find_admin_billing_collector(&pool, collector_id).await
}
pub(crate) async fn update_admin_billing_collector(
&self,
collector_id: &str,
input: &AdminBillingCollectorWriteInput,
) -> Result<LocalMutationOutcome<AdminBillingCollectorRecord>, GatewayError> {
#[cfg(test)]
if let Some(store) = self.admin_billing_collector_store.as_ref() {
let mut guard = store
.lock()
.expect("admin billing collector store should lock");
let Some(record) = guard.get_mut(collector_id) else {
return Ok(LocalMutationOutcome::NotFound);
};
record.api_format = input.api_format.clone();
record.task_type = input.task_type.clone();
record.dimension_name = input.dimension_name.clone();
record.source_type = input.source_type.clone();
record.source_path = input.source_path.clone();
record.value_type = input.value_type.clone();
record.transform_expression = input.transform_expression.clone();
record.default_value = input.default_value.clone();
record.priority = input.priority;
record.is_enabled = input.is_enabled;
record.updated_at_unix_secs = chrono::Utc::now().timestamp().max(0) as u64;
return Ok(LocalMutationOutcome::Applied(record.clone()));
}
let Some(pool) = self.postgres_pool() else {
return Ok(LocalMutationOutcome::Unavailable);
};
billing_query::update_admin_billing_collector(&pool, collector_id, input).await
}
pub(crate) async fn apply_admin_billing_preset(
&self,
preset: &str,
mode: &str,
collectors: &[AdminBillingCollectorWriteInput],
) -> Result<LocalMutationOutcome<AdminBillingPresetApplyResult>, GatewayError> {
#[cfg(test)]
if let Some(store) = self.admin_billing_collector_store.as_ref() {
let mut created = 0_u64;
let mut updated = 0_u64;
let mut skipped = 0_u64;
let now_unix_secs = chrono::Utc::now().timestamp().max(0) as u64;
let mut guard = store
.lock()
.expect("admin billing collector store should lock");
for collector in collectors {
let existing_id = guard
.values()
.find(|record| {
record.api_format == collector.api_format
&& record.task_type == collector.task_type
&& record.dimension_name == collector.dimension_name
&& record.priority == collector.priority
&& record.is_enabled
})
.map(|record| record.id.clone());
match existing_id {
Some(existing_id) if mode == "overwrite" => {
if let Some(record) = guard.get_mut(&existing_id) {
record.source_type = collector.source_type.clone();
record.source_path = collector.source_path.clone();
record.value_type = collector.value_type.clone();
record.transform_expression = collector.transform_expression.clone();
record.default_value = collector.default_value.clone();
record.is_enabled = collector.is_enabled;
record.updated_at_unix_secs = now_unix_secs;
updated += 1;
} else {
skipped += 1;
}
}
Some(_) => {
skipped += 1;
}
None => {
let record = AdminBillingCollectorRecord {
id: uuid::Uuid::new_v4().to_string(),
api_format: collector.api_format.clone(),
task_type: collector.task_type.clone(),
dimension_name: collector.dimension_name.clone(),
source_type: collector.source_type.clone(),
source_path: collector.source_path.clone(),
value_type: collector.value_type.clone(),
transform_expression: collector.transform_expression.clone(),
default_value: collector.default_value.clone(),
priority: collector.priority,
is_enabled: collector.is_enabled,
created_at_unix_secs: now_unix_secs,
updated_at_unix_secs: now_unix_secs,
};
guard.insert(record.id.clone(), record);
created += 1;
}
}
}
return Ok(LocalMutationOutcome::Applied(
AdminBillingPresetApplyResult {
preset: preset.to_string(),
mode: mode.to_string(),
created,
updated,
skipped,
errors: Vec::new(),
},
));
}
let Some(pool) = self.postgres_pool() else {
return Ok(LocalMutationOutcome::Unavailable);
};
billing_query::apply_admin_billing_preset(&pool, preset, mode, collectors).await
}
}

View File

@@ -0,0 +1,464 @@
use aether_data::repository::wallet::{
AdminPaymentOrderListQuery, AdminWalletLedgerQuery, AdminWalletListQuery,
AdminWalletRefundRequestListQuery, StoredAdminPaymentCallback, StoredAdminPaymentOrder,
StoredAdminWalletLedgerItem, StoredAdminWalletListItem, StoredAdminWalletRefund,
StoredAdminWalletRefundRequestItem, StoredAdminWalletTransaction,
};
use crate::state::AdminPaymentCallbackRecord;
use crate::{
AdminWalletMutationOutcome, AdminWalletPaymentOrderRecord, AdminWalletRefundRecord, AppState,
GatewayError,
};
impl AppState {
pub(crate) async fn list_admin_wallets(
&self,
status: Option<&str>,
owner_type: Option<&str>,
limit: usize,
offset: usize,
) -> Result<(Vec<StoredAdminWalletListItem>, u64), GatewayError> {
let page = self
.data
.list_admin_wallets(&AdminWalletListQuery {
status: status.map(ToOwned::to_owned),
owner_type: owner_type.map(ToOwned::to_owned),
limit,
offset,
})
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
Ok((page.items, page.total))
}
pub(crate) async fn list_admin_wallet_ledger(
&self,
category: Option<&str>,
reason_code: Option<&str>,
owner_type: Option<&str>,
limit: usize,
offset: usize,
) -> Result<(Vec<StoredAdminWalletLedgerItem>, u64), GatewayError> {
let page = self
.data
.list_admin_wallet_ledger(&AdminWalletLedgerQuery {
category: category.map(ToOwned::to_owned),
reason_code: reason_code.map(ToOwned::to_owned),
owner_type: owner_type.map(ToOwned::to_owned),
limit,
offset,
})
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
Ok((page.items, page.total))
}
pub(crate) async fn list_admin_payment_orders(
&self,
status: Option<&str>,
payment_method: Option<&str>,
limit: usize,
offset: usize,
) -> Result<Option<(Vec<AdminWalletPaymentOrderRecord>, u64)>, GatewayError> {
#[cfg(test)]
if let Some(store) = self.admin_wallet_payment_order_store.as_ref() {
let now_unix_secs = chrono::Utc::now().timestamp().max(0) as u64;
let mut items = store
.lock()
.expect("admin wallet payment order store should lock")
.values()
.filter(|order| {
payment_method.is_none_or(|expected| order.payment_method == expected)
&& status.is_none_or(|expected| {
let effective_status = if order.status == "pending"
&& order
.expires_at_unix_secs
.is_some_and(|value| value < now_unix_secs)
{
"expired"
} else {
order.status.as_str()
};
effective_status == expected
})
})
.cloned()
.collect::<Vec<_>>();
items.sort_by(|left, right| {
right
.created_at_unix_secs
.cmp(&left.created_at_unix_secs)
.then_with(|| right.id.cmp(&left.id))
});
let total = items.len() as u64;
let items = items
.into_iter()
.skip(offset)
.take(limit)
.collect::<Vec<_>>();
return Ok(Some((items, total)));
}
let page = self
.data
.list_admin_payment_orders(&AdminPaymentOrderListQuery {
status: status.map(ToOwned::to_owned),
payment_method: payment_method.map(ToOwned::to_owned),
limit,
offset,
})
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
Ok(Some((
page.items
.into_iter()
.map(stored_admin_payment_order_to_gateway)
.collect(),
page.total,
)))
}
pub(crate) async fn list_admin_payment_callbacks(
&self,
payment_method: Option<&str>,
limit: usize,
offset: usize,
) -> Result<Option<(Vec<AdminPaymentCallbackRecord>, u64)>, GatewayError> {
#[cfg(test)]
if let Some(store) = self.admin_payment_callback_store.as_ref() {
let mut items = store
.lock()
.expect("admin payment callback store should lock")
.values()
.filter(|callback| {
payment_method.is_none_or(|expected| callback.payment_method == expected)
})
.cloned()
.collect::<Vec<_>>();
items.sort_by(|left, right| {
right
.created_at_unix_secs
.cmp(&left.created_at_unix_secs)
.then_with(|| right.id.cmp(&left.id))
});
let total = items.len() as u64;
let items = items
.into_iter()
.skip(offset)
.take(limit)
.collect::<Vec<_>>();
return Ok(Some((items, total)));
}
let page = self
.data
.list_admin_payment_callbacks(payment_method, limit, offset)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
Ok(Some((
page.items
.into_iter()
.map(stored_admin_payment_callback_to_gateway)
.collect(),
page.total,
)))
}
pub(crate) async fn list_admin_wallet_transactions(
&self,
wallet_id: &str,
limit: usize,
offset: usize,
) -> Result<(Vec<StoredAdminWalletTransaction>, u64), GatewayError> {
#[cfg(test)]
if let Some(store) = self.admin_wallet_transaction_store.as_ref() {
let mut items = store
.lock()
.expect("admin wallet transaction store should lock")
.values()
.filter(|transaction| transaction.wallet_id == wallet_id)
.cloned()
.collect::<Vec<_>>();
items.sort_by(|left, right| {
right
.created_at_unix_secs
.cmp(&left.created_at_unix_secs)
.then_with(|| right.id.cmp(&left.id))
});
let total = items.len() as u64;
let items = items
.into_iter()
.skip(offset)
.take(limit)
.map(|record| StoredAdminWalletTransaction {
id: record.id,
wallet_id: record.wallet_id,
category: record.category,
reason_code: record.reason_code,
amount: record.amount,
balance_before: record.balance_before,
balance_after: record.balance_after,
recharge_balance_before: record.recharge_balance_before,
recharge_balance_after: record.recharge_balance_after,
gift_balance_before: record.gift_balance_before,
gift_balance_after: record.gift_balance_after,
link_type: record.link_type,
link_id: record.link_id,
operator_id: record.operator_id,
operator_name: None,
operator_email: None,
description: record.description,
created_at_unix_secs: Some(record.created_at_unix_secs),
})
.collect::<Vec<_>>();
return Ok((items, total));
}
let page = self
.data
.list_admin_wallet_transactions(wallet_id, limit, offset)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
Ok((page.items, page.total))
}
pub(crate) async fn list_admin_wallet_refunds(
&self,
wallet_id: &str,
limit: usize,
offset: usize,
) -> Result<(Vec<AdminWalletRefundRecord>, u64), GatewayError> {
#[cfg(test)]
if let Some(store) = self.admin_wallet_refund_store.as_ref() {
let mut items = store
.lock()
.expect("admin wallet refund store should lock")
.values()
.filter(|refund| refund.wallet_id == wallet_id)
.cloned()
.collect::<Vec<_>>();
items.sort_by(|left, right| {
right
.created_at_unix_secs
.cmp(&left.created_at_unix_secs)
.then_with(|| right.id.cmp(&left.id))
});
let total = items.len() as u64;
let items = items
.into_iter()
.skip(offset)
.take(limit)
.collect::<Vec<_>>();
return Ok((items, total));
}
let page = self
.data
.list_admin_wallet_refunds(wallet_id, limit, offset)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
Ok((
page.items
.into_iter()
.map(stored_admin_wallet_refund_to_gateway)
.collect(),
page.total,
))
}
pub(crate) async fn list_admin_wallet_refund_requests(
&self,
status: Option<&str>,
limit: usize,
offset: usize,
) -> Result<(Vec<StoredAdminWalletRefundRequestItem>, u64), GatewayError> {
#[cfg(test)]
if let (Some(wallet_store), Some(refund_store)) = (
self.auth_wallet_store.as_ref(),
self.admin_wallet_refund_store.as_ref(),
) {
let wallets = wallet_store
.lock()
.expect("auth wallet store should lock")
.clone();
let mut items = refund_store
.lock()
.expect("admin wallet refund store should lock")
.values()
.filter(|refund| status.is_none_or(|expected| refund.status == expected))
.filter(|refund| {
wallets
.get(&refund.wallet_id)
.is_some_and(|wallet| wallet.user_id.is_some())
})
.cloned()
.collect::<Vec<_>>();
items.sort_by(|left, right| {
right
.created_at_unix_secs
.cmp(&left.created_at_unix_secs)
.then_with(|| right.id.cmp(&left.id))
});
let total = items.len() as u64;
let items = items
.into_iter()
.skip(offset)
.take(limit)
.filter_map(|refund| {
wallets.get(&refund.wallet_id).map(|wallet| {
StoredAdminWalletRefundRequestItem {
id: refund.id,
refund_no: refund.refund_no,
wallet_id: refund.wallet_id,
user_id: refund.user_id,
payment_order_id: refund.payment_order_id,
source_type: refund.source_type,
source_id: refund.source_id,
refund_mode: refund.refund_mode,
amount_usd: refund.amount_usd,
status: refund.status,
reason: refund.reason,
failure_reason: refund.failure_reason,
gateway_refund_id: refund.gateway_refund_id,
payout_method: refund.payout_method,
payout_reference: refund.payout_reference,
payout_proof: refund.payout_proof,
requested_by: refund.requested_by,
approved_by: refund.approved_by,
processed_by: refund.processed_by,
wallet_user_id: wallet.user_id.clone(),
wallet_user_name: None,
wallet_api_key_id: wallet.api_key_id.clone(),
api_key_name: None,
wallet_status: wallet.status.clone(),
created_at_unix_secs: Some(refund.created_at_unix_secs),
updated_at_unix_secs: Some(refund.updated_at_unix_secs),
processed_at_unix_secs: refund.processed_at_unix_secs,
completed_at_unix_secs: refund.completed_at_unix_secs,
}
})
})
.collect::<Vec<_>>();
return Ok((items, total));
}
let page = self
.data
.list_admin_wallet_refund_requests(&AdminWalletRefundRequestListQuery {
status: status.map(ToOwned::to_owned),
limit,
offset,
})
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
Ok((page.items, page.total))
}
pub(crate) async fn read_admin_payment_order(
&self,
order_id: &str,
) -> Result<AdminWalletMutationOutcome<AdminWalletPaymentOrderRecord>, GatewayError> {
#[cfg(test)]
if let Some(store) = self.admin_wallet_payment_order_store.as_ref() {
return Ok(store
.lock()
.expect("admin wallet payment order store should lock")
.get(order_id)
.cloned()
.map(AdminWalletMutationOutcome::Applied)
.unwrap_or(AdminWalletMutationOutcome::NotFound));
}
if !self.has_wallet_data_reader() {
return Ok(AdminWalletMutationOutcome::Unavailable);
}
let order = self
.data
.find_admin_payment_order(order_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
match order {
Some(record) => Ok(AdminWalletMutationOutcome::Applied(
stored_admin_payment_order_to_gateway(record),
)),
None => Ok(AdminWalletMutationOutcome::NotFound),
}
}
}
fn stored_admin_payment_order_to_gateway(
record: StoredAdminPaymentOrder,
) -> AdminWalletPaymentOrderRecord {
AdminWalletPaymentOrderRecord {
id: record.id,
order_no: record.order_no,
wallet_id: record.wallet_id,
user_id: record.user_id,
amount_usd: record.amount_usd,
pay_amount: record.pay_amount,
pay_currency: record.pay_currency,
exchange_rate: record.exchange_rate,
refunded_amount_usd: record.refunded_amount_usd,
refundable_amount_usd: record.refundable_amount_usd,
payment_method: record.payment_method,
gateway_order_id: record.gateway_order_id,
status: record.status,
gateway_response: record.gateway_response,
created_at_unix_secs: record.created_at_unix_secs,
paid_at_unix_secs: record.paid_at_unix_secs,
credited_at_unix_secs: record.credited_at_unix_secs,
expires_at_unix_secs: record.expires_at_unix_secs,
}
}
fn stored_admin_payment_callback_to_gateway(
record: StoredAdminPaymentCallback,
) -> AdminPaymentCallbackRecord {
AdminPaymentCallbackRecord {
id: record.id,
payment_order_id: record.payment_order_id,
payment_method: record.payment_method,
callback_key: record.callback_key,
order_no: record.order_no,
gateway_order_id: record.gateway_order_id,
payload_hash: record.payload_hash,
signature_valid: record.signature_valid,
status: record.status,
payload: record.payload,
error_message: record.error_message,
created_at_unix_secs: record.created_at_unix_secs,
processed_at_unix_secs: record.processed_at_unix_secs,
}
}
fn stored_admin_wallet_refund_to_gateway(
record: StoredAdminWalletRefund,
) -> AdminWalletRefundRecord {
AdminWalletRefundRecord {
id: record.id,
refund_no: record.refund_no,
wallet_id: record.wallet_id,
user_id: record.user_id,
payment_order_id: record.payment_order_id,
source_type: record.source_type,
source_id: record.source_id,
refund_mode: record.refund_mode,
amount_usd: record.amount_usd,
status: record.status,
reason: record.reason,
failure_reason: record.failure_reason,
gateway_refund_id: record.gateway_refund_id,
payout_method: record.payout_method,
payout_reference: record.payout_reference,
payout_proof: record.payout_proof,
requested_by: record.requested_by,
approved_by: record.approved_by,
processed_by: record.processed_by,
created_at_unix_secs: record.created_at_unix_secs,
updated_at_unix_secs: record.updated_at_unix_secs,
processed_at_unix_secs: record.processed_at_unix_secs,
completed_at_unix_secs: record.completed_at_unix_secs,
}
}

View File

@@ -0,0 +1,8 @@
use super::super::{
AdminBillingCollectorRecord, AdminBillingCollectorWriteInput, AdminBillingPresetApplyResult,
AdminBillingRuleRecord, AdminBillingRuleWriteInput, AppState, GatewayError,
LocalMutationOutcome,
};
mod admin;
mod finance_queries;

View File

@@ -0,0 +1,63 @@
use crate::{AppState, GatewayError};
impl AppState {
pub(crate) async fn list_minimal_candidate_selection_rows_for_api_format(
&self,
api_format: &str,
) -> Result<
Vec<aether_data::repository::candidate_selection::StoredMinimalCandidateSelectionRow>,
GatewayError,
> {
self.data
.list_minimal_candidate_selection_rows_for_api_format(api_format)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn list_minimal_candidate_selection_rows_for_api_format_and_global_model(
&self,
api_format: &str,
global_model_name: &str,
) -> Result<
Vec<aether_data::repository::candidate_selection::StoredMinimalCandidateSelectionRow>,
GatewayError,
> {
self.data
.list_minimal_candidate_selection_rows(api_format, global_model_name)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn read_provider_quota_snapshot(
&self,
provider_id: &str,
) -> Result<Option<aether_data::repository::quota::StoredProviderQuotaSnapshot>, GatewayError>
{
self.data
.find_provider_quota_by_provider_id(provider_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn read_recent_request_candidates(
&self,
limit: usize,
) -> Result<Vec<aether_data::repository::candidates::StoredRequestCandidate>, GatewayError>
{
self.data
.list_recent_request_candidates(limit)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn upsert_request_candidate(
&self,
candidate: aether_data::repository::candidates::UpsertRequestCandidateRecord,
) -> Result<Option<aether_data::repository::candidates::StoredRequestCandidate>, GatewayError>
{
self.data
.upsert_request_candidate(candidate)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
}

View File

@@ -0,0 +1,92 @@
use crate::{AppState, GatewayError};
impl AppState {
pub(crate) async fn upsert_gemini_file_mapping(
&self,
record: aether_data::repository::gemini_file_mappings::UpsertGeminiFileMappingRecord,
) -> Result<
Option<aether_data::repository::gemini_file_mappings::StoredGeminiFileMapping>,
GatewayError,
> {
self.data
.upsert_gemini_file_mapping(record)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn list_gemini_file_mappings(
&self,
query: &aether_data::repository::gemini_file_mappings::GeminiFileMappingListQuery,
) -> Result<
aether_data::repository::gemini_file_mappings::StoredGeminiFileMappingListPage,
GatewayError,
> {
self.data
.list_gemini_file_mappings(query)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn summarize_gemini_file_mappings(
&self,
now_unix_secs: u64,
) -> Result<aether_data::repository::gemini_file_mappings::GeminiFileMappingStats, GatewayError>
{
self.data
.summarize_gemini_file_mappings(now_unix_secs)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn delete_gemini_file_mapping_by_file_name(
&self,
file_name: &str,
) -> Result<bool, GatewayError> {
self.data
.delete_gemini_file_mapping_by_file_name(file_name)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn delete_gemini_file_mapping_by_id(
&self,
mapping_id: &str,
) -> Result<
Option<aether_data::repository::gemini_file_mappings::StoredGeminiFileMapping>,
GatewayError,
> {
self.data
.delete_gemini_file_mapping_by_id(mapping_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn delete_expired_gemini_file_mappings(
&self,
now_unix_secs: u64,
) -> Result<usize, GatewayError> {
self.data
.delete_expired_gemini_file_mappings(now_unix_secs)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn cache_set_string_with_ttl(
&self,
key: &str,
value: &str,
ttl_seconds: u64,
) -> Result<(), GatewayError> {
self.data
.cache_set_string_with_ttl(key, value, ttl_seconds)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn cache_delete_key(&self, key: &str) -> Result<(), GatewayError> {
self.data
.cache_delete_key(key)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
}

View File

@@ -0,0 +1,159 @@
use super::super::error::GatewayError;
use super::super::{scheduler, usage};
use super::{
AdminBillingCollectorRecord, AdminBillingCollectorWriteInput, AdminBillingPresetApplyResult,
AdminBillingRuleRecord, AdminBillingRuleWriteInput, AdminPaymentCallbackRecord,
AdminSecurityBlacklistEntry, AdminWalletMutationOutcome, AdminWalletPaymentOrderRecord,
AdminWalletRefundRecord, AdminWalletTransactionRecord, AppState, LocalMutationOutcome,
AUTH_API_KEY_LAST_USED_MAX_ENTRIES, AUTH_API_KEY_LAST_USED_TTL,
};
mod announcements;
mod api_key_exports;
mod audit;
mod auth;
mod billing;
mod candidate_queries;
mod gemini_files;
mod payments;
mod security;
mod shadow_results;
mod usage_queries;
mod user_preferences;
mod wallet;
impl AppState {
pub fn has_announcement_data_reader(&self) -> bool {
self.data.has_announcement_reader()
}
pub fn has_announcement_data_writer(&self) -> bool {
self.data.has_announcement_writer()
}
pub fn has_video_task_data_reader(&self) -> bool {
self.data.has_video_task_reader()
}
pub fn has_video_task_data_writer(&self) -> bool {
self.data.has_video_task_writer()
}
pub fn has_request_candidate_data_reader(&self) -> bool {
self.data.has_request_candidate_reader()
}
pub fn has_request_candidate_data_writer(&self) -> bool {
self.data.has_request_candidate_writer()
}
pub fn has_usage_data_reader(&self) -> bool {
self.data.has_usage_reader()
}
pub fn has_user_data_reader(&self) -> bool {
self.data.has_user_reader()
}
pub fn has_usage_data_writer(&self) -> bool {
self.data.has_usage_writer()
}
pub fn has_usage_worker_backend(&self) -> bool {
self.data.has_usage_worker_runner()
}
pub fn has_wallet_data_reader(&self) -> bool {
self.data.has_wallet_reader()
}
pub fn has_wallet_data_writer(&self) -> bool {
self.data.has_wallet_writer()
}
pub fn has_auth_user_write_capability(&self) -> bool {
#[cfg(test)]
if self.auth_user_store.is_some() {
return true;
}
self.postgres_pool().is_some()
}
pub fn has_auth_wallet_write_capability(&self) -> bool {
#[cfg(test)]
if self.auth_wallet_store.is_some() {
return true;
}
self.postgres_pool().is_some()
}
pub fn has_provider_quota_data_writer(&self) -> bool {
self.data.has_provider_quota_writer()
}
pub fn has_shadow_result_data_writer(&self) -> bool {
self.data.has_shadow_result_writer()
}
pub fn has_shadow_result_data_reader(&self) -> bool {
self.data.has_shadow_result_reader()
}
pub(crate) async fn count_active_admin_users(&self) -> Result<u64, GatewayError> {
#[cfg(test)]
if let Some(store) = self.auth_user_store.as_ref() {
let total = store
.lock()
.expect("auth user store should lock")
.values()
.filter(|user| {
user.role.eq_ignore_ascii_case("admin") && user.is_active && !user.is_deleted
})
.count() as u64;
return Ok(total);
}
self.data
.count_active_admin_users()
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn count_user_pending_refunds(
&self,
user_id: &str,
) -> Result<u64, GatewayError> {
#[cfg(test)]
{
let _ = user_id;
if self.auth_user_store.is_some() {
return Ok(0);
}
}
self.data
.count_user_pending_refunds(user_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn count_user_pending_payment_orders(
&self,
user_id: &str,
) -> Result<u64, GatewayError> {
#[cfg(test)]
{
let _ = user_id;
if self.auth_user_store.is_some() {
return Ok(0);
}
}
self.data
.count_user_pending_payment_orders(user_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
}

View File

@@ -0,0 +1,3 @@
pub(super) use super::wallet::admin_payment_gateway_response_map;
mod order_lifecycle;

View File

@@ -0,0 +1,259 @@
use super::admin_payment_gateway_response_map;
use crate::{
AdminWalletMutationOutcome, AdminWalletPaymentOrderRecord, AppState, GatewayError,
};
impl AppState {
pub(crate) async fn admin_expire_payment_order(
&self,
order_id: &str,
) -> Result<AdminWalletMutationOutcome<(AdminWalletPaymentOrderRecord, bool)>, GatewayError>
{
#[cfg(test)]
if let Some(store) = self.admin_wallet_payment_order_store.as_ref() {
let mut guard = store
.lock()
.expect("admin wallet payment order store should lock");
let Some(order) = guard.get_mut(order_id) else {
return Ok(AdminWalletMutationOutcome::NotFound);
};
if order.status == "credited" {
return Ok(AdminWalletMutationOutcome::Invalid(
"credited order cannot be expired".to_string(),
));
}
if order.status == "expired" {
return Ok(AdminWalletMutationOutcome::Applied((order.clone(), false)));
}
if order.status != "pending" {
return Ok(AdminWalletMutationOutcome::Invalid(format!(
"only pending order can be expired: {}",
order.status
)));
}
let mut gateway_response =
admin_payment_gateway_response_map(order.gateway_response.take());
gateway_response.insert(
"expire_reason".to_string(),
serde_json::Value::String("admin_mark_expired".to_string()),
);
gateway_response.insert(
"expired_at".to_string(),
serde_json::Value::String(chrono::Utc::now().to_rfc3339()),
);
order.status = "expired".to_string();
order.gateway_response = Some(serde_json::Value::Object(gateway_response));
return Ok(AdminWalletMutationOutcome::Applied((order.clone(), true)));
}
match self.expire_admin_payment_order(order_id).await? {
Some(aether_data::repository::wallet::WalletMutationOutcome::Applied((
order,
changed,
))) => Ok(AdminWalletMutationOutcome::Applied((
stored_admin_payment_order_to_gateway(order),
changed,
))),
Some(aether_data::repository::wallet::WalletMutationOutcome::NotFound) => {
Ok(AdminWalletMutationOutcome::NotFound)
}
Some(aether_data::repository::wallet::WalletMutationOutcome::Invalid(detail)) => {
Ok(AdminWalletMutationOutcome::Invalid(detail))
}
None => Ok(AdminWalletMutationOutcome::Unavailable),
}
}
pub(crate) async fn admin_fail_payment_order(
&self,
order_id: &str,
) -> Result<AdminWalletMutationOutcome<AdminWalletPaymentOrderRecord>, GatewayError> {
#[cfg(test)]
if let Some(store) = self.admin_wallet_payment_order_store.as_ref() {
let mut guard = store
.lock()
.expect("admin wallet payment order store should lock");
let Some(order) = guard.get_mut(order_id) else {
return Ok(AdminWalletMutationOutcome::NotFound);
};
if order.status == "credited" {
return Ok(AdminWalletMutationOutcome::Invalid(
"credited order cannot be failed".to_string(),
));
}
let mut gateway_response =
admin_payment_gateway_response_map(order.gateway_response.take());
gateway_response.insert(
"failure_reason".to_string(),
serde_json::Value::String("admin_mark_failed".to_string()),
);
gateway_response.insert(
"failed_at".to_string(),
serde_json::Value::String(chrono::Utc::now().to_rfc3339()),
);
order.status = "failed".to_string();
order.gateway_response = Some(serde_json::Value::Object(gateway_response));
return Ok(AdminWalletMutationOutcome::Applied(order.clone()));
}
match self.fail_admin_payment_order(order_id).await? {
Some(aether_data::repository::wallet::WalletMutationOutcome::Applied(order)) => Ok(
AdminWalletMutationOutcome::Applied(stored_admin_payment_order_to_gateway(order)),
),
Some(aether_data::repository::wallet::WalletMutationOutcome::NotFound) => {
Ok(AdminWalletMutationOutcome::NotFound)
}
Some(aether_data::repository::wallet::WalletMutationOutcome::Invalid(detail)) => {
Ok(AdminWalletMutationOutcome::Invalid(detail))
}
None => Ok(AdminWalletMutationOutcome::Unavailable),
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn admin_credit_payment_order(
&self,
order_id: &str,
gateway_order_id: Option<&str>,
pay_amount: Option<f64>,
pay_currency: Option<&str>,
exchange_rate: Option<f64>,
gateway_response_patch: Option<serde_json::Value>,
operator_id: Option<&str>,
) -> Result<AdminWalletMutationOutcome<(AdminWalletPaymentOrderRecord, bool)>, GatewayError>
{
#[cfg(test)]
if let (Some(order_store), Some(wallet_store)) = (
self.admin_wallet_payment_order_store.as_ref(),
self.auth_wallet_store.as_ref(),
) {
let mut orders = order_store
.lock()
.expect("admin wallet payment order store should lock");
let Some(order) = orders.get_mut(order_id) else {
return Ok(AdminWalletMutationOutcome::NotFound);
};
if order.status == "credited" {
return Ok(AdminWalletMutationOutcome::Applied((order.clone(), false)));
}
if matches!(order.status.as_str(), "failed" | "expired" | "refunded") {
return Ok(AdminWalletMutationOutcome::Invalid(format!(
"payment order is not creditable: {}",
order.status
)));
}
if order
.expires_at_unix_secs
.is_some_and(|value| value < chrono::Utc::now().timestamp().max(0) as u64)
{
return Ok(AdminWalletMutationOutcome::Invalid(
"payment order expired".to_string(),
));
}
let mut wallets = wallet_store.lock().expect("auth wallet store should lock");
let Some(wallet) = wallets.get_mut(&order.wallet_id) else {
return Ok(AdminWalletMutationOutcome::Invalid(
"wallet not found".to_string(),
));
};
if wallet.status != "active" {
return Ok(AdminWalletMutationOutcome::Invalid(
"wallet is not active".to_string(),
));
}
let now_unix_secs = chrono::Utc::now().timestamp().max(0) as u64;
let mut gateway_response =
admin_payment_gateway_response_map(order.gateway_response.take());
if let Some(serde_json::Value::Object(map)) = gateway_response_patch {
gateway_response.extend(map);
}
gateway_response.insert("manual_credit".to_string(), serde_json::Value::Bool(true));
gateway_response.insert(
"credited_by".to_string(),
operator_id
.map(|value| serde_json::Value::String(value.to_string()))
.unwrap_or(serde_json::Value::Null),
);
wallet.balance += order.amount_usd;
wallet.total_recharged += order.amount_usd;
wallet.updated_at_unix_secs = now_unix_secs;
if let Some(value) = gateway_order_id {
order.gateway_order_id = Some(value.to_string());
}
if let Some(value) = pay_amount {
order.pay_amount = Some(value);
}
if let Some(value) = pay_currency {
order.pay_currency = Some(value.to_string());
}
if let Some(value) = exchange_rate {
order.exchange_rate = Some(value);
}
order.status = "credited".to_string();
order.paid_at_unix_secs = order.paid_at_unix_secs.or(Some(now_unix_secs));
order.credited_at_unix_secs = Some(now_unix_secs);
order.refundable_amount_usd = order.amount_usd;
order.gateway_response = Some(serde_json::Value::Object(gateway_response));
return Ok(AdminWalletMutationOutcome::Applied((order.clone(), true)));
}
match self
.credit_admin_payment_order(
aether_data::repository::wallet::CreditAdminPaymentOrderInput {
order_id: order_id.to_string(),
gateway_order_id: gateway_order_id.map(ToOwned::to_owned),
pay_amount,
pay_currency: pay_currency.map(ToOwned::to_owned),
exchange_rate,
gateway_response_patch,
operator_id: operator_id.map(ToOwned::to_owned),
},
)
.await?
{
Some(aether_data::repository::wallet::WalletMutationOutcome::Applied((
order,
changed,
))) => Ok(AdminWalletMutationOutcome::Applied((
stored_admin_payment_order_to_gateway(order),
changed,
))),
Some(aether_data::repository::wallet::WalletMutationOutcome::NotFound) => {
Ok(AdminWalletMutationOutcome::NotFound)
}
Some(aether_data::repository::wallet::WalletMutationOutcome::Invalid(detail)) => {
Ok(AdminWalletMutationOutcome::Invalid(detail))
}
None => Ok(AdminWalletMutationOutcome::Unavailable),
}
}
}
fn stored_admin_payment_order_to_gateway(
order: aether_data::repository::wallet::StoredAdminPaymentOrder,
) -> AdminWalletPaymentOrderRecord {
AdminWalletPaymentOrderRecord {
id: order.id,
order_no: order.order_no,
wallet_id: order.wallet_id,
user_id: order.user_id,
amount_usd: order.amount_usd,
pay_amount: order.pay_amount,
pay_currency: order.pay_currency,
exchange_rate: order.exchange_rate,
refunded_amount_usd: order.refunded_amount_usd,
refundable_amount_usd: order.refundable_amount_usd,
payment_method: order.payment_method,
gateway_order_id: order.gateway_order_id,
status: order.status,
gateway_response: order.gateway_response,
created_at_unix_secs: order.created_at_unix_secs,
paid_at_unix_secs: order.paid_at_unix_secs,
credited_at_unix_secs: order.credited_at_unix_secs,
expires_at_unix_secs: order.expires_at_unix_secs,
}
}

View File

@@ -0,0 +1,326 @@
use crate::state::AdminSecurityBlacklistEntry;
use crate::{AppState, GatewayError};
impl AppState {
pub(crate) async fn add_admin_security_blacklist(
&self,
ip_address: &str,
reason: &str,
ttl_seconds: Option<u64>,
) -> Result<bool, GatewayError> {
const ADMIN_SECURITY_BLACKLIST_PREFIX: &str = "ip:blacklist:";
if let Some(runner) = self.redis_kv_runner() {
let mut connection = match runner.client().get_multiplexed_async_connection().await {
Ok(value) => value,
Err(_) => return Ok(false),
};
let key = runner
.keyspace()
.key(&format!("{ADMIN_SECURITY_BLACKLIST_PREFIX}{ip_address}"));
let result = if let Some(ttl_seconds) = ttl_seconds {
redis::cmd("SETEX")
.arg(&key)
.arg(ttl_seconds)
.arg(reason)
.query_async::<String>(&mut connection)
.await
} else {
redis::cmd("SET")
.arg(&key)
.arg(reason)
.query_async::<String>(&mut connection)
.await
};
return Ok(result.is_ok());
}
#[cfg(test)]
if let Some(store) = self.admin_security_blacklist_store.as_ref() {
store
.lock()
.expect("admin security blacklist store should lock")
.insert(ip_address.to_string(), reason.to_string());
return Ok(true);
}
Ok(false)
}
pub(crate) async fn remove_admin_security_blacklist(
&self,
ip_address: &str,
) -> Result<bool, GatewayError> {
const ADMIN_SECURITY_BLACKLIST_PREFIX: &str = "ip:blacklist:";
if let Some(runner) = self.redis_kv_runner() {
let mut connection = match runner.client().get_multiplexed_async_connection().await {
Ok(value) => value,
Err(_) => return Ok(false),
};
let key = runner
.keyspace()
.key(&format!("{ADMIN_SECURITY_BLACKLIST_PREFIX}{ip_address}"));
let deleted = match redis::cmd("DEL")
.arg(&key)
.query_async::<i64>(&mut connection)
.await
{
Ok(value) => value,
Err(_) => return Ok(false),
};
return Ok(deleted > 0);
}
#[cfg(test)]
if let Some(store) = self.admin_security_blacklist_store.as_ref() {
let removed = store
.lock()
.expect("admin security blacklist store should lock")
.remove(ip_address)
.is_some();
return Ok(removed);
}
Ok(false)
}
pub(crate) async fn admin_security_blacklist_stats(
&self,
) -> Result<(bool, usize, Option<String>), GatewayError> {
const ADMIN_SECURITY_BLACKLIST_PREFIX: &str = "ip:blacklist:";
if let Some(runner) = self.redis_kv_runner() {
let mut connection = match runner.client().get_multiplexed_async_connection().await {
Ok(value) => value,
Err(_) => return Ok((false, 0, Some("Redis 不可用".to_string()))),
};
let pattern = runner
.keyspace()
.key(&format!("{ADMIN_SECURITY_BLACKLIST_PREFIX}*"));
let mut cursor = 0u64;
let mut total = 0usize;
loop {
let (next_cursor, keys) = match redis::cmd("SCAN")
.arg(cursor)
.arg("MATCH")
.arg(&pattern)
.arg("COUNT")
.arg(100)
.query_async::<(u64, Vec<String>)>(&mut connection)
.await
{
Ok(value) => value,
Err(err) => return Ok((false, 0, Some(err.to_string()))),
};
total += keys.len();
if next_cursor == 0 {
break;
}
cursor = next_cursor;
}
return Ok((true, total, None));
}
#[cfg(test)]
if let Some(store) = self.admin_security_blacklist_store.as_ref() {
let total = store
.lock()
.expect("admin security blacklist store should lock")
.len();
return Ok((true, total, None));
}
Ok((false, 0, Some("Redis 不可用".to_string())))
}
pub(crate) async fn list_admin_security_blacklist(
&self,
) -> Result<Vec<AdminSecurityBlacklistEntry>, GatewayError> {
const ADMIN_SECURITY_BLACKLIST_PREFIX: &str = "ip:blacklist:";
if let Some(runner) = self.redis_kv_runner() {
let mut connection = match runner.client().get_multiplexed_async_connection().await {
Ok(value) => value,
Err(_) => return Ok(Vec::new()),
};
let pattern = runner
.keyspace()
.key(&format!("{ADMIN_SECURITY_BLACKLIST_PREFIX}*"));
let prefix = runner.keyspace().key(ADMIN_SECURITY_BLACKLIST_PREFIX);
let mut cursor = 0u64;
let mut entries = Vec::new();
loop {
let (next_cursor, keys) = match redis::cmd("SCAN")
.arg(cursor)
.arg("MATCH")
.arg(&pattern)
.arg("COUNT")
.arg(100)
.query_async::<(u64, Vec<String>)>(&mut connection)
.await
{
Ok(value) => value,
Err(_) => break,
};
for full_key in keys {
let ip_address = full_key
.strip_prefix(prefix.as_str())
.map(|value| value.to_string())
.unwrap_or_else(|| full_key.clone());
let reason: Result<String, _> = redis::cmd("GET")
.arg(&full_key)
.query_async(&mut connection)
.await;
let reason = match reason {
Ok(value) => value,
Err(_) => continue,
};
let ttl = match redis::cmd("TTL")
.arg(&full_key)
.query_async::<i64>(&mut connection)
.await
{
Ok(value) if value >= 0 => Some(value),
_ => None,
};
entries.push(AdminSecurityBlacklistEntry {
ip_address,
reason,
ttl_seconds: ttl,
});
}
if next_cursor == 0 {
break;
}
cursor = next_cursor;
}
entries.sort_by(|a, b| a.ip_address.cmp(&b.ip_address));
return Ok(entries);
}
#[cfg(test)]
if let Some(store) = self.admin_security_blacklist_store.as_ref() {
let mut entries = store
.lock()
.expect("admin security blacklist store should lock")
.iter()
.map(|(ip, reason)| AdminSecurityBlacklistEntry {
ip_address: ip.clone(),
reason: reason.clone(),
ttl_seconds: None,
})
.collect::<Vec<_>>();
entries.sort_by(|a, b| a.ip_address.cmp(&b.ip_address));
return Ok(entries);
}
Ok(Vec::new())
}
pub(crate) async fn add_admin_security_whitelist(
&self,
ip_address: &str,
) -> Result<bool, GatewayError> {
const ADMIN_SECURITY_WHITELIST_KEY: &str = "ip:whitelist";
if let Some(runner) = self.redis_kv_runner() {
let mut connection = match runner.client().get_multiplexed_async_connection().await {
Ok(value) => value,
Err(_) => return Ok(false),
};
let key = runner.keyspace().key(ADMIN_SECURITY_WHITELIST_KEY);
let added = match redis::cmd("SADD")
.arg(&key)
.arg(ip_address)
.query_async::<i64>(&mut connection)
.await
{
Ok(value) => value,
Err(_) => return Ok(false),
};
return Ok(added >= 0);
}
#[cfg(test)]
if let Some(store) = self.admin_security_whitelist_store.as_ref() {
store
.lock()
.expect("admin security whitelist store should lock")
.insert(ip_address.to_string());
return Ok(true);
}
Ok(false)
}
pub(crate) async fn remove_admin_security_whitelist(
&self,
ip_address: &str,
) -> Result<bool, GatewayError> {
const ADMIN_SECURITY_WHITELIST_KEY: &str = "ip:whitelist";
if let Some(runner) = self.redis_kv_runner() {
let mut connection = match runner.client().get_multiplexed_async_connection().await {
Ok(value) => value,
Err(_) => return Ok(false),
};
let key = runner.keyspace().key(ADMIN_SECURITY_WHITELIST_KEY);
let removed = match redis::cmd("SREM")
.arg(&key)
.arg(ip_address)
.query_async::<i64>(&mut connection)
.await
{
Ok(value) => value,
Err(_) => return Ok(false),
};
return Ok(removed > 0);
}
#[cfg(test)]
if let Some(store) = self.admin_security_whitelist_store.as_ref() {
let removed = store
.lock()
.expect("admin security whitelist store should lock")
.remove(ip_address);
return Ok(removed);
}
Ok(false)
}
pub(crate) async fn list_admin_security_whitelist(&self) -> Result<Vec<String>, GatewayError> {
const ADMIN_SECURITY_WHITELIST_KEY: &str = "ip:whitelist";
if let Some(runner) = self.redis_kv_runner() {
let mut connection = match runner.client().get_multiplexed_async_connection().await {
Ok(value) => value,
Err(_) => return Ok(Vec::new()),
};
let key = runner.keyspace().key(ADMIN_SECURITY_WHITELIST_KEY);
let mut whitelist = match redis::cmd("SMEMBERS")
.arg(&key)
.query_async::<Vec<String>>(&mut connection)
.await
{
Ok(value) => value,
Err(_) => return Ok(Vec::new()),
};
whitelist.sort();
return Ok(whitelist);
}
#[cfg(test)]
if let Some(store) = self.admin_security_whitelist_store.as_ref() {
return Ok(store
.lock()
.expect("admin security whitelist store should lock")
.iter()
.cloned()
.collect());
}
Ok(Vec::new())
}
}

View File

@@ -0,0 +1 @@
mod blacklist;

View File

@@ -0,0 +1,25 @@
use crate::{AppState, GatewayError};
impl AppState {
pub(crate) async fn record_shadow_result_sample(
&self,
sample: aether_data::repository::shadow_results::RecordShadowResultSample,
) -> Result<Option<aether_data::repository::shadow_results::StoredShadowResult>, GatewayError>
{
self.data
.record_shadow_result_sample(sample)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn list_recent_shadow_results(
&self,
limit: usize,
) -> Result<Vec<aether_data::repository::shadow_results::StoredShadowResult>, GatewayError>
{
self.data
.list_recent_shadow_results(limit)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
}

View File

@@ -0,0 +1,78 @@
use crate::{AppState, GatewayError};
impl AppState {
pub(crate) async fn read_request_candidates_by_request_id(
&self,
request_id: &str,
) -> Result<Vec<aether_data::repository::candidates::StoredRequestCandidate>, GatewayError>
{
self.data
.list_request_candidates_by_request_id(request_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn read_request_candidates_by_provider_id(
&self,
provider_id: &str,
limit: usize,
) -> Result<Vec<aether_data::repository::candidates::StoredRequestCandidate>, GatewayError>
{
self.data
.list_request_candidates_by_provider_id(provider_id, limit)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn summarize_provider_usage_since(
&self,
provider_id: &str,
since_unix_secs: u64,
) -> Result<aether_data::repository::usage::StoredProviderUsageSummary, GatewayError> {
self.data
.summarize_provider_usage_since(provider_id, since_unix_secs)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn list_usage_audits(
&self,
query: &aether_data::repository::usage::UsageAuditListQuery,
) -> Result<Vec<aether_data::repository::usage::StoredRequestUsageAudit>, GatewayError> {
self.data
.list_usage_audits(query)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn list_recent_usage_audits(
&self,
user_id: Option<&str>,
limit: usize,
) -> Result<Vec<aether_data::repository::usage::StoredRequestUsageAudit>, GatewayError> {
self.data
.list_recent_usage_audits(user_id, limit)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn summarize_usage_total_tokens_by_api_key_ids(
&self,
api_key_ids: &[String],
) -> Result<std::collections::BTreeMap<String, u64>, GatewayError> {
self.data
.summarize_usage_total_tokens_by_api_key_ids(api_key_ids)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn list_users_by_ids(
&self,
user_ids: &[String],
) -> Result<Vec<aether_data::repository::users::StoredUserSummary>, GatewayError> {
self.data
.list_users_by_ids(user_ids)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
}

View File

@@ -0,0 +1,25 @@
use crate::{AppState, GatewayError};
impl AppState {
pub(crate) async fn read_user_preferences(
&self,
user_id: &str,
) -> Result<Option<crate::data::state::StoredUserPreferenceRecord>, GatewayError>
{
self.data
.read_user_preferences(user_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn write_user_preferences(
&self,
preferences: &crate::data::state::StoredUserPreferenceRecord,
) -> Result<Option<crate::data::state::StoredUserPreferenceRecord>, GatewayError>
{
self.data
.write_user_preferences(preferences)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
}

View File

@@ -0,0 +1,225 @@
use crate::{
AdminWalletPaymentOrderRecord, AdminWalletTransactionRecord, AppState, GatewayError,
};
use super::admin_wallet_build_order_no;
impl AppState {
pub(crate) async fn admin_adjust_wallet_balance(
&self,
wallet_id: &str,
amount_usd: f64,
balance_type: &str,
operator_id: Option<&str>,
description: Option<&str>,
) -> Result<
Option<(
aether_data::repository::wallet::StoredWalletSnapshot,
AdminWalletTransactionRecord,
)>,
GatewayError,
> {
#[cfg(test)]
if let Some(store) = self.auth_wallet_store.as_ref() {
let mut guard = store.lock().expect("auth wallet store should lock");
let Some(wallet) = guard.get_mut(wallet_id) else {
return Ok(None);
};
let before_recharge = wallet.balance;
let before_gift = wallet.gift_balance;
let before_total = before_recharge + before_gift;
let mut after_recharge = before_recharge;
let mut after_gift = before_gift;
if amount_usd > 0.0 {
if balance_type.eq_ignore_ascii_case("gift") {
after_gift += amount_usd;
} else {
after_recharge += amount_usd;
}
} else {
let mut remaining = -amount_usd;
let consume_positive_bucket = |balance: &mut f64, to_consume: &mut f64| {
if *to_consume <= 0.0 {
return;
}
let available = (*balance).max(0.0);
let consumed = available.min(*to_consume);
*balance -= consumed;
*to_consume -= consumed;
};
if balance_type.eq_ignore_ascii_case("gift") {
consume_positive_bucket(&mut after_gift, &mut remaining);
consume_positive_bucket(&mut after_recharge, &mut remaining);
} else {
consume_positive_bucket(&mut after_recharge, &mut remaining);
consume_positive_bucket(&mut after_gift, &mut remaining);
}
if remaining > 0.0 {
after_recharge -= remaining;
}
}
wallet.balance = after_recharge;
wallet.gift_balance = after_gift;
wallet.total_adjusted += amount_usd;
wallet.updated_at_unix_secs = chrono::Utc::now().timestamp().max(0) as u64;
let transaction = AdminWalletTransactionRecord {
id: uuid::Uuid::new_v4().to_string(),
wallet_id: wallet.id.clone(),
category: "adjust".to_string(),
reason_code: "adjust_admin".to_string(),
amount: amount_usd,
balance_before: before_total,
balance_after: after_recharge + after_gift,
recharge_balance_before: before_recharge,
recharge_balance_after: after_recharge,
gift_balance_before: before_gift,
gift_balance_after: after_gift,
link_type: Some("admin_action".to_string()),
link_id: Some(wallet.id.clone()),
operator_id: operator_id.map(ToOwned::to_owned),
description: Some(
description
.filter(|value| !value.trim().is_empty())
.unwrap_or("管理员调账")
.to_string(),
),
created_at_unix_secs: chrono::Utc::now().timestamp().max(0) as u64,
};
return Ok(Some((wallet.clone(), transaction)));
}
Ok(self
.adjust_wallet_balance(aether_data::repository::wallet::AdjustWalletBalanceInput {
wallet_id: wallet_id.to_string(),
amount_usd,
balance_type: balance_type.to_string(),
operator_id: operator_id.map(ToOwned::to_owned),
description: description.map(ToOwned::to_owned),
})
.await?
.map(|(wallet, transaction)| {
(wallet, stored_wallet_transaction_to_gateway(transaction))
}))
}
pub(crate) async fn admin_create_manual_wallet_recharge(
&self,
wallet_id: &str,
amount_usd: f64,
payment_method: &str,
operator_id: Option<&str>,
description: Option<&str>,
) -> Result<
Option<(
aether_data::repository::wallet::StoredWalletSnapshot,
AdminWalletPaymentOrderRecord,
)>,
GatewayError,
> {
#[cfg(test)]
if let Some(store) = self.auth_wallet_store.as_ref() {
let mut guard = store.lock().expect("auth wallet store should lock");
let Some(wallet) = guard.get_mut(wallet_id) else {
return Ok(None);
};
wallet.balance += amount_usd;
wallet.total_recharged += amount_usd;
wallet.updated_at_unix_secs = chrono::Utc::now().timestamp().max(0) as u64;
let now = chrono::Utc::now();
let created_at = now.timestamp().max(0) as u64;
let order = AdminWalletPaymentOrderRecord {
id: uuid::Uuid::new_v4().to_string(),
order_no: admin_wallet_build_order_no(now),
wallet_id: wallet.id.clone(),
user_id: wallet.user_id.clone(),
amount_usd,
pay_amount: None,
pay_currency: None,
exchange_rate: None,
refunded_amount_usd: 0.0,
refundable_amount_usd: amount_usd,
payment_method: payment_method.to_string(),
gateway_order_id: None,
status: "credited".to_string(),
gateway_response: Some(serde_json::json!({
"source": "manual",
"operator_id": operator_id,
"description": description,
})),
created_at_unix_secs: created_at,
paid_at_unix_secs: Some(created_at),
credited_at_unix_secs: Some(created_at),
expires_at_unix_secs: None,
};
return Ok(Some((wallet.clone(), order)));
}
let now = chrono::Utc::now();
let order_no = admin_wallet_build_order_no(now);
Ok(self
.create_manual_wallet_recharge(
aether_data::repository::wallet::CreateManualWalletRechargeInput {
wallet_id: wallet_id.to_string(),
amount_usd,
payment_method: payment_method.to_string(),
operator_id: operator_id.map(ToOwned::to_owned),
description: description.map(ToOwned::to_owned),
order_no,
},
)
.await?
.map(|(wallet, order)| (wallet, stored_admin_payment_order_to_gateway(order))))
}
}
fn stored_wallet_transaction_to_gateway(
transaction: aether_data::repository::wallet::StoredAdminWalletTransaction,
) -> AdminWalletTransactionRecord {
AdminWalletTransactionRecord {
id: transaction.id,
wallet_id: transaction.wallet_id,
category: transaction.category,
reason_code: transaction.reason_code,
amount: transaction.amount,
balance_before: transaction.balance_before,
balance_after: transaction.balance_after,
recharge_balance_before: transaction.recharge_balance_before,
recharge_balance_after: transaction.recharge_balance_after,
gift_balance_before: transaction.gift_balance_before,
gift_balance_after: transaction.gift_balance_after,
link_type: transaction.link_type,
link_id: transaction.link_id,
operator_id: transaction.operator_id,
description: transaction.description,
created_at_unix_secs: transaction.created_at_unix_secs.unwrap_or_default(),
}
}
fn stored_admin_payment_order_to_gateway(
order: aether_data::repository::wallet::StoredAdminPaymentOrder,
) -> AdminWalletPaymentOrderRecord {
AdminWalletPaymentOrderRecord {
id: order.id,
order_no: order.order_no,
wallet_id: order.wallet_id,
user_id: order.user_id,
amount_usd: order.amount_usd,
pay_amount: order.pay_amount,
pay_currency: order.pay_currency,
exchange_rate: order.exchange_rate,
refunded_amount_usd: order.refunded_amount_usd,
refundable_amount_usd: order.refundable_amount_usd,
payment_method: order.payment_method,
gateway_order_id: order.gateway_order_id,
status: order.status,
gateway_response: order.gateway_response,
created_at_unix_secs: order.created_at_unix_secs,
paid_at_unix_secs: order.paid_at_unix_secs,
credited_at_unix_secs: order.credited_at_unix_secs,
expires_at_unix_secs: order.expires_at_unix_secs,
}
}

View File

@@ -0,0 +1,294 @@
use sqlx::Row;
use super::{
AdminBillingCollectorRecord, AdminBillingRuleRecord, AdminWalletPaymentOrderRecord,
AdminWalletRefundRecord, GatewayError,
};
pub(crate) fn admin_wallet_build_order_no(now: chrono::DateTime<chrono::Utc>) -> String {
format!(
"po_{}_{}",
now.format("%Y%m%d%H%M%S%6f"),
&uuid::Uuid::new_v4().simple().to_string()[..12]
)
}
pub(crate) fn admin_payment_gateway_response_map(
value: Option<serde_json::Value>,
) -> serde_json::Map<String, serde_json::Value> {
match value {
Some(serde_json::Value::Object(map)) => map,
_ => serde_json::Map::new(),
}
}
pub(super) fn admin_wallet_snapshot_from_row(
row: &sqlx::postgres::PgRow,
) -> Result<aether_data::repository::wallet::StoredWalletSnapshot, GatewayError> {
aether_data::repository::wallet::StoredWalletSnapshot::new(
row.try_get("id")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
row.try_get("user_id")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
row.try_get("api_key_id")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
row.try_get("balance")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
row.try_get("gift_balance")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
row.try_get("limit_mode")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
row.try_get("currency")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
row.try_get("status")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
row.try_get("total_recharged")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
row.try_get("total_consumed")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
row.try_get("total_refunded")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
row.try_get("total_adjusted")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
row.try_get("updated_at_unix_secs")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
)
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(super) fn admin_wallet_payment_order_from_row(
row: &sqlx::postgres::PgRow,
) -> Result<AdminWalletPaymentOrderRecord, GatewayError> {
Ok(AdminWalletPaymentOrderRecord {
id: row
.try_get("id")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
order_no: row
.try_get("order_no")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
wallet_id: row
.try_get("wallet_id")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
user_id: row
.try_get("user_id")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
amount_usd: row
.try_get("amount_usd")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
pay_amount: row
.try_get("pay_amount")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
pay_currency: row
.try_get("pay_currency")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
exchange_rate: row
.try_get("exchange_rate")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
refunded_amount_usd: row
.try_get("refunded_amount_usd")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
refundable_amount_usd: row
.try_get("refundable_amount_usd")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
payment_method: row
.try_get("payment_method")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
gateway_order_id: row
.try_get("gateway_order_id")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
status: row
.try_get("status")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
gateway_response: row
.try_get("gateway_response")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
created_at_unix_secs: row
.try_get::<i64, _>("created_at_unix_secs")
.map_err(|err| GatewayError::Internal(err.to_string()))?
.max(0) as u64,
paid_at_unix_secs: row
.try_get::<Option<i64>, _>("paid_at_unix_secs")
.map_err(|err| GatewayError::Internal(err.to_string()))?
.map(|value| value.max(0) as u64),
credited_at_unix_secs: row
.try_get::<Option<i64>, _>("credited_at_unix_secs")
.map_err(|err| GatewayError::Internal(err.to_string()))?
.map(|value| value.max(0) as u64),
expires_at_unix_secs: row
.try_get::<Option<i64>, _>("expires_at_unix_secs")
.map_err(|err| GatewayError::Internal(err.to_string()))?
.map(|value| value.max(0) as u64),
})
}
pub(super) fn admin_wallet_refund_from_row(
row: &sqlx::postgres::PgRow,
) -> Result<AdminWalletRefundRecord, GatewayError> {
Ok(AdminWalletRefundRecord {
id: row
.try_get("id")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
refund_no: row
.try_get("refund_no")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
wallet_id: row
.try_get("wallet_id")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
user_id: row
.try_get("user_id")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
payment_order_id: row
.try_get("payment_order_id")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
source_type: row
.try_get("source_type")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
source_id: row
.try_get("source_id")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
refund_mode: row
.try_get("refund_mode")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
amount_usd: row
.try_get("amount_usd")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
status: row
.try_get("status")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
reason: row
.try_get("reason")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
failure_reason: row
.try_get("failure_reason")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
gateway_refund_id: row
.try_get("gateway_refund_id")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
payout_method: row
.try_get("payout_method")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
payout_reference: row
.try_get("payout_reference")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
payout_proof: row
.try_get("payout_proof")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
requested_by: row
.try_get("requested_by")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
approved_by: row
.try_get("approved_by")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
processed_by: row
.try_get("processed_by")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
created_at_unix_secs: row
.try_get::<i64, _>("created_at_unix_secs")
.map_err(|err| GatewayError::Internal(err.to_string()))?
.max(0) as u64,
updated_at_unix_secs: row
.try_get::<i64, _>("updated_at_unix_secs")
.map_err(|err| GatewayError::Internal(err.to_string()))?
.max(0) as u64,
processed_at_unix_secs: row
.try_get::<Option<i64>, _>("processed_at_unix_secs")
.map_err(|err| GatewayError::Internal(err.to_string()))?
.map(|value| value.max(0) as u64),
completed_at_unix_secs: row
.try_get::<Option<i64>, _>("completed_at_unix_secs")
.map_err(|err| GatewayError::Internal(err.to_string()))?
.map(|value| value.max(0) as u64),
})
}
pub(super) fn admin_billing_rule_from_row(
row: &sqlx::postgres::PgRow,
) -> Result<AdminBillingRuleRecord, GatewayError> {
Ok(AdminBillingRuleRecord {
id: row
.try_get("id")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
name: row
.try_get("name")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
task_type: row
.try_get("task_type")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
global_model_id: row
.try_get("global_model_id")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
model_id: row
.try_get("model_id")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
expression: row
.try_get("expression")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
variables: row
.try_get::<Option<serde_json::Value>, _>("variables")
.map_err(|err| GatewayError::Internal(err.to_string()))?
.unwrap_or_else(|| serde_json::json!({})),
dimension_mappings: row
.try_get::<Option<serde_json::Value>, _>("dimension_mappings")
.map_err(|err| GatewayError::Internal(err.to_string()))?
.unwrap_or_else(|| serde_json::json!({})),
is_enabled: row
.try_get("is_enabled")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
created_at_unix_secs: row
.try_get::<i64, _>("created_at_unix_secs")
.map_err(|err| GatewayError::Internal(err.to_string()))?
.max(0) as u64,
updated_at_unix_secs: row
.try_get::<i64, _>("updated_at_unix_secs")
.map_err(|err| GatewayError::Internal(err.to_string()))?
.max(0) as u64,
})
}
pub(super) fn admin_billing_collector_from_row(
row: &sqlx::postgres::PgRow,
) -> Result<AdminBillingCollectorRecord, GatewayError> {
Ok(AdminBillingCollectorRecord {
id: row
.try_get("id")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
api_format: row
.try_get("api_format")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
task_type: row
.try_get("task_type")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
dimension_name: row
.try_get("dimension_name")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
source_type: row
.try_get("source_type")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
source_path: row
.try_get("source_path")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
value_type: row
.try_get("value_type")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
transform_expression: row
.try_get("transform_expression")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
default_value: row
.try_get("default_value")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
priority: row
.try_get("priority")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
is_enabled: row
.try_get("is_enabled")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
created_at_unix_secs: row
.try_get::<i64, _>("created_at_unix_secs")
.map_err(|err| GatewayError::Internal(err.to_string()))?
.max(0) as u64,
updated_at_unix_secs: row
.try_get::<i64, _>("updated_at_unix_secs")
.map_err(|err| GatewayError::Internal(err.to_string()))?
.max(0) as u64,
})
}

View File

@@ -0,0 +1,13 @@
use super::super::{
AdminBillingCollectorRecord, AdminBillingRuleRecord, AdminWalletMutationOutcome,
AdminWalletPaymentOrderRecord, AdminWalletRefundRecord, AdminWalletTransactionRecord, AppState,
GatewayError,
};
mod balance_mutations;
mod billing;
mod mutations;
mod reads;
mod refund_lifecycle;
pub(super) use self::billing::{admin_payment_gateway_response_map, admin_wallet_build_order_no};

View File

@@ -0,0 +1,173 @@
use aether_data::repository::wallet::{
AdjustWalletBalanceInput, CompleteAdminWalletRefundInput, CreateManualWalletRechargeInput,
CreateWalletRechargeOrderInput, CreateWalletRechargeOrderOutcome,
CreateWalletRefundRequestInput, CreateWalletRefundRequestOutcome, CreditAdminPaymentOrderInput,
FailAdminWalletRefundInput, ProcessAdminWalletRefundInput, ProcessPaymentCallbackInput,
ProcessPaymentCallbackOutcome, WalletMutationOutcome,
};
use crate::{AppState, GatewayError};
impl AppState {
pub(crate) async fn create_wallet_recharge_order(
&self,
input: CreateWalletRechargeOrderInput,
) -> Result<Option<CreateWalletRechargeOrderOutcome>, GatewayError> {
self.data
.create_wallet_recharge_order(input)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn create_wallet_refund_request(
&self,
input: CreateWalletRefundRequestInput,
) -> Result<Option<CreateWalletRefundRequestOutcome>, GatewayError> {
self.data
.create_wallet_refund_request(input)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn process_payment_callback(
&self,
input: ProcessPaymentCallbackInput,
) -> Result<Option<ProcessPaymentCallbackOutcome>, GatewayError> {
self.data
.process_payment_callback(input)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn adjust_wallet_balance(
&self,
input: AdjustWalletBalanceInput,
) -> Result<
Option<(
aether_data::repository::wallet::StoredWalletSnapshot,
aether_data::repository::wallet::StoredAdminWalletTransaction,
)>,
GatewayError,
> {
self.data
.adjust_wallet_balance(input)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn create_manual_wallet_recharge(
&self,
input: CreateManualWalletRechargeInput,
) -> Result<
Option<(
aether_data::repository::wallet::StoredWalletSnapshot,
aether_data::repository::wallet::StoredAdminPaymentOrder,
)>,
GatewayError,
> {
self.data
.create_manual_wallet_recharge(input)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn process_admin_wallet_refund(
&self,
input: ProcessAdminWalletRefundInput,
) -> Result<
Option<
WalletMutationOutcome<(
aether_data::repository::wallet::StoredWalletSnapshot,
aether_data::repository::wallet::StoredAdminWalletRefund,
aether_data::repository::wallet::StoredAdminWalletTransaction,
)>,
>,
GatewayError,
> {
self.data
.process_admin_wallet_refund(input)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn complete_admin_wallet_refund(
&self,
input: CompleteAdminWalletRefundInput,
) -> Result<
Option<WalletMutationOutcome<aether_data::repository::wallet::StoredAdminWalletRefund>>,
GatewayError,
> {
self.data
.complete_admin_wallet_refund(input)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn fail_admin_wallet_refund(
&self,
input: FailAdminWalletRefundInput,
) -> Result<
Option<
WalletMutationOutcome<(
aether_data::repository::wallet::StoredWalletSnapshot,
aether_data::repository::wallet::StoredAdminWalletRefund,
Option<aether_data::repository::wallet::StoredAdminWalletTransaction>,
)>,
>,
GatewayError,
> {
self.data
.fail_admin_wallet_refund(input)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn expire_admin_payment_order(
&self,
order_id: &str,
) -> Result<
Option<
WalletMutationOutcome<(
aether_data::repository::wallet::StoredAdminPaymentOrder,
bool,
)>,
>,
GatewayError,
> {
self.data
.expire_admin_payment_order(order_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn fail_admin_payment_order(
&self,
order_id: &str,
) -> Result<
Option<WalletMutationOutcome<aether_data::repository::wallet::StoredAdminPaymentOrder>>,
GatewayError,
> {
self.data
.fail_admin_payment_order(order_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn credit_admin_payment_order(
&self,
input: CreditAdminPaymentOrderInput,
) -> Result<
Option<
WalletMutationOutcome<(
aether_data::repository::wallet::StoredAdminPaymentOrder,
bool,
)>,
>,
GatewayError,
> {
self.data
.credit_admin_payment_order(input)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
}

View File

@@ -0,0 +1,152 @@
use crate::{AppState, GatewayError};
impl AppState {
pub(crate) async fn find_wallet(
&self,
lookup: aether_data::repository::wallet::WalletLookupKey<'_>,
) -> Result<Option<aether_data::repository::wallet::StoredWalletSnapshot>, GatewayError> {
#[cfg(test)]
if let Some(store) = self.auth_wallet_store.as_ref() {
let wallet = {
let wallets = store.lock().expect("auth wallet store should lock");
match lookup {
aether_data::repository::wallet::WalletLookupKey::WalletId(wallet_id) => {
wallets.get(wallet_id).cloned()
}
aether_data::repository::wallet::WalletLookupKey::UserId(user_id) => wallets
.values()
.find(|wallet| wallet.user_id.as_deref() == Some(user_id))
.cloned(),
aether_data::repository::wallet::WalletLookupKey::ApiKeyId(api_key_id) => {
wallets
.values()
.find(|wallet| wallet.api_key_id.as_deref() == Some(api_key_id))
.cloned()
}
}
};
if wallet.is_some() {
return Ok(wallet);
}
}
self.data
.find_wallet(lookup)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn read_wallet_snapshot_for_auth(
&self,
user_id: &str,
api_key_id: &str,
api_key_is_standalone: bool,
) -> Result<Option<aether_data::repository::wallet::StoredWalletSnapshot>, GatewayError> {
let lookup = if api_key_is_standalone {
if api_key_id.trim().is_empty() {
None
} else {
Some(aether_data::repository::wallet::WalletLookupKey::ApiKeyId(
api_key_id,
))
}
} else if !user_id.trim().is_empty() {
Some(aether_data::repository::wallet::WalletLookupKey::UserId(
user_id,
))
} else if !api_key_id.trim().is_empty() {
Some(aether_data::repository::wallet::WalletLookupKey::ApiKeyId(
api_key_id,
))
} else {
None
};
let Some(lookup) = lookup else {
return Ok(None);
};
self.find_wallet(lookup).await
}
pub(crate) async fn list_wallet_snapshots_by_user_ids(
&self,
user_ids: &[String],
) -> Result<Vec<aether_data::repository::wallet::StoredWalletSnapshot>, GatewayError> {
self.data
.list_wallets_by_user_ids(user_ids)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn list_wallet_snapshots_by_api_key_ids(
&self,
api_key_ids: &[String],
) -> Result<Vec<aether_data::repository::wallet::StoredWalletSnapshot>, GatewayError> {
self.data
.list_wallets_by_api_key_ids(api_key_ids)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn find_wallet_today_usage(
&self,
wallet_id: &str,
billing_timezone: &str,
) -> Result<Option<aether_data::repository::wallet::StoredWalletDailyUsageLedger>, GatewayError>
{
self.data
.find_wallet_today_usage(wallet_id, billing_timezone)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn list_wallet_daily_usage_history(
&self,
wallet_id: &str,
billing_timezone: &str,
limit: usize,
) -> Result<aether_data::repository::wallet::StoredWalletDailyUsageLedgerPage, GatewayError>
{
self.data
.list_wallet_daily_usage_history(wallet_id, billing_timezone, limit)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn list_wallet_payment_orders_by_user_id(
&self,
user_id: &str,
limit: usize,
offset: usize,
) -> Result<aether_data::repository::wallet::StoredAdminPaymentOrderPage, GatewayError> {
self.data
.list_wallet_payment_orders_by_user_id(user_id, limit, offset)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn find_wallet_payment_order_by_user_id(
&self,
user_id: &str,
order_id: &str,
) -> Result<Option<aether_data::repository::wallet::StoredAdminPaymentOrder>, GatewayError>
{
self.data
.find_wallet_payment_order_by_user_id(user_id, order_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn find_wallet_refund(
&self,
wallet_id: &str,
refund_id: &str,
) -> Result<Option<aether_data::repository::wallet::StoredAdminWalletRefund>, GatewayError>
{
self.data
.find_wallet_refund(wallet_id, refund_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
}

View File

@@ -0,0 +1,446 @@
use super::{
AdminWalletMutationOutcome, AdminWalletRefundRecord, AdminWalletTransactionRecord, AppState,
GatewayError,
};
impl AppState {
pub(crate) async fn admin_process_wallet_refund(
&self,
wallet_id: &str,
refund_id: &str,
operator_id: Option<&str>,
) -> Result<
AdminWalletMutationOutcome<(
aether_data::repository::wallet::StoredWalletSnapshot,
AdminWalletRefundRecord,
AdminWalletTransactionRecord,
)>,
GatewayError,
> {
#[cfg(test)]
if let (Some(wallet_store), Some(refund_store)) = (
self.auth_wallet_store.as_ref(),
self.admin_wallet_refund_store.as_ref(),
) {
let Some(wallet) = wallet_store
.lock()
.expect("auth wallet store should lock")
.get(wallet_id)
.cloned()
else {
return Ok(AdminWalletMutationOutcome::NotFound);
};
let Some(refund) = refund_store
.lock()
.expect("admin wallet refund store should lock")
.get(refund_id)
.filter(|refund| refund.wallet_id == wallet_id)
.cloned()
else {
return Ok(AdminWalletMutationOutcome::NotFound);
};
if !matches!(refund.status.as_str(), "approved" | "pending_approval") {
return Ok(AdminWalletMutationOutcome::Invalid(
"refund status is not approvable".to_string(),
));
}
let amount_usd = refund.amount_usd;
let mut updated_wallet = wallet.clone();
let before_recharge = updated_wallet.balance;
let before_gift = updated_wallet.gift_balance;
let before_total = before_recharge + before_gift;
let after_recharge = before_recharge - amount_usd;
if after_recharge < 0.0 {
return Ok(AdminWalletMutationOutcome::Invalid(
"refund amount exceeds refundable recharge balance".to_string(),
));
}
let mut updated_order = None;
if let Some(payment_order_id) = refund.payment_order_id.clone() {
let Some(order_store) = self.admin_wallet_payment_order_store.as_ref() else {
return Ok(AdminWalletMutationOutcome::Unavailable);
};
let Some(order) = order_store
.lock()
.expect("admin wallet payment order store should lock")
.get(&payment_order_id)
.cloned()
else {
return Ok(AdminWalletMutationOutcome::Invalid(
"payment order not found".to_string(),
));
};
if amount_usd > order.refundable_amount_usd {
return Ok(AdminWalletMutationOutcome::Invalid(
"refund amount exceeds refundable amount".to_string(),
));
}
let mut order = order;
order.refunded_amount_usd += amount_usd;
order.refundable_amount_usd -= amount_usd;
updated_order = Some(order);
}
let now_unix_secs = chrono::Utc::now().timestamp().max(0) as u64;
updated_wallet.balance = after_recharge;
updated_wallet.total_refunded = (updated_wallet.total_refunded + amount_usd).max(0.0);
updated_wallet.updated_at_unix_secs = now_unix_secs;
let transaction = AdminWalletTransactionRecord {
id: uuid::Uuid::new_v4().to_string(),
wallet_id: updated_wallet.id.clone(),
category: "refund".to_string(),
reason_code: "refund_out".to_string(),
amount: -amount_usd,
balance_before: before_total,
balance_after: after_recharge + before_gift,
recharge_balance_before: before_recharge,
recharge_balance_after: after_recharge,
gift_balance_before: before_gift,
gift_balance_after: before_gift,
link_type: Some("refund_request".to_string()),
link_id: Some(refund.id.clone()),
operator_id: operator_id.map(ToOwned::to_owned),
description: Some("退款占款".to_string()),
created_at_unix_secs: now_unix_secs,
};
let mut updated_refund = refund.clone();
updated_refund.status = "processing".to_string();
updated_refund.approved_by = operator_id.map(ToOwned::to_owned);
updated_refund.processed_by = operator_id.map(ToOwned::to_owned);
updated_refund.processed_at_unix_secs = Some(now_unix_secs);
updated_refund.updated_at_unix_secs = now_unix_secs;
wallet_store
.lock()
.expect("auth wallet store should lock")
.insert(updated_wallet.id.clone(), updated_wallet.clone());
refund_store
.lock()
.expect("admin wallet refund store should lock")
.insert(updated_refund.id.clone(), updated_refund.clone());
if let Some(updated_order) = updated_order {
self.admin_wallet_payment_order_store
.as_ref()
.expect("admin wallet payment order store should exist")
.lock()
.expect("admin wallet payment order store should lock")
.insert(updated_order.id.clone(), updated_order);
}
return Ok(AdminWalletMutationOutcome::Applied((
updated_wallet,
updated_refund,
transaction,
)));
}
match self
.process_admin_wallet_refund(
aether_data::repository::wallet::ProcessAdminWalletRefundInput {
wallet_id: wallet_id.to_string(),
refund_id: refund_id.to_string(),
operator_id: operator_id.map(ToOwned::to_owned),
},
)
.await?
{
Some(aether_data::repository::wallet::WalletMutationOutcome::Applied((
wallet,
refund,
transaction,
))) => Ok(AdminWalletMutationOutcome::Applied((
wallet,
stored_admin_wallet_refund_to_gateway(refund),
stored_admin_wallet_transaction_to_gateway(transaction),
))),
Some(aether_data::repository::wallet::WalletMutationOutcome::NotFound) => {
Ok(AdminWalletMutationOutcome::NotFound)
}
Some(aether_data::repository::wallet::WalletMutationOutcome::Invalid(detail)) => {
Ok(AdminWalletMutationOutcome::Invalid(detail))
}
None => Ok(AdminWalletMutationOutcome::Unavailable),
}
}
pub(crate) async fn admin_complete_wallet_refund(
&self,
wallet_id: &str,
refund_id: &str,
gateway_refund_id: Option<&str>,
payout_reference: Option<&str>,
payout_proof: Option<serde_json::Value>,
) -> Result<AdminWalletMutationOutcome<AdminWalletRefundRecord>, GatewayError> {
#[cfg(test)]
if let Some(refund_store) = self.admin_wallet_refund_store.as_ref() {
let Some(refund) = refund_store
.lock()
.expect("admin wallet refund store should lock")
.get(refund_id)
.filter(|refund| refund.wallet_id == wallet_id)
.cloned()
else {
return Ok(AdminWalletMutationOutcome::NotFound);
};
if refund.status != "processing" {
return Ok(AdminWalletMutationOutcome::Invalid(
"refund status must be processing before completion".to_string(),
));
}
let now_unix_secs = chrono::Utc::now().timestamp().max(0) as u64;
let mut updated_refund = refund;
updated_refund.status = "succeeded".to_string();
updated_refund.gateway_refund_id = gateway_refund_id.map(ToOwned::to_owned);
updated_refund.payout_reference = payout_reference.map(ToOwned::to_owned);
updated_refund.payout_proof = payout_proof;
updated_refund.completed_at_unix_secs = Some(now_unix_secs);
updated_refund.updated_at_unix_secs = now_unix_secs;
refund_store
.lock()
.expect("admin wallet refund store should lock")
.insert(updated_refund.id.clone(), updated_refund.clone());
return Ok(AdminWalletMutationOutcome::Applied(updated_refund));
}
match self
.complete_admin_wallet_refund(
aether_data::repository::wallet::CompleteAdminWalletRefundInput {
wallet_id: wallet_id.to_string(),
refund_id: refund_id.to_string(),
gateway_refund_id: gateway_refund_id.map(ToOwned::to_owned),
payout_reference: payout_reference.map(ToOwned::to_owned),
payout_proof,
},
)
.await?
{
Some(aether_data::repository::wallet::WalletMutationOutcome::Applied(refund)) => Ok(
AdminWalletMutationOutcome::Applied(stored_admin_wallet_refund_to_gateway(refund)),
),
Some(aether_data::repository::wallet::WalletMutationOutcome::NotFound) => {
Ok(AdminWalletMutationOutcome::NotFound)
}
Some(aether_data::repository::wallet::WalletMutationOutcome::Invalid(detail)) => {
Ok(AdminWalletMutationOutcome::Invalid(detail))
}
None => Ok(AdminWalletMutationOutcome::Unavailable),
}
}
pub(crate) async fn admin_fail_wallet_refund(
&self,
wallet_id: &str,
refund_id: &str,
reason: &str,
operator_id: Option<&str>,
) -> Result<
AdminWalletMutationOutcome<(
aether_data::repository::wallet::StoredWalletSnapshot,
AdminWalletRefundRecord,
Option<AdminWalletTransactionRecord>,
)>,
GatewayError,
> {
#[cfg(test)]
if let (Some(wallet_store), Some(refund_store)) = (
self.auth_wallet_store.as_ref(),
self.admin_wallet_refund_store.as_ref(),
) {
let Some(wallet) = wallet_store
.lock()
.expect("auth wallet store should lock")
.get(wallet_id)
.cloned()
else {
return Ok(AdminWalletMutationOutcome::NotFound);
};
let Some(refund) = refund_store
.lock()
.expect("admin wallet refund store should lock")
.get(refund_id)
.filter(|refund| refund.wallet_id == wallet_id)
.cloned()
else {
return Ok(AdminWalletMutationOutcome::NotFound);
};
let now_unix_secs = chrono::Utc::now().timestamp().max(0) as u64;
if matches!(refund.status.as_str(), "pending_approval" | "approved") {
let mut updated_refund = refund;
updated_refund.status = "failed".to_string();
updated_refund.failure_reason = Some(reason.to_string());
updated_refund.updated_at_unix_secs = now_unix_secs;
refund_store
.lock()
.expect("admin wallet refund store should lock")
.insert(updated_refund.id.clone(), updated_refund.clone());
return Ok(AdminWalletMutationOutcome::Applied((
wallet,
updated_refund,
None,
)));
}
if refund.status != "processing" {
return Ok(AdminWalletMutationOutcome::Invalid(format!(
"cannot fail refund in status: {}",
refund.status
)));
}
let amount_usd = refund.amount_usd;
let before_recharge = wallet.balance;
let before_gift = wallet.gift_balance;
let before_total = before_recharge + before_gift;
let after_recharge = before_recharge + amount_usd;
let mut updated_wallet = wallet.clone();
updated_wallet.balance = after_recharge;
updated_wallet.total_refunded = (updated_wallet.total_refunded - amount_usd).max(0.0);
updated_wallet.updated_at_unix_secs = now_unix_secs;
let transaction = AdminWalletTransactionRecord {
id: uuid::Uuid::new_v4().to_string(),
wallet_id: updated_wallet.id.clone(),
category: "refund".to_string(),
reason_code: "refund_revert".to_string(),
amount: amount_usd,
balance_before: before_total,
balance_after: after_recharge + before_gift,
recharge_balance_before: before_recharge,
recharge_balance_after: after_recharge,
gift_balance_before: before_gift,
gift_balance_after: before_gift,
link_type: Some("refund_request".to_string()),
link_id: Some(refund.id.clone()),
operator_id: operator_id.map(ToOwned::to_owned),
description: Some("退款失败回补".to_string()),
created_at_unix_secs: now_unix_secs,
};
if let Some(payment_order_id) = refund.payment_order_id.clone() {
let Some(order_store) = self.admin_wallet_payment_order_store.as_ref() else {
return Ok(AdminWalletMutationOutcome::Unavailable);
};
let maybe_order = order_store
.lock()
.expect("admin wallet payment order store should lock")
.get(&payment_order_id)
.cloned();
if let Some(mut order) = maybe_order {
order.refunded_amount_usd -= amount_usd;
order.refundable_amount_usd += amount_usd;
order_store
.lock()
.expect("admin wallet payment order store should lock")
.insert(order.id.clone(), order);
}
}
let mut updated_refund = refund;
updated_refund.status = "failed".to_string();
updated_refund.failure_reason = Some(reason.to_string());
updated_refund.updated_at_unix_secs = now_unix_secs;
wallet_store
.lock()
.expect("auth wallet store should lock")
.insert(updated_wallet.id.clone(), updated_wallet.clone());
refund_store
.lock()
.expect("admin wallet refund store should lock")
.insert(updated_refund.id.clone(), updated_refund.clone());
return Ok(AdminWalletMutationOutcome::Applied((
updated_wallet,
updated_refund,
Some(transaction),
)));
}
match self
.fail_admin_wallet_refund(
aether_data::repository::wallet::FailAdminWalletRefundInput {
wallet_id: wallet_id.to_string(),
refund_id: refund_id.to_string(),
reason: reason.to_string(),
operator_id: operator_id.map(ToOwned::to_owned),
},
)
.await?
{
Some(aether_data::repository::wallet::WalletMutationOutcome::Applied((
wallet,
refund,
transaction,
))) => Ok(AdminWalletMutationOutcome::Applied((
wallet,
stored_admin_wallet_refund_to_gateway(refund),
transaction.map(stored_admin_wallet_transaction_to_gateway),
))),
Some(aether_data::repository::wallet::WalletMutationOutcome::NotFound) => {
Ok(AdminWalletMutationOutcome::NotFound)
}
Some(aether_data::repository::wallet::WalletMutationOutcome::Invalid(detail)) => {
Ok(AdminWalletMutationOutcome::Invalid(detail))
}
None => Ok(AdminWalletMutationOutcome::Unavailable),
}
}
}
fn stored_admin_wallet_refund_to_gateway(
refund: aether_data::repository::wallet::StoredAdminWalletRefund,
) -> AdminWalletRefundRecord {
AdminWalletRefundRecord {
id: refund.id,
refund_no: refund.refund_no,
wallet_id: refund.wallet_id,
user_id: refund.user_id,
payment_order_id: refund.payment_order_id,
source_type: refund.source_type,
source_id: refund.source_id,
refund_mode: refund.refund_mode,
amount_usd: refund.amount_usd,
status: refund.status,
reason: refund.reason,
failure_reason: refund.failure_reason,
gateway_refund_id: refund.gateway_refund_id,
payout_method: refund.payout_method,
payout_reference: refund.payout_reference,
payout_proof: refund.payout_proof,
requested_by: refund.requested_by,
approved_by: refund.approved_by,
processed_by: refund.processed_by,
created_at_unix_secs: refund.created_at_unix_secs,
updated_at_unix_secs: refund.updated_at_unix_secs,
processed_at_unix_secs: refund.processed_at_unix_secs,
completed_at_unix_secs: refund.completed_at_unix_secs,
}
}
fn stored_admin_wallet_transaction_to_gateway(
transaction: aether_data::repository::wallet::StoredAdminWalletTransaction,
) -> AdminWalletTransactionRecord {
AdminWalletTransactionRecord {
id: transaction.id,
wallet_id: transaction.wallet_id,
category: transaction.category,
reason_code: transaction.reason_code,
amount: transaction.amount,
balance_before: transaction.balance_before,
balance_after: transaction.balance_after,
recharge_balance_before: transaction.recharge_balance_before,
recharge_balance_after: transaction.recharge_balance_after,
gift_balance_before: transaction.gift_balance_before,
gift_balance_after: transaction.gift_balance_after,
link_type: transaction.link_type,
link_id: transaction.link_id,
operator_id: transaction.operator_id,
description: transaction.description,
created_at_unix_secs: transaction.created_at_unix_secs.unwrap_or_default(),
}
}

View File

@@ -0,0 +1,612 @@
use std::collections::HashMap;
use std::sync::{Arc, Mutex as StdMutex};
use serde_json::json;
use super::{AppState, GatewayDataState};
use crate::{provider_transport, usage};
#[cfg(test)]
impl AppState {
pub(crate) fn with_data_state_for_tests(mut self, data_state: GatewayDataState) -> Self {
self.replace_data_state(Arc::new(data_state));
self
}
pub(crate) fn with_tunnel_identity_for_tests(
mut self,
instance_id: &str,
relay_base_url: Option<&str>,
) -> Self {
self.tunnel = crate::tunnel::EmbeddedTunnelState::with_data_and_directory(
Arc::clone(&self.data),
crate::tunnel::TunnelAttachmentDirectory::for_tests(
instance_id,
relay_base_url,
90,
),
);
self
}
pub(crate) fn with_video_task_data_reader_for_tests(
mut self,
repository: Arc<dyn aether_data::repository::video_tasks::VideoTaskReadRepository>,
) -> Self {
self.replace_data_state(Arc::new(
GatewayDataState::with_video_task_reader_for_tests(repository),
));
self
}
pub(crate) fn with_video_task_data_repository_for_tests<T>(mut self, repository: Arc<T>) -> Self
where
T: aether_data::repository::video_tasks::VideoTaskRepository + 'static,
{
self.replace_data_state(Arc::new(
GatewayDataState::with_video_task_repository_for_tests(repository),
));
self
}
pub(crate) fn with_video_task_repository_and_provider_transport_for_tests<T>(
mut self,
repository: Arc<T>,
provider_catalog_repository: Arc<
dyn aether_data::repository::provider_catalog::ProviderCatalogReadRepository,
>,
encryption_key: impl Into<String>,
) -> Self
where
T: aether_data::repository::video_tasks::VideoTaskRepository + 'static,
{
self.replace_data_state(Arc::new(
GatewayDataState::with_video_task_repository_and_provider_transport_for_tests(
repository,
provider_catalog_repository,
encryption_key,
),
));
self
}
pub(crate) fn with_request_candidate_data_reader_for_tests(
mut self,
repository: Arc<dyn aether_data::repository::candidates::RequestCandidateReadRepository>,
) -> Self {
self.replace_data_state(Arc::new(
GatewayDataState::with_request_candidate_reader_for_tests(repository),
));
self
}
pub(crate) fn with_decision_trace_data_readers_for_tests(
mut self,
request_candidate_repository: Arc<
dyn aether_data::repository::candidates::RequestCandidateReadRepository,
>,
provider_catalog_repository: Arc<
dyn aether_data::repository::provider_catalog::ProviderCatalogReadRepository,
>,
) -> Self {
self.replace_data_state(Arc::new(
GatewayDataState::with_decision_trace_readers_for_tests(
request_candidate_repository,
provider_catalog_repository,
),
));
self
}
pub(crate) fn with_request_audit_data_readers_for_tests(
mut self,
auth_api_key_repository: Arc<dyn aether_data::repository::auth::AuthApiKeyReadRepository>,
request_candidate_repository: Arc<
dyn aether_data::repository::candidates::RequestCandidateReadRepository,
>,
provider_catalog_repository: Arc<
dyn aether_data::repository::provider_catalog::ProviderCatalogReadRepository,
>,
usage_repository: Arc<dyn aether_data::repository::usage::UsageReadRepository>,
) -> Self {
self.replace_data_state(Arc::new(
GatewayDataState::with_request_audit_readers_for_tests(
auth_api_key_repository,
request_candidate_repository,
provider_catalog_repository,
usage_repository,
),
));
self
}
pub(crate) fn with_auth_api_key_data_reader_for_tests(
mut self,
repository: Arc<dyn aether_data::repository::auth::AuthApiKeyReadRepository>,
) -> Self {
self.replace_data_state(Arc::new(
GatewayDataState::with_auth_api_key_reader_for_tests(repository),
));
self
}
pub(crate) fn with_frontdoor_system_default_rpm_for_tests(mut self, limit: u32) -> Self {
self.frontdoor_user_rpm = Arc::new(
(*self.frontdoor_user_rpm)
.clone()
.with_system_default_limit_for_tests(limit),
);
self
}
pub(crate) fn with_usage_data_reader_for_tests(
mut self,
repository: Arc<dyn aether_data::repository::usage::UsageReadRepository>,
) -> Self {
self.replace_data_state(Arc::new(GatewayDataState::with_usage_reader_for_tests(
repository,
)));
self
}
pub(crate) fn with_user_data_reader_for_tests(
mut self,
repository: Arc<dyn aether_data::repository::users::UserReadRepository>,
) -> Self {
self.replace_data_state(Arc::new(GatewayDataState::with_user_reader_for_tests(
repository,
)));
self
}
pub(crate) fn with_usage_data_repository_for_tests<T>(mut self, repository: Arc<T>) -> Self
where
T: aether_data::repository::usage::UsageRepository + 'static,
{
self.replace_data_state(Arc::new(GatewayDataState::with_usage_repository_for_tests(
repository,
)));
self
}
pub(crate) fn with_usage_runtime_for_tests(
mut self,
config: usage::UsageRuntimeConfig,
) -> Self {
self.usage_runtime =
Arc::new(usage::UsageRuntime::new(config).expect("usage runtime config should build"));
self
}
pub(crate) fn with_oauth_refresh_coordinator_for_tests(
mut self,
coordinator: provider_transport::LocalOAuthRefreshCoordinator,
) -> Self {
self.oauth_refresh = Arc::new(coordinator);
self
}
pub(crate) fn with_provider_oauth_state_entry_for_tests(
mut self,
nonce: &str,
payload: serde_json::Value,
) -> Self {
let store = self
.provider_oauth_state_store
.get_or_insert_with(|| Arc::new(StdMutex::new(HashMap::new())));
store
.lock()
.expect("provider oauth state store should lock")
.insert(format!("provider_oauth_state:{nonce}"), payload.to_string());
self
}
pub(crate) fn with_provider_oauth_device_session_entry_for_tests(
mut self,
session_id: &str,
payload: serde_json::Value,
) -> Self {
let store = self
.provider_oauth_device_session_store
.get_or_insert_with(|| Arc::new(StdMutex::new(HashMap::new())));
store
.lock()
.expect("provider oauth device session store should lock")
.insert(
format!("device_auth_session:{session_id}"),
payload.to_string(),
);
self
}
pub(crate) fn with_provider_oauth_batch_task_entry_for_tests(
mut self,
task_id: &str,
payload: serde_json::Value,
) -> Self {
let store = self
.provider_oauth_batch_task_store
.get_or_insert_with(|| Arc::new(StdMutex::new(HashMap::new())));
store
.lock()
.expect("provider oauth batch task store should lock")
.insert(
format!("provider_oauth_batch_task:{task_id}"),
payload.to_string(),
);
self
}
pub(crate) fn with_auth_session_for_tests(
self,
session: crate::data::state::StoredUserSessionRecord,
) -> Self {
self.with_auth_sessions_for_tests([session])
}
pub(crate) fn with_auth_sessions_for_tests<I>(mut self, sessions: I) -> Self
where
I: IntoIterator<Item = crate::data::state::StoredUserSessionRecord>,
{
let store = self
.auth_session_store
.get_or_insert_with(|| Arc::new(StdMutex::new(HashMap::new())));
let mut guard = store.lock().expect("auth session store should lock");
for session in sessions {
guard.insert(format!("{}:{}", session.user_id, session.id), session);
}
drop(guard);
self
}
pub(crate) fn with_auth_users_for_tests<I>(mut self, users: I) -> Self
where
I: IntoIterator<Item = aether_data::repository::users::StoredUserAuthRecord>,
{
let store = self
.auth_user_store
.get_or_insert_with(|| Arc::new(StdMutex::new(HashMap::new())));
let mut guard = store.lock().expect("auth user store should lock");
for user in users {
guard.insert(user.id.clone(), user);
}
drop(guard);
self
}
pub(crate) fn without_auth_user_store_for_tests(mut self) -> Self {
self.auth_user_store = None;
self
}
pub(crate) fn without_auth_user_model_capability_store_for_tests(mut self) -> Self {
self.auth_user_model_capability_store = None;
self
}
pub(crate) fn with_auth_wallets_for_tests<I>(mut self, wallets: I) -> Self
where
I: IntoIterator<Item = aether_data::repository::wallet::StoredWalletSnapshot>,
{
let store = self
.auth_wallet_store
.get_or_insert_with(|| Arc::new(StdMutex::new(HashMap::new())));
let mut guard = store.lock().expect("auth wallet store should lock");
for wallet in wallets {
guard.insert(wallet.id.clone(), wallet);
}
drop(guard);
self
}
pub(crate) fn with_admin_wallet_payment_orders_for_tests<I>(mut self, orders: I) -> Self
where
I: IntoIterator<Item = crate::AdminWalletPaymentOrderRecord>,
{
let store = self
.admin_wallet_payment_order_store
.get_or_insert_with(|| Arc::new(StdMutex::new(HashMap::new())));
let mut guard = store
.lock()
.expect("admin wallet payment order store should lock");
for order in orders {
guard.insert(order.id.clone(), order);
}
drop(guard);
self
}
pub(crate) fn with_admin_payment_callbacks_for_tests<I>(mut self, callbacks: I) -> Self
where
I: IntoIterator<Item = crate::state::AdminPaymentCallbackRecord>,
{
let store = self
.admin_payment_callback_store
.get_or_insert_with(|| Arc::new(StdMutex::new(HashMap::new())));
let mut guard = store
.lock()
.expect("admin payment callback store should lock");
for callback in callbacks {
guard.insert(callback.id.clone(), callback);
}
drop(guard);
self
}
pub(crate) fn with_admin_wallet_transactions_for_tests<I>(mut self, transactions: I) -> Self
where
I: IntoIterator<Item = crate::AdminWalletTransactionRecord>,
{
let store = self
.admin_wallet_transaction_store
.get_or_insert_with(|| Arc::new(StdMutex::new(HashMap::new())));
let mut guard = store
.lock()
.expect("admin wallet transaction store should lock");
for transaction in transactions {
guard.insert(transaction.id.clone(), transaction);
}
drop(guard);
self
}
pub(crate) fn with_admin_wallet_refunds_for_tests<I>(mut self, refunds: I) -> Self
where
I: IntoIterator<Item = crate::AdminWalletRefundRecord>,
{
let store = self
.admin_wallet_refund_store
.get_or_insert_with(|| Arc::new(StdMutex::new(HashMap::new())));
let mut guard = store.lock().expect("admin wallet refund store should lock");
for refund in refunds {
guard.insert(refund.id.clone(), refund);
}
drop(guard);
self
}
pub(crate) fn with_admin_billing_rules_for_tests<I>(mut self, rules: I) -> Self
where
I: IntoIterator<Item = crate::AdminBillingRuleRecord>,
{
let store = self
.admin_billing_rule_store
.get_or_insert_with(|| Arc::new(StdMutex::new(HashMap::new())));
let mut guard = store.lock().expect("admin billing rule store should lock");
for rule in rules {
guard.insert(rule.id.clone(), rule);
}
drop(guard);
self
}
pub(crate) fn with_admin_billing_collectors_for_tests<I>(mut self, collectors: I) -> Self
where
I: IntoIterator<Item = crate::AdminBillingCollectorRecord>,
{
let store = self
.admin_billing_collector_store
.get_or_insert_with(|| Arc::new(StdMutex::new(HashMap::new())));
let mut guard = store
.lock()
.expect("admin billing collector store should lock");
for collector in collectors {
guard.insert(collector.id.clone(), collector);
}
drop(guard);
self
}
pub(crate) fn with_admin_security_blacklist_for_tests<I>(mut self, entries: I) -> Self
where
I: IntoIterator<Item = (String, String)>,
{
let store = self
.admin_security_blacklist_store
.get_or_insert_with(|| Arc::new(StdMutex::new(HashMap::new())));
let mut guard = store
.lock()
.expect("admin security blacklist store should lock");
for (ip_address, reason) in entries {
guard.insert(ip_address, reason);
}
drop(guard);
self
}
pub(crate) fn with_admin_security_whitelist_for_tests<I>(mut self, entries: I) -> Self
where
I: IntoIterator<Item = String>,
{
let store = self
.admin_security_whitelist_store
.get_or_insert_with(|| Arc::new(StdMutex::new(std::collections::BTreeSet::new())));
let mut guard = store
.lock()
.expect("admin security whitelist store should lock");
for ip_address in entries {
guard.insert(ip_address);
}
drop(guard);
self
}
pub(crate) fn with_admin_monitoring_cache_affinity_entry_for_tests(
mut self,
cache_key: &str,
payload: serde_json::Value,
) -> Self {
let store = self
.admin_monitoring_cache_affinity_store
.get_or_insert_with(|| Arc::new(StdMutex::new(HashMap::new())));
store
.lock()
.expect("admin monitoring cache affinity store should lock")
.insert(cache_key.to_string(), payload.to_string());
self
}
pub(crate) fn list_admin_monitoring_cache_affinity_entries_for_tests(
&self,
) -> Vec<(String, String)> {
self.admin_monitoring_cache_affinity_store
.as_ref()
.map(|store| {
store
.lock()
.expect("admin monitoring cache affinity store should lock")
.iter()
.map(|(key, value)| (key.clone(), value.clone()))
.collect()
})
.unwrap_or_default()
}
pub(crate) fn remove_admin_monitoring_cache_affinity_entries_for_tests(
&self,
raw_keys: &[String],
) -> usize {
let Some(store) = self.admin_monitoring_cache_affinity_store.as_ref() else {
return 0;
};
let mut guard = store
.lock()
.expect("admin monitoring cache affinity store should lock");
raw_keys
.iter()
.filter(|raw_key| guard.remove(raw_key.as_str()).is_some())
.count()
}
pub(crate) fn with_admin_monitoring_redis_key_for_tests(
mut self,
cache_key: &str,
payload: serde_json::Value,
) -> Self {
let store = self
.admin_monitoring_redis_key_store
.get_or_insert_with(|| Arc::new(StdMutex::new(HashMap::new())));
store
.lock()
.expect("admin monitoring redis key store should lock")
.insert(cache_key.to_string(), payload.to_string());
self
}
pub(crate) fn list_admin_monitoring_redis_keys_for_tests(&self) -> Vec<String> {
self.admin_monitoring_redis_key_store
.as_ref()
.map(|store| {
store
.lock()
.expect("admin monitoring redis key store should lock")
.keys()
.cloned()
.collect()
})
.unwrap_or_default()
}
pub(crate) fn remove_admin_monitoring_redis_keys_for_tests(
&self,
raw_keys: &[String],
) -> usize {
let Some(store) = self.admin_monitoring_redis_key_store.as_ref() else {
return 0;
};
let mut guard = store
.lock()
.expect("admin monitoring redis key store should lock");
raw_keys
.iter()
.filter(|raw_key| guard.remove(raw_key.as_str()).is_some())
.count()
}
pub(crate) fn with_auth_email_verification_pending_for_tests(
mut self,
email: &str,
code: &str,
created_at: chrono::DateTime<chrono::Utc>,
) -> Self {
let store = self
.auth_email_verification_store
.get_or_insert_with(|| Arc::new(StdMutex::new(HashMap::new())));
store
.lock()
.expect("auth email verification store should lock")
.insert(
format!("email:verification:{}", email.trim().to_ascii_lowercase()),
json!({
"code": code,
"created_at": created_at.to_rfc3339(),
})
.to_string(),
);
self
}
pub(crate) fn with_auth_email_verified_for_tests(mut self, email: &str) -> Self {
let store = self
.auth_email_verification_store
.get_or_insert_with(|| Arc::new(StdMutex::new(HashMap::new())));
store
.lock()
.expect("auth email verification store should lock")
.insert(
format!("email:verified:{}", email.trim().to_ascii_lowercase()),
"verified".to_string(),
);
self
}
pub(crate) fn with_auth_user_model_capability_settings_for_tests(
mut self,
user_id: &str,
settings: serde_json::Value,
) -> Self {
let store = self
.auth_user_model_capability_store
.get_or_insert_with(|| Arc::new(StdMutex::new(HashMap::new())));
store
.lock()
.expect("auth user model capability store should lock")
.insert(user_id.to_string(), settings);
self
}
pub(crate) fn with_provider_oauth_token_url_for_tests(
self,
provider_type: &str,
token_url: impl Into<String>,
) -> Self {
self.provider_oauth_token_url_overrides
.lock()
.expect("provider oauth token url overrides should lock")
.insert(provider_type.trim().to_ascii_lowercase(), token_url.into());
self
}
pub(crate) fn with_shadow_result_data_writer_for_tests(
mut self,
repository: Arc<dyn aether_data::repository::shadow_results::ShadowResultWriteRepository>,
) -> Self {
self.replace_data_state(Arc::new(
GatewayDataState::with_shadow_result_writer_for_tests(repository),
));
self
}
pub(crate) fn with_shadow_result_data_repository_for_tests<T>(
mut self,
repository: Arc<T>,
) -> Self
where
T: aether_data::repository::shadow_results::ShadowResultRepository + 'static,
{
self.replace_data_state(Arc::new(
GatewayDataState::with_shadow_result_repository_for_tests(repository),
));
self
}
}

View File

@@ -0,0 +1,56 @@
#[derive(Debug, Clone, Default)]
pub(crate) struct LocalProviderDeleteTaskState {
pub task_id: String,
pub provider_id: String,
pub status: String,
pub stage: String,
pub total_keys: usize,
pub deleted_keys: usize,
pub total_endpoints: usize,
pub deleted_endpoints: usize,
pub message: String,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum LocalMutationOutcome<T> {
Applied(T),
NotFound,
Invalid(String),
Unavailable,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct LocalExecutionRuntimeMissDiagnostic {
pub(crate) reason: String,
pub(crate) route_family: Option<String>,
pub(crate) route_kind: Option<String>,
pub(crate) public_path: Option<String>,
pub(crate) plan_kind: Option<String>,
pub(crate) requested_model: Option<String>,
pub(crate) candidate_count: Option<usize>,
pub(crate) skipped_candidate_count: Option<usize>,
pub(crate) skip_reasons: std::collections::BTreeMap<String, usize>,
}
impl LocalExecutionRuntimeMissDiagnostic {
pub(crate) fn skip_reasons_summary(&self) -> Option<String> {
if self.skip_reasons.is_empty() {
return None;
}
Some(
self.skip_reasons
.iter()
.map(|(reason, count)| format!("{reason}={count}"))
.collect::<Vec<_>>()
.join(","),
)
}
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum AdminWalletMutationOutcome<T> {
Applied(T),
NotFound,
Invalid(String),
Unavailable,
}

View File

@@ -0,0 +1,179 @@
use super::{AppState, GatewayError};
use crate::{async_task, video_tasks};
impl AppState {
pub(crate) async fn read_data_backed_video_task_response(
&self,
route_family: Option<&str>,
request_path: &str,
) -> Result<Option<video_tasks::LocalVideoTaskReadResponse>, GatewayError> {
self.data
.read_video_task_response(route_family, request_path)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn find_video_task_by_id(
&self,
task_id: &str,
) -> Result<Option<aether_data::repository::video_tasks::StoredVideoTask>, GatewayError> {
self.data
.find_video_task(aether_data::repository::video_tasks::VideoTaskLookupKey::Id(task_id))
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn find_video_task_by_short_id(
&self,
short_id: &str,
) -> Result<Option<aether_data::repository::video_tasks::StoredVideoTask>, GatewayError> {
self.data
.find_video_task(
aether_data::repository::video_tasks::VideoTaskLookupKey::ShortId(short_id),
)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn upsert_video_task_snapshot(
&self,
snapshot: &video_tasks::LocalVideoTaskSnapshot,
) -> Result<Option<aether_data::repository::video_tasks::StoredVideoTask>, GatewayError> {
self.data
.upsert_video_task(snapshot.to_upsert_record())
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn hydrate_video_task_for_route(
&self,
route_family: Option<&str>,
request_path: &str,
) -> Result<bool, GatewayError> {
let lookup =
video_tasks::resolve_video_task_hydration_lookup_key(route_family, request_path);
let Some(lookup) = lookup else {
return Ok(false);
};
let Some(task) = self
.data
.find_video_task(lookup)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?
else {
return Ok(false);
};
if self.video_tasks.hydrate_from_stored_task(&task) {
return Ok(true);
}
let Some(snapshot) = self.reconstruct_video_task_snapshot(&task).await? else {
return Ok(false);
};
self.video_tasks.record_snapshot(snapshot);
Ok(true)
}
pub(crate) async fn reconstruct_video_task_snapshot(
&self,
task: &aether_data::repository::video_tasks::StoredVideoTask,
) -> Result<Option<video_tasks::LocalVideoTaskSnapshot>, GatewayError> {
crate::provider_transport::reconstruct_local_video_task_snapshot(self, task)
.await
.map_err(GatewayError::Internal)
}
pub(crate) async fn claim_due_video_tasks(
&self,
now_unix_secs: u64,
claim_until_unix_secs: u64,
limit: usize,
) -> Result<Vec<aether_data::repository::video_tasks::StoredVideoTask>, GatewayError> {
self.data
.claim_due_video_tasks(now_unix_secs, claim_until_unix_secs, limit)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn update_active_video_task(
&self,
task: aether_data::repository::video_tasks::UpsertVideoTask,
) -> Result<Option<aether_data::repository::video_tasks::StoredVideoTask>, GatewayError> {
self.data
.update_active_video_task(task)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn list_video_task_page(
&self,
filter: &aether_data::repository::video_tasks::VideoTaskQueryFilter,
offset: usize,
limit: usize,
) -> Result<Vec<aether_data::repository::video_tasks::StoredVideoTask>, GatewayError> {
self.data
.list_video_task_page(filter, offset, limit)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn count_video_tasks(
&self,
filter: &aether_data::repository::video_tasks::VideoTaskQueryFilter,
) -> Result<u64, GatewayError> {
self.data
.count_video_tasks(filter)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn count_video_tasks_by_status(
&self,
filter: &aether_data::repository::video_tasks::VideoTaskQueryFilter,
) -> Result<Vec<aether_data::repository::video_tasks::VideoTaskStatusCount>, GatewayError> {
self.data
.count_video_tasks_by_status(filter)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn count_distinct_video_task_users(
&self,
filter: &aether_data::repository::video_tasks::VideoTaskQueryFilter,
) -> Result<u64, GatewayError> {
self.data
.count_distinct_video_task_users(filter)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn top_video_task_models(
&self,
filter: &aether_data::repository::video_tasks::VideoTaskQueryFilter,
limit: usize,
) -> Result<Vec<aether_data::repository::video_tasks::VideoTaskModelCount>, GatewayError> {
self.data
.top_video_task_models(filter, limit)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn count_video_tasks_created_since(
&self,
filter: &aether_data::repository::video_tasks::VideoTaskQueryFilter,
created_since_unix_secs: u64,
) -> Result<u64, GatewayError> {
self.data
.count_video_tasks_created_since(filter, created_since_unix_secs)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn execute_video_task_refresh_plan(
&self,
refresh_plan: &video_tasks::LocalVideoTaskReadRefreshPlan,
) -> Result<bool, GatewayError> {
async_task::execute_video_task_refresh_plan(self, refresh_plan).await
}
}