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,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(),
}
}