mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat(admin): manual request-records cleanup with typed confirmation
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -310,7 +310,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aether-proxy"
|
name = "aether-proxy"
|
||||||
version = "0.3.9"
|
version = "0.3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aether-contracts",
|
"aether-contracts",
|
||||||
"aether-gateway",
|
"aether-gateway",
|
||||||
|
|||||||
@@ -103,6 +103,26 @@ pub(super) fn classify_admin_system_family_route(
|
|||||||
"admin:system",
|
"admin:system",
|
||||||
false,
|
false,
|
||||||
))
|
))
|
||||||
|
} else if method == http::Method::POST
|
||||||
|
&& normalized_path == "/api/admin/system/cleanup/usage/manual"
|
||||||
|
{
|
||||||
|
Some(classified(
|
||||||
|
"admin_proxy",
|
||||||
|
"system_manage",
|
||||||
|
"cleanup_usage_manual",
|
||||||
|
"admin:system",
|
||||||
|
false,
|
||||||
|
))
|
||||||
|
} else if method == http::Method::GET
|
||||||
|
&& normalized_path == "/api/admin/system/cleanup/usage/preview"
|
||||||
|
{
|
||||||
|
Some(classified(
|
||||||
|
"admin_proxy",
|
||||||
|
"system_manage",
|
||||||
|
"cleanup_usage_preview",
|
||||||
|
"admin:system",
|
||||||
|
false,
|
||||||
|
))
|
||||||
} else if method == http::Method::POST && normalized_path == "/api/admin/system/purge/config" {
|
} else if method == http::Method::POST && normalized_path == "/api/admin/system/purge/config" {
|
||||||
Some(classified(
|
Some(classified(
|
||||||
"admin_proxy",
|
"admin_proxy",
|
||||||
|
|||||||
@@ -978,6 +978,19 @@ impl GatewayDataState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn preview_usage_cleanup(
|
||||||
|
&self,
|
||||||
|
window: &UsageCleanupWindow,
|
||||||
|
) -> Result<aether_data_contracts::repository::usage::UsageCleanupPreviewCounts, DataLayerError>
|
||||||
|
{
|
||||||
|
match &self.usage_writer {
|
||||||
|
Some(repository) => repository.preview_usage_cleanup(window).await,
|
||||||
|
None => {
|
||||||
|
Ok(aether_data_contracts::repository::usage::UsageCleanupPreviewCounts::default())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn find_request_usage_by_request_id(
|
pub(crate) async fn find_request_usage_by_request_id(
|
||||||
&self,
|
&self,
|
||||||
request_id: &str,
|
request_id: &str,
|
||||||
|
|||||||
@@ -204,6 +204,24 @@ pub(super) async fn maybe_build_local_admin_core_system_response(
|
|||||||
return Ok(Some(Json(json!({ "items": records })).into_response()));
|
return Ok(Some(Json(json!({ "items": records })).into_response()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if decision.route_kind.as_deref() == Some("cleanup_usage_manual")
|
||||||
|
&& request_method == http::Method::POST
|
||||||
|
&& request_path == "/api/admin/system/cleanup/usage/manual"
|
||||||
|
{
|
||||||
|
return Ok(Some(
|
||||||
|
build_manual_usage_cleanup_response(state, request_body).await?,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if decision.route_kind.as_deref() == Some("cleanup_usage_preview")
|
||||||
|
&& request_method == http::Method::GET
|
||||||
|
&& request_path == "/api/admin/system/cleanup/usage/preview"
|
||||||
|
{
|
||||||
|
return Ok(Some(
|
||||||
|
build_manual_usage_cleanup_preview_response(state, request_context).await?,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
if let Some((task_kind, action, object_type, object_id)) =
|
if let Some((task_kind, action, object_type, object_id)) =
|
||||||
admin_system_purge_task_for_route_kind(decision.route_kind.as_deref())
|
admin_system_purge_task_for_route_kind(decision.route_kind.as_deref())
|
||||||
{
|
{
|
||||||
@@ -550,3 +568,171 @@ async fn build_admin_system_cleanup_payload(
|
|||||||
"cleaned": cleaned,
|
"cleaned": cleaned,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn build_manual_usage_cleanup_response(
|
||||||
|
state: &AdminAppState<'_>,
|
||||||
|
request_body: Option<&Bytes>,
|
||||||
|
) -> Result<Response<Body>, GatewayError> {
|
||||||
|
let older_than_days = match parse_manual_usage_cleanup_request(request_body) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(response) => return Ok(response),
|
||||||
|
};
|
||||||
|
|
||||||
|
match crate::maintenance::run_manual_usage_cleanup_once(
|
||||||
|
&state.app().data,
|
||||||
|
older_than_days,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(summary) => {
|
||||||
|
let total = summary
|
||||||
|
.body_externalized
|
||||||
|
.saturating_add(summary.legacy_body_refs_migrated)
|
||||||
|
.saturating_add(summary.body_cleaned)
|
||||||
|
.saturating_add(summary.header_cleaned)
|
||||||
|
.saturating_add(summary.keys_cleaned)
|
||||||
|
.saturating_add(summary.records_deleted);
|
||||||
|
let message = match older_than_days {
|
||||||
|
Some(days) => {
|
||||||
|
format!("请求记录手动清理完成,清理 {days} 天前的记录,影响 {total} 项")
|
||||||
|
}
|
||||||
|
None => format!("请求记录手动清理完成(按当前策略),影响 {total} 项"),
|
||||||
|
};
|
||||||
|
let payload = json!({
|
||||||
|
"message": message,
|
||||||
|
"requested_older_than_days": older_than_days,
|
||||||
|
"summary": {
|
||||||
|
"body_externalized": summary.body_externalized,
|
||||||
|
"legacy_body_refs_migrated": summary.legacy_body_refs_migrated,
|
||||||
|
"body_cleaned": summary.body_cleaned,
|
||||||
|
"header_cleaned": summary.header_cleaned,
|
||||||
|
"keys_cleaned": summary.keys_cleaned,
|
||||||
|
"records_deleted": summary.records_deleted,
|
||||||
|
},
|
||||||
|
"total_affected": total,
|
||||||
|
});
|
||||||
|
Ok(attach_admin_audit_response(
|
||||||
|
Json(payload).into_response(),
|
||||||
|
"admin_system_usage_cleanup_completed",
|
||||||
|
"manual_usage_cleanup",
|
||||||
|
"usage_cleanup",
|
||||||
|
"global",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Err(crate::maintenance::ManualUsageCleanupError::AlreadyRunning) => Ok((
|
||||||
|
http::StatusCode::CONFLICT,
|
||||||
|
Json(json!({
|
||||||
|
"detail": "usage_cleanup_already_running",
|
||||||
|
"message": "已有一次清理正在进行中,请稍后再试",
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response()),
|
||||||
|
Err(crate::maintenance::ManualUsageCleanupError::DataLayer(err)) => {
|
||||||
|
Err(GatewayError::Internal(err.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn build_manual_usage_cleanup_preview_response(
|
||||||
|
state: &AdminAppState<'_>,
|
||||||
|
request_context: &AdminRequestContext<'_>,
|
||||||
|
) -> Result<Response<Body>, GatewayError> {
|
||||||
|
let older_than_days = match parse_older_than_days_query(request_context.query_string()) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(response) => return Ok(response),
|
||||||
|
};
|
||||||
|
let preview =
|
||||||
|
crate::maintenance::preview_manual_usage_cleanup(&state.app().data, older_than_days)
|
||||||
|
.await
|
||||||
|
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||||
|
Ok(Json(json!({
|
||||||
|
"requested_older_than_days": preview.requested_older_than_days,
|
||||||
|
"effective_cutoffs": {
|
||||||
|
"detail": preview.detail_cutoff,
|
||||||
|
"compressed": preview.compressed_cutoff,
|
||||||
|
"header": preview.header_cutoff,
|
||||||
|
"log": preview.log_cutoff,
|
||||||
|
},
|
||||||
|
"counts": {
|
||||||
|
"detail": preview.detail_count,
|
||||||
|
"compressed": preview.compressed_count,
|
||||||
|
"header": preview.header_count,
|
||||||
|
"log": preview.log_count,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_manual_usage_cleanup_request(
|
||||||
|
request_body: Option<&Bytes>,
|
||||||
|
) -> Result<Option<u32>, Response<Body>> {
|
||||||
|
let Some(body) = request_body else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if body.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let parsed: serde_json::Value = match serde_json::from_slice(body) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(err) => {
|
||||||
|
return Err((
|
||||||
|
http::StatusCode::BAD_REQUEST,
|
||||||
|
Json(json!({ "detail": format!("请求体无效 JSON: {err}") })),
|
||||||
|
)
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let Some(object) = parsed.as_object() else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
match object.get("older_than_days") {
|
||||||
|
None | Some(serde_json::Value::Null) => Ok(None),
|
||||||
|
Some(value) => value
|
||||||
|
.as_u64()
|
||||||
|
.and_then(|value| u32::try_from(value).ok())
|
||||||
|
.filter(|days| *days >= 1)
|
||||||
|
.map(Some)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
(
|
||||||
|
http::StatusCode::BAD_REQUEST,
|
||||||
|
Json(json!({
|
||||||
|
"detail": "older_than_days 必须为正整数",
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_older_than_days_query(query_string: Option<&str>) -> Result<Option<u32>, Response<Body>> {
|
||||||
|
let Some(query) = query_string.filter(|value| !value.is_empty()) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let value = query
|
||||||
|
.split('&')
|
||||||
|
.filter_map(|pair| pair.split_once('='))
|
||||||
|
.find_map(|(key, value)| {
|
||||||
|
if key == "older_than_days" && !value.is_empty() {
|
||||||
|
Some(value)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let Some(raw) = value else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
raw.parse::<u32>()
|
||||||
|
.ok()
|
||||||
|
.filter(|days| *days >= 1)
|
||||||
|
.map(Some)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
(
|
||||||
|
http::StatusCode::BAD_REQUEST,
|
||||||
|
Json(json!({
|
||||||
|
"detail": "older_than_days 必须为正整数",
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,22 +6,24 @@ pub(crate) use runtime::{
|
|||||||
cancel_proxy_upgrade_rollout, clear_proxy_upgrade_rollout_conflicts,
|
cancel_proxy_upgrade_rollout, clear_proxy_upgrade_rollout_conflicts,
|
||||||
ensure_provider_key_pool_scores_for_keys, inspect_proxy_upgrade_rollout,
|
ensure_provider_key_pool_scores_for_keys, inspect_proxy_upgrade_rollout,
|
||||||
list_admin_cleanup_run_records, perform_oauth_token_refresh_once,
|
list_admin_cleanup_run_records, perform_oauth_token_refresh_once,
|
||||||
perform_pool_quota_probe_once, perform_provider_checkin_once, rebuild_admin_stats_once,
|
perform_pool_quota_probe_once, perform_provider_checkin_once, preview_manual_usage_cleanup,
|
||||||
record_completed_cleanup_run, record_proxy_upgrade_traffic_success,
|
rebuild_admin_stats_once, record_completed_cleanup_run, record_proxy_upgrade_traffic_success,
|
||||||
restore_proxy_upgrade_rollout_skipped_nodes, retry_proxy_upgrade_rollout_node,
|
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,
|
run_admin_system_cleanup_once, run_manual_usage_cleanup_once, skip_proxy_upgrade_rollout_node,
|
||||||
spawn_db_maintenance_worker, spawn_gemini_file_mapping_cleanup_worker,
|
spawn_audit_cleanup_worker, spawn_db_maintenance_worker,
|
||||||
spawn_oauth_token_refresh_worker, spawn_pending_cleanup_worker, spawn_pool_monitor_worker,
|
spawn_gemini_file_mapping_cleanup_worker, spawn_oauth_token_refresh_worker,
|
||||||
spawn_pool_quota_probe_worker, spawn_pool_score_rebuild_worker, spawn_provider_checkin_worker,
|
spawn_pending_cleanup_worker, spawn_pool_monitor_worker, spawn_pool_quota_probe_worker,
|
||||||
|
spawn_pool_score_rebuild_worker, spawn_provider_checkin_worker,
|
||||||
spawn_proxy_node_metrics_cleanup_worker, spawn_proxy_node_stale_cleanup_worker,
|
spawn_proxy_node_metrics_cleanup_worker, spawn_proxy_node_stale_cleanup_worker,
|
||||||
spawn_proxy_upgrade_rollout_worker, spawn_request_candidate_cleanup_worker,
|
spawn_proxy_upgrade_rollout_worker, spawn_request_candidate_cleanup_worker,
|
||||||
spawn_stats_aggregation_worker, spawn_stats_hourly_aggregation_worker,
|
spawn_stats_aggregation_worker, spawn_stats_hourly_aggregation_worker,
|
||||||
spawn_usage_cleanup_worker, spawn_wallet_daily_usage_aggregation_worker,
|
spawn_usage_cleanup_worker, spawn_wallet_daily_usage_aggregation_worker,
|
||||||
start_admin_request_body_cleanup_task, start_admin_system_purge_task,
|
start_admin_request_body_cleanup_task, start_admin_system_purge_task,
|
||||||
start_proxy_upgrade_rollout, AdminCleanupRunRecord, AdminCleanupTaskKind,
|
start_proxy_upgrade_rollout, AdminCleanupRunRecord, AdminCleanupTaskKind,
|
||||||
AdminStatsRebuildSummary, AdminSystemCleanupSummary, OAuthTokenRefreshRunSummary,
|
AdminStatsRebuildSummary, AdminSystemCleanupSummary, ManualUsageCleanupError,
|
||||||
PoolQuotaProbeRunSummary, ProviderCheckinRunSummary, ProxyUpgradeRolloutCancelSummary,
|
OAuthTokenRefreshRunSummary, PoolQuotaProbeRunSummary, ProviderCheckinRunSummary,
|
||||||
ProxyUpgradeRolloutConflictClearSummary, ProxyUpgradeRolloutNodeActionSummary,
|
ProxyUpgradeRolloutCancelSummary, ProxyUpgradeRolloutConflictClearSummary,
|
||||||
ProxyUpgradeRolloutProbeConfig, ProxyUpgradeRolloutSkippedRestoreSummary,
|
ProxyUpgradeRolloutNodeActionSummary, ProxyUpgradeRolloutProbeConfig,
|
||||||
ProxyUpgradeRolloutStatus, ProxyUpgradeRolloutTrackedNodeState,
|
ProxyUpgradeRolloutSkippedRestoreSummary, ProxyUpgradeRolloutStatus,
|
||||||
|
ProxyUpgradeRolloutTrackedNodeState,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ use audit_cleanup::*;
|
|||||||
pub(crate) use cleanup_runs::{
|
pub(crate) use cleanup_runs::{
|
||||||
list_admin_cleanup_run_records, record_completed_cleanup_run, record_failed_cleanup_run,
|
list_admin_cleanup_run_records, record_completed_cleanup_run, record_failed_cleanup_run,
|
||||||
start_admin_request_body_cleanup_task, start_admin_system_purge_task, AdminCleanupRunRecord,
|
start_admin_request_body_cleanup_task, start_admin_system_purge_task, AdminCleanupRunRecord,
|
||||||
AdminCleanupTaskKind,
|
AdminCleanupTaskKind, USAGE_CLEANUP_KIND,
|
||||||
};
|
};
|
||||||
use config::*;
|
use config::*;
|
||||||
use db_maintenance::*;
|
use db_maintenance::*;
|
||||||
@@ -90,9 +90,11 @@ pub(crate) use proxy_upgrade_rollout::{
|
|||||||
};
|
};
|
||||||
use request_candidate_cleanup::*;
|
use request_candidate_cleanup::*;
|
||||||
use runners::*;
|
use runners::*;
|
||||||
|
pub(crate) use runners::{run_manual_usage_cleanup_once, ManualUsageCleanupError};
|
||||||
use schedule::*;
|
use schedule::*;
|
||||||
use stats_daily::*;
|
use stats_daily::*;
|
||||||
use stats_hourly::*;
|
use stats_hourly::*;
|
||||||
|
pub(crate) use usage_cleanup::preview_manual_usage_cleanup;
|
||||||
use usage_cleanup::*;
|
use usage_cleanup::*;
|
||||||
use wallet_daily_usage::*;
|
use wallet_daily_usage::*;
|
||||||
pub(crate) use workers::*;
|
pub(crate) use workers::*;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const CLEANUP_RUN_HISTORY_KEY: &str = "admin_cleanup_run_history";
|
|||||||
const CLEANUP_RUN_HISTORY_LIMIT: usize = 50;
|
const CLEANUP_RUN_HISTORY_LIMIT: usize = 50;
|
||||||
const REQUEST_BODY_PROGRESS_UPDATE_BATCHES: usize = 10;
|
const REQUEST_BODY_PROGRESS_UPDATE_BATCHES: usize = 10;
|
||||||
|
|
||||||
|
pub(crate) const USAGE_CLEANUP_KIND: &str = "usage_cleanup";
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub(crate) struct AdminCleanupRunRecord {
|
pub(crate) struct AdminCleanupRunRecord {
|
||||||
pub(crate) id: String,
|
pub(crate) id: String,
|
||||||
|
|||||||
@@ -111,12 +111,33 @@ pub(super) async fn usage_cleanup_settings(
|
|||||||
pub(super) fn usage_cleanup_window(
|
pub(super) fn usage_cleanup_window(
|
||||||
now_utc: DateTime<Utc>,
|
now_utc: DateTime<Utc>,
|
||||||
settings: UsageCleanupSettings,
|
settings: UsageCleanupSettings,
|
||||||
|
) -> UsageCleanupWindow {
|
||||||
|
usage_cleanup_window_with_override(now_utc, settings, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clamp is non-aggressive: each tier's cutoff becomes `max(policy_cutoff, now - override)`.
|
||||||
|
/// A later cutoff = fewer records deleted, so the override can only make cleanup more
|
||||||
|
/// conservative than the configured retention, never more destructive.
|
||||||
|
pub(super) fn usage_cleanup_window_with_override(
|
||||||
|
now_utc: DateTime<Utc>,
|
||||||
|
settings: UsageCleanupSettings,
|
||||||
|
override_older_than: Option<chrono::Duration>,
|
||||||
) -> UsageCleanupWindow {
|
) -> UsageCleanupWindow {
|
||||||
let minutes = |days: u64| chrono::Duration::days(i64::try_from(days).unwrap_or(i64::MAX));
|
let minutes = |days: u64| chrono::Duration::days(i64::try_from(days).unwrap_or(i64::MAX));
|
||||||
UsageCleanupWindow {
|
let policy = UsageCleanupWindow {
|
||||||
detail_cutoff: now_utc - minutes(settings.detail_retention_days),
|
detail_cutoff: now_utc - minutes(settings.detail_retention_days),
|
||||||
compressed_cutoff: now_utc - minutes(settings.compressed_retention_days),
|
compressed_cutoff: now_utc - minutes(settings.compressed_retention_days),
|
||||||
header_cutoff: now_utc - minutes(settings.header_retention_days),
|
header_cutoff: now_utc - minutes(settings.header_retention_days),
|
||||||
log_cutoff: now_utc - minutes(settings.log_retention_days),
|
log_cutoff: now_utc - minutes(settings.log_retention_days),
|
||||||
|
};
|
||||||
|
let Some(override_duration) = override_older_than else {
|
||||||
|
return policy;
|
||||||
|
};
|
||||||
|
let manual_cutoff = now_utc - override_duration;
|
||||||
|
UsageCleanupWindow {
|
||||||
|
detail_cutoff: policy.detail_cutoff.max(manual_cutoff),
|
||||||
|
compressed_cutoff: policy.compressed_cutoff.max(manual_cutoff),
|
||||||
|
header_cutoff: policy.header_cutoff.max(manual_cutoff),
|
||||||
|
log_cutoff: policy.log_cutoff.max(manual_cutoff),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ use super::{
|
|||||||
cleanup_stale_proxy_nodes_once, collect_proxy_upgrade_rollout_probes, now_unix_secs,
|
cleanup_stale_proxy_nodes_once, collect_proxy_upgrade_rollout_probes, now_unix_secs,
|
||||||
perform_db_maintenance_once, perform_provider_checkin_once, perform_stats_aggregation_once,
|
perform_db_maintenance_once, perform_provider_checkin_once, perform_stats_aggregation_once,
|
||||||
perform_stats_hourly_aggregation_once, perform_usage_cleanup_once,
|
perform_stats_hourly_aggregation_once, perform_usage_cleanup_once,
|
||||||
perform_wallet_daily_usage_aggregation_once, record_completed_cleanup_run,
|
perform_usage_cleanup_once_with_override, perform_wallet_daily_usage_aggregation_once,
|
||||||
record_failed_cleanup_run, record_proxy_upgrade_traffic_success, summarize_database_pool,
|
record_completed_cleanup_run, record_failed_cleanup_run, record_proxy_upgrade_traffic_success,
|
||||||
|
summarize_database_pool,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub(super) async fn run_audit_cleanup_once(data: &GatewayDataState) -> Result<(), DataLayerError> {
|
pub(super) async fn run_audit_cleanup_once(data: &GatewayDataState) -> Result<(), DataLayerError> {
|
||||||
@@ -298,6 +299,110 @@ pub(super) async fn run_usage_cleanup_once(data: &GatewayDataState) -> Result<()
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn run_manual_usage_cleanup_once(
|
||||||
|
data: &GatewayDataState,
|
||||||
|
override_older_than_days: Option<u32>,
|
||||||
|
actor_user_id: Option<String>,
|
||||||
|
) -> Result<aether_data_contracts::repository::usage::UsageCleanupSummary, ManualUsageCleanupError>
|
||||||
|
{
|
||||||
|
use super::{list_admin_cleanup_run_records, USAGE_CLEANUP_KIND};
|
||||||
|
|
||||||
|
let existing = list_admin_cleanup_run_records(data)
|
||||||
|
.await
|
||||||
|
.map_err(ManualUsageCleanupError::DataLayer)?;
|
||||||
|
if existing
|
||||||
|
.iter()
|
||||||
|
.any(|record| record.kind == USAGE_CLEANUP_KIND && record.status == "processing")
|
||||||
|
{
|
||||||
|
return Err(ManualUsageCleanupError::AlreadyRunning);
|
||||||
|
}
|
||||||
|
|
||||||
|
let started_at_unix_secs = now_unix_secs();
|
||||||
|
let started_at = Instant::now();
|
||||||
|
let override_duration =
|
||||||
|
override_older_than_days.map(|days| chrono::Duration::days(i64::from(days)));
|
||||||
|
let summary = match perform_usage_cleanup_once_with_override(data, override_duration).await {
|
||||||
|
Ok(summary) => summary,
|
||||||
|
Err(err) => {
|
||||||
|
record_failed_cleanup_run(
|
||||||
|
data,
|
||||||
|
USAGE_CLEANUP_KIND,
|
||||||
|
"manual",
|
||||||
|
started_at_unix_secs,
|
||||||
|
started_at,
|
||||||
|
&err,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return Err(ManualUsageCleanupError::DataLayer(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let total = summary
|
||||||
|
.body_externalized
|
||||||
|
.saturating_add(summary.legacy_body_refs_migrated)
|
||||||
|
.saturating_add(summary.body_cleaned)
|
||||||
|
.saturating_add(summary.header_cleaned)
|
||||||
|
.saturating_add(summary.keys_cleaned)
|
||||||
|
.saturating_add(summary.records_deleted);
|
||||||
|
let message = match override_older_than_days {
|
||||||
|
Some(days) => format!("请求记录手动清理完成,清理 {days} 天前的记录,影响 {total} 项"),
|
||||||
|
None => format!("请求记录手动清理完成(按当前策略),影响 {total} 项"),
|
||||||
|
};
|
||||||
|
record_completed_cleanup_run(
|
||||||
|
data,
|
||||||
|
USAGE_CLEANUP_KIND,
|
||||||
|
"manual",
|
||||||
|
started_at_unix_secs,
|
||||||
|
started_at,
|
||||||
|
json!({
|
||||||
|
"body_externalized": summary.body_externalized,
|
||||||
|
"legacy_body_refs_migrated": summary.legacy_body_refs_migrated,
|
||||||
|
"body_cleaned": summary.body_cleaned,
|
||||||
|
"header_cleaned": summary.header_cleaned,
|
||||||
|
"keys_cleaned": summary.keys_cleaned,
|
||||||
|
"records_deleted": summary.records_deleted,
|
||||||
|
"requested_older_than_days": override_older_than_days,
|
||||||
|
"actor_user_id": actor_user_id,
|
||||||
|
}),
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
info!(
|
||||||
|
event_name = "usage_cleanup_manual_completed",
|
||||||
|
log_type = "ops",
|
||||||
|
worker = "usage_cleanup",
|
||||||
|
trigger = "manual",
|
||||||
|
requested_older_than_days = override_older_than_days,
|
||||||
|
actor_user_id = actor_user_id.as_deref(),
|
||||||
|
total_affected = total,
|
||||||
|
"gateway finished manual usage cleanup"
|
||||||
|
);
|
||||||
|
Ok(summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) enum ManualUsageCleanupError {
|
||||||
|
AlreadyRunning,
|
||||||
|
DataLayer(DataLayerError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ManualUsageCleanupError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::AlreadyRunning => f.write_str("a usage cleanup run is already in progress"),
|
||||||
|
Self::DataLayer(err) => write!(f, "{err}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for ManualUsageCleanupError {
|
||||||
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||||
|
match self {
|
||||||
|
Self::AlreadyRunning => None,
|
||||||
|
Self::DataLayer(err) => Some(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn run_pool_monitor_once(data: &GatewayDataState) {
|
pub(super) fn run_pool_monitor_once(data: &GatewayDataState) {
|
||||||
let Some(summary) = summarize_database_pool(data) else {
|
let Some(summary) = summarize_database_pool(data) else {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ use super::{
|
|||||||
spawn_usage_cleanup_worker, spawn_wallet_daily_usage_aggregation_worker,
|
spawn_usage_cleanup_worker, spawn_wallet_daily_usage_aggregation_worker,
|
||||||
start_proxy_upgrade_rollout, stats_aggregation_target_day,
|
start_proxy_upgrade_rollout, stats_aggregation_target_day,
|
||||||
stats_hourly_aggregation_target_hour, summarize_database_pool, usage_cleanup_settings,
|
stats_hourly_aggregation_target_hour, summarize_database_pool, usage_cleanup_settings,
|
||||||
usage_cleanup_window, wallet_daily_usage_aggregation_target, AppState, DbMaintenanceRunSummary,
|
usage_cleanup_window, usage_cleanup_window_with_override,
|
||||||
|
wallet_daily_usage_aggregation_target, AppState, DbMaintenanceRunSummary,
|
||||||
FailedPendingUsageRow, GatewayDataState, ProxyNodeMetricsCleanupSettings,
|
FailedPendingUsageRow, GatewayDataState, ProxyNodeMetricsCleanupSettings,
|
||||||
ProxyUpgradeRolloutProbeConfig, StalePendingUsageRow, UsageCleanupSettings, USAGE_CLEANUP_HOUR,
|
ProxyUpgradeRolloutProbeConfig, StalePendingUsageRow, UsageCleanupSettings, USAGE_CLEANUP_HOUR,
|
||||||
USAGE_CLEANUP_MINUTE, WALLET_DAILY_USAGE_AGGREGATION_HOUR,
|
USAGE_CLEANUP_MINUTE, WALLET_DAILY_USAGE_AGGREGATION_HOUR,
|
||||||
@@ -940,6 +941,42 @@ fn usage_cleanup_window_uses_non_overlapping_ranges() {
|
|||||||
assert!(window.compressed_cutoff > window.log_cutoff);
|
assert!(window.compressed_cutoff > window.log_cutoff);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn usage_cleanup_window_with_override_is_always_non_aggressive() {
|
||||||
|
let now_utc = "2026-03-18T03:00:00Z"
|
||||||
|
.parse::<DateTime<Utc>>()
|
||||||
|
.expect("timestamp should parse");
|
||||||
|
let settings = UsageCleanupSettings {
|
||||||
|
detail_retention_days: 7,
|
||||||
|
compressed_retention_days: 30,
|
||||||
|
header_retention_days: 90,
|
||||||
|
log_retention_days: 365,
|
||||||
|
batch_size: 123,
|
||||||
|
auto_delete_expired_keys: false,
|
||||||
|
};
|
||||||
|
let policy = usage_cleanup_window(now_utc, settings);
|
||||||
|
|
||||||
|
let override_duration = chrono::Duration::days(180);
|
||||||
|
let clamped = usage_cleanup_window_with_override(now_utc, settings, Some(override_duration));
|
||||||
|
|
||||||
|
assert_eq!(clamped.detail_cutoff, policy.detail_cutoff);
|
||||||
|
assert_eq!(clamped.compressed_cutoff, policy.compressed_cutoff);
|
||||||
|
assert_eq!(clamped.header_cutoff, policy.header_cutoff);
|
||||||
|
assert_eq!(clamped.log_cutoff, now_utc - override_duration);
|
||||||
|
assert!(clamped.log_cutoff > policy.log_cutoff);
|
||||||
|
|
||||||
|
let far_override = chrono::Duration::days(5);
|
||||||
|
let far = usage_cleanup_window_with_override(now_utc, settings, Some(far_override));
|
||||||
|
assert_eq!(far.detail_cutoff, now_utc - far_override);
|
||||||
|
assert_eq!(far.compressed_cutoff, now_utc - far_override);
|
||||||
|
assert_eq!(far.header_cutoff, now_utc - far_override);
|
||||||
|
assert_eq!(far.log_cutoff, now_utc - far_override);
|
||||||
|
assert!(far.log_cutoff > policy.log_cutoff);
|
||||||
|
|
||||||
|
let passthrough = usage_cleanup_window_with_override(now_utc, settings, None);
|
||||||
|
assert_eq!(passthrough, policy);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn summarize_database_pool_uses_busy_connections_for_usage_rate() {
|
async fn summarize_database_pool_uses_busy_connections_for_usage_rate() {
|
||||||
let data = GatewayDataState::from_config(crate::data::GatewayDataConfig::from_postgres_config(
|
let data = GatewayDataState::from_config(crate::data::GatewayDataConfig::from_postgres_config(
|
||||||
|
|||||||
@@ -1,23 +1,48 @@
|
|||||||
use aether_data_contracts::repository::usage::UsageCleanupSummary;
|
use aether_data_contracts::repository::usage::{UsageCleanupSummary, UsageCleanupWindow};
|
||||||
use aether_data_contracts::DataLayerError;
|
use aether_data_contracts::DataLayerError;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
|
|
||||||
use crate::data::GatewayDataState;
|
use crate::data::GatewayDataState;
|
||||||
|
|
||||||
use super::{system_config_bool, usage_cleanup_settings, usage_cleanup_window};
|
use super::{
|
||||||
|
system_config_bool, usage_cleanup_settings, usage_cleanup_window,
|
||||||
|
usage_cleanup_window_with_override,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, serde::Serialize)]
|
||||||
|
pub(crate) struct ManualUsageCleanupPreview {
|
||||||
|
pub detail_cutoff: chrono::DateTime<Utc>,
|
||||||
|
pub compressed_cutoff: chrono::DateTime<Utc>,
|
||||||
|
pub header_cutoff: chrono::DateTime<Utc>,
|
||||||
|
pub log_cutoff: chrono::DateTime<Utc>,
|
||||||
|
pub requested_older_than_days: Option<u32>,
|
||||||
|
pub detail_count: u64,
|
||||||
|
pub compressed_count: u64,
|
||||||
|
pub header_count: u64,
|
||||||
|
pub log_count: u64,
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) async fn perform_usage_cleanup_once(
|
pub(super) async fn perform_usage_cleanup_once(
|
||||||
data: &GatewayDataState,
|
data: &GatewayDataState,
|
||||||
|
) -> Result<UsageCleanupSummary, DataLayerError> {
|
||||||
|
perform_usage_cleanup_once_with_override(data, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn perform_usage_cleanup_once_with_override(
|
||||||
|
data: &GatewayDataState,
|
||||||
|
override_older_than: Option<chrono::Duration>,
|
||||||
) -> Result<UsageCleanupSummary, DataLayerError> {
|
) -> Result<UsageCleanupSummary, DataLayerError> {
|
||||||
if !data.has_usage_writer() {
|
if !data.has_usage_writer() {
|
||||||
return Ok(UsageCleanupSummary::default());
|
return Ok(UsageCleanupSummary::default());
|
||||||
}
|
}
|
||||||
if !system_config_bool(data, "enable_auto_cleanup", true).await? {
|
if override_older_than.is_none()
|
||||||
|
&& !system_config_bool(data, "enable_auto_cleanup", true).await?
|
||||||
|
{
|
||||||
return Ok(UsageCleanupSummary::default());
|
return Ok(UsageCleanupSummary::default());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let window = compute_usage_cleanup_window(data, override_older_than).await?;
|
||||||
let settings = usage_cleanup_settings(data).await?;
|
let settings = usage_cleanup_settings(data).await?;
|
||||||
let window = usage_cleanup_window(Utc::now(), settings);
|
|
||||||
data.cleanup_usage(
|
data.cleanup_usage(
|
||||||
&window,
|
&window,
|
||||||
settings.batch_size,
|
settings.batch_size,
|
||||||
@@ -25,3 +50,35 @@ pub(super) async fn perform_usage_cleanup_once(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn preview_manual_usage_cleanup(
|
||||||
|
data: &GatewayDataState,
|
||||||
|
override_older_than_days: Option<u32>,
|
||||||
|
) -> Result<ManualUsageCleanupPreview, DataLayerError> {
|
||||||
|
let override_duration =
|
||||||
|
override_older_than_days.map(|days| chrono::Duration::days(i64::from(days)));
|
||||||
|
let window = compute_usage_cleanup_window(data, override_duration).await?;
|
||||||
|
let counts = data.preview_usage_cleanup(&window).await?;
|
||||||
|
Ok(ManualUsageCleanupPreview {
|
||||||
|
detail_cutoff: window.detail_cutoff,
|
||||||
|
compressed_cutoff: window.compressed_cutoff,
|
||||||
|
header_cutoff: window.header_cutoff,
|
||||||
|
log_cutoff: window.log_cutoff,
|
||||||
|
requested_older_than_days: override_older_than_days,
|
||||||
|
detail_count: counts.detail,
|
||||||
|
compressed_count: counts.compressed,
|
||||||
|
header_count: counts.header,
|
||||||
|
log_count: counts.log,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn compute_usage_cleanup_window(
|
||||||
|
data: &GatewayDataState,
|
||||||
|
override_older_than: Option<chrono::Duration>,
|
||||||
|
) -> Result<UsageCleanupWindow, DataLayerError> {
|
||||||
|
let settings = usage_cleanup_settings(data).await?;
|
||||||
|
Ok(match override_older_than {
|
||||||
|
Some(duration) => usage_cleanup_window_with_override(Utc::now(), settings, Some(duration)),
|
||||||
|
None => usage_cleanup_window(Utc::now(), settings),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ pub use types::{
|
|||||||
UsageAuditListQuery, UsageAuditSummaryQuery, UsageBodyCaptureResult, UsageBodyCaptureState,
|
UsageAuditListQuery, UsageAuditSummaryQuery, UsageBodyCaptureResult, UsageBodyCaptureState,
|
||||||
UsageBodyCaptureStorage, UsageBodyField, UsageBreakdownGroupBy, UsageBreakdownSummaryQuery,
|
UsageBodyCaptureStorage, UsageBodyField, UsageBreakdownGroupBy, UsageBreakdownSummaryQuery,
|
||||||
UsageCacheAffinityHitSummaryQuery, UsageCacheAffinityIntervalGroupBy,
|
UsageCacheAffinityHitSummaryQuery, UsageCacheAffinityIntervalGroupBy,
|
||||||
UsageCacheAffinityIntervalQuery, UsageCacheHitSummaryQuery, UsageCleanupSummary,
|
UsageCacheAffinityIntervalQuery, UsageCacheHitSummaryQuery, UsageCleanupPreviewCounts,
|
||||||
UsageCleanupWindow, UsageCostSavingsSummaryQuery, UsageDailyHeatmapQuery,
|
UsageCleanupSummary, UsageCleanupWindow, UsageCostSavingsSummaryQuery, UsageDailyHeatmapQuery,
|
||||||
UsageDashboardDailyBreakdownQuery, UsageDashboardProviderCountsQuery,
|
UsageDashboardDailyBreakdownQuery, UsageDashboardProviderCountsQuery,
|
||||||
UsageDashboardSummaryQuery, UsageErrorDistributionQuery, UsageLeaderboardGroupBy,
|
UsageDashboardSummaryQuery, UsageErrorDistributionQuery, UsageLeaderboardGroupBy,
|
||||||
UsageLeaderboardQuery, UsageMonitoringErrorCountQuery, UsageMonitoringErrorListQuery,
|
UsageLeaderboardQuery, UsageMonitoringErrorCountQuery, UsageMonitoringErrorListQuery,
|
||||||
|
|||||||
@@ -1692,6 +1692,14 @@ pub trait UsageWriteRepository: Send + Sync {
|
|||||||
let _ = (window, batch_size, auto_delete_expired_keys);
|
let _ = (window, batch_size, auto_delete_expired_keys);
|
||||||
Ok(UsageCleanupSummary::default())
|
Ok(UsageCleanupSummary::default())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn preview_usage_cleanup(
|
||||||
|
&self,
|
||||||
|
window: &UsageCleanupWindow,
|
||||||
|
) -> Result<UsageCleanupPreviewCounts, crate::DataLayerError> {
|
||||||
|
let _ = window;
|
||||||
|
Ok(UsageCleanupPreviewCounts::default())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait UsageRepository: UsageReadRepository + UsageWriteRepository + Send + Sync {}
|
pub trait UsageRepository: UsageReadRepository + UsageWriteRepository + Send + Sync {}
|
||||||
@@ -1722,6 +1730,14 @@ pub struct UsageCleanupWindow {
|
|||||||
pub log_cutoff: DateTime<Utc>,
|
pub log_cutoff: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct UsageCleanupPreviewCounts {
|
||||||
|
pub detail: u64,
|
||||||
|
pub compressed: u64,
|
||||||
|
pub header: u64,
|
||||||
|
pub log: u64,
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_u64(value: i32, field_name: &str) -> Result<u64, crate::DataLayerError> {
|
fn parse_u64(value: i32, field_name: &str) -> Result<u64, crate::DataLayerError> {
|
||||||
u64::try_from(value).map_err(|_| {
|
u64::try_from(value).map_err(|_| {
|
||||||
crate::DataLayerError::UnexpectedValue(format!("invalid {field_name}: {value}"))
|
crate::DataLayerError::UnexpectedValue(format!("invalid {field_name}: {value}"))
|
||||||
|
|||||||
@@ -378,8 +378,8 @@ pub(crate) use aether_data_contracts::repository::usage::{
|
|||||||
UsageAuditAggregationGroupBy, UsageAuditAggregationQuery, UsageAuditKeywordSearchQuery,
|
UsageAuditAggregationGroupBy, UsageAuditAggregationQuery, UsageAuditKeywordSearchQuery,
|
||||||
UsageAuditListQuery, UsageAuditSummaryQuery, UsageBreakdownGroupBy, UsageBreakdownSummaryQuery,
|
UsageAuditListQuery, UsageAuditSummaryQuery, UsageBreakdownGroupBy, UsageBreakdownSummaryQuery,
|
||||||
UsageCacheAffinityHitSummaryQuery, UsageCacheAffinityIntervalGroupBy,
|
UsageCacheAffinityHitSummaryQuery, UsageCacheAffinityIntervalGroupBy,
|
||||||
UsageCacheAffinityIntervalQuery, UsageCacheHitSummaryQuery, UsageCleanupSummary,
|
UsageCacheAffinityIntervalQuery, UsageCacheHitSummaryQuery, UsageCleanupPreviewCounts,
|
||||||
UsageCleanupWindow, UsageCostSavingsSummaryQuery, UsageDailyHeatmapQuery,
|
UsageCleanupSummary, UsageCleanupWindow, UsageCostSavingsSummaryQuery, UsageDailyHeatmapQuery,
|
||||||
UsageDashboardDailyBreakdownQuery, UsageDashboardProviderCountsQuery,
|
UsageDashboardDailyBreakdownQuery, UsageDashboardProviderCountsQuery,
|
||||||
UsageDashboardSummaryQuery, UsageErrorDistributionQuery, UsageLeaderboardGroupBy,
|
UsageDashboardSummaryQuery, UsageErrorDistributionQuery, UsageLeaderboardGroupBy,
|
||||||
UsageLeaderboardQuery, UsageMonitoringErrorCountQuery, UsageMonitoringErrorListQuery,
|
UsageLeaderboardQuery, UsageMonitoringErrorCountQuery, UsageMonitoringErrorListQuery,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
|
||||||
use aether_data_contracts::repository::usage::{
|
use aether_data_contracts::repository::usage::{
|
||||||
parse_usage_body_ref, usage_body_ref, UsageBodyField, UsageCleanupSummary, UsageCleanupWindow,
|
parse_usage_body_ref, usage_body_ref, UsageBodyField, UsageCleanupPreviewCounts,
|
||||||
|
UsageCleanupSummary, UsageCleanupWindow,
|
||||||
};
|
};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use flate2::{write::GzEncoder, Compression};
|
use flate2::{write::GzEncoder, Compression};
|
||||||
@@ -473,6 +474,48 @@ impl SqlxUsageReadRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn preview_usage_cleanup_impl(
|
||||||
|
pool: &PostgresPool,
|
||||||
|
window: &UsageCleanupWindow,
|
||||||
|
) -> Result<UsageCleanupPreviewCounts, DataLayerError> {
|
||||||
|
let detail: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*)::bigint FROM usage WHERE created_at < $1 AND created_at >= $2",
|
||||||
|
)
|
||||||
|
.bind(window.detail_cutoff)
|
||||||
|
.bind(window.compressed_cutoff)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.map_err(postgres_error)?;
|
||||||
|
let compressed: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*)::bigint FROM usage WHERE created_at < $1 AND created_at >= $2",
|
||||||
|
)
|
||||||
|
.bind(window.compressed_cutoff)
|
||||||
|
.bind(window.log_cutoff)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.map_err(postgres_error)?;
|
||||||
|
let header: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*)::bigint FROM usage WHERE created_at < $1 AND created_at >= $2",
|
||||||
|
)
|
||||||
|
.bind(window.header_cutoff)
|
||||||
|
.bind(window.log_cutoff)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.map_err(postgres_error)?;
|
||||||
|
let log: i64 = sqlx::query_scalar("SELECT COUNT(*)::bigint FROM usage WHERE created_at < $1")
|
||||||
|
.bind(window.log_cutoff)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.map_err(postgres_error)?;
|
||||||
|
|
||||||
|
Ok(UsageCleanupPreviewCounts {
|
||||||
|
detail: u64::try_from(detail).unwrap_or(0),
|
||||||
|
compressed: u64::try_from(compressed).unwrap_or(0),
|
||||||
|
header: u64::try_from(header).unwrap_or(0),
|
||||||
|
log: u64::try_from(log).unwrap_or(0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async fn delete_old_usage_records(
|
async fn delete_old_usage_records(
|
||||||
pool: &PostgresPool,
|
pool: &PostgresPool,
|
||||||
cutoff_time: DateTime<Utc>,
|
cutoff_time: DateTime<Utc>,
|
||||||
|
|||||||
@@ -8221,6 +8221,15 @@ impl UsageWriteRepository for SqlxUsageReadRepository {
|
|||||||
) -> Result<UsageCleanupSummary, DataLayerError> {
|
) -> Result<UsageCleanupSummary, DataLayerError> {
|
||||||
Self::cleanup_usage(self, window, batch_size, auto_delete_expired_keys).await
|
Self::cleanup_usage(self, window, batch_size, auto_delete_expired_keys).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn preview_usage_cleanup(
|
||||||
|
&self,
|
||||||
|
window: &UsageCleanupWindow,
|
||||||
|
) -> Result<aether_data_contracts::repository::usage::UsageCleanupPreviewCounts, DataLayerError>
|
||||||
|
{
|
||||||
|
crate::repository::usage::postgres::cleanup::preview_usage_cleanup_impl(&self.pool, window)
|
||||||
|
.await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct StalePendingUsageRow {
|
struct StalePendingUsageRow {
|
||||||
|
|||||||
@@ -1,8 +1,20 @@
|
|||||||
import apiClient from './client'
|
import apiClient from './client'
|
||||||
|
import axios from 'axios'
|
||||||
import { cachedRequest, buildCacheKey } from '@/utils/cache'
|
import { cachedRequest, buildCacheKey } from '@/utils/cache'
|
||||||
import type { BillingSummary } from './auth'
|
import type { BillingSummary } from './auth'
|
||||||
import type { ApiKeyInstallSession, InstallSessionTargetSystem, InstallTargetCli } from './me'
|
import type { ApiKeyInstallSession, InstallSessionTargetSystem, InstallTargetCli } from './me'
|
||||||
|
|
||||||
|
function extractConflictPayload(error: unknown): ManualUsageCleanupConflict | null {
|
||||||
|
if (!axios.isAxiosError(error) || error.response?.status !== 409) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const data = error.response.data as ManualUsageCleanupConflict | undefined
|
||||||
|
if (!data || data.detail !== 'usage_cleanup_already_running') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
// LDAP 配置导出结构
|
// LDAP 配置导出结构
|
||||||
export interface LDAPConfigExport {
|
export interface LDAPConfigExport {
|
||||||
server_url: string
|
server_url: string
|
||||||
@@ -280,6 +292,43 @@ export interface CleanupTaskResponse {
|
|||||||
task: CleanupRunRecord
|
task: CleanupRunRecord
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ManualUsageCleanupSummary {
|
||||||
|
body_externalized: number
|
||||||
|
legacy_body_refs_migrated: number
|
||||||
|
body_cleaned: number
|
||||||
|
header_cleaned: number
|
||||||
|
keys_cleaned: number
|
||||||
|
records_deleted: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManualUsageCleanupResponse {
|
||||||
|
message: string
|
||||||
|
requested_older_than_days: number | null
|
||||||
|
summary: ManualUsageCleanupSummary
|
||||||
|
total_affected: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManualUsageCleanupPreview {
|
||||||
|
requested_older_than_days: number | null
|
||||||
|
effective_cutoffs: {
|
||||||
|
detail: string
|
||||||
|
compressed: string
|
||||||
|
header: string
|
||||||
|
log: string
|
||||||
|
}
|
||||||
|
counts: {
|
||||||
|
detail: number
|
||||||
|
compressed: number
|
||||||
|
header: number
|
||||||
|
log: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManualUsageCleanupConflict {
|
||||||
|
detail: 'usage_cleanup_already_running'
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
// 检查更新响应
|
// 检查更新响应
|
||||||
export interface CheckUpdateResponse {
|
export interface CheckUpdateResponse {
|
||||||
current_version: string
|
current_version: string
|
||||||
@@ -1132,6 +1181,42 @@ export const adminApi = {
|
|||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async runManualUsageCleanup(
|
||||||
|
params: { older_than_days?: number } = {}
|
||||||
|
): Promise<ManualUsageCleanupResponse | ManualUsageCleanupConflict> {
|
||||||
|
const body: Record<string, number> = {}
|
||||||
|
if (typeof params.older_than_days === 'number') {
|
||||||
|
body.older_than_days = params.older_than_days
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post<ManualUsageCleanupResponse>(
|
||||||
|
'/api/admin/system/cleanup/usage/manual',
|
||||||
|
body
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
} catch (error) {
|
||||||
|
const conflict = extractConflictPayload(error)
|
||||||
|
if (conflict) {
|
||||||
|
return conflict
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async previewManualUsageCleanup(
|
||||||
|
params: { older_than_days?: number } = {}
|
||||||
|
): Promise<ManualUsageCleanupPreview> {
|
||||||
|
const query: Record<string, number> = {}
|
||||||
|
if (typeof params.older_than_days === 'number') {
|
||||||
|
query.older_than_days = params.older_than_days
|
||||||
|
}
|
||||||
|
const response = await apiClient.get<ManualUsageCleanupPreview>(
|
||||||
|
'/api/admin/system/cleanup/usage/preview',
|
||||||
|
{ params: query }
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
async getTimeSeries(params?: {
|
async getTimeSeries(params?: {
|
||||||
start_date?: string
|
start_date?: string
|
||||||
end_date?: string
|
end_date?: string
|
||||||
|
|||||||
@@ -23,6 +23,15 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
:disabled="manualCleanupRunning"
|
||||||
|
@click="openManualCleanupDialog"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-3.5 h-3.5 mr-1.5" />
|
||||||
|
{{ manualCleanupRunning ? '清理中…' : '立即清理' }}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
:disabled="loading || !hasChanges"
|
:disabled="loading || !hasChanges"
|
||||||
@@ -276,6 +285,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ManualCleanupConfirmDialog
|
||||||
|
:open="manualCleanupDialogOpen"
|
||||||
|
@update:open="manualCleanupDialogOpen = $event"
|
||||||
|
@confirm="handleManualCleanupConfirm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="manualCleanupResult"
|
||||||
|
class="mt-4 rounded-md border border-border bg-muted/30 px-4 py-3 text-sm"
|
||||||
|
>
|
||||||
|
<div class="font-medium">
|
||||||
|
{{ manualCleanupResult.title }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="manualCleanupResult.description"
|
||||||
|
class="mt-1 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ manualCleanupResult.description }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 border border-border rounded-lg overflow-hidden">
|
<div class="mt-4 border border-border rounded-lg overflow-hidden">
|
||||||
<div class="flex items-center justify-between px-4 py-3 border-b border-border">
|
<div class="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||||
<div>
|
<div>
|
||||||
@@ -371,13 +401,16 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import { RefreshCw } from 'lucide-vue-next'
|
import { RefreshCw, Trash2 } from 'lucide-vue-next'
|
||||||
import { adminApi, type CleanupRunRecord } from '@/api/admin'
|
import { adminApi, type CleanupRunRecord, type ManualUsageCleanupResponse } from '@/api/admin'
|
||||||
import Button from '@/components/ui/button.vue'
|
import Button from '@/components/ui/button.vue'
|
||||||
import Input from '@/components/ui/input.vue'
|
import Input from '@/components/ui/input.vue'
|
||||||
import Label from '@/components/ui/label.vue'
|
import Label from '@/components/ui/label.vue'
|
||||||
import Switch from '@/components/ui/switch.vue'
|
import Switch from '@/components/ui/switch.vue'
|
||||||
import { CardSection } from '@/components/layout'
|
import { CardSection } from '@/components/layout'
|
||||||
|
import ManualCleanupConfirmDialog from './ManualCleanupConfirmDialog.vue'
|
||||||
|
import { useToast } from '@/composables/useToast'
|
||||||
|
import { parseApiError } from '@/utils/errorParser'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
enableAutoCleanup: boolean
|
enableAutoCleanup: boolean
|
||||||
@@ -416,6 +449,64 @@ const cleanupRuns = ref<CleanupRunRecord[]>([])
|
|||||||
const cleanupRunsLoading = ref(false)
|
const cleanupRunsLoading = ref(false)
|
||||||
let cleanupRunsTimer: ReturnType<typeof window.setInterval> | null = null
|
let cleanupRunsTimer: ReturnType<typeof window.setInterval> | null = null
|
||||||
|
|
||||||
|
const manualCleanupDialogOpen = ref(false)
|
||||||
|
const manualCleanupRunning = ref(false)
|
||||||
|
const manualCleanupResult = ref<{ title: string; description?: string } | null>(null)
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
function openManualCleanupDialog() {
|
||||||
|
if (manualCleanupRunning.value) return
|
||||||
|
manualCleanupDialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleManualCleanupConfirm(olderThanDays: number | undefined) {
|
||||||
|
manualCleanupRunning.value = true
|
||||||
|
try {
|
||||||
|
const response = await adminApi.runManualUsageCleanup(
|
||||||
|
typeof olderThanDays === 'number' ? { older_than_days: olderThanDays } : {},
|
||||||
|
)
|
||||||
|
if ('detail' in response && response.detail === 'usage_cleanup_already_running') {
|
||||||
|
manualCleanupResult.value = {
|
||||||
|
title: '已有一次清理正在进行中',
|
||||||
|
description: response.message,
|
||||||
|
}
|
||||||
|
toast.warning(response.message)
|
||||||
|
} else {
|
||||||
|
const completed = response as ManualUsageCleanupResponse
|
||||||
|
manualCleanupResult.value = {
|
||||||
|
title: completed.message,
|
||||||
|
description: summarizeManualCleanup(completed),
|
||||||
|
}
|
||||||
|
toast.success(completed.message)
|
||||||
|
}
|
||||||
|
manualCleanupDialogOpen.value = false
|
||||||
|
} catch (error) {
|
||||||
|
const message = parseApiError(error).message
|
||||||
|
manualCleanupResult.value = {
|
||||||
|
title: '请求记录清理失败',
|
||||||
|
description: message,
|
||||||
|
}
|
||||||
|
toast.error(message)
|
||||||
|
} finally {
|
||||||
|
manualCleanupRunning.value = false
|
||||||
|
void loadCleanupRuns()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeManualCleanup(response: ManualUsageCleanupResponse): string {
|
||||||
|
const { summary } = response
|
||||||
|
const parts: string[] = []
|
||||||
|
if (summary.records_deleted > 0) parts.push(`删除整条记录 ${summary.records_deleted}`)
|
||||||
|
if (summary.body_externalized > 0) parts.push(`压缩 body ${summary.body_externalized}`)
|
||||||
|
if (summary.body_cleaned > 0) parts.push(`清除过期 body ${summary.body_cleaned}`)
|
||||||
|
if (summary.header_cleaned > 0) parts.push(`清除 headers ${summary.header_cleaned}`)
|
||||||
|
if (summary.legacy_body_refs_migrated > 0) {
|
||||||
|
parts.push(`迁移遗留引用 ${summary.legacy_body_refs_migrated}`)
|
||||||
|
}
|
||||||
|
if (summary.keys_cleaned > 0) parts.push(`回收 Key ${summary.keys_cleaned}`)
|
||||||
|
return parts.length > 0 ? parts.join(' / ') : '无数据变更'
|
||||||
|
}
|
||||||
|
|
||||||
async function loadCleanupRuns() {
|
async function loadCleanupRuns() {
|
||||||
cleanupRunsLoading.value = true
|
cleanupRunsLoading.value = true
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
:open="open"
|
||||||
|
size="lg"
|
||||||
|
title="立即清理请求记录"
|
||||||
|
description="按现有分级保留策略主动清理请求记录,可选指定清理更早时间的数据。操作不可逆。"
|
||||||
|
:persistent="submitting"
|
||||||
|
@update:open="handleOpenChange"
|
||||||
|
>
|
||||||
|
<div class="px-4 sm:px-6 py-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label
|
||||||
|
for="manual-cleanup-older-than-days"
|
||||||
|
class="block text-sm font-medium"
|
||||||
|
>
|
||||||
|
清理 N 天前的记录(可选)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="manual-cleanup-older-than-days"
|
||||||
|
:model-value="olderThanDays ?? ''"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
placeholder="留空代表按当前保留策略"
|
||||||
|
class="mt-1"
|
||||||
|
:disabled="submitting"
|
||||||
|
@update:model-value="handleDaysChange"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
留空代表按当前保留策略清理。填入数字代表清理 N 天前的记录;该值只能比策略更宽松,不会删除更新的数据。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-md border border-border bg-muted/30 px-4 py-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h4 class="text-sm font-medium">
|
||||||
|
预计影响
|
||||||
|
</h4>
|
||||||
|
<button
|
||||||
|
v-if="!previewLoading"
|
||||||
|
type="button"
|
||||||
|
class="text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
:disabled="submitting"
|
||||||
|
@click="loadPreview"
|
||||||
|
>
|
||||||
|
刷新预估
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
正在计算…
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="previewError"
|
||||||
|
class="mt-2 text-xs text-destructive"
|
||||||
|
>
|
||||||
|
{{ previewError }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="preview"
|
||||||
|
class="mt-2 grid grid-cols-2 gap-y-1 gap-x-4 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
<div>详细记录待压缩</div>
|
||||||
|
<div class="text-right text-foreground">
|
||||||
|
{{ formatCount(preview.counts.detail) }}
|
||||||
|
</div>
|
||||||
|
<div>压缩记录待清体</div>
|
||||||
|
<div class="text-right text-foreground">
|
||||||
|
{{ formatCount(preview.counts.compressed) }}
|
||||||
|
</div>
|
||||||
|
<div>请求头待清空</div>
|
||||||
|
<div class="text-right text-foreground">
|
||||||
|
{{ formatCount(preview.counts.header) }}
|
||||||
|
</div>
|
||||||
|
<div>整条记录待删除</div>
|
||||||
|
<div class="text-right text-destructive font-medium">
|
||||||
|
{{ formatCount(preview.counts.log) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="!previewLoading"
|
||||||
|
class="mt-2 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
尚未计算预估数据
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label
|
||||||
|
for="manual-cleanup-confirm-phrase"
|
||||||
|
class="block text-sm font-medium"
|
||||||
|
>
|
||||||
|
输入「{{ confirmPhrase }}」以确认清理
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="manual-cleanup-confirm-phrase"
|
||||||
|
:model-value="typedPhrase"
|
||||||
|
class="mt-1"
|
||||||
|
autocomplete="off"
|
||||||
|
:placeholder="confirmPhrase"
|
||||||
|
:disabled="submitting"
|
||||||
|
@update:model-value="typedPhrase = String($event)"
|
||||||
|
@keydown.enter.prevent="maybeSubmitOnEnter"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
确认操作后会立刻执行清理,且不可撤销。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
:disabled="!canSubmit"
|
||||||
|
@click="handleConfirm"
|
||||||
|
>
|
||||||
|
{{ submitting ? '清理中…' : '确认清理' }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
:disabled="submitting"
|
||||||
|
@click="handleCancel"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { Dialog } from '@/components/ui'
|
||||||
|
import Button from '@/components/ui/button.vue'
|
||||||
|
import Input from '@/components/ui/input.vue'
|
||||||
|
import Label from '@/components/ui/label.vue'
|
||||||
|
import { adminApi, type ManualUsageCleanupPreview } from '@/api/admin'
|
||||||
|
import { parseApiError } from '@/utils/errorParser'
|
||||||
|
import {
|
||||||
|
MANUAL_USAGE_CLEANUP_CONFIRM_PHRASE,
|
||||||
|
isConfirmPhraseMatched,
|
||||||
|
normalizeOlderThanDaysInput,
|
||||||
|
} from './manualCleanupForm'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
open: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:open': [value: boolean]
|
||||||
|
confirm: [olderThanDays: number | undefined]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const confirmPhrase = MANUAL_USAGE_CLEANUP_CONFIRM_PHRASE
|
||||||
|
|
||||||
|
const olderThanDays = ref<number | null>(null)
|
||||||
|
const typedPhrase = ref('')
|
||||||
|
const preview = ref<ManualUsageCleanupPreview | null>(null)
|
||||||
|
const previewLoading = ref(false)
|
||||||
|
const previewError = ref<string | null>(null)
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
|
let previewDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let previewSeq = 0
|
||||||
|
|
||||||
|
const normalizedPhrase = computed(() => typedPhrase.value)
|
||||||
|
|
||||||
|
const canSubmit = computed(
|
||||||
|
() =>
|
||||||
|
!submitting.value &&
|
||||||
|
!previewLoading.value &&
|
||||||
|
isConfirmPhraseMatched(normalizedPhrase.value),
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
(isOpen) => {
|
||||||
|
if (isOpen) {
|
||||||
|
resetForm()
|
||||||
|
void loadPreview()
|
||||||
|
} else if (previewDebounceTimer) {
|
||||||
|
clearTimeout(previewDebounceTimer)
|
||||||
|
previewDebounceTimer = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
olderThanDays.value = null
|
||||||
|
typedPhrase.value = ''
|
||||||
|
preview.value = null
|
||||||
|
previewError.value = null
|
||||||
|
previewLoading.value = false
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDaysChange(value: string | number) {
|
||||||
|
olderThanDays.value = normalizeOlderThanDaysInput(value)
|
||||||
|
schedulePreview()
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulePreview() {
|
||||||
|
if (previewDebounceTimer) {
|
||||||
|
clearTimeout(previewDebounceTimer)
|
||||||
|
}
|
||||||
|
previewDebounceTimer = setTimeout(() => {
|
||||||
|
previewDebounceTimer = null
|
||||||
|
void loadPreview()
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPreview() {
|
||||||
|
const seq = ++previewSeq
|
||||||
|
previewLoading.value = true
|
||||||
|
previewError.value = null
|
||||||
|
try {
|
||||||
|
const params: { older_than_days?: number } = {}
|
||||||
|
if (olderThanDays.value !== null) {
|
||||||
|
params.older_than_days = olderThanDays.value
|
||||||
|
}
|
||||||
|
const result = await adminApi.previewManualUsageCleanup(params)
|
||||||
|
if (seq === previewSeq) {
|
||||||
|
preview.value = result
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (seq === previewSeq) {
|
||||||
|
preview.value = null
|
||||||
|
previewError.value = parseApiError(error).message
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (seq === previewSeq) {
|
||||||
|
previewLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenChange(value: boolean) {
|
||||||
|
if (!value && submitting.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('update:open', value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
if (submitting.value) return
|
||||||
|
emit('update:open', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeSubmitOnEnter() {
|
||||||
|
if (canSubmit.value) {
|
||||||
|
void handleConfirm()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConfirm() {
|
||||||
|
if (!canSubmit.value) return
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
emit('confirm', olderThanDays.value ?? undefined)
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCount(value: number): string {
|
||||||
|
return value.toLocaleString()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import {
|
||||||
|
MANUAL_USAGE_CLEANUP_CONFIRM_PHRASE,
|
||||||
|
isConfirmPhraseMatched,
|
||||||
|
normalizeConfirmPhraseInput,
|
||||||
|
normalizeOlderThanDaysInput,
|
||||||
|
} from '../manualCleanupForm'
|
||||||
|
|
||||||
|
describe('manualCleanupForm', () => {
|
||||||
|
describe('normalizeConfirmPhraseInput', () => {
|
||||||
|
it('trims leading and trailing whitespace', () => {
|
||||||
|
expect(normalizeConfirmPhraseInput(' 确认清理 ')).toBe(MANUAL_USAGE_CLEANUP_CONFIRM_PHRASE)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('strips newlines typed or pasted into the input', () => {
|
||||||
|
expect(normalizeConfirmPhraseInput('确认清理\n')).toBe(MANUAL_USAGE_CLEANUP_CONFIRM_PHRASE)
|
||||||
|
expect(normalizeConfirmPhraseInput('确认\n清理')).toBe('确认清理')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves non-whitespace content untouched', () => {
|
||||||
|
expect(normalizeConfirmPhraseInput('取消')).toBe('取消')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isConfirmPhraseMatched', () => {
|
||||||
|
it('matches the exact phrase including pasted whitespace', () => {
|
||||||
|
expect(isConfirmPhraseMatched('确认清理')).toBe(true)
|
||||||
|
expect(isConfirmPhraseMatched(' 确认清理 ')).toBe(true)
|
||||||
|
expect(isConfirmPhraseMatched('确认清理\n')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects partial prefixes and unrelated strings', () => {
|
||||||
|
expect(isConfirmPhraseMatched('确认')).toBe(false)
|
||||||
|
expect(isConfirmPhraseMatched('确认清理了')).toBe(false)
|
||||||
|
expect(isConfirmPhraseMatched('')).toBe(false)
|
||||||
|
expect(isConfirmPhraseMatched('取消')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is case/character sensitive', () => {
|
||||||
|
expect(isConfirmPhraseMatched('Confirm')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('normalizeOlderThanDaysInput', () => {
|
||||||
|
it('returns null for empty or non-positive inputs', () => {
|
||||||
|
expect(normalizeOlderThanDaysInput('')).toBeNull()
|
||||||
|
expect(normalizeOlderThanDaysInput(null)).toBeNull()
|
||||||
|
expect(normalizeOlderThanDaysInput(undefined)).toBeNull()
|
||||||
|
expect(normalizeOlderThanDaysInput(0)).toBeNull()
|
||||||
|
expect(normalizeOlderThanDaysInput(-3)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns an integer for positive numeric inputs', () => {
|
||||||
|
expect(normalizeOlderThanDaysInput(7)).toBe(7)
|
||||||
|
expect(normalizeOlderThanDaysInput('30')).toBe(30)
|
||||||
|
expect(normalizeOlderThanDaysInput('7.9')).toBe(7)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for non-numeric strings', () => {
|
||||||
|
expect(normalizeOlderThanDaysInput('abc')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export const MANUAL_USAGE_CLEANUP_CONFIRM_PHRASE = '确认清理'
|
||||||
|
|
||||||
|
export function normalizeConfirmPhraseInput(raw: string): string {
|
||||||
|
return raw.replace(/\r?\n/g, '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isConfirmPhraseMatched(raw: string): boolean {
|
||||||
|
return normalizeConfirmPhraseInput(raw) === MANUAL_USAGE_CLEANUP_CONFIRM_PHRASE
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeOlderThanDaysInput(raw: string | number | null | undefined): number | null {
|
||||||
|
if (raw === null || raw === undefined || raw === '') return null
|
||||||
|
const parsed = typeof raw === 'number' ? raw : Number(raw)
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) return null
|
||||||
|
return Math.floor(parsed)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user