Merge remote-tracking branch 'origin/pr/395' into aether-rust-pioneer

This commit is contained in:
fawney19
2026-05-08 02:47:07 +08:00
37 changed files with 2836 additions and 103 deletions

View File

@@ -423,4 +423,28 @@ impl GatewayDataState {
None => Ok(super::AdminSystemStats::default()), None => Ok(super::AdminSystemStats::default()),
} }
} }
pub(crate) async fn purge_admin_system_data(
&self,
target: aether_data::repository::system::AdminSystemPurgeTarget,
) -> Result<aether_data::repository::system::AdminSystemPurgeSummary, DataLayerError> {
if matches!(
target,
aether_data::repository::system::AdminSystemPurgeTarget::Config
) {
if let Some(values) = &self.system_config_values {
let mut values = values.write().expect("system config values lock");
let deleted = values.len() as u64;
values.clear();
let mut summary =
aether_data::repository::system::AdminSystemPurgeSummary::default();
summary.add("system_configs", deleted);
return Ok(summary);
}
}
match self.backends.as_ref() {
Some(backends) => backends.purge_admin_system_data(target).await,
None => Ok(aether_data::repository::system::AdminSystemPurgeSummary::default()),
}
}
} }

View File

@@ -60,6 +60,25 @@ impl<'a> AdminAppState<'a> {
self.app.read_admin_system_stats().await self.app.read_admin_system_stats().await
} }
pub(crate) async fn purge_admin_system_data(
&self,
target: aether_data::repository::system::AdminSystemPurgeTarget,
) -> Result<aether_data::repository::system::AdminSystemPurgeSummary, GatewayError> {
self.app.purge_admin_system_data(target).await
}
pub(crate) async fn run_admin_system_cleanup_once(
&self,
) -> Result<crate::maintenance::AdminSystemCleanupSummary, GatewayError> {
self.app.run_admin_system_cleanup_once().await
}
pub(crate) async fn rebuild_admin_stats_once(
&self,
) -> Result<crate::maintenance::AdminStatsRebuildSummary, GatewayError> {
self.app.rebuild_admin_stats_once().await
}
pub(crate) async fn find_proxy_node( pub(crate) async fn find_proxy_node(
&self, &self,
node_id: &str, node_id: &str,

View File

@@ -18,6 +18,7 @@ use crate::handlers::admin::system::shared::settings::{
}; };
use crate::handlers::admin::system::shared::smtp::build_admin_smtp_test_payload; use crate::handlers::admin::system::shared::smtp::build_admin_smtp_test_payload;
use crate::GatewayError; use crate::GatewayError;
use aether_data::repository::system::AdminSystemPurgeTarget;
use axum::{ use axum::{
body::{Body, Bytes}, body::{Body, Bytes},
http, http,
@@ -183,26 +184,29 @@ pub(super) async fn maybe_build_local_admin_core_system_response(
)); ));
} }
if matches!( if decision.route_kind.as_deref() == Some("cleanup") && request_method == http::Method::POST {
decision.route_kind.as_deref(), return Ok(Some(attach_admin_audit_response(
Some( Json(build_admin_system_cleanup_payload(state).await?).into_response(),
"cleanup" "admin_system_cleanup_completed",
| "purge_config" "cleanup_system_data",
| "purge_users" "system_cleanup",
| "purge_usage" "global",
| "purge_audit_logs" )));
| "purge_request_bodies" }
| "purge_stats"
) if let Some((target, action, object_type, object_id)) =
) && request_method == http::Method::POST admin_system_purge_target_for_route_kind(decision.route_kind.as_deref())
{ {
return Ok(Some( if request_method != http::Method::POST {
( return Ok(None);
http::StatusCode::SERVICE_UNAVAILABLE, }
Json(json!({ "detail": "Admin system data unavailable" })), return Ok(Some(attach_admin_audit_response(
) Json(build_admin_system_purge_payload(state, target).await?).into_response(),
.into_response(), "admin_system_data_purged",
)); action,
object_type,
object_id,
)));
} }
if decision.route_kind.as_deref() == Some("settings_set") if decision.route_kind.as_deref() == Some("settings_set")
@@ -430,3 +434,128 @@ pub(super) async fn maybe_build_local_admin_core_system_response(
Ok(None) Ok(None)
} }
fn admin_system_purge_target_for_route_kind(
route_kind: Option<&str>,
) -> Option<(
AdminSystemPurgeTarget,
&'static str,
&'static str,
&'static str,
)> {
match route_kind {
Some("purge_config") => Some((
AdminSystemPurgeTarget::Config,
"purge_system_config",
"system_config",
"global",
)),
Some("purge_users") => Some((
AdminSystemPurgeTarget::Users,
"purge_non_admin_users",
"users",
"non_admin",
)),
Some("purge_usage") => Some((
AdminSystemPurgeTarget::Usage,
"purge_usage_records",
"usage",
"all",
)),
Some("purge_audit_logs") => Some((
AdminSystemPurgeTarget::AuditLogs,
"purge_audit_logs",
"audit_logs",
"all",
)),
Some("purge_request_bodies") => Some((
AdminSystemPurgeTarget::RequestBodies,
"purge_request_bodies",
"request_bodies",
"all",
)),
Some("purge_stats") => Some((AdminSystemPurgeTarget::Stats, "purge_stats", "stats", "all")),
_ => None,
}
}
async fn build_admin_system_purge_payload(
state: &AdminAppState<'_>,
target: AdminSystemPurgeTarget,
) -> Result<serde_json::Value, GatewayError> {
let summary = state.purge_admin_system_data(target).await?;
let total = summary.total();
let affected = summary.affected.clone();
if target == AdminSystemPurgeTarget::Stats {
let rebuild = state.rebuild_admin_stats_once().await?;
let message = if rebuild.capped {
format!(
"统计聚合已清空,已重建 {} 个小时桶和 {} 个日桶,仍有历史统计待后台任务继续重建",
rebuild.hourly_buckets, rebuild.daily_buckets
)
} else {
format!(
"统计聚合已清空并重建,删除 {} 行,重建 {} 个小时桶和 {} 个日桶",
total, rebuild.hourly_buckets, rebuild.daily_buckets
)
};
return Ok(json!({
"message": message,
"deleted": affected,
"rebuilt": {
"hourly_buckets": rebuild.hourly_buckets,
"daily_buckets": rebuild.daily_buckets,
"capped": rebuild.capped,
},
}));
}
let (message, count_key) = match target {
AdminSystemPurgeTarget::Config => ("系统配置已清空", "deleted"),
AdminSystemPurgeTarget::Users => ("非管理员用户已清空", "deleted"),
AdminSystemPurgeTarget::Usage => ("使用记录已清空", "deleted"),
AdminSystemPurgeTarget::AuditLogs => ("审计日志已清空", "deleted"),
AdminSystemPurgeTarget::RequestBodies => ("请求/响应体已清空", "cleaned"),
AdminSystemPurgeTarget::Stats => unreachable!("stats handled above"),
};
Ok(json!({
"message": format!("{message},影响 {} 行", total),
count_key: affected,
}))
}
async fn build_admin_system_cleanup_payload(
state: &AdminAppState<'_>,
) -> Result<serde_json::Value, GatewayError> {
let summary = state.run_admin_system_cleanup_once().await?;
let cleaned = json!({
"audit_logs": summary.audit_logs_deleted,
"request_candidates": summary.request_candidates_deleted,
"pending_failed": summary.pending_failed,
"pending_recovered": summary.pending_recovered,
"usage_body_externalized": summary.usage.body_externalized,
"usage_legacy_body_refs_migrated": summary.usage.legacy_body_refs_migrated,
"usage_body_cleaned": summary.usage.body_cleaned,
"usage_header_cleaned": summary.usage.header_cleaned,
"usage_keys_cleaned": summary.usage.keys_cleaned,
"usage_records_deleted": summary.usage.records_deleted,
});
let total = summary
.audit_logs_deleted
.saturating_add(summary.request_candidates_deleted)
.saturating_add(summary.pending_failed)
.saturating_add(summary.pending_recovered)
.saturating_add(summary.usage.body_externalized)
.saturating_add(summary.usage.legacy_body_refs_migrated)
.saturating_add(summary.usage.body_cleaned)
.saturating_add(summary.usage.header_cleaned)
.saturating_add(summary.usage.keys_cleaned)
.saturating_add(summary.usage.records_deleted);
Ok(json!({
"message": format!("系统清理已执行,影响 {} 项", total),
"cleaned": cleaned,
}))
}

View File

