mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
feat: 扩展 Rust gateway 全功能模块,新增 billing/crypto/wallet crate 及完整数据层
- 新增 aether-billing、aether-crypto、aether-wallet 独立 crate - aether-data 扩展 repository 层:announcements、auth_modules、billing、 candidate_selection、gemini_file_mappings、global_models、management_tokens、 oauth_providers、proxy_nodes、quota、users、wallet 等模块 - aether-gateway 新增 api/auth/billing/control/middleware/scheduler/usage/ video_tasks/hooks/maintenance/model_fetch/provider_transport 等功能模块 - 重构 executor decision 和 gateway state 为模块目录结构 - 新增 gateway router、frontdoor 路由层及对应测试 - Python 侧 API 路由重构,新增 compat/support 模块 - 前端 Logo 组件更新及 Provider 管理页面调整
This commit is contained in:
321
crates/aether-data/src/repository/gemini_file_mappings/memory.rs
Normal file
321
crates/aether-data/src/repository/gemini_file_mappings/memory.rs
Normal file
@@ -0,0 +1,321 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::RwLock;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use super::types::{
|
||||
GeminiFileMappingListQuery, GeminiFileMappingMimeTypeCount, GeminiFileMappingReadRepository,
|
||||
GeminiFileMappingStats, GeminiFileMappingWriteRepository, StoredGeminiFileMapping,
|
||||
StoredGeminiFileMappingListPage, UpsertGeminiFileMappingRecord,
|
||||
};
|
||||
use crate::DataLayerError;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct InMemoryGeminiFileMappingRepository {
|
||||
by_file: RwLock<BTreeMap<String, StoredGeminiFileMapping>>,
|
||||
}
|
||||
|
||||
impl InMemoryGeminiFileMappingRepository {
|
||||
pub fn seed<I>(items: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = StoredGeminiFileMapping>,
|
||||
{
|
||||
let mut by_file = BTreeMap::new();
|
||||
for item in items {
|
||||
by_file.insert(item.file_name.clone(), item);
|
||||
}
|
||||
Self {
|
||||
by_file: RwLock::new(by_file),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GeminiFileMappingReadRepository for InMemoryGeminiFileMappingRepository {
|
||||
async fn find_by_file_name(
|
||||
&self,
|
||||
file_name: &str,
|
||||
) -> Result<Option<StoredGeminiFileMapping>, DataLayerError> {
|
||||
let guard = self.by_file.read().expect("gemini mapping repository lock");
|
||||
Ok(guard.get(file_name).cloned())
|
||||
}
|
||||
|
||||
async fn list_mappings(
|
||||
&self,
|
||||
query: &GeminiFileMappingListQuery,
|
||||
) -> Result<StoredGeminiFileMappingListPage, DataLayerError> {
|
||||
let guard = self.by_file.read().expect("gemini mapping repository lock");
|
||||
let search = query
|
||||
.search
|
||||
.as_deref()
|
||||
.map(|value| value.to_ascii_lowercase());
|
||||
let mut items = guard
|
||||
.values()
|
||||
.filter(|item| query.include_expired || item.expires_at_unix_secs > query.now_unix_secs)
|
||||
.filter(|item| {
|
||||
search.as_deref().is_none_or(|needle| {
|
||||
item.file_name.to_ascii_lowercase().contains(needle)
|
||||
|| item
|
||||
.display_name
|
||||
.as_deref()
|
||||
.map(|value| value.to_ascii_lowercase().contains(needle))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
items.sort_by(|left, right| {
|
||||
right
|
||||
.created_at_unix_secs
|
||||
.cmp(&left.created_at_unix_secs)
|
||||
.then_with(|| left.file_name.cmp(&right.file_name))
|
||||
});
|
||||
let total = items.len();
|
||||
let page_items = items
|
||||
.into_iter()
|
||||
.skip(query.offset)
|
||||
.take(query.limit)
|
||||
.collect::<Vec<_>>();
|
||||
Ok(StoredGeminiFileMappingListPage {
|
||||
items: page_items,
|
||||
total,
|
||||
})
|
||||
}
|
||||
|
||||
async fn summarize_mappings(
|
||||
&self,
|
||||
now_unix_secs: u64,
|
||||
) -> Result<GeminiFileMappingStats, DataLayerError> {
|
||||
let guard = self.by_file.read().expect("gemini mapping repository lock");
|
||||
let total_mappings = guard.len();
|
||||
let active_items = guard
|
||||
.values()
|
||||
.filter(|item| item.expires_at_unix_secs > now_unix_secs)
|
||||
.collect::<Vec<_>>();
|
||||
let active_mappings = active_items.len();
|
||||
let expired_mappings = total_mappings.saturating_sub(active_mappings);
|
||||
let mut by_mime_type = BTreeMap::<String, usize>::new();
|
||||
for item in active_items {
|
||||
let mime_type = item
|
||||
.mime_type
|
||||
.clone()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
*by_mime_type.entry(mime_type).or_default() += 1;
|
||||
}
|
||||
Ok(GeminiFileMappingStats {
|
||||
total_mappings,
|
||||
active_mappings,
|
||||
expired_mappings,
|
||||
by_mime_type: by_mime_type
|
||||
.into_iter()
|
||||
.map(|(mime_type, count)| GeminiFileMappingMimeTypeCount { mime_type, count })
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GeminiFileMappingWriteRepository for InMemoryGeminiFileMappingRepository {
|
||||
async fn upsert(
|
||||
&self,
|
||||
record: UpsertGeminiFileMappingRecord,
|
||||
) -> Result<StoredGeminiFileMapping, DataLayerError> {
|
||||
record.validate()?;
|
||||
let mut guard = self
|
||||
.by_file
|
||||
.write()
|
||||
.expect("gemini mapping repository lock");
|
||||
let created_at_unix_secs = guard
|
||||
.get(&record.file_name)
|
||||
.map(|existing| existing.created_at_unix_secs)
|
||||
.unwrap_or_else(current_unix_secs);
|
||||
let mapping = StoredGeminiFileMapping {
|
||||
id: record.id.clone(),
|
||||
file_name: record.file_name.clone(),
|
||||
key_id: record.key_id.clone(),
|
||||
user_id: record.user_id.clone(),
|
||||
display_name: record.display_name.clone(),
|
||||
mime_type: record.mime_type.clone(),
|
||||
source_hash: record.source_hash.clone(),
|
||||
created_at_unix_secs,
|
||||
expires_at_unix_secs: record.expires_at_unix_secs,
|
||||
};
|
||||
guard.insert(record.file_name.clone(), mapping.clone());
|
||||
Ok(mapping)
|
||||
}
|
||||
|
||||
async fn delete_by_file_name(&self, file_name: &str) -> Result<bool, DataLayerError> {
|
||||
let mut guard = self
|
||||
.by_file
|
||||
.write()
|
||||
.expect("gemini mapping repository lock");
|
||||
Ok(guard.remove(file_name).is_some())
|
||||
}
|
||||
|
||||
async fn delete_by_id(
|
||||
&self,
|
||||
mapping_id: &str,
|
||||
) -> Result<Option<StoredGeminiFileMapping>, DataLayerError> {
|
||||
let mut guard = self
|
||||
.by_file
|
||||
.write()
|
||||
.expect("gemini mapping repository lock");
|
||||
let Some(file_name) = guard
|
||||
.iter()
|
||||
.find_map(|(file_name, item)| (item.id == mapping_id).then(|| file_name.clone()))
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(guard.remove(&file_name))
|
||||
}
|
||||
|
||||
async fn delete_expired_before(&self, now_unix_secs: u64) -> Result<usize, DataLayerError> {
|
||||
let mut guard = self
|
||||
.by_file
|
||||
.write()
|
||||
.expect("gemini mapping repository lock");
|
||||
let before = guard.len();
|
||||
guard.retain(|_, item| item.expires_at_unix_secs > now_unix_secs);
|
||||
Ok(before.saturating_sub(guard.len()))
|
||||
}
|
||||
}
|
||||
|
||||
fn current_unix_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::repository::gemini_file_mappings::{
|
||||
GeminiFileMappingListQuery, GeminiFileMappingReadRepository,
|
||||
GeminiFileMappingWriteRepository,
|
||||
};
|
||||
|
||||
use super::{InMemoryGeminiFileMappingRepository, UpsertGeminiFileMappingRecord};
|
||||
use crate::DataLayerError;
|
||||
|
||||
fn sample_record(id: &str, file_name: &str) -> UpsertGeminiFileMappingRecord {
|
||||
UpsertGeminiFileMappingRecord {
|
||||
id: id.to_string(),
|
||||
file_name: file_name.to_string(),
|
||||
key_id: "key-1".to_string(),
|
||||
user_id: Some("user-1".to_string()),
|
||||
display_name: Some("display".to_string()),
|
||||
mime_type: Some("image/png".to_string()),
|
||||
source_hash: Some("hash-1".to_string()),
|
||||
expires_at_unix_secs: 4_102_444_800,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upsert_and_find() -> Result<(), DataLayerError> {
|
||||
let repo = InMemoryGeminiFileMappingRepository::default();
|
||||
let record = sample_record("id-1", "files/abc");
|
||||
let stored = repo.upsert(record.clone()).await?;
|
||||
assert_eq!(stored.file_name, "files/abc");
|
||||
|
||||
let fetched = repo.find_by_file_name("files/abc").await?;
|
||||
assert_eq!(fetched.unwrap().key_id, "key-1");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_removes_entry() -> Result<(), DataLayerError> {
|
||||
let repo = InMemoryGeminiFileMappingRepository::default();
|
||||
let record = sample_record("id-2", "files/def");
|
||||
let _stored = repo.upsert(record).await?;
|
||||
assert!(repo.find_by_file_name("files/def").await?.is_some());
|
||||
assert!(repo.delete_by_file_name("files/def").await?);
|
||||
assert!(repo.find_by_file_name("files/def").await?.is_none());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upsert_preserves_created_at_when_replacing_existing_file_name(
|
||||
) -> Result<(), DataLayerError> {
|
||||
let repo = InMemoryGeminiFileMappingRepository::default();
|
||||
let first = repo.upsert(sample_record("id-1", "files/same")).await?;
|
||||
let replaced = repo.upsert(sample_record("id-2", "files/same")).await?;
|
||||
|
||||
assert_eq!(replaced.created_at_unix_secs, first.created_at_unix_secs);
|
||||
assert_eq!(replaced.id, "id-2");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_and_summarize_mappings() -> Result<(), DataLayerError> {
|
||||
let repo = InMemoryGeminiFileMappingRepository::seed(vec![
|
||||
repo_item("id-1", "files/alpha", "image/png", 10, 200),
|
||||
repo_item("id-2", "files/beta", "video/mp4", 20, 50),
|
||||
repo_item("id-3", "files/gamma", "", 30, 220),
|
||||
]);
|
||||
|
||||
let page = repo
|
||||
.list_mappings(&GeminiFileMappingListQuery {
|
||||
include_expired: false,
|
||||
search: Some("ga".to_string()),
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
now_unix_secs: 100,
|
||||
})
|
||||
.await?;
|
||||
assert_eq!(page.total, 1);
|
||||
assert_eq!(page.items[0].id, "id-3");
|
||||
|
||||
let stats = repo.summarize_mappings(100).await?;
|
||||
assert_eq!(stats.total_mappings, 3);
|
||||
assert_eq!(stats.active_mappings, 2);
|
||||
assert_eq!(stats.expired_mappings, 1);
|
||||
assert_eq!(stats.by_mime_type.len(), 2);
|
||||
assert_eq!(stats.by_mime_type[0].mime_type, "image/png");
|
||||
assert_eq!(stats.by_mime_type[0].count, 1);
|
||||
assert_eq!(stats.by_mime_type[1].mime_type, "unknown");
|
||||
assert_eq!(stats.by_mime_type[1].count, 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_by_id_and_cleanup_expired() -> Result<(), DataLayerError> {
|
||||
let repo = InMemoryGeminiFileMappingRepository::seed(vec![
|
||||
repo_item("id-1", "files/alpha", "image/png", 10, 200),
|
||||
repo_item("id-2", "files/beta", "video/mp4", 20, 50),
|
||||
]);
|
||||
|
||||
let deleted = repo.delete_by_id("id-1").await?;
|
||||
assert_eq!(
|
||||
deleted.as_ref().map(|item| item.file_name.as_str()),
|
||||
Some("files/alpha")
|
||||
);
|
||||
assert!(repo.find_by_file_name("files/alpha").await?.is_none());
|
||||
|
||||
let deleted_count = repo.delete_expired_before(100).await?;
|
||||
assert_eq!(deleted_count, 1);
|
||||
assert!(repo.find_by_file_name("files/beta").await?.is_none());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn repo_item(
|
||||
id: &str,
|
||||
file_name: &str,
|
||||
mime_type: &str,
|
||||
created_at_unix_secs: u64,
|
||||
expires_at_unix_secs: u64,
|
||||
) -> crate::repository::gemini_file_mappings::StoredGeminiFileMapping {
|
||||
crate::repository::gemini_file_mappings::StoredGeminiFileMapping {
|
||||
id: id.to_string(),
|
||||
file_name: file_name.to_string(),
|
||||
key_id: "key-1".to_string(),
|
||||
user_id: Some("user-1".to_string()),
|
||||
display_name: Some(format!("display-{id}")),
|
||||
mime_type: (!mime_type.is_empty()).then(|| mime_type.to_string()),
|
||||
source_hash: Some(format!("hash-{id}")),
|
||||
created_at_unix_secs,
|
||||
expires_at_unix_secs,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
pub mod memory;
|
||||
pub mod sql;
|
||||
pub mod types;
|
||||
|
||||
pub use memory::InMemoryGeminiFileMappingRepository;
|
||||
pub use sql::SqlxGeminiFileMappingRepository;
|
||||
pub use types::{
|
||||
GeminiFileMappingListQuery, GeminiFileMappingMimeTypeCount, GeminiFileMappingReadRepository,
|
||||
GeminiFileMappingRepository, GeminiFileMappingStats, GeminiFileMappingWriteRepository,
|
||||
StoredGeminiFileMapping, StoredGeminiFileMappingListPage, UpsertGeminiFileMappingRecord,
|
||||
};
|
||||
319
crates/aether-data/src/repository/gemini_file_mappings/sql.rs
Normal file
319
crates/aether-data/src/repository/gemini_file_mappings/sql.rs
Normal file
@@ -0,0 +1,319 @@
|
||||
use async_trait::async_trait;
|
||||
use sqlx::{postgres::PgRow, PgPool, Postgres, QueryBuilder, Row};
|
||||
|
||||
use super::types::{
|
||||
GeminiFileMappingListQuery, GeminiFileMappingMimeTypeCount, GeminiFileMappingReadRepository,
|
||||
GeminiFileMappingStats, GeminiFileMappingWriteRepository, StoredGeminiFileMapping,
|
||||
StoredGeminiFileMappingListPage, UpsertGeminiFileMappingRecord,
|
||||
};
|
||||
use crate::DataLayerError;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SqlxGeminiFileMappingRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl SqlxGeminiFileMappingRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
fn map_row(row: &PgRow) -> Result<StoredGeminiFileMapping, DataLayerError> {
|
||||
Ok(StoredGeminiFileMapping {
|
||||
id: row.try_get("id")?,
|
||||
file_name: row.try_get("file_name")?,
|
||||
key_id: row.try_get("key_id")?,
|
||||
user_id: row.try_get("user_id").ok().flatten(),
|
||||
display_name: row.try_get("display_name").ok().flatten(),
|
||||
mime_type: row.try_get("mime_type").ok().flatten(),
|
||||
source_hash: row.try_get("source_hash").ok().flatten(),
|
||||
created_at_unix_secs: u64::try_from(row.try_get::<i64, _>("created_at_unix_secs")?)
|
||||
.map_err(|_| {
|
||||
DataLayerError::UnexpectedValue(
|
||||
"gemini_file_mappings.created_at is invalid".to_string(),
|
||||
)
|
||||
})?,
|
||||
expires_at_unix_secs: u64::try_from(row.try_get::<i64, _>("expires_at_unix_secs")?)
|
||||
.map_err(|_| {
|
||||
DataLayerError::UnexpectedValue(
|
||||
"gemini_file_mappings.expires_at is invalid".to_string(),
|
||||
)
|
||||
})?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GeminiFileMappingReadRepository for SqlxGeminiFileMappingRepository {
|
||||
async fn find_by_file_name(
|
||||
&self,
|
||||
file_name: &str,
|
||||
) -> Result<Option<StoredGeminiFileMapping>, DataLayerError> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
id,
|
||||
file_name,
|
||||
key_id,
|
||||
user_id,
|
||||
display_name,
|
||||
mime_type,
|
||||
source_hash,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint AS created_at_unix_secs,
|
||||
EXTRACT(EPOCH FROM expires_at)::bigint AS expires_at_unix_secs
|
||||
FROM gemini_file_mappings
|
||||
WHERE file_name = $1
|
||||
"#,
|
||||
)
|
||||
.bind(file_name)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
match row {
|
||||
Some(row) => Ok(Some(Self::map_row(&row)?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_mappings(
|
||||
&self,
|
||||
query: &GeminiFileMappingListQuery,
|
||||
) -> Result<StoredGeminiFileMappingListPage, DataLayerError> {
|
||||
let total = build_list_count_query(query)
|
||||
.build_query_scalar::<i64>()
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
let rows = build_list_rows_query(query)
|
||||
.build()
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(StoredGeminiFileMappingListPage {
|
||||
items: rows
|
||||
.iter()
|
||||
.map(Self::map_row)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
total: usize::try_from(total).unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn summarize_mappings(
|
||||
&self,
|
||||
now_unix_secs: u64,
|
||||
) -> Result<GeminiFileMappingStats, DataLayerError> {
|
||||
let totals = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
COUNT(*)::bigint AS total_mappings,
|
||||
COUNT(*) FILTER (WHERE expires_at > TO_TIMESTAMP($1::double precision))::bigint AS active_mappings
|
||||
FROM gemini_file_mappings
|
||||
"#,
|
||||
)
|
||||
.bind(now_unix_secs as f64)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
let total_mappings =
|
||||
usize::try_from(totals.try_get::<i64, _>("total_mappings")?).unwrap_or_default();
|
||||
let active_mappings =
|
||||
usize::try_from(totals.try_get::<i64, _>("active_mappings")?).unwrap_or_default();
|
||||
let by_mime_type_rows = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
COALESCE(NULLIF(TRIM(mime_type), ''), 'unknown') AS mime_type,
|
||||
COUNT(*)::bigint AS count
|
||||
FROM gemini_file_mappings
|
||||
WHERE expires_at > TO_TIMESTAMP($1::double precision)
|
||||
GROUP BY COALESCE(NULLIF(TRIM(mime_type), ''), 'unknown')
|
||||
ORDER BY mime_type ASC
|
||||
"#,
|
||||
)
|
||||
.bind(now_unix_secs as f64)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(GeminiFileMappingStats {
|
||||
total_mappings,
|
||||
active_mappings,
|
||||
expired_mappings: total_mappings.saturating_sub(active_mappings),
|
||||
by_mime_type: by_mime_type_rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
Ok(GeminiFileMappingMimeTypeCount {
|
||||
mime_type: row.try_get("mime_type")?,
|
||||
count: usize::try_from(row.try_get::<i64, _>("count")?).unwrap_or_default(),
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, DataLayerError>>()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GeminiFileMappingWriteRepository for SqlxGeminiFileMappingRepository {
|
||||
async fn upsert(
|
||||
&self,
|
||||
record: UpsertGeminiFileMappingRecord,
|
||||
) -> Result<StoredGeminiFileMapping, DataLayerError> {
|
||||
record.validate()?;
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO gemini_file_mappings (
|
||||
id,
|
||||
file_name,
|
||||
key_id,
|
||||
user_id,
|
||||
display_name,
|
||||
mime_type,
|
||||
source_hash,
|
||||
created_at,
|
||||
expires_at
|
||||
)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,NOW(),TO_TIMESTAMP($8::double precision))
|
||||
ON CONFLICT (file_name)
|
||||
DO UPDATE
|
||||
SET
|
||||
key_id = EXCLUDED.key_id,
|
||||
user_id = EXCLUDED.user_id,
|
||||
display_name = EXCLUDED.display_name,
|
||||
mime_type = EXCLUDED.mime_type,
|
||||
source_hash = EXCLUDED.source_hash,
|
||||
expires_at = EXCLUDED.expires_at
|
||||
RETURNING
|
||||
id,
|
||||
file_name,
|
||||
key_id,
|
||||
user_id,
|
||||
display_name,
|
||||
mime_type,
|
||||
source_hash,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint AS created_at_unix_secs,
|
||||
EXTRACT(EPOCH FROM expires_at)::bigint AS expires_at_unix_secs
|
||||
"#,
|
||||
)
|
||||
.bind(record.id.clone())
|
||||
.bind(record.file_name.clone())
|
||||
.bind(record.key_id.clone())
|
||||
.bind(record.user_id.clone())
|
||||
.bind(record.display_name.clone())
|
||||
.bind(record.mime_type.clone())
|
||||
.bind(record.source_hash.clone())
|
||||
.bind(record.expires_at_unix_secs as f64)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
Self::map_row(&row)
|
||||
}
|
||||
|
||||
async fn delete_by_file_name(&self, file_name: &str) -> Result<bool, DataLayerError> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
DELETE FROM gemini_file_mappings
|
||||
WHERE file_name = $1
|
||||
#"#,
|
||||
)
|
||||
.bind(file_name)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
async fn delete_by_id(
|
||||
&self,
|
||||
mapping_id: &str,
|
||||
) -> Result<Option<StoredGeminiFileMapping>, DataLayerError> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
DELETE FROM gemini_file_mappings
|
||||
WHERE id = $1
|
||||
RETURNING
|
||||
id,
|
||||
file_name,
|
||||
key_id,
|
||||
user_id,
|
||||
display_name,
|
||||
mime_type,
|
||||
source_hash,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint AS created_at_unix_secs,
|
||||
EXTRACT(EPOCH FROM expires_at)::bigint AS expires_at_unix_secs
|
||||
"#,
|
||||
)
|
||||
.bind(mapping_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
match row {
|
||||
Some(row) => Ok(Some(Self::map_row(&row)?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_expired_before(&self, now_unix_secs: u64) -> Result<usize, DataLayerError> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
DELETE FROM gemini_file_mappings
|
||||
WHERE expires_at <= TO_TIMESTAMP($1::double precision)
|
||||
"#,
|
||||
)
|
||||
.bind(now_unix_secs as f64)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(usize::try_from(result.rows_affected()).unwrap_or_default())
|
||||
}
|
||||
}
|
||||
|
||||
fn build_list_count_query(query: &GeminiFileMappingListQuery) -> QueryBuilder<'_, Postgres> {
|
||||
let mut builder = QueryBuilder::<Postgres>::new(
|
||||
"SELECT COUNT(*)::bigint AS total FROM gemini_file_mappings WHERE 1=1",
|
||||
);
|
||||
apply_list_filters(&mut builder, query);
|
||||
builder
|
||||
}
|
||||
|
||||
fn build_list_rows_query(query: &GeminiFileMappingListQuery) -> QueryBuilder<'_, Postgres> {
|
||||
let mut builder = QueryBuilder::<Postgres>::new(
|
||||
r#"
|
||||
SELECT
|
||||
id,
|
||||
file_name,
|
||||
key_id,
|
||||
user_id,
|
||||
display_name,
|
||||
mime_type,
|
||||
source_hash,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint AS created_at_unix_secs,
|
||||
EXTRACT(EPOCH FROM expires_at)::bigint AS expires_at_unix_secs
|
||||
FROM gemini_file_mappings
|
||||
WHERE 1=1
|
||||
"#,
|
||||
);
|
||||
apply_list_filters(&mut builder, query);
|
||||
builder.push(" ORDER BY created_at DESC, file_name ASC LIMIT ");
|
||||
builder.push_bind(i64::try_from(query.limit).unwrap_or(i64::MAX));
|
||||
builder.push(" OFFSET ");
|
||||
builder.push_bind(i64::try_from(query.offset).unwrap_or(i64::MAX));
|
||||
builder
|
||||
}
|
||||
|
||||
fn apply_list_filters(
|
||||
builder: &mut QueryBuilder<'_, Postgres>,
|
||||
query: &GeminiFileMappingListQuery,
|
||||
) {
|
||||
if !query.include_expired {
|
||||
builder.push(" AND expires_at > TO_TIMESTAMP(");
|
||||
builder.push_bind(query.now_unix_secs as f64);
|
||||
builder.push("::double precision)");
|
||||
}
|
||||
if let Some(search) = query
|
||||
.search
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
let pattern = format!("%{search}%");
|
||||
builder.push(" AND (file_name ILIKE ");
|
||||
builder.push_bind(pattern.clone());
|
||||
builder.push(" OR COALESCE(display_name, '') ILIKE ");
|
||||
builder.push_bind(pattern);
|
||||
builder.push(")");
|
||||
}
|
||||
}
|
||||
165
crates/aether-data/src/repository/gemini_file_mappings/types.rs
Normal file
165
crates/aether-data/src/repository/gemini_file_mappings/types.rs
Normal file
@@ -0,0 +1,165 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct GeminiFileMappingListQuery {
|
||||
pub include_expired: bool,
|
||||
pub search: Option<String>,
|
||||
pub offset: usize,
|
||||
pub limit: usize,
|
||||
pub now_unix_secs: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct StoredGeminiFileMappingListPage {
|
||||
pub items: Vec<StoredGeminiFileMapping>,
|
||||
pub total: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GeminiFileMappingMimeTypeCount {
|
||||
pub mime_type: String,
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GeminiFileMappingStats {
|
||||
pub total_mappings: usize,
|
||||
pub active_mappings: usize,
|
||||
pub expired_mappings: usize,
|
||||
pub by_mime_type: Vec<GeminiFileMappingMimeTypeCount>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct StoredGeminiFileMapping {
|
||||
pub id: String,
|
||||
pub file_name: String,
|
||||
pub key_id: String,
|
||||
pub user_id: Option<String>,
|
||||
pub display_name: Option<String>,
|
||||
pub mime_type: Option<String>,
|
||||
pub source_hash: Option<String>,
|
||||
pub created_at_unix_secs: u64,
|
||||
pub expires_at_unix_secs: u64,
|
||||
}
|
||||
|
||||
impl StoredGeminiFileMapping {
|
||||
pub fn new(
|
||||
id: String,
|
||||
file_name: String,
|
||||
key_id: String,
|
||||
created_at_unix_secs: i64,
|
||||
expires_at_unix_secs: i64,
|
||||
) -> Result<Self, crate::DataLayerError> {
|
||||
if file_name.trim().is_empty() {
|
||||
return Err(crate::DataLayerError::UnexpectedValue(
|
||||
"gemini_file_mappings.file_name is empty".to_string(),
|
||||
));
|
||||
}
|
||||
if key_id.trim().is_empty() {
|
||||
return Err(crate::DataLayerError::UnexpectedValue(
|
||||
"gemini_file_mappings.key_id is empty".to_string(),
|
||||
));
|
||||
}
|
||||
let created_at_unix_secs = u64::try_from(created_at_unix_secs).map_err(|_| {
|
||||
crate::DataLayerError::UnexpectedValue(format!(
|
||||
"invalid gemini_file_mappings.created_at: {created_at_unix_secs}"
|
||||
))
|
||||
})?;
|
||||
let expires_at_unix_secs = u64::try_from(expires_at_unix_secs).map_err(|_| {
|
||||
crate::DataLayerError::UnexpectedValue(format!(
|
||||
"invalid gemini_file_mappings.expires_at: {expires_at_unix_secs}"
|
||||
))
|
||||
})?;
|
||||
Ok(Self {
|
||||
id,
|
||||
file_name,
|
||||
key_id,
|
||||
user_id: None,
|
||||
display_name: None,
|
||||
mime_type: None,
|
||||
source_hash: None,
|
||||
created_at_unix_secs,
|
||||
expires_at_unix_secs,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpsertGeminiFileMappingRecord {
|
||||
pub id: String,
|
||||
pub file_name: String,
|
||||
pub key_id: String,
|
||||
pub user_id: Option<String>,
|
||||
pub display_name: Option<String>,
|
||||
pub mime_type: Option<String>,
|
||||
pub source_hash: Option<String>,
|
||||
pub expires_at_unix_secs: u64,
|
||||
}
|
||||
|
||||
impl UpsertGeminiFileMappingRecord {
|
||||
pub fn validate(&self) -> Result<(), crate::DataLayerError> {
|
||||
if self.file_name.trim().is_empty() {
|
||||
return Err(crate::DataLayerError::InvalidInput(
|
||||
"gemini_file_mappings.file_name is empty".to_string(),
|
||||
));
|
||||
}
|
||||
if self.key_id.trim().is_empty() {
|
||||
return Err(crate::DataLayerError::InvalidInput(
|
||||
"gemini_file_mappings.key_id is empty".to_string(),
|
||||
));
|
||||
}
|
||||
if self.expires_at_unix_secs == 0 {
|
||||
return Err(crate::DataLayerError::InvalidInput(
|
||||
"gemini_file_mappings.expires_at is empty".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait GeminiFileMappingReadRepository: Send + Sync {
|
||||
async fn find_by_file_name(
|
||||
&self,
|
||||
file_name: &str,
|
||||
) -> Result<Option<StoredGeminiFileMapping>, crate::DataLayerError>;
|
||||
|
||||
async fn list_mappings(
|
||||
&self,
|
||||
query: &GeminiFileMappingListQuery,
|
||||
) -> Result<StoredGeminiFileMappingListPage, crate::DataLayerError>;
|
||||
|
||||
async fn summarize_mappings(
|
||||
&self,
|
||||
now_unix_secs: u64,
|
||||
) -> Result<GeminiFileMappingStats, crate::DataLayerError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait GeminiFileMappingWriteRepository: Send + Sync {
|
||||
async fn upsert(
|
||||
&self,
|
||||
record: UpsertGeminiFileMappingRecord,
|
||||
) -> Result<StoredGeminiFileMapping, crate::DataLayerError>;
|
||||
async fn delete_by_file_name(&self, file_name: &str) -> Result<bool, crate::DataLayerError>;
|
||||
|
||||
async fn delete_by_id(
|
||||
&self,
|
||||
mapping_id: &str,
|
||||
) -> Result<Option<StoredGeminiFileMapping>, crate::DataLayerError>;
|
||||
|
||||
async fn delete_expired_before(
|
||||
&self,
|
||||
now_unix_secs: u64,
|
||||
) -> Result<usize, crate::DataLayerError>;
|
||||
}
|
||||
|
||||
pub trait GeminiFileMappingRepository:
|
||||
GeminiFileMappingReadRepository + GeminiFileMappingWriteRepository
|
||||
{
|
||||
}
|
||||
|
||||
impl<T> GeminiFileMappingRepository for T where
|
||||
T: GeminiFileMappingReadRepository + GeminiFileMappingWriteRepository
|
||||
{
|
||||
}
|
||||
Reference in New Issue
Block a user