fix(admin): repair system maintenance controls

Implement server-side searchable user filtering for usage records, including backend search parameters and a shared frontend selector with loading, empty, and pinned-selected states.

Fix announcement deletion by cascading announcement read rows through a Postgres migration and defensive repository cleanup across supported backends.

Wire admin system purge and cleanup endpoints to real data deletion/maintenance flows, improve DataManagement messages, rebuild stats after stats purge, and add runtime OAuth token refresh maintenance when enabled.

Verified with rust-ci equivalent checks: cargo fmt, split clippy, split cargo tests, SQLite/Postgres/MySQL smoke tests, plus frontend npm ci, build, pages build, type-check, and targeted usage selector tests.
This commit is contained in:
Entropy.Xu
2026-05-07 21:26:40 +08:00
parent 2b439094c5
commit dd8c2ebec6
37 changed files with 2836 additions and 103 deletions

View File

@@ -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,
}
}
}

View File

@@ -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