@@ -29,6 +29,9 @@ pub(in super::super) async fn build_admin_list_users_response(
.map(|value| value.trim().to_ascii_lowercase()) .map(|value| value.trim().to_ascii_lowercase())
.filter(|value| !value.is_empty()); .filter(|value| !value.is_empty());
let is_active = query_param_optional_bool(request_context.query_string(), "is_active"); let is_active = query_param_optional_bool(request_context.query_string(), "is_active");
let search = query_param_value(request_context.query_string(), "search")
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let paged_rows = state let paged_rows = state
.list_export_users_page(&aether_data::repository::users::UserExportListQuery { .list_export_users_page(&aether_data::repository::users::UserExportListQuery {
@@ -36,6 +39,7 @@ pub(in super::super) async fn build_admin_list_users_response(
limit, limit,
role: role.clone(), role: role.clone(),
is_active, is_active,
search,
}) })
.await?; .await?;
let user_ids = paged_rows let user_ids = paged_rows

View File

@@ -4,18 +4,20 @@ mod tests;
pub(crate) use runtime::{ pub(crate) use runtime::{
cancel_proxy_upgrade_rollout, clear_proxy_upgrade_rollout_conflicts, cancel_proxy_upgrade_rollout, clear_proxy_upgrade_rollout_conflicts,
inspect_proxy_upgrade_rollout, perform_pool_quota_probe_once, perform_provider_checkin_once, inspect_proxy_upgrade_rollout, perform_oauth_token_refresh_once, perform_pool_quota_probe_once,
record_proxy_upgrade_traffic_success, restore_proxy_upgrade_rollout_skipped_nodes, perform_provider_checkin_once, rebuild_admin_stats_once, record_proxy_upgrade_traffic_success,
retry_proxy_upgrade_rollout_node, skip_proxy_upgrade_rollout_node, spawn_audit_cleanup_worker, restore_proxy_upgrade_rollout_skipped_nodes, retry_proxy_upgrade_rollout_node,
run_admin_system_cleanup_once, skip_proxy_upgrade_rollout_node, spawn_audit_cleanup_worker,
spawn_db_maintenance_worker, spawn_gemini_file_mapping_cleanup_worker, spawn_db_maintenance_worker, spawn_gemini_file_mapping_cleanup_worker,
spawn_pending_cleanup_worker, spawn_pool_monitor_worker, spawn_pool_quota_probe_worker, spawn_oauth_token_refresh_worker, spawn_pending_cleanup_worker, spawn_pool_monitor_worker,
spawn_provider_checkin_worker, spawn_proxy_node_stale_cleanup_worker, spawn_pool_quota_probe_worker, spawn_provider_checkin_worker,
spawn_proxy_upgrade_rollout_worker, spawn_request_candidate_cleanup_worker, spawn_proxy_node_stale_cleanup_worker, spawn_proxy_upgrade_rollout_worker,
spawn_stats_aggregation_worker, spawn_stats_hourly_aggregation_worker, spawn_request_candidate_cleanup_worker, spawn_stats_aggregation_worker,
spawn_usage_cleanup_worker, spawn_wallet_daily_usage_aggregation_worker, spawn_stats_hourly_aggregation_worker, spawn_usage_cleanup_worker,
start_proxy_upgrade_rollout, PoolQuotaProbeRunSummary, ProviderCheckinRunSummary, spawn_wallet_daily_usage_aggregation_worker, start_proxy_upgrade_rollout,
ProxyUpgradeRolloutCancelSummary, ProxyUpgradeRolloutConflictClearSummary, AdminStatsRebuildSummary, AdminSystemCleanupSummary, OAuthTokenRefreshRunSummary,
ProxyUpgradeRolloutNodeActionSummary, ProxyUpgradeRolloutProbeConfig, PoolQuotaProbeRunSummary, ProviderCheckinRunSummary, ProxyUpgradeRolloutCancelSummary,
ProxyUpgradeRolloutSkippedRestoreSummary, ProxyUpgradeRolloutStatus, ProxyUpgradeRolloutConflictClearSummary, ProxyUpgradeRolloutNodeActionSummary,
ProxyUpgradeRolloutTrackedNodeState, ProxyUpgradeRolloutProbeConfig, ProxyUpgradeRolloutSkippedRestoreSummary,
ProxyUpgradeRolloutStatus, ProxyUpgradeRolloutTrackedNodeState,
}; };

View File

@@ -12,6 +12,8 @@ mod audit_cleanup;
mod config; mod config;
#[path = "runtime/db_maintenance.rs"] #[path = "runtime/db_maintenance.rs"]
mod db_maintenance; mod db_maintenance;
#[path = "runtime/oauth_token_refresh.rs"]
mod oauth_token_refresh;
#[path = "runtime/pending_cleanup.rs"] #[path = "runtime/pending_cleanup.rs"]
mod pending_cleanup; mod pending_cleanup;
#[path = "runtime/pool_quota_probe.rs"] #[path = "runtime/pool_quota_probe.rs"]
@@ -47,6 +49,9 @@ pub(crate) use aether_data_contracts::repository::usage::{
use audit_cleanup::*; use audit_cleanup::*;
use config::*; use config::*;
use db_maintenance::*; use db_maintenance::*;
pub(crate) use oauth_token_refresh::{
perform_oauth_token_refresh_once, OAuthTokenRefreshRunSummary,
};
use pending_cleanup::*; use pending_cleanup::*;
pub(crate) use pool_quota_probe::{ pub(crate) use pool_quota_probe::{
perform_pool_quota_probe_once, perform_pool_quota_probe_once_with_config, perform_pool_quota_probe_once, perform_pool_quota_probe_once_with_config,
@@ -89,6 +94,7 @@ const PROXY_UPGRADE_ROLLOUT_INTERVAL: Duration = Duration::from_secs(15);
const PROXY_NODE_STALE_MIN_GRACE_SECS: u64 = 15; const PROXY_NODE_STALE_MIN_GRACE_SECS: u64 = 15;
const PROXY_NODE_STALE_MISSED_HEARTBEATS: u64 = 3; const PROXY_NODE_STALE_MISSED_HEARTBEATS: u64 = 3;
const POOL_MONITOR_INTERVAL: Duration = Duration::from_secs(5 * 60); const POOL_MONITOR_INTERVAL: Duration = Duration::from_secs(5 * 60);
const OAUTH_TOKEN_REFRESH_INTERVAL: Duration = Duration::from_secs(60);
const PROVIDER_CHECKIN_CONCURRENCY: usize = 3; const PROVIDER_CHECKIN_CONCURRENCY: usize = 3;
const PROVIDER_CHECKIN_DEFAULT_TIME: &str = "01:05"; const PROVIDER_CHECKIN_DEFAULT_TIME: &str = "01:05";
const REQUEST_CANDIDATE_CLEANUP_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60); const REQUEST_CANDIDATE_CLEANUP_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
@@ -105,6 +111,7 @@ const DB_MAINTENANCE_HOUR: u32 = 5;
const DB_MAINTENANCE_MINUTE: u32 = 0; const DB_MAINTENANCE_MINUTE: u32 = 0;
const MAINTENANCE_DEFAULT_TIMEZONE: &str = "Asia/Shanghai"; const MAINTENANCE_DEFAULT_TIMEZONE: &str = "Asia/Shanghai";
const DB_MAINTENANCE_TABLES: &[&str] = &["usage", "request_candidates", "audit_logs"]; const DB_MAINTENANCE_TABLES: &[&str] = &["usage", "request_candidates", "audit_logs"];
const MAX_ADMIN_STATS_REBUILD_BUCKETS: usize = 100_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct UsageCleanupSettings { struct UsageCleanupSettings {
@@ -116,6 +123,76 @@ struct UsageCleanupSettings {
auto_delete_expired_keys: bool, auto_delete_expired_keys: bool,
} }
#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize)]
pub(crate) struct AdminSystemCleanupSummary {
pub(crate) audit_logs_deleted: usize,
pub(crate) request_candidates_deleted: usize,
pub(crate) pending_failed: usize,
pub(crate) pending_recovered: usize,
pub(crate) usage: UsageCleanupSummary,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize)]
pub(crate) struct AdminStatsRebuildSummary {
pub(crate) hourly_buckets: usize,
pub(crate) daily_buckets: usize,
pub(crate) capped: bool,
}
pub(crate) async fn run_admin_system_cleanup_once(
data: &GatewayDataState,
) -> Result<AdminSystemCleanupSummary, aether_data::DataLayerError> {
let audit_logs_deleted = cleanup_audit_logs_once(data).await?;
let request_candidates_deleted = cleanup_request_candidates_once(data).await?;
let pending = cleanup_stale_pending_requests_once(data).await?;
let usage = perform_usage_cleanup_once(data).await?;
Ok(AdminSystemCleanupSummary {
audit_logs_deleted,
request_candidates_deleted,
pending_failed: pending.failed,
pending_recovered: pending.recovered,
usage,
})
}
pub(crate) async fn rebuild_admin_stats_once(
data: &GatewayDataState,
) -> Result<AdminStatsRebuildSummary, aether_data::DataLayerError> {
let now_utc = chrono::Utc::now();
let mut summary = AdminStatsRebuildSummary::default();
if data.has_stats_hourly_aggregation_backend() {
let input = aether_data::StatsHourlyAggregationInput {
target_hour_utc: stats_hourly_aggregation_target_hour(now_utc),
aggregated_at: now_utc,
};
while data.aggregate_stats_hourly(&input).await?.is_some() {
summary.hourly_buckets = summary.hourly_buckets.saturating_add(1);
if summary.hourly_buckets >= MAX_ADMIN_STATS_REBUILD_BUCKETS {
summary.capped = true;
break;
}
}
}
if data.has_stats_daily_aggregation_backend() {
let input = aether_data::StatsDailyAggregationInput {
target_day_utc: stats_aggregation_target_day(now_utc),
aggregated_at: now_utc,
};
while data.aggregate_stats_daily(&input).await?.is_some() {
summary.daily_buckets = summary.daily_buckets.saturating_add(1);
if summary.daily_buckets >= MAX_ADMIN_STATS_REBUILD_BUCKETS {
summary.capped = true;
break;
}
}
}
Ok(summary)
}
pub(crate) async fn cleanup_expired_gemini_file_mappings_once( pub(crate) async fn cleanup_expired_gemini_file_mappings_once(
data: &GatewayDataState, data: &GatewayDataState,
) -> Result<usize, aether_data::DataLayerError> { ) -> Result<usize, aether_data::DataLayerError> {

View File

@@ -0,0 +1,278 @@
use std::collections::BTreeMap;
use std::time::{SystemTime, UNIX_EPOCH};
use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
};
use serde_json::Value;
use tracing::{info, warn};
use crate::provider_key_auth::provider_key_is_oauth_managed;
use crate::{AppState, GatewayError};
use super::system_config_bool;
const OAUTH_TOKEN_REFRESH_LOOKAHEAD_SECS: u64 = 120;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize)]
pub(crate) struct OAuthTokenRefreshRunSummary {
pub(crate) scanned: usize,
pub(crate) eligible: usize,
pub(crate) refreshed: usize,
pub(crate) resolved: usize,
pub(crate) skipped: usize,
pub(crate) failed: usize,
}
pub(crate) async fn perform_oauth_token_refresh_once(
state: &AppState,
) -> Result<OAuthTokenRefreshRunSummary, GatewayError> {
if !state.has_provider_catalog_data_reader() || !state.has_provider_catalog_data_writer() {
return Ok(OAuthTokenRefreshRunSummary::default());
}
if !system_config_bool(&state.data, "enable_oauth_token_refresh", true)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?
{
return Ok(OAuthTokenRefreshRunSummary::default());
}
let providers = state.list_provider_catalog_providers(true).await?;
let provider_ids = providers
.iter()
.map(|provider| provider.id.clone())
.collect::<Vec<_>>();
if provider_ids.is_empty() {
return Ok(OAuthTokenRefreshRunSummary::default());
}
let endpoints = state
.list_provider_catalog_endpoints_by_provider_ids(&provider_ids)
.await?;
let keys = state
.list_provider_catalog_keys_by_provider_ids(&provider_ids)
.await?;
let endpoints_by_provider = group_endpoints_by_provider(endpoints);
let keys_by_provider = group_keys_by_provider(keys);
let mut summary = OAuthTokenRefreshRunSummary::default();
let refresh_cutoff_unix_secs =
now_unix_secs().saturating_add(OAUTH_TOKEN_REFRESH_LOOKAHEAD_SECS);
for provider in providers {
let provider_keys = keys_by_provider
.get(provider.id.as_str())
.map(Vec::as_slice)
.unwrap_or(&[]);
let provider_endpoints = endpoints_by_provider
.get(provider.id.as_str())
.map(Vec::as_slice)
.unwrap_or(&[]);
for key in provider_keys {
summary.scanned = summary.scanned.saturating_add(1);
if !oauth_refresh_candidate(&provider, key, refresh_cutoff_unix_secs) {
summary.skipped = summary.skipped.saturating_add(1);
continue;
}
summary.eligible = summary.eligible.saturating_add(1);
let Some(endpoint) =
oauth_runtime_endpoint_for_provider(&provider.provider_type, provider_endpoints)
else {
summary.skipped = summary.skipped.saturating_add(1);
continue;
};
let Some(transport) = state
.read_provider_transport_snapshot(&provider.id, &endpoint.id, &key.id)
.await?
else {
summary.skipped = summary.skipped.saturating_add(1);
continue;
};
if !auth_config_has_refresh_token(transport.key.decrypted_auth_config.as_deref()) {
summary.skipped = summary.skipped.saturating_add(1);
continue;
}
match state.resolve_local_oauth_request_auth(&transport).await {
Ok(Some(_auth)) => {
summary.resolved = summary.resolved.saturating_add(1);
if provider_key_credentials_changed(state, key).await? {
summary.refreshed = summary.refreshed.saturating_add(1);
}
}
Ok(None) => {
summary.skipped = summary.skipped.saturating_add(1);
}
Err(err) => {
summary.failed = summary.failed.saturating_add(1);
warn!(
event_name = "oauth_token_refresh_failed",
log_type = "ops",
worker = "oauth_token_refresh",
provider_id = %provider.id,
key_id = %key.id,
error = ?err,
"gateway oauth token auto refresh failed"
);
}
}
}
}
if summary.eligible > 0 || summary.refreshed > 0 || summary.failed > 0 {
info!(
event_name = "oauth_token_refresh_completed",
log_type = "ops",
worker = "oauth_token_refresh",
scanned = summary.scanned,
eligible = summary.eligible,
refreshed = summary.refreshed,
resolved = summary.resolved,
skipped = summary.skipped,
failed = summary.failed,
"gateway completed oauth token auto refresh scan"
);
}
Ok(summary)
}
fn group_endpoints_by_provider(
endpoints: Vec<StoredProviderCatalogEndpoint>,
) -> BTreeMap<String, Vec<StoredProviderCatalogEndpoint>> {
let mut grouped = BTreeMap::new();
for endpoint in endpoints {
grouped
.entry(endpoint.provider_id.clone())
.or_insert_with(Vec::new)
.push(endpoint);
}
grouped
}
fn group_keys_by_provider(
keys: Vec<StoredProviderCatalogKey>,
) -> BTreeMap<String, Vec<StoredProviderCatalogKey>> {
let mut grouped = BTreeMap::new();
for key in keys {
grouped
.entry(key.provider_id.clone())
.or_insert_with(Vec::new)
.push(key);
}
grouped
}
fn oauth_refresh_candidate(
provider: &StoredProviderCatalogProvider,
key: &StoredProviderCatalogKey,
refresh_cutoff_unix_secs: u64,
) -> bool {
key.is_active
&& key.oauth_invalid_at_unix_secs.is_none()
&& key
.encrypted_auth_config
.as_deref()
.map(str::trim)
.is_some_and(|value| !value.is_empty())
&& key
.expires_at_unix_secs
.is_some_and(|expires_at| expires_at <= refresh_cutoff_unix_secs)
&& provider_key_is_oauth_managed(key, provider.provider_type.as_str())
}
fn oauth_runtime_endpoint_for_provider(
provider_type: &str,
endpoints: &[StoredProviderCatalogEndpoint],
) -> Option<StoredProviderCatalogEndpoint> {
let provider_type = provider_type.trim().to_ascii_lowercase();
match provider_type.as_str() {
"codex" => endpoints
.iter()
.find(|endpoint| {
endpoint.is_active
&& crate::ai_serving::is_openai_responses_format(&endpoint.api_format)
})
.cloned(),
"chatgpt_web" => endpoints
.iter()
.find(|endpoint| {
endpoint.is_active
&& endpoint
.api_format
.trim()
.eq_ignore_ascii_case("openai:image")
})
.cloned(),
"antigravity" => endpoints
.iter()
.find(|endpoint| {
endpoint.is_active
&& endpoint
.api_format
.trim()
.eq_ignore_ascii_case("gemini:generate_content")
})
.cloned(),
"kiro" => endpoints
.iter()
.find(|endpoint| {
endpoint.is_active
&& endpoint
.api_format
.trim()
.eq_ignore_ascii_case("claude:messages")
})
.cloned()
.or_else(|| {
endpoints
.iter()
.find(|endpoint| endpoint.is_active)
.cloned()
}),
_ => endpoints
.iter()
.find(|endpoint| endpoint.is_active)
.cloned(),
}
}
async fn provider_key_credentials_changed(
state: &AppState,
before: &StoredProviderCatalogKey,
) -> Result<bool, GatewayError> {
let Some(after) = state
.list_provider_catalog_keys_by_ids(std::slice::from_ref(&before.id))
.await?
.into_iter()
.next()
else {
return Ok(false);
};
Ok(after.encrypted_api_key != before.encrypted_api_key
|| after.encrypted_auth_config != before.encrypted_auth_config
|| after.expires_at_unix_secs != before.expires_at_unix_secs)
}
fn auth_config_has_refresh_token(auth_config: Option<&str>) -> bool {
let Some(auth_config) = auth_config.map(str::trim).filter(|value| !value.is_empty()) else {
return false;
};
let Ok(value) = serde_json::from_str::<Value>(auth_config) else {
return false;
};
value
.as_object()
.and_then(|object| object.get("refresh_token"))
.and_then(Value::as_str)
.map(str::trim)
.is_some_and(|value| !value.is_empty())
}
fn now_unix_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or_default()
}

View File

@@ -20,17 +20,17 @@ use super::{
pending_cleanup_batch_size, pending_cleanup_timeout_minutes, plan_pending_cleanup_batch, pending_cleanup_batch_size, pending_cleanup_timeout_minutes, plan_pending_cleanup_batch,
provider_checkin_schedule, record_proxy_upgrade_traffic_success, run_db_maintenance_with, provider_checkin_schedule, record_proxy_upgrade_traffic_success, run_db_maintenance_with,
run_proxy_upgrade_rollout_once, spawn_audit_cleanup_worker, spawn_db_maintenance_worker, run_proxy_upgrade_rollout_once, spawn_audit_cleanup_worker, spawn_db_maintenance_worker,
spawn_pending_cleanup_worker, spawn_pool_monitor_worker, spawn_pool_quota_probe_worker, spawn_oauth_token_refresh_worker, spawn_pending_cleanup_worker, spawn_pool_monitor_worker,
spawn_provider_checkin_worker, spawn_proxy_node_stale_cleanup_worker, spawn_pool_quota_probe_worker, spawn_provider_checkin_worker,
spawn_proxy_upgrade_rollout_worker, spawn_stats_aggregation_worker, spawn_proxy_node_stale_cleanup_worker, spawn_proxy_upgrade_rollout_worker,
spawn_stats_hourly_aggregation_worker, spawn_usage_cleanup_worker, spawn_stats_aggregation_worker, spawn_stats_hourly_aggregation_worker,
spawn_wallet_daily_usage_aggregation_worker, start_proxy_upgrade_rollout, spawn_usage_cleanup_worker, spawn_wallet_daily_usage_aggregation_worker,
stats_aggregation_target_day, stats_hourly_aggregation_target_hour, summarize_database_pool, start_proxy_upgrade_rollout, stats_aggregation_target_day,
usage_cleanup_settings, usage_cleanup_window, wallet_daily_usage_aggregation_target, AppState, stats_hourly_aggregation_target_hour, summarize_database_pool, usage_cleanup_settings,
DbMaintenanceRunSummary, FailedPendingUsageRow, GatewayDataState, usage_cleanup_window, wallet_daily_usage_aggregation_target, AppState, DbMaintenanceRunSummary,
ProxyUpgradeRolloutProbeConfig, StalePendingUsageRow, UsageCleanupSettings, USAGE_CLEANUP_HOUR, FailedPendingUsageRow, GatewayDataState, ProxyUpgradeRolloutProbeConfig, StalePendingUsageRow,
USAGE_CLEANUP_MINUTE, WALLET_DAILY_USAGE_AGGREGATION_HOUR, UsageCleanupSettings, USAGE_CLEANUP_HOUR, USAGE_CLEANUP_MINUTE,
WALLET_DAILY_USAGE_AGGREGATION_MINUTE, WALLET_DAILY_USAGE_AGGREGATION_HOUR, WALLET_DAILY_USAGE_AGGREGATION_MINUTE,
}; };
#[tokio::test] #[tokio::test]
@@ -63,6 +63,14 @@ async fn spawn_proxy_upgrade_rollout_worker_skips_when_proxy_nodes_unavailable()
assert!(spawn_proxy_upgrade_rollout_worker(state).is_none()); assert!(spawn_proxy_upgrade_rollout_worker(state).is_none());
} }
#[tokio::test]
async fn spawn_oauth_token_refresh_worker_skips_when_provider_catalog_unavailable() {
let state = AppState::new()
.expect("gateway state should build")
.with_data_state_for_tests(GatewayDataState::disabled());
assert!(spawn_oauth_token_refresh_worker(state).is_none());
}
#[tokio::test] #[tokio::test]
async fn spawn_proxy_upgrade_rollout_worker_skips_when_system_config_unavailable() { async fn spawn_proxy_upgrade_rollout_worker_skips_when_system_config_unavailable() {
let repository = Arc::new(InMemoryProxyNodeRepository::seed(vec![])); let repository = Arc::new(InMemoryProxyNodeRepository::seed(vec![]));

View File

@@ -9,16 +9,18 @@ use crate::AppState;
use super::{ use super::{
duration_until_next_daily_run, duration_until_next_db_maintenance_run, duration_until_next_daily_run, duration_until_next_db_maintenance_run,
duration_until_next_stats_aggregation_run, duration_until_next_stats_hourly_aggregation_run, duration_until_next_stats_aggregation_run, duration_until_next_stats_hourly_aggregation_run,
maintenance_timezone, parse_hhmm_time, provider_checkin_schedule, run_audit_cleanup_once, maintenance_timezone, parse_hhmm_time, perform_oauth_token_refresh_once,
run_db_maintenance_once, run_gemini_file_mapping_cleanup_once, run_pending_cleanup_once, provider_checkin_schedule, run_audit_cleanup_once, run_db_maintenance_once,
run_pool_monitor_once, run_provider_checkin_once, run_proxy_node_stale_cleanup_once, run_gemini_file_mapping_cleanup_once, run_pending_cleanup_once, run_pool_monitor_once,
run_proxy_upgrade_rollout_once, run_request_candidate_cleanup_once, run_stats_aggregation_once, run_provider_checkin_once, run_proxy_node_stale_cleanup_once, run_proxy_upgrade_rollout_once,
run_request_candidate_cleanup_once, run_stats_aggregation_once,
run_stats_hourly_aggregation_once, run_usage_cleanup_once, run_stats_hourly_aggregation_once, run_usage_cleanup_once,
run_wallet_daily_usage_aggregation_once, AUDIT_LOG_CLEANUP_INTERVAL, run_wallet_daily_usage_aggregation_once, AUDIT_LOG_CLEANUP_INTERVAL,
GEMINI_FILE_MAPPING_CLEANUP_INTERVAL, PENDING_CLEANUP_INTERVAL, POOL_MONITOR_INTERVAL, GEMINI_FILE_MAPPING_CLEANUP_INTERVAL, OAUTH_TOKEN_REFRESH_INTERVAL, PENDING_CLEANUP_INTERVAL,
PROVIDER_CHECKIN_DEFAULT_TIME, PROXY_NODE_STALE_SWEEP_INTERVAL, PROXY_UPGRADE_ROLLOUT_INTERVAL, POOL_MONITOR_INTERVAL, PROVIDER_CHECKIN_DEFAULT_TIME, PROXY_NODE_STALE_SWEEP_INTERVAL,
REQUEST_CANDIDATE_CLEANUP_INTERVAL, USAGE_CLEANUP_HOUR, USAGE_CLEANUP_MINUTE, PROXY_UPGRADE_ROLLOUT_INTERVAL, REQUEST_CANDIDATE_CLEANUP_INTERVAL, USAGE_CLEANUP_HOUR,
WALLET_DAILY_USAGE_AGGREGATION_HOUR, WALLET_DAILY_USAGE_AGGREGATION_MINUTE, USAGE_CLEANUP_MINUTE, WALLET_DAILY_USAGE_AGGREGATION_HOUR,
WALLET_DAILY_USAGE_AGGREGATION_MINUTE,
}; };
const STATS_DAILY_CATCH_UP_BURST_LIMIT: usize = 14; const STATS_DAILY_CATCH_UP_BURST_LIMIT: usize = 14;
@@ -198,6 +200,29 @@ pub(crate) fn spawn_provider_checkin_worker(
})) }))
} }
pub(crate) fn spawn_oauth_token_refresh_worker(
state: AppState,
) -> Option<tokio::task::JoinHandle<()>> {
if !state.has_provider_catalog_data_reader() || !state.has_provider_catalog_data_writer() {
return None;
}
Some(tokio::spawn(async move {
if let Err(err) = perform_oauth_token_refresh_once(&state).await {
log_maintenance_worker_failure("oauth_token_refresh", "startup", &err);
}
let mut interval = tokio::time::interval(OAUTH_TOKEN_REFRESH_INTERVAL);
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
interval.tick().await;
loop {
interval.tick().await;
if let Err(err) = perform_oauth_token_refresh_once(&state).await {
log_maintenance_worker_failure("oauth_token_refresh", "tick", &err);
}
}
}))
}
pub(crate) fn spawn_gemini_file_mapping_cleanup_worker( pub(crate) fn spawn_gemini_file_mapping_cleanup_worker(
data: Arc<GatewayDataState>, data: Arc<GatewayDataState>,
) -> Option<tokio::task::JoinHandle<()>> { ) -> Option<tokio::task::JoinHandle<()>> {

View File

@@ -41,6 +41,7 @@ use super::super::{provider_transport, usage};
use crate::maintenance::spawn_audit_cleanup_worker; use crate::maintenance::spawn_audit_cleanup_worker;
use crate::maintenance::spawn_db_maintenance_worker; use crate::maintenance::spawn_db_maintenance_worker;
use crate::maintenance::spawn_gemini_file_mapping_cleanup_worker; use crate::maintenance::spawn_gemini_file_mapping_cleanup_worker;
use crate::maintenance::spawn_oauth_token_refresh_worker;
use crate::maintenance::spawn_pending_cleanup_worker; use crate::maintenance::spawn_pending_cleanup_worker;
use crate::maintenance::spawn_pool_monitor_worker; use crate::maintenance::spawn_pool_monitor_worker;
use crate::maintenance::spawn_pool_quota_probe_worker; use crate::maintenance::spawn_pool_quota_probe_worker;
@@ -526,6 +527,44 @@ impl AppState {
.map_err(|err| GatewayError::Internal(err.to_string())) .map_err(|err| GatewayError::Internal(err.to_string()))
} }
pub(crate) async fn purge_admin_system_data(
&self,
target: aether_data::repository::system::AdminSystemPurgeTarget,
) -> Result<aether_data::repository::system::AdminSystemPurgeSummary, GatewayError> {
let summary = self
.data
.purge_admin_system_data(target)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
if matches!(
target,
aether_data::repository::system::AdminSystemPurgeTarget::Config
| aether_data::repository::system::AdminSystemPurgeTarget::Users
| aether_data::repository::system::AdminSystemPurgeTarget::Usage
| aether_data::repository::system::AdminSystemPurgeTarget::Stats
) {
self.system_config_cache.clear();
self.clear_provider_transport_snapshot_cache();
}
Ok(summary)
}
pub(crate) async fn run_admin_system_cleanup_once(
&self,
) -> Result<crate::maintenance::AdminSystemCleanupSummary, GatewayError> {
crate::maintenance::run_admin_system_cleanup_once(&self.data)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn rebuild_admin_stats_once(
&self,
) -> Result<crate::maintenance::AdminStatsRebuildSummary, GatewayError> {
crate::maintenance::rebuild_admin_stats_once(&self.data)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn find_proxy_node( pub(crate) async fn find_proxy_node(
&self, &self,
node_id: &str, node_id: &str,
@@ -946,6 +985,9 @@ impl AppState {
if let Some(handle) = spawn_provider_checkin_worker(self.clone()) { if let Some(handle) = spawn_provider_checkin_worker(self.clone()) {
tasks.push(handle); tasks.push(handle);
} }
if let Some(handle) = spawn_oauth_token_refresh_worker(self.clone()) {
tasks.push(handle);
}
if let Some(handle) = spawn_request_candidate_cleanup_worker(self.data.clone()) { if let Some(handle) = spawn_request_candidate_cleanup_worker(self.data.clone()) {
tasks.push(handle); tasks.push(handle);
} }

View File

@@ -791,9 +791,11 @@ async fn gateway_handles_admin_system_unavailable_write_routes_locally_with_trus
let (gateway_url, gateway_handle) = start_server(gateway).await; let (gateway_url, gateway_handle) = start_server(gateway).await;
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let paths = [ let unavailable_paths = [
"/api/admin/system/config/import", "/api/admin/system/config/import",
"/api/admin/system/users/import", "/api/admin/system/users/import",
];
let local_paths = [
"/api/admin/system/cleanup", "/api/admin/system/cleanup",
"/api/admin/system/purge/config", "/api/admin/system/purge/config",
"/api/admin/system/purge/users", "/api/admin/system/purge/users",
@@ -803,7 +805,7 @@ async fn gateway_handles_admin_system_unavailable_write_routes_locally_with_trus
"/api/admin/system/purge/stats", "/api/admin/system/purge/stats",
]; ];
for path in paths { for path in unavailable_paths {
let response = client let response = client
.post(format!("{gateway_url}{path}")) .post(format!("{gateway_url}{path}"))
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b") .header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
@@ -820,6 +822,28 @@ async fn gateway_handles_admin_system_unavailable_write_routes_locally_with_trus
assert_eq!(payload["detail"], DETAIL, "path={path}"); assert_eq!(payload["detail"], DETAIL, "path={path}");
} }
for path in local_paths {
let response = client
.post(format!("{gateway_url}{path}"))
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.json(&json!({}))
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK, "path={path}");
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert!(
payload["message"]
.as_str()
.is_some_and(|value| !value.is_empty()),
"path={path}"
);
}
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0); assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort(); gateway_handle.abort();

View File

@@ -313,6 +313,74 @@ async fn gateway_handles_admin_users_root_locally_with_trusted_admin_principal()
assert_eq!(items[0]["is_active"], true); assert_eq!(items[0]["is_active"], true);
assert_eq!(items[0]["request_count"], 2); assert_eq!(items[0]["request_count"], 2);
assert_eq!(items[0]["total_tokens"], 100); assert_eq!(items[0]["total_tokens"], 100);
let search_response = reqwest::Client::new()
.get(format!(
"{gateway_url}/api/admin/users?skip=0&limit=20&search=carol"
))
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.send()
.await
.expect("search request should succeed");
assert_eq!(search_response.status(), StatusCode::OK);
let search_payload: serde_json::Value = search_response
.json()
.await
.expect("search json body should parse");
let search_items = search_payload
.as_array()
.expect("search list payload should be array");
assert_eq!(search_items.len(), 1);
assert_eq!(search_items[0]["id"], "user-3");
assert_eq!(search_items[0]["email"], "carol@example.com");
let id_search_response = reqwest::Client::new()
.get(format!(
"{gateway_url}/api/admin/users?skip=0&limit=20&search=user-3"
))
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.send()
.await
.expect("id search request should succeed");
assert_eq!(id_search_response.status(), StatusCode::OK);
let id_search_payload: serde_json::Value = id_search_response
.json()
.await
.expect("id search json body should parse");
let id_search_items = id_search_payload
.as_array()
.expect("id search list payload should be array");
assert_eq!(id_search_items.len(), 1);
assert_eq!(id_search_items[0]["id"], "user-3");
let limited_search_response = reqwest::Client::new()
.get(format!(
"{gateway_url}/api/admin/users?skip=0&limit=2&search=example.com"
))
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.send()
.await
.expect("limited search request should succeed");
assert_eq!(limited_search_response.status(), StatusCode::OK);
let limited_search_payload: serde_json::Value = limited_search_response
.json()
.await
.expect("limited search json body should parse");
let limited_search_items = limited_search_payload
.as_array()
.expect("limited search list payload should be array");
assert_eq!(limited_search_items.len(), 2);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0); assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort(); gateway_handle.abort();

View File

@@ -3774,7 +3774,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_dimension_collectors_enabled ON public.dime
DO $mig$ BEGIN DO $mig$ BEGIN
ALTER TABLE ONLY public.announcement_reads 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 EXCEPTION
WHEN duplicate_object THEN NULL; WHEN duplicate_object THEN NULL;
WHEN duplicate_table THEN NULL; WHEN duplicate_table THEN NULL;

View File

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

View File

@@ -3,7 +3,7 @@
DO $mig$ BEGIN DO $mig$ BEGIN
ALTER TABLE ONLY public.announcement_reads 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 EXCEPTION
WHEN duplicate_object THEN NULL; WHEN duplicate_object THEN NULL;
WHEN duplicate_table THEN NULL; WHEN duplicate_table THEN NULL;

View File

@@ -3,7 +3,7 @@
DO $mig$ BEGIN DO $mig$ BEGIN
ALTER TABLE ONLY public.announcement_reads 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 EXCEPTION
WHEN duplicate_object THEN NULL; WHEN duplicate_object THEN NULL;
WHEN duplicate_table THEN NULL; WHEN duplicate_table THEN NULL;

View File

@@ -7,7 +7,9 @@ use crate::maintenance::{
StatsDailyAggregationSummary, StatsHourlyAggregationInput, StatsHourlyAggregationSummary, StatsDailyAggregationSummary, StatsHourlyAggregationInput, StatsHourlyAggregationSummary,
WalletDailyUsageAggregationInput, WalletDailyUsageAggregationResult, WalletDailyUsageAggregationInput, WalletDailyUsageAggregationResult,
}; };
use crate::repository::system::{AdminSystemStats, StoredSystemConfigEntry}; use crate::repository::system::{
AdminSystemPurgeSummary, AdminSystemPurgeTarget, AdminSystemStats, StoredSystemConfigEntry,
};
use crate::DataLayerError; use crate::DataLayerError;
use sqlx::migrate::MigrateError; use sqlx::migrate::MigrateError;
@@ -182,6 +184,16 @@ impl DataBackends {
None => Ok(AdminSystemStats::default()), 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 { impl PostgresBackend {
@@ -471,4 +483,15 @@ impl<'a> SqlBackendRef<'a> {
Self::Sqlite(sqlite) => sqlite.read_admin_system_stats().await, 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 { mod tests {
use super::SqliteBackend; use super::SqliteBackend;
use crate::lifecycle::migrate::run_sqlite_migrations; use crate::lifecycle::migrate::run_sqlite_migrations;
use crate::repository::system::AdminSystemPurgeTarget;
use crate::{ use crate::{
DatabaseDriver, SqlDatabaseConfig, SqlPoolConfig, StatsDailyAggregationInput, DatabaseDriver, SqlDatabaseConfig, SqlPoolConfig, StatsDailyAggregationInput,
StatsHourlyAggregationInput, WalletDailyUsageAggregationInput, StatsHourlyAggregationInput, WalletDailyUsageAggregationInput,
@@ -313,6 +314,123 @@ mod tests {
assert_eq!(summary.succeeded, 3); 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] #[tokio::test]
async fn wallet_daily_usage_aggregation_uses_settlement_wallets_after_sqlite_migrations() { async fn wallet_daily_usage_aggregation_uses_settlement_wallets_after_sqlite_migrations() {
let config = SqlDatabaseConfig { let config = SqlDatabaseConfig {

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ use tracing::info;
// Generated by build.rs from schema/bootstrap/postgres. // Generated by build.rs from schema/bootstrap/postgres.
pub(crate) static EMPTY_DATABASE_SNAPSHOT_SQL: &str = pub(crate) static EMPTY_DATABASE_SNAPSHOT_SQL: &str =
include_str!(concat!(env!("OUT_DIR"), "/empty_database_snapshot.sql")); 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#" const PUBLIC_BASE_TABLE_COUNT_SQL: &str = r#"
SELECT COUNT(*)::BIGINT SELECT COUNT(*)::BIGINT

View File

@@ -291,6 +291,7 @@ fn empty_database_snapshot_covers_current_cutoff_versions() {
20260502000000, 20260502000000,
20260505000000, 20260505000000,
20260505130000, 20260505130000,
20260507000000,
] ]
); );
} }
@@ -1010,6 +1011,7 @@ fn pending_migrations_from_applied_skips_versions_already_applied() {
20260502000000, 20260502000000,
20260505000000, 20260505000000,
20260505130000, 20260505130000,
20260507000000,
] ]
); );
} }

View File

@@ -218,7 +218,14 @@ impl AnnouncementWriteRepository for InMemoryAnnouncementReadRepository {
.expect("announcement repository lock"); .expect("announcement repository lock");
let original_len = announcements.len(); let original_len = announcements.len();
announcements.retain(|announcement| announcement.id != announcement_id); 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( async fn mark_announcement_as_read(

View File

@@ -233,12 +233,19 @@ WHERE id = ?
} }
async fn delete_announcement(&self, announcement_id: &str) -> Result<bool, DataLayerError> { 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 = ?") let rows_affected = sqlx::query("DELETE FROM announcements WHERE id = ?")
.bind(announcement_id) .bind(announcement_id)
.execute(&self.pool) .execute(&mut *tx)
.await .await
.map_sql_err()? .map_sql_err()?
.rows_affected(); .rows_affected();
tx.commit().await.map_sql_err()?;
Ok(rows_affected > 0) Ok(rows_affected > 0)
} }

View File

@@ -163,6 +163,10 @@ const DELETE_ANNOUNCEMENT_SQL: &str = r#"
DELETE FROM announcements DELETE FROM announcements
WHERE id = $1 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#" const MARK_ANNOUNCEMENT_AS_READ_SQL: &str = r#"
INSERT INTO announcement_reads ( INSERT INTO announcement_reads (
@@ -295,11 +299,18 @@ impl AnnouncementWriteRepository for SqlxAnnouncementReadRepository {
} }
async fn delete_announcement(&self, announcement_id: &str) -> Result<bool, DataLayerError> { 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) .bind(announcement_id)
.execute(&self.pool) .execute(&mut *tx)
.await .await
.map_postgres_err()?; .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) Ok(result.rows_affected() > 0)
} }

View File

@@ -233,12 +233,19 @@ WHERE id = ?
} }
async fn delete_announcement(&self, announcement_id: &str) -> Result<bool, DataLayerError> { 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 = ?") let rows_affected = sqlx::query("DELETE FROM announcements WHERE id = ?")
.bind(announcement_id) .bind(announcement_id)
.execute(&self.pool) .execute(&mut *tx)
.await .await
.map_sql_err()? .map_sql_err()?
.rows_affected(); .rows_affected();
tx.commit().await.map_sql_err()?;
Ok(rows_affected > 0) Ok(rows_affected > 0)
} }

View File

@@ -20,3 +20,28 @@ pub struct AdminSystemStats {
pub total_api_keys: u64, pub total_api_keys: u64,
pub total_requests: 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()
}
}

View File

@@ -329,6 +329,24 @@ impl UserReadRepository for InMemoryUserReadRepository {
if let Some(is_active) = query.is_active { if let Some(is_active) = query.is_active {
rows.retain(|row| row.is_active == 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 Ok(rows
.into_iter() .into_iter()
.skip(query.skip) .skip(query.skip)
@@ -2054,6 +2072,7 @@ mod tests {
limit: 10, limit: 10,
role: Some("user".to_string()), role: Some("user".to_string()),
is_active: Some(true), is_active: Some(true),
search: None,
}) })
.await .await
.expect("paged export should succeed"); .expect("paged export should succeed");

View File

@@ -224,6 +224,22 @@ impl UserReadRepository for MysqlUserReadRepository {
if let Some(is_active) = query.is_active { if let Some(is_active) = query.is_active {
builder.push(" AND is_active = ").push_bind(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 builder
.push(" ORDER BY id ASC LIMIT ") .push(" ORDER BY id ASC LIMIT ")
.push_bind(i64::try_from(query.limit).map_err(|_| { .push_bind(i64::try_from(query.limit).map_err(|_| {

View File

@@ -626,6 +626,22 @@ impl SqlxUserReadRepository {
if let Some(is_active) = query.is_active { if let Some(is_active) = query.is_active {
builder.push(" AND is_active = ").push_bind(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 builder
.push(" ORDER BY id ASC OFFSET ") .push(" ORDER BY id ASC OFFSET ")

View File

@@ -224,6 +224,22 @@ impl UserReadRepository for SqliteUserReadRepository {
if let Some(is_active) = query.is_active { if let Some(is_active) = query.is_active {
builder.push(" AND is_active = ").push_bind(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 builder
.push(" ORDER BY id ASC LIMIT ") .push(" ORDER BY id ASC LIMIT ")
.push_bind(i64::try_from(query.limit).map_err(|_| { .push_bind(i64::try_from(query.limit).map_err(|_| {
@@ -1543,6 +1559,7 @@ INSERT INTO users (
limit: 10, limit: 10,
role: Some("user".to_string()), role: Some("user".to_string()),
is_active: Some(true), is_active: Some(true),
search: None,
}) })
.await .await
.expect("export page should load"); .expect("export page should load");

View File

@@ -428,6 +428,7 @@ pub struct UserExportListQuery {
pub limit: usize, pub limit: usize,
pub role: Option<String>, pub role: Option<String>,
pub is_active: Option<bool>, pub is_active: Option<bool>,
pub search: Option<String>,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]

View File

@@ -149,13 +149,38 @@ export interface UpsertUserApiKeyRequest {
export type UserSession = SessionRecord export type UserSession = SessionRecord
export interface GetAllUsersOptions {
search?: string
skip?: number
limit?: number
cacheTtlMs?: number
}
export const usersApi = { export const usersApi = {
async getAllUsers(options: { cacheTtlMs?: number } = {}): Promise<User[]> { async getAllUsers(options: GetAllUsersOptions = {}): Promise<User[]> {
const cacheTtlMs = options.cacheTtlMs ?? 0 const cacheTtlMs = options.cacheTtlMs ?? 0
const params: Record<string, string | number> = {}
const search = options.search?.trim()
if (search) params.search = search
if (options.skip !== undefined) params.skip = options.skip
if (options.limit !== undefined) params.limit = options.limit
const cacheKey = Object.keys(params).length === 0
? 'admin:users:list'
: [
'admin:users:list',
search ?? '',
options.skip ?? '',
options.limit ?? '',
].join(':')
return cachedRequest( return cachedRequest(
'admin:users:list', cacheKey,
async () => { async () => {
const response = await apiClient.get<User[]>('/api/admin/users') const response = await apiClient.get<User[]>('/api/admin/users', {
params: Object.keys(params).length > 0 ? params : undefined,
})
return response.data return response.data
}, },
cacheTtlMs, cacheTtlMs,

View File

@@ -0,0 +1,231 @@
<template>
<div
ref="rootRef"
:class="dropdown ? 'relative' : ''"
>
<button
v-if="dropdown"
type="button"
class="flex h-8 w-full min-w-0 items-center justify-between gap-2 rounded-md border border-border/60 bg-background px-3 text-left text-xs"
@click="toggleOpen"
>
<span class="truncate">{{ selectedLabel }}</span>
<ChevronDown class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</button>
<div
v-if="!dropdown || open"
:class="dropdown ? 'absolute left-0 top-full z-50 mt-1 w-64 rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg' : ''"
>
<div class="relative mb-1">
<Search class="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
v-model="searchText"
class="h-8 pl-8 text-xs"
placeholder="搜索用户"
@keydown.stop
/>
</div>
<div class="max-h-64 overflow-y-auto pr-0.5">
<button
type="button"
class="relative flex w-full items-center rounded-lg py-1.5 pl-8 pr-2 text-left text-sm transition-colors hover:bg-accent focus:bg-accent"
@click="selectUser('__all__')"
>
<Check
class="absolute left-2 h-4 w-4"
:class="modelValue === '__all__' ? 'opacity-100' : 'opacity-0'"
/>
<span>全部用户</span>
</button>
<div
v-if="pinnedUser"
class="my-1 border-t border-border/60 pt-1"
>
<button
type="button"
class="relative flex w-full items-center rounded-lg py-1.5 pl-8 pr-2 text-left text-sm transition-colors hover:bg-accent focus:bg-accent"
@click="selectUser(pinnedUser.id)"
>
<Check class="absolute left-2 h-4 w-4 opacity-100" />
<span class="min-w-0">
<span class="block truncate">{{ getUserLabel(pinnedUser) }}</span>
<span
v-if="pinnedUser.email && pinnedUser.email !== pinnedUser.username"
class="block truncate text-xs text-muted-foreground"
>{{ pinnedUser.email }}</span>
</span>
</button>
</div>
<div
v-if="loading"
class="px-3 py-6 text-center text-xs text-muted-foreground"
>
加载中...
</div>
<div
v-else-if="visibleUsers.length === 0"
class="px-3 py-6 text-center text-xs text-muted-foreground"
>
未找到用户
</div>
<button
v-for="user in visibleUsers"
v-else
:key="user.id"
type="button"
class="relative flex w-full items-center rounded-lg py-1.5 pl-8 pr-2 text-left text-sm transition-colors hover:bg-accent focus:bg-accent"
@click="selectUser(user.id)"
>
<Check
class="absolute left-2 h-4 w-4"
:class="modelValue === user.id ? 'opacity-100' : 'opacity-0'"
/>
<span class="min-w-0">
<span class="block truncate">{{ getUserLabel(user) }}</span>
<span
v-if="user.email && user.email !== user.username"
class="block truncate text-xs text-muted-foreground"
>{{ user.email }}</span>
</span>
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useDebounceFn } from '@vueuse/core'
import { Check, ChevronDown, Search } from 'lucide-vue-next'
import { Input } from '@/components/ui'
import { usersApi } from '@/api/users'
import type { UserOption } from './UsageRecordsTable.vue'
const props = withDefaults(defineProps<{
modelValue: string
initialUsers?: UserOption[]
dropdown?: boolean
}>(), {
initialUsers: () => [],
dropdown: false,
})
const emit = defineEmits<{
'update:modelValue': [value: string]
select: [value: string]
}>()
const rootRef = ref<HTMLElement | null>(null)
const open = ref(false)
const loading = ref(false)
const users = ref<UserOption[]>([])
const knownUsers = ref(new Map<string, UserOption>())
const searchText = ref('')
let requestId = 0
let loadedInitialBatch = false
const selectedUser = computed(() => knownUsers.value.get(props.modelValue))
const selectedLabel = computed(() => {
if (props.modelValue === '__all__') return '全部用户'
const user = selectedUser.value
return user ? getUserLabel(user) : `User ${props.modelValue}`
})
const pinnedUser = computed(() => {
if (props.modelValue === '__all__') return null
const selected = selectedUser.value
if (!selected) return null
return users.value.some((user) => user.id === selected.id) ? null : selected
})
const visibleUsers = computed(() => {
if (!pinnedUser.value) return users.value
return users.value.filter((user) => user.id !== pinnedUser.value?.id)
})
watch(() => props.initialUsers, (nextUsers) => {
rememberUsers(nextUsers)
if (users.value.length === 0 && nextUsers.length > 0) {
users.value = [...nextUsers]
}
}, { immediate: true })
watch(searchText, useDebounceFn(() => {
void loadUsers(searchText.value)
}, 300))
watch(open, (isOpen) => {
if (isOpen && !loadedInitialBatch && !loading.value) {
void loadUsers('')
}
})
function getUserLabel(user: UserOption): string {
return user.username || user.email || user.id
}
function rememberUsers(nextUsers: UserOption[]) {
const nextMap = new Map(knownUsers.value)
for (const user of nextUsers) {
nextMap.set(user.id, user)
}
knownUsers.value = nextMap
}
async function loadUsers(search: string) {
const currentRequest = ++requestId
loading.value = true
try {
const result = await usersApi.getAllUsers({
search,
skip: 0,
limit: 50,
cacheTtlMs: search.trim() ? 0 : 30_000,
})
if (currentRequest !== requestId) return
const options = result.map((user) => ({
id: user.id,
username: user.username,
email: user.email,
}))
if (!search.trim()) loadedInitialBatch = true
users.value = options
rememberUsers(options)
} catch {
if (currentRequest === requestId) users.value = []
} finally {
if (currentRequest === requestId) loading.value = false
}
}
function selectUser(value: string) {
emit('update:modelValue', value)
emit('select', value)
if (props.dropdown) open.value = false
}
function toggleOpen() {
open.value = !open.value
}
function handleDocumentPointerDown(event: PointerEvent) {
if (!props.dropdown || !open.value) return
const target = event.target
if (target instanceof Node && rootRef.value?.contains(target)) return
open.value = false
}
onMounted(() => {
if (!props.dropdown && !loadedInitialBatch) {
void loadUsers('')
}
document.addEventListener('pointerdown', handleDocumentPointerDown)
})
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', handleDocumentPointerDown)
})
</script>

View File

@@ -23,27 +23,14 @@
<div class="contents md:hidden"> <div class="contents md:hidden">
<!-- 用户筛选仅管理员可见 --> <!-- 用户筛选仅管理员可见 -->
<Select <ServerUserSelector
v-if="isAdmin && availableUsers.length > 0" v-if="isAdmin"
class="flex-1 min-w-0 sm:flex-none sm:w-40"
:model-value="filterUser" :model-value="filterUser"
:initial-users="availableUsers"
dropdown
@update:model-value="$emit('update:filterUser', $event)" @update:model-value="$emit('update:filterUser', $event)"
> />
<SelectTrigger class="flex-1 min-w-0 sm:flex-none sm:w-36 h-8 text-xs border-border/60">
<SelectValue placeholder="用户" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">
全部用户
</SelectItem>
<SelectItem
v-for="user in availableUsers"
:key="user.id"
:value="user.id"
>
{{ user.username || user.email }}
</SelectItem>
</SelectContent>
</Select>
<!-- 模型筛选 --> <!-- 模型筛选 -->
<Select <Select
@@ -340,13 +327,13 @@
:sortable="false" :sortable="false"
:filter-active="filterUser !== '__all__'" :filter-active="filterUser !== '__all__'"
filter-title="筛选用户" filter-title="筛选用户"
filter-content-class="w-48 p-1 rounded-2xl border-border bg-card text-foreground shadow-2xl backdrop-blur-xl" filter-content-class="w-64 p-1 rounded-2xl border-border bg-card text-foreground shadow-2xl backdrop-blur-xl"
> >
用户 用户
<template #filter="{ close }"> <template #filter="{ close }">
<TableFilterMenu <ServerUserSelector
:model-value="filterUser" :model-value="filterUser"
:options="userFilterOptions" :initial-users="availableUsers"
@update:model-value="$emit('update:filterUser', $event)" @update:model-value="$emit('update:filterUser', $event)"
@select="close" @select="close"
/> />
@@ -823,6 +810,7 @@ import { formatApiFormat } from '@/api/endpoints/types/api-format'
import type { DateRangeParams, UsageRecord } from '../types' import type { DateRangeParams, UsageRecord } from '../types'
import { TimeRangePicker } from '@/components/common' import { TimeRangePicker } from '@/components/common'
import ElapsedTimeText from './ElapsedTimeText.vue' import ElapsedTimeText from './ElapsedTimeText.vue'
import ServerUserSelector from './ServerUserSelector.vue'
export interface UserOption { export interface UserOption {
id: string id: string
@@ -893,14 +881,6 @@ const AVAILABLE_API_FORMATS = [
// 使用模块级常量 // 使用模块级常量
const availableApiFormats = AVAILABLE_API_FORMATS const availableApiFormats = AVAILABLE_API_FORMATS
const userFilterOptions = computed<FilterOption[]>(() => [
{ value: '__all__', label: '全部用户' },
...props.availableUsers.map((user) => ({
value: user.id,
label: user.username || user.email,
})),
])
const modelFilterOptions = computed<FilterOption[]>(() => [ const modelFilterOptions = computed<FilterOption[]>(() => [
{ value: '__all__', label: '全部模型' }, { value: '__all__', label: '全部模型' },
...props.availableModels.map((model) => ({ ...props.availableModels.map((model) => ({

View File

@@ -0,0 +1,150 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick, type App } from 'vue'
import ServerUserSelector from '../ServerUserSelector.vue'
const getAllUsersMock = vi.hoisted(() => vi.fn())
vi.mock('@/api/users', () => ({
usersApi: {
getAllUsers: getAllUsersMock,
},
}))
vi.mock('@/components/ui', async () => {
const { defineComponent, h } = await import('vue')
return {
Input: defineComponent({
name: 'InputStub',
props: { modelValue: String },
emits: ['update:modelValue'],
setup(props, { attrs, emit }) {
return () => h('input', {
...attrs,
value: props.modelValue ?? '',
onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).value),
})
},
}),
}
})
vi.mock('lucide-vue-next', async () => {
const { defineComponent, h } = await import('vue')
const Icon = defineComponent({
name: 'IconStub',
setup() {
return () => h('span')
},
})
return {
Check: Icon,
ChevronDown: Icon,
Search: Icon,
}
})
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
function flushPromises() {
return Promise.resolve().then(() => undefined)
}
function mountSelector(props: Record<string, unknown> = {}) {
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(defineComponent({
setup() {
return () => h(ServerUserSelector, {
modelValue: '__all__',
initialUsers: [],
dropdown: true,
...props,
})
},
}))
app.mount(root)
mountedApps.push({ app, root })
return root
}
beforeEach(() => {
vi.useRealTimers()
getAllUsersMock.mockReset()
})
afterEach(() => {
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
}
vi.useRealTimers()
})
describe('ServerUserSelector', () => {
it('loads the initial user batch when opened', async () => {
getAllUsersMock.mockResolvedValue([
{ id: 'user-1', username: 'alice', email: 'alice@example.com' },
])
const root = mountSelector()
root.querySelector('button')?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await nextTick()
await flushPromises()
expect(getAllUsersMock).toHaveBeenCalledWith({
search: '',
skip: 0,
limit: 50,
cacheTtlMs: 30_000,
})
expect(root.textContent).toContain('alice')
})
it('debounces remote search and bypasses cache for typed queries', async () => {
vi.useFakeTimers()
getAllUsersMock.mockResolvedValue([])
const root = mountSelector()
root.querySelector('button')?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await nextTick()
const input = root.querySelector('input') as HTMLInputElement
input.value = 'bob'
input.dispatchEvent(new Event('input', { bubbles: true }))
await vi.advanceTimersByTimeAsync(299)
expect(getAllUsersMock).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1)
await flushPromises()
expect(getAllUsersMock).toHaveBeenLastCalledWith({
search: 'bob',
skip: 0,
limit: 50,
cacheTtlMs: 0,
})
})
it('keeps the selected user pinned when search results do not include it', async () => {
getAllUsersMock.mockResolvedValue([
{ id: 'user-1', username: 'alice', email: 'alice@example.com' },
])
const root = mountSelector({
modelValue: 'user-99',
initialUsers: [
{ id: 'user-99', username: 'pinned', email: 'pinned@example.com' },
],
})
root.querySelector('button')?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await nextTick()
await flushPromises()
expect(root.textContent).toContain('pinned')
expect(root.textContent).toContain('alice')
})
})

View File

@@ -76,6 +76,8 @@ vi.mock('lucide-vue-next', async () => {
return { return {
RefreshCcw: Icon, RefreshCcw: Icon,
Search: Icon, Search: Icon,
ChevronDown: Icon,
Check: Icon,
} }
}) })
@@ -88,6 +90,15 @@ vi.mock('../ElapsedTimeText.vue', () => ({
}), }),
})) }))
vi.mock('../ServerUserSelector.vue', () => ({
default: defineComponent({
name: 'ServerUserSelectorStub',
setup() {
return () => h('div', 'user selector')
},
}),
}))
const mountedApps: Array<{ app: App, root: HTMLElement }> = [] const mountedApps: Array<{ app: App, root: HTMLElement }> = []
function buildRecord(overrides: Partial<UsageRecord> = {}): UsageRecord { function buildRecord(overrides: Partial<UsageRecord> = {}): UsageRecord {

View File

@@ -106,11 +106,11 @@ const purgeItems: PurgeItem[] = [
}, },
{ {
key: 'stats', key: 'stats',
title: '清空聚合数据', title: '清空统计聚合',
description: '清空仪表盘统计聚合数据,保留原始使用记录', description: '删除统计聚合数据,保留原始使用记录;统计可从原始使用记录重新构建',
buttonText: '清空聚合数据', buttonText: '清空统计聚合',
icon: markRaw(PieChart), icon: markRaw(PieChart),
confirmMessage: '确定要清空全部聚合统计数据吗?仪表盘数据将被清除,用户和 Key 的累计统计也会归零,操作不可逆。', confirmMessage: '确定要清空全部统计聚合数据吗?原始使用记录会保留,仪表盘和累计统计可从原始记录重新构建。',
action: () => adminApi.purgeStats(), action: () => adminApi.purgeStats(),
}, },
] ]
@@ -122,9 +122,9 @@ async function handlePurge(item: PurgeItem) {
loadingKey.value = item.key loadingKey.value = item.key
try { try {
const result = await item.action() const result = await item.action()
success(result.message) success(result.message || '操作成功')
} catch (e) { } catch (e) {
error(parseApiError(e)) error(parseApiError(e, '清空失败'))
} finally { } finally {
loadingKey.value = null loadingKey.value = null
} }