mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
Merge remote-tracking branch 'origin/pr/395' into aether-rust-pioneer
This commit is contained in:
@@ -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()),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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![]));
|
||||||
|
|||||||
@@ -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<()>> {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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(|_| {
|
||||||
|
|||||||
@@ -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 ")
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
231
frontend/src/features/usage/components/ServerUserSelector.vue
Normal file
231
frontend/src/features/usage/components/ServerUserSelector.vue
Normal 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>
|
||||||
@@ -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) => ({
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user