mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
Merge remote-tracking branch 'origin/pr/395' into aether-rust-pioneer
This commit is contained in:
@@ -3774,7 +3774,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_dimension_collectors_enabled ON public.dime
|
||||
|
||||
DO $mig$ BEGIN
|
||||
ALTER TABLE ONLY public.announcement_reads
|
||||
ADD CONSTRAINT announcement_reads_announcement_id_fkey FOREIGN KEY (announcement_id) REFERENCES public.announcements(id);
|
||||
ADD CONSTRAINT announcement_reads_announcement_id_fkey FOREIGN KEY (announcement_id) REFERENCES public.announcements(id) ON DELETE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
WHEN duplicate_table THEN NULL;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
ALTER TABLE IF EXISTS public.announcement_reads
|
||||
DROP CONSTRAINT IF EXISTS announcement_reads_announcement_id_fkey;
|
||||
|
||||
ALTER TABLE IF EXISTS public.announcement_reads
|
||||
ADD CONSTRAINT announcement_reads_announcement_id_fkey
|
||||
FOREIGN KEY (announcement_id)
|
||||
REFERENCES public.announcements(id)
|
||||
ON DELETE CASCADE;
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
DO $mig$ BEGIN
|
||||
ALTER TABLE ONLY public.announcement_reads
|
||||
ADD CONSTRAINT announcement_reads_announcement_id_fkey FOREIGN KEY (announcement_id) REFERENCES public.announcements(id);
|
||||
ADD CONSTRAINT announcement_reads_announcement_id_fkey FOREIGN KEY (announcement_id) REFERENCES public.announcements(id) ON DELETE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
WHEN duplicate_table THEN NULL;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
DO $mig$ BEGIN
|
||||
ALTER TABLE ONLY public.announcement_reads
|
||||
ADD CONSTRAINT announcement_reads_announcement_id_fkey FOREIGN KEY (announcement_id) REFERENCES public.announcements(id);
|
||||
ADD CONSTRAINT announcement_reads_announcement_id_fkey FOREIGN KEY (announcement_id) REFERENCES public.announcements(id) ON DELETE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
WHEN duplicate_table THEN NULL;
|
||||
|
||||
@@ -7,7 +7,9 @@ use crate::maintenance::{
|
||||
StatsDailyAggregationSummary, StatsHourlyAggregationInput, StatsHourlyAggregationSummary,
|
||||
WalletDailyUsageAggregationInput, WalletDailyUsageAggregationResult,
|
||||
};
|
||||
use crate::repository::system::{AdminSystemStats, StoredSystemConfigEntry};
|
||||
use crate::repository::system::{
|
||||
AdminSystemPurgeSummary, AdminSystemPurgeTarget, AdminSystemStats, StoredSystemConfigEntry,
|
||||
};
|
||||
use crate::DataLayerError;
|
||||
use sqlx::migrate::MigrateError;
|
||||
|
||||
@@ -182,6 +184,16 @@ impl DataBackends {
|
||||
None => Ok(AdminSystemStats::default()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn purge_admin_system_data(
|
||||
&self,
|
||||
target: AdminSystemPurgeTarget,
|
||||
) -> Result<AdminSystemPurgeSummary, DataLayerError> {
|
||||
match self.sql_backend() {
|
||||
Some(backend) => backend.purge_admin_system_data(target).await,
|
||||
None => Ok(AdminSystemPurgeSummary::default()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PostgresBackend {
|
||||
@@ -471,4 +483,15 @@ impl<'a> SqlBackendRef<'a> {
|
||||
Self::Sqlite(sqlite) => sqlite.read_admin_system_stats().await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn purge_admin_system_data(
|
||||
self,
|
||||
target: AdminSystemPurgeTarget,
|
||||
) -> Result<AdminSystemPurgeSummary, DataLayerError> {
|
||||
match self {
|
||||
Self::Postgres(postgres) => postgres.purge_admin_system_data(target).await,
|
||||
Self::Mysql(mysql) => mysql.purge_admin_system_data(target).await,
|
||||
Self::Sqlite(sqlite) => sqlite.purge_admin_system_data(target).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,6 +227,7 @@ impl SqliteBackend {
|
||||
mod tests {
|
||||
use super::SqliteBackend;
|
||||
use crate::lifecycle::migrate::run_sqlite_migrations;
|
||||
use crate::repository::system::AdminSystemPurgeTarget;
|
||||
use crate::{
|
||||
DatabaseDriver, SqlDatabaseConfig, SqlPoolConfig, StatsDailyAggregationInput,
|
||||
StatsHourlyAggregationInput, WalletDailyUsageAggregationInput,
|
||||
@@ -313,6 +314,123 @@ mod tests {
|
||||
assert_eq!(summary.succeeded, 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_system_config_purge_deletes_config_scope_and_preserves_users() {
|
||||
let config = SqlDatabaseConfig {
|
||||
driver: DatabaseDriver::Sqlite,
|
||||
url: "sqlite::memory:".to_string(),
|
||||
pool: SqlPoolConfig {
|
||||
max_connections: 1,
|
||||
..SqlPoolConfig::default()
|
||||
},
|
||||
};
|
||||
let backend = SqliteBackend::from_config(config).expect("backend should build");
|
||||
run_sqlite_migrations(backend.pool())
|
||||
.await
|
||||
.expect("sqlite migrations should run");
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO users (id, email, username, role, created_at, updated_at) VALUES ('admin-1', 'admin@example.com', 'admin', 'admin', 1, 1)",
|
||||
)
|
||||
.execute(backend.pool())
|
||||
.await
|
||||
.expect("user should insert");
|
||||
sqlx::query(
|
||||
"INSERT INTO providers (id, name, provider_type, created_at, updated_at) VALUES ('provider-1', 'OpenAI', 'openai', 1, 1)",
|
||||
)
|
||||
.execute(backend.pool())
|
||||
.await
|
||||
.expect("provider should insert");
|
||||
sqlx::query(
|
||||
"INSERT INTO system_configs (id, key, value, created_at, updated_at) VALUES ('config-1', 'site_name', '\"Aether\"', 1, 1)",
|
||||
)
|
||||
.execute(backend.pool())
|
||||
.await
|
||||
.expect("system config should insert");
|
||||
|
||||
let summary = backend
|
||||
.purge_admin_system_data(AdminSystemPurgeTarget::Config)
|
||||
.await
|
||||
.expect("config purge should run");
|
||||
assert!(summary.total() >= 2);
|
||||
assert_eq!(sqlite_count(backend.pool(), "system_configs").await, 0);
|
||||
assert_eq!(sqlite_count(backend.pool(), "providers").await, 0);
|
||||
assert_eq!(sqlite_count(backend.pool(), "users").await, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_system_users_purge_deletes_only_non_admin_users_and_keys() {
|
||||
let config = SqlDatabaseConfig {
|
||||
driver: DatabaseDriver::Sqlite,
|
||||
url: "sqlite::memory:".to_string(),
|
||||
pool: SqlPoolConfig {
|
||||
max_connections: 1,
|
||||
..SqlPoolConfig::default()
|
||||
},
|
||||
};
|
||||
let backend = SqliteBackend::from_config(config).expect("backend should build");
|
||||
run_sqlite_migrations(backend.pool())
|
||||
.await
|
||||
.expect("sqlite migrations should run");
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO users (id, email, username, role, created_at, updated_at)
|
||||
VALUES
|
||||
('admin-1', 'admin@example.com', 'admin', 'admin', 1, 1),
|
||||
('user-1', 'user@example.com', 'alice', 'user', 1, 1)
|
||||
"#,
|
||||
)
|
||||
.execute(backend.pool())
|
||||
.await
|
||||
.expect("users should insert");
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO api_keys (id, user_id, key_hash, name, created_at, updated_at, total_requests, total_tokens, total_cost_usd)
|
||||
VALUES
|
||||
('admin-key-1', 'admin-1', 'hash-admin', 'admin-key', 1, 1, 5, 50, 0.5),
|
||||
('user-key-1', 'user-1', 'hash-user', 'user-key', 1, 1, 7, 70, 0.7)
|
||||
"#,
|
||||
)
|
||||
.execute(backend.pool())
|
||||
.await
|
||||
.expect("api keys should insert");
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO stats_daily_api_key (id, api_key_id, "date", total_requests, created_at, updated_at)
|
||||
VALUES
|
||||
('admin-key-stats-1', 'admin-key-1', 1, 5, 1, 1),
|
||||
('user-key-stats-1', 'user-key-1', 1, 7, 1, 1)
|
||||
"#,
|
||||
)
|
||||
.execute(backend.pool())
|
||||
.await
|
||||
.expect("api key stats should insert");
|
||||
|
||||
let summary = backend
|
||||
.purge_admin_system_data(AdminSystemPurgeTarget::Users)
|
||||
.await
|
||||
.expect("users purge should run");
|
||||
assert!(summary.total() >= 2);
|
||||
assert_eq!(sqlite_count(backend.pool(), "users").await, 1);
|
||||
assert_eq!(sqlite_count(backend.pool(), "api_keys").await, 1);
|
||||
assert_eq!(sqlite_count(backend.pool(), "stats_daily_api_key").await, 1);
|
||||
let admin_exists: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE id = 'admin-1'")
|
||||
.fetch_one(backend.pool())
|
||||
.await
|
||||
.expect("admin count should load");
|
||||
assert_eq!(admin_exists, 1);
|
||||
}
|
||||
|
||||
async fn sqlite_count(pool: &sqlx::SqlitePool, table: &str) -> i64 {
|
||||
let sql = format!("SELECT COUNT(*) FROM \"{table}\"");
|
||||
sqlx::query_scalar::<_, i64>(&sql)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("count should load")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wallet_daily_usage_aggregation_uses_settlement_wallets_after_sqlite_migrations() {
|
||||
let config = SqlDatabaseConfig {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ use tracing::info;
|
||||
// Generated by build.rs from schema/bootstrap/postgres.
|
||||
pub(crate) static EMPTY_DATABASE_SNAPSHOT_SQL: &str =
|
||||
include_str!(concat!(env!("OUT_DIR"), "/empty_database_snapshot.sql"));
|
||||
pub(crate) const EMPTY_DATABASE_SNAPSHOT_CUTOFF_VERSION: i64 = 20260505130000;
|
||||
pub(crate) const EMPTY_DATABASE_SNAPSHOT_CUTOFF_VERSION: i64 = 20260507000000;
|
||||
|
||||
const PUBLIC_BASE_TABLE_COUNT_SQL: &str = r#"
|
||||
SELECT COUNT(*)::BIGINT
|
||||
|
||||
@@ -291,6 +291,7 @@ fn empty_database_snapshot_covers_current_cutoff_versions() {
|
||||
20260502000000,
|
||||
20260505000000,
|
||||
20260505130000,
|
||||
20260507000000,
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -1010,6 +1011,7 @@ fn pending_migrations_from_applied_skips_versions_already_applied() {
|
||||
20260502000000,
|
||||
20260505000000,
|
||||
20260505130000,
|
||||
20260507000000,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -218,7 +218,14 @@ impl AnnouncementWriteRepository for InMemoryAnnouncementReadRepository {
|
||||
.expect("announcement repository lock");
|
||||
let original_len = announcements.len();
|
||||
announcements.retain(|announcement| announcement.id != announcement_id);
|
||||
Ok(announcements.len() != original_len)
|
||||
let deleted = announcements.len() != original_len;
|
||||
if deleted {
|
||||
self.announcement_reads
|
||||
.write()
|
||||
.expect("announcement reads repository lock")
|
||||
.retain(|(_, read_announcement_id)| read_announcement_id != announcement_id);
|
||||
}
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
async fn mark_announcement_as_read(
|
||||
|
||||
@@ -233,12 +233,19 @@ WHERE id = ?
|
||||
}
|
||||
|
||||
async fn delete_announcement(&self, announcement_id: &str) -> Result<bool, DataLayerError> {
|
||||
let mut tx = self.pool.begin().await.map_sql_err()?;
|
||||
sqlx::query("DELETE FROM announcement_reads WHERE announcement_id = ?")
|
||||
.bind(announcement_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_sql_err()?;
|
||||
let rows_affected = sqlx::query("DELETE FROM announcements WHERE id = ?")
|
||||
.bind(announcement_id)
|
||||
.execute(&self.pool)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_sql_err()?
|
||||
.rows_affected();
|
||||
tx.commit().await.map_sql_err()?;
|
||||
Ok(rows_affected > 0)
|
||||
}
|
||||
|
||||
|
||||
@@ -163,6 +163,10 @@ const DELETE_ANNOUNCEMENT_SQL: &str = r#"
|
||||
DELETE FROM announcements
|
||||
WHERE id = $1
|
||||
"#;
|
||||
const DELETE_ANNOUNCEMENT_READS_SQL: &str = r#"
|
||||
DELETE FROM announcement_reads
|
||||
WHERE announcement_id = $1
|
||||
"#;
|
||||
|
||||
const MARK_ANNOUNCEMENT_AS_READ_SQL: &str = r#"
|
||||
INSERT INTO announcement_reads (
|
||||
@@ -295,11 +299,18 @@ impl AnnouncementWriteRepository for SqlxAnnouncementReadRepository {
|
||||
}
|
||||
|
||||
async fn delete_announcement(&self, announcement_id: &str) -> Result<bool, DataLayerError> {
|
||||
let result = sqlx::query(DELETE_ANNOUNCEMENT_SQL)
|
||||
let mut tx = self.pool.begin().await.map_postgres_err()?;
|
||||
sqlx::query(DELETE_ANNOUNCEMENT_READS_SQL)
|
||||
.bind(announcement_id)
|
||||
.execute(&self.pool)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
let result = sqlx::query(DELETE_ANNOUNCEMENT_SQL)
|
||||
.bind(announcement_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
tx.commit().await.map_postgres_err()?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
|
||||
@@ -233,12 +233,19 @@ WHERE id = ?
|
||||
}
|
||||
|
||||
async fn delete_announcement(&self, announcement_id: &str) -> Result<bool, DataLayerError> {
|
||||
let mut tx = self.pool.begin().await.map_sql_err()?;
|
||||
sqlx::query("DELETE FROM announcement_reads WHERE announcement_id = ?")
|
||||
.bind(announcement_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_sql_err()?;
|
||||
let rows_affected = sqlx::query("DELETE FROM announcements WHERE id = ?")
|
||||
.bind(announcement_id)
|
||||
.execute(&self.pool)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_sql_err()?
|
||||
.rows_affected();
|
||||
tx.commit().await.map_sql_err()?;
|
||||
Ok(rows_affected > 0)
|
||||
}
|
||||
|
||||
|
||||
@@ -20,3 +20,28 @@ pub struct AdminSystemStats {
|
||||
pub total_api_keys: u64,
|
||||
pub total_requests: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AdminSystemPurgeTarget {
|
||||
Config,
|
||||
Users,
|
||||
Usage,
|
||||
AuditLogs,
|
||||
RequestBodies,
|
||||
Stats,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub struct AdminSystemPurgeSummary {
|
||||
pub affected: std::collections::BTreeMap<String, u64>,
|
||||
}
|
||||
|
||||
impl AdminSystemPurgeSummary {
|
||||
pub fn add(&mut self, key: impl Into<String>, count: u64) {
|
||||
*self.affected.entry(key.into()).or_insert(0) += count;
|
||||
}
|
||||
|
||||
pub fn total(&self) -> u64 {
|
||||
self.affected.values().copied().sum()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,6 +329,24 @@ impl UserReadRepository for InMemoryUserReadRepository {
|
||||
if let Some(is_active) = query.is_active {
|
||||
rows.retain(|row| row.is_active == is_active);
|
||||
}
|
||||
if let Some(search) = query
|
||||
.search
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
let search = search.to_ascii_lowercase();
|
||||
rows.retain(|row| {
|
||||
row.id.to_ascii_lowercase().contains(&search)
|
||||
|| row.username.to_ascii_lowercase().contains(&search)
|
||||
|| row
|
||||
.email
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase()
|
||||
.contains(&search)
|
||||
});
|
||||
}
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.skip(query.skip)
|
||||
@@ -2054,6 +2072,7 @@ mod tests {
|
||||
limit: 10,
|
||||
role: Some("user".to_string()),
|
||||
is_active: Some(true),
|
||||
search: None,
|
||||
})
|
||||
.await
|
||||
.expect("paged export should succeed");
|
||||
|
||||
@@ -224,6 +224,22 @@ impl UserReadRepository for MysqlUserReadRepository {
|
||||
if let Some(is_active) = query.is_active {
|
||||
builder.push(" AND is_active = ").push_bind(is_active);
|
||||
}
|
||||
if let Some(search) = query
|
||||
.search
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
let pattern = format!("%{}%", search.to_ascii_lowercase());
|
||||
builder
|
||||
.push(" AND (LOWER(id) LIKE ")
|
||||
.push_bind(pattern.clone())
|
||||
.push(" OR LOWER(username) LIKE ")
|
||||
.push_bind(pattern.clone())
|
||||
.push(" OR LOWER(COALESCE(email, '')) LIKE ")
|
||||
.push_bind(pattern)
|
||||
.push(")");
|
||||
}
|
||||
builder
|
||||
.push(" ORDER BY id ASC LIMIT ")
|
||||
.push_bind(i64::try_from(query.limit).map_err(|_| {
|
||||
|
||||
@@ -626,6 +626,22 @@ impl SqlxUserReadRepository {
|
||||
if let Some(is_active) = query.is_active {
|
||||
builder.push(" AND is_active = ").push_bind(is_active);
|
||||
}
|
||||
if let Some(search) = query
|
||||
.search
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
let pattern = format!("%{}%", search.to_ascii_lowercase());
|
||||
builder
|
||||
.push(" AND (LOWER(id) LIKE ")
|
||||
.push_bind(pattern.clone())
|
||||
.push(" OR LOWER(username) LIKE ")
|
||||
.push_bind(pattern.clone())
|
||||
.push(" OR LOWER(COALESCE(email, '')) LIKE ")
|
||||
.push_bind(pattern)
|
||||
.push(")");
|
||||
}
|
||||
|
||||
builder
|
||||
.push(" ORDER BY id ASC OFFSET ")
|
||||
|
||||
@@ -224,6 +224,22 @@ impl UserReadRepository for SqliteUserReadRepository {
|
||||
if let Some(is_active) = query.is_active {
|
||||
builder.push(" AND is_active = ").push_bind(is_active);
|
||||
}
|
||||
if let Some(search) = query
|
||||
.search
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
let pattern = format!("%{}%", search.to_ascii_lowercase());
|
||||
builder
|
||||
.push(" AND (LOWER(id) LIKE ")
|
||||
.push_bind(pattern.clone())
|
||||
.push(" OR LOWER(username) LIKE ")
|
||||
.push_bind(pattern.clone())
|
||||
.push(" OR LOWER(COALESCE(email, '')) LIKE ")
|
||||
.push_bind(pattern)
|
||||
.push(")");
|
||||
}
|
||||
builder
|
||||
.push(" ORDER BY id ASC LIMIT ")
|
||||
.push_bind(i64::try_from(query.limit).map_err(|_| {
|
||||
@@ -1543,6 +1559,7 @@ INSERT INTO users (
|
||||
limit: 10,
|
||||
role: Some("user".to_string()),
|
||||
is_active: Some(true),
|
||||
search: None,
|
||||
})
|
||||
.await
|
||||
.expect("export page should load");
|
||||
|
||||
@@ -428,6 +428,7 @@ pub struct UserExportListQuery {
|
||||
pub limit: usize,
|
||||
pub role: Option<String>,
|
||||
pub is_active: Option<bool>,
|
||||
pub search: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||||
|
||||
Reference in New Issue
Block a user