feat: add scoped manual usage cleanup

This commit is contained in:
HsungKayphoon
2026-05-16 14:53:10 +08:00
parent 75b7319465
commit 74a1e5ad7d
17 changed files with 1746 additions and 305 deletions

View File

@@ -17,7 +17,9 @@ use crate::handlers::admin::system::shared::settings::{
build_admin_system_stats_payload, current_aether_version, fetch_latest_admin_system_release,
};
use crate::handlers::admin::system::shared::smtp::build_admin_smtp_test_payload;
use crate::maintenance::{ManualUsageCleanupMode, ManualUsageCleanupOptions};
use crate::GatewayError;
use aether_data_contracts::repository::usage::UsageCleanupTargets;
use axum::{
body::{Body, Bytes},
http,
@@ -26,6 +28,7 @@ use axum::{
};
use serde_json::json;
use std::time::Instant;
use url::form_urlencoded;
pub(super) async fn maybe_build_local_admin_core_system_response(
state: &AdminAppState<'_>,
@@ -263,7 +266,7 @@ pub(super) async fn maybe_build_local_admin_core_system_response(
&& request_path == "/api/admin/system/cleanup/usage/manual"
{
return Ok(Some(
build_manual_usage_cleanup_response(state, request_body).await?,
build_manual_usage_cleanup_response(state, request_context, request_body).await?,
));
}
@@ -625,50 +628,36 @@ async fn build_admin_system_cleanup_payload(
async fn build_manual_usage_cleanup_response(
state: &AdminAppState<'_>,
request_context: &AdminRequestContext<'_>,
request_body: Option<&Bytes>,
) -> Result<Response<Body>, GatewayError> {
let older_than_days = match parse_manual_usage_cleanup_request(request_body) {
let options = match parse_manual_usage_cleanup_request(request_body) {
Ok(value) => value,
Err(response) => return Ok(response),
};
let actor_user_id = request_context
.decision()
.and_then(|decision| decision.admin_principal.as_ref())
.map(|principal| principal.user_id.clone());
match crate::maintenance::run_manual_usage_cleanup_once(
&state.app().data,
older_than_days,
None,
match crate::maintenance::start_manual_usage_cleanup_task(
std::sync::Arc::clone(&state.app().data),
options,
actor_user_id,
)
.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}"),
};
Ok(task) => {
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,
"message": task.message,
"mode": options.mode.as_str(),
"requested_older_than_days": options.requested_older_than_days,
"targets": options.targets,
"task": task,
});
Ok(attach_admin_audit_response(
Json(payload).into_response(),
"admin_system_usage_cleanup_completed",
"admin_system_usage_cleanup_started",
"manual_usage_cleanup",
"usage_cleanup",
"global",
@@ -696,12 +685,20 @@ async fn build_manual_usage_cleanup_preview_response(
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()))?;
let options = match parse_manual_usage_cleanup_query_options(
request_context.query_string(),
older_than_days,
) {
Ok(value) => value,
Err(response) => return Ok(response),
};
let preview = crate::maintenance::preview_manual_usage_cleanup(&state.app().data, options)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
Ok(Json(json!({
"mode": preview.mode.as_str(),
"requested_older_than_days": preview.requested_older_than_days,
"targets": preview.targets,
"effective_cutoffs": {
"detail": preview.detail_cutoff,
"compressed": preview.compressed_cutoff,
@@ -720,12 +717,12 @@ async fn build_manual_usage_cleanup_preview_response(
fn parse_manual_usage_cleanup_request(
request_body: Option<&Bytes>,
) -> Result<Option<u32>, Response<Body>> {
) -> Result<ManualUsageCleanupOptions, Response<Body>> {
let Some(body) = request_body else {
return Ok(None);
return Ok(ManualUsageCleanupOptions::policy());
};
if body.is_empty() {
return Ok(None);
return Ok(ManualUsageCleanupOptions::policy());
}
let parsed: serde_json::Value = match serde_json::from_slice(body) {
Ok(value) => value,
@@ -738,45 +735,208 @@ fn parse_manual_usage_cleanup_request(
}
};
let Some(object) = parsed.as_object() else {
return Ok(None);
return Err(bad_manual_cleanup_request("请求体必须为 JSON 对象"));
};
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()
}),
parse_manual_usage_cleanup_options(
object.get("mode").and_then(serde_json::Value::as_str),
object.get("older_than_days"),
object.get("targets"),
)
}
fn parse_manual_usage_cleanup_query_options(
query_string: Option<&str>,
older_than_days: Option<u32>,
) -> Result<ManualUsageCleanupOptions, Response<Body>> {
let mode = query_param(query_string, "mode");
let targets = query_param(query_string, "targets").map(serde_json::Value::String);
let older_value =
older_than_days.map(|days| serde_json::Value::Number(serde_json::Number::from(days)));
parse_manual_usage_cleanup_options(mode.as_deref(), older_value.as_ref(), targets.as_ref())
}
fn parse_manual_usage_cleanup_options(
raw_mode: Option<&str>,
older_than_days: Option<&serde_json::Value>,
targets_value: Option<&serde_json::Value>,
) -> Result<ManualUsageCleanupOptions, Response<Body>> {
let (requested_older_than_days, requested_before_now) =
parse_manual_cleanup_older_than_days(older_than_days)?;
let mode =
parse_manual_cleanup_mode(raw_mode, requested_older_than_days, requested_before_now)?;
if mode == ManualUsageCleanupMode::OlderThanDays && requested_older_than_days.is_none() {
return Err(bad_manual_cleanup_request(
"older_than_days 模式必须提供正整数天数",
));
}
if mode == ManualUsageCleanupMode::BeforeNow && requested_older_than_days.is_some() {
return Err(bad_manual_cleanup_request(
"before_now 模式不能同时提供 older_than_days",
));
}
if raw_mode.is_some() && requested_before_now && mode != ManualUsageCleanupMode::BeforeNow {
return Err(bad_manual_cleanup_request(
"older_than_days 为 0 时必须使用 before_now 模式",
));
}
if raw_mode.is_some()
&& mode == ManualUsageCleanupMode::Policy
&& requested_older_than_days.is_some()
{
return Err(bad_manual_cleanup_request(
"policy 模式不能同时提供 older_than_days",
));
}
let targets = parse_manual_cleanup_targets(targets_value, mode)?;
if !targets.any_selected() {
return Err(bad_manual_cleanup_request("至少选择一个清理范围"));
}
if mode == ManualUsageCleanupMode::BeforeNow
&& (targets.headers || targets.records || targets.expired_keys)
{
return Err(bad_manual_cleanup_request(
"清理当前时刻之前只允许选择详细请求体和压缩请求体",
));
}
Ok(ManualUsageCleanupOptions {
mode,
requested_older_than_days,
targets,
})
}
fn parse_manual_cleanup_mode(
raw_mode: Option<&str>,
requested_older_than_days: Option<u32>,
requested_before_now: bool,
) -> Result<ManualUsageCleanupMode, Response<Body>> {
let Some(raw) = raw_mode.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(if requested_before_now {
ManualUsageCleanupMode::BeforeNow
} else if requested_older_than_days.is_some() {
ManualUsageCleanupMode::OlderThanDays
} else {
ManualUsageCleanupMode::Policy
});
};
match raw {
"policy" => Ok(ManualUsageCleanupMode::Policy),
"older_than_days" => Ok(ManualUsageCleanupMode::OlderThanDays),
"before_now" => Ok(ManualUsageCleanupMode::BeforeNow),
_ => Err(bad_manual_cleanup_request(
"mode 必须为 policy、older_than_days 或 before_now",
)),
}
}
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
fn parse_manual_cleanup_older_than_days(
value: Option<&serde_json::Value>,
) -> Result<(Option<u32>, bool), Response<Body>> {
match value {
None | Some(serde_json::Value::Null) => Ok((None, false)),
Some(value) => {
let Some(raw) = value.as_u64() else {
return Err(bad_manual_cleanup_request("older_than_days 必须为非负整数"));
};
if raw == 0 {
return Ok((None, true));
}
let days = u32::try_from(raw)
.ok()
.filter(|days| *days >= 1)
.ok_or_else(|| bad_manual_cleanup_request("older_than_days 必须为正整数"))?;
Ok((Some(days), false))
}
}
}
fn parse_manual_cleanup_targets(
value: Option<&serde_json::Value>,
mode: ManualUsageCleanupMode,
) -> Result<UsageCleanupTargets, Response<Body>> {
let Some(value) = value else {
return Ok(match mode {
ManualUsageCleanupMode::BeforeNow => UsageCleanupTargets::body_targets(),
ManualUsageCleanupMode::Policy | ManualUsageCleanupMode::OlderThanDays => {
UsageCleanupTargets::all_policy_targets()
}
});
let Some(raw) = value else {
};
if value.is_null() {
return Ok(match mode {
ManualUsageCleanupMode::BeforeNow => UsageCleanupTargets::body_targets(),
ManualUsageCleanupMode::Policy | ManualUsageCleanupMode::OlderThanDays => {
UsageCleanupTargets::all_policy_targets()
}
});
}
let raw_targets = match value {
serde_json::Value::Array(items) => items
.iter()
.map(|item| {
item.as_str()
.map(str::to_string)
.ok_or_else(|| bad_manual_cleanup_request("targets 必须为字符串数组"))
})
.collect::<Result<Vec<_>, _>>()?,
serde_json::Value::String(raw) => raw
.split(',')
.map(str::trim)
.filter(|item| !item.is_empty())
.map(str::to_string)
.collect(),
_ => return Err(bad_manual_cleanup_request("targets 必须为字符串数组")),
};
let mut targets = UsageCleanupTargets {
detail_body: false,
compressed_body: false,
headers: false,
records: false,
expired_keys: false,
};
for raw in raw_targets {
match raw.as_str() {
"detail_body" | "detail" | "raw_body" => targets.detail_body = true,
"compressed_body" | "compressed" => targets.compressed_body = true,
"headers" | "header" => targets.headers = true,
"records" | "log" | "logs" => targets.records = true,
"expired_keys" => targets.expired_keys = true,
"all" => targets = UsageCleanupTargets::all_policy_targets(),
_ => {
return Err(bad_manual_cleanup_request(
"targets 只能包含 detail_body、compressed_body、headers、records",
))
}
}
}
Ok(targets)
}
fn bad_manual_cleanup_request(detail: impl Into<String>) -> Response<Body> {
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": detail.into() })),
)
.into_response()
}
fn query_param(query_string: Option<&str>, name: &str) -> Option<String> {
let query = query_string.filter(|value| !value.is_empty())?;
form_urlencoded::parse(query.as_bytes())
.find_map(|(key, value)| (key == name && !value.is_empty()).then(|| value.into_owned()))
}
fn parse_older_than_days_query(query_string: Option<&str>) -> Result<Option<u32>, Response<Body>> {
let Some(value) = query_param(query_string, "older_than_days") else {
return Ok(None);
};
raw.parse::<u32>()
value
.parse::<u32>()
.ok()
.filter(|days| *days >= 1)
.map(Some)
@@ -790,3 +950,55 @@ fn parse_older_than_days_query(query_string: Option<&str>) -> Result<Option<u32>
.into_response()
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn manual_cleanup_request_defaults_to_policy_targets() {
let options = parse_manual_usage_cleanup_request(None).expect("default request is valid");
assert_eq!(options.mode, ManualUsageCleanupMode::Policy);
assert_eq!(options.requested_older_than_days, None);
assert_eq!(options.targets, UsageCleanupTargets::all_policy_targets());
}
#[test]
fn manual_cleanup_request_treats_zero_days_as_before_now_body_only() {
let body = Bytes::from_static(br#"{"older_than_days":0}"#);
let options =
parse_manual_usage_cleanup_request(Some(&body)).expect("before-now request is valid");
assert_eq!(options.mode, ManualUsageCleanupMode::BeforeNow);
assert_eq!(options.requested_older_than_days, None);
assert_eq!(options.targets, UsageCleanupTargets::body_targets());
}
#[test]
fn manual_cleanup_request_rejects_before_now_headers() {
let body = Bytes::from_static(br#"{"mode":"before_now","targets":["headers"]}"#);
assert!(parse_manual_usage_cleanup_request(Some(&body)).is_err());
}
#[test]
fn manual_cleanup_preview_query_decodes_comma_separated_targets() {
let options = parse_manual_usage_cleanup_query_options(
Some("mode=before_now&targets=detail_body%2Ccompressed_body"),
None,
)
.expect("encoded targets query is valid");
assert_eq!(options.mode, ManualUsageCleanupMode::BeforeNow);
assert_eq!(options.targets, UsageCleanupTargets::body_targets());
}
#[test]
fn manual_cleanup_request_rejects_non_object_body() {
let body = Bytes::from_static(br#"[]"#);
assert!(parse_manual_usage_cleanup_request(Some(&body)).is_err());
}
}