feat(gateway): 重构 usage 数据层、迁移系统与系统导入

数据库迁移:
- 引入 baseline v2 bootstrap,空库首次启动自动初始化
- 服务启动不再自动执行迁移,需显式 `--migrate` 运行
- 新增 pending migration 检测,schema 落后时拒绝启动

Usage 数据层:
- usage body 存储外部化为独立 blob 表
- 新增 HTTP audit 表拆分存储请求/响应头与 body ref
- 后台清理任务支持 legacy body ref 元数据迁移
- usage runtime 写入迁移到专用 tokio runtime(独立线程池, 8MB 栈)

系统导入/导出:
- 支持用户、API Keys、钱包数据的完整导入
- 兼容 legacy 与 v1.3+ 两种导出格式

其他改进:
- executor outcome 增加 runtime miss 诊断上下文
- 主 tokio runtime 栈大小调整为 8MB
- 前端 provider 管理支持 base URL 配置
- dev.sh 支持 --migrate 参数
This commit is contained in:
fawney19
2026-04-13 14:01:22 +08:00
parent 3698e5a833
commit 5bb08e6aa4
106 changed files with 21736 additions and 1529 deletions

View File

@@ -32,10 +32,12 @@ pub(super) async fn load_user_leaderboard_metadata(
},
);
}
return Ok(metadata);
}
for user_id in user_ids {
if metadata.contains_key(user_id) {
continue;
}
let Some(user) = state.find_user_auth_by_id(user_id).await? else {
continue;
};

View File

@@ -130,24 +130,32 @@ pub(super) async fn maybe_build_local_admin_stats_leaderboard_response(
let usage = state
.list_admin_usage_for_optional_range(time_range.as_ref(), &filters)
.await?;
let api_key_ids: Vec<String> = usage
.iter()
.filter_map(|item| item.api_key_id.clone())
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect();
let snapshots = if state.has_auth_api_key_data_reader() {
let api_key_ids: Vec<String> = usage
.iter()
.filter_map(|item| item.api_key_id.clone())
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect();
Some(
state
.list_auth_api_key_snapshots_by_ids(&api_key_ids)
.resolve_auth_api_key_snapshots_by_ids(&api_key_ids)
.await?,
)
} else {
None
};
let api_key_names = if state.has_auth_api_key_data_reader() {
state
.resolve_auth_api_key_names_by_ids(&api_key_ids)
.await?
} else {
std::collections::BTreeMap::new()
};
let mut leaderboard = build_api_key_leaderboard_items(
&usage,
snapshots.as_deref(),
&api_key_names,
include_inactive,
exclude_admin,
);
@@ -220,8 +228,13 @@ pub(super) async fn maybe_build_local_admin_stats_leaderboard_response(
.into_iter()
.collect();
let user_metadata = load_user_leaderboard_metadata(state, &user_ids).await?;
let mut leaderboard =
build_user_leaderboard_items(&usage, &user_metadata, include_inactive, exclude_admin);
let mut leaderboard = build_user_leaderboard_items(
&usage,
&user_metadata,
state.has_auth_user_data_reader(),
include_inactive,
exclude_admin,
);
leaderboard.sort_by(|left, right| compare_leaderboard_items(metric, order, left, right));
return Ok(Some(build_admin_stats_leaderboard_response(

View File

@@ -28,3 +28,24 @@ pub(in super::super) async fn admin_usage_provider_key_names(
.map(|key| (key.id, key.name))
.collect())
}
pub(in super::super) async fn admin_usage_api_key_names(
state: &AdminAppState<'_>,
usage: &[StoredRequestUsageAudit],
) -> Result<BTreeMap<String, String>, GatewayError> {
if !state.has_auth_api_key_data_reader() {
return Ok(BTreeMap::new());
}
let api_key_ids = usage
.iter()
.filter_map(|item| item.api_key_id.clone())
.collect::<BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>();
if api_key_ids.is_empty() {
return Ok(BTreeMap::new());
}
state.resolve_auth_api_key_names_by_ids(&api_key_ids).await
}

View File

@@ -4,4 +4,4 @@ mod filters;
pub(super) use aggregations::admin_usage_aggregation_by_user_json;
pub(super) use cache_affinity::list_recent_completed_usage_for_cache_affinity;
pub(super) use filters::admin_usage_provider_key_names;
pub(super) use filters::{admin_usage_api_key_names, admin_usage_provider_key_names};

View File

@@ -16,6 +16,20 @@ use axum::{
use serde_json::json;
use std::collections::{BTreeMap, BTreeSet};
async fn load_usage_cache_affinity_usernames(
state: &AdminAppState<'_>,
user_ids: &[String],
) -> Result<BTreeMap<String, String>, GatewayError> {
Ok(state
.resolve_auth_user_summaries_by_ids(user_ids)
.await?
.into_iter()
.filter_map(|(user_id, user)| {
(!user.username.trim().is_empty()).then_some((user_id, user.username))
})
.collect())
}
pub(super) async fn build_admin_usage_cache_affinity_interval_timeline_response(
state: &AdminAppState<'_>,
request_context: &AdminRequestContext<'_>,
@@ -41,6 +55,7 @@ pub(super) async fn build_admin_usage_cache_affinity_interval_timeline_response(
list_recent_completed_usage_for_cache_affinity(state, hours, user_id.as_deref()).await?;
let mut grouped: BTreeMap<String, Vec<serde_json::Value>> = BTreeMap::new();
let mut models = BTreeSet::new();
let mut legacy_usernames_by_user_id = BTreeMap::new();
let mut usernames_by_user_id = BTreeMap::new();
for (group_user_id, items) in admin_usage_group_completed_by_user(&usage) {
@@ -67,7 +82,7 @@ pub(super) async fn build_admin_usage_cache_affinity_interval_timeline_response(
if include_user_info && user_id.is_none() {
point["user_id"] = json!(group_user_id.clone());
if let Some(username) = item.username.clone() {
usernames_by_user_id
legacy_usernames_by_user_id
.entry(group_user_id.clone())
.or_insert(username);
}
@@ -82,17 +97,17 @@ pub(super) async fn build_admin_usage_cache_affinity_interval_timeline_response(
}
}
if include_user_info && user_id.is_none() && state.has_user_data_reader() {
if include_user_info && user_id.is_none() {
let user_ids: Vec<_> = grouped.keys().cloned().collect();
let user_map: BTreeMap<_, _> = state
.list_users_by_ids(&user_ids)
.await?
.into_iter()
.map(|user| (user.id, user.username))
.collect();
let user_map = load_usage_cache_affinity_usernames(state, &user_ids).await?;
for (user_id, username) in user_map {
usernames_by_user_id.insert(user_id, username);
}
if !state.has_auth_user_data_reader() {
for (user_id, username) in legacy_usernames_by_user_id {
usernames_by_user_id.entry(user_id).or_insert(username);
}
}
}
let total_points_before_limit: usize = grouped.values().map(Vec::len).sum();

View File

@@ -1,8 +1,9 @@
use super::analytics::admin_usage_provider_key_names;
use super::analytics::{admin_usage_api_key_names, admin_usage_provider_key_names};
use super::replay::{
admin_usage_curl_headers, admin_usage_curl_url, admin_usage_headers_from_value,
admin_usage_id_from_action_path, admin_usage_id_from_detail_path,
admin_usage_resolve_request_preview_body, build_admin_usage_curl_response,
admin_usage_resolve_body_value, admin_usage_resolve_request_preview_body,
admin_usage_resolve_request_preview_body_for_item, build_admin_usage_curl_response,
build_admin_usage_detail_payload, build_admin_usage_replay_response,
};
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
@@ -12,6 +13,7 @@ use aether_admin::observability::usage::{
admin_usage_bad_request_response, admin_usage_data_unavailable_response,
admin_usage_provider_key_name, ADMIN_USAGE_DATA_UNAVAILABLE_DETAIL,
};
use aether_data_contracts::repository::usage::UsageBodyField;
use axum::{
body::Body,
http,
@@ -82,10 +84,22 @@ pub(super) async fn maybe_build_local_admin_usage_detail_response(
.and_then(admin_usage_headers_from_value)
.filter(|headers| !headers.is_empty())
.unwrap_or_else(admin_usage_curl_headers);
let body = item
.provider_request_body
.clone()
.or_else(|| item.request_body.clone())
let provider_request_body = admin_usage_resolve_body_value(
state,
&item,
item.provider_request_body.as_ref(),
UsageBodyField::ProviderRequestBody,
)
.await?;
let request_body = admin_usage_resolve_body_value(
state,
&item,
item.request_body.as_ref(),
UsageBodyField::RequestBody,
)
.await?;
let body = provider_request_body
.or(request_body)
.unwrap_or_else(|| admin_usage_resolve_request_preview_body(&item, None));
return Ok(Some(attach_admin_audit_response(
build_admin_usage_curl_response(&item, url, headers_json, &headers, &body),
@@ -146,32 +160,50 @@ pub(super) async fn maybe_build_local_admin_usage_detail_response(
};
let users_by_id: BTreeMap<String, aether_data::repository::users::StoredUserSummary> =
if state.has_user_data_reader() {
if let Some(user_id) = item.user_id.as_ref() {
state
.list_users_by_ids(std::slice::from_ref(user_id))
.await?
.into_iter()
.map(|user| (user.id.clone(), user))
.collect()
} else {
BTreeMap::new()
}
} else {
BTreeMap::new()
};
state
.resolve_auth_user_summaries_by_ids(
&item.user_id.clone().into_iter().collect::<Vec<_>>(),
)
.await?;
let provider_key_names =
admin_usage_provider_key_names(state, std::slice::from_ref(&item)).await?;
let api_key_names =
admin_usage_api_key_names(state, std::slice::from_ref(&item)).await?;
let provider_key_name = admin_usage_provider_key_name(&item, &provider_key_names);
let request_body = item
.request_body
.clone()
.unwrap_or_else(|| admin_usage_resolve_request_preview_body(&item, None));
let request_body =
admin_usage_resolve_request_preview_body_for_item(state, &item, None).await?;
let mut detail_item = item.clone();
if include_bodies {
detail_item.provider_request_body = admin_usage_resolve_body_value(
state,
&item,
item.provider_request_body.as_ref(),
UsageBodyField::ProviderRequestBody,
)
.await?;
detail_item.response_body = admin_usage_resolve_body_value(
state,
&item,
item.response_body.as_ref(),
UsageBodyField::ResponseBody,
)
.await?;
detail_item.client_response_body = admin_usage_resolve_body_value(
state,
&item,
item.client_response_body.as_ref(),
UsageBodyField::ClientResponseBody,
)
.await?;
}
let default_headers = admin_usage_curl_headers();
let payload = build_admin_usage_detail_payload(
&item,
&detail_item,
&users_by_id,
&api_key_names,
state.has_auth_user_data_reader(),
state.has_auth_api_key_data_reader(),
provider_key_name.as_deref(),
include_bodies,
request_body,

View File

@@ -5,7 +5,8 @@ use aether_admin::observability::usage::{
ADMIN_USAGE_DATA_UNAVAILABLE_DETAIL,
};
use aether_data_contracts::repository::{
provider_catalog::StoredProviderCatalogEndpoint, usage::StoredRequestUsageAudit,
provider_catalog::StoredProviderCatalogEndpoint,
usage::{StoredRequestUsageAudit, UsageBodyField},
};
use axum::{
body::Body,
@@ -46,6 +47,50 @@ pub(super) fn admin_usage_resolve_request_preview_body(
)
}
pub(super) async fn admin_usage_resolve_body_value(
state: &AdminAppState<'_>,
item: &StoredRequestUsageAudit,
inline_body: Option<&Value>,
field: UsageBodyField,
) -> Result<Option<Value>, GatewayError> {
let resolved_ref_body = match item.body_ref(field) {
Some(body_ref) => state.resolve_request_usage_body_ref(body_ref).await?,
None => None,
};
Ok(admin_usage_body_value_from_sources(
resolved_ref_body,
inline_body,
))
}
fn admin_usage_body_value_from_sources(
resolved_ref_body: Option<Value>,
inline_body: Option<&Value>,
) -> Option<Value> {
resolved_ref_body.or_else(|| inline_body.cloned())
}
pub(super) async fn admin_usage_resolve_request_preview_body_for_item(
state: &AdminAppState<'_>,
item: &StoredRequestUsageAudit,
body_override: Option<serde_json::Value>,
) -> Result<serde_json::Value, GatewayError> {
if let Some(body_override) = body_override {
return Ok(body_override);
}
if let Some(body) = admin_usage_resolve_body_value(
state,
item,
item.request_body.as_ref(),
UsageBodyField::RequestBody,
)
.await?
{
return Ok(body);
}
Ok(admin_usage_resolve_request_preview_body(item, None))
}
pub(super) fn build_admin_usage_curl_response(
item: &StoredRequestUsageAudit,
url: Option<String>,
@@ -62,9 +107,46 @@ pub(super) fn build_admin_usage_curl_response(
)
}
#[cfg(test)]
mod tests {
use super::admin_usage_body_value_from_sources;
use serde_json::json;
#[test]
fn resolved_reference_body_wins_over_inline_fallback() {
let inline_body = json!({
"truncated": true,
"reason": "usage_capture_limits_exceeded"
});
let ref_body = json!({
"messages": [{"role": "user", "content": "real request body"}]
});
assert_eq!(
admin_usage_body_value_from_sources(Some(ref_body.clone()), Some(&inline_body)),
Some(ref_body)
);
}
#[test]
fn inline_body_is_used_when_reference_body_is_unavailable() {
let inline_body = json!({
"messages": [{"role": "user", "content": "fallback inline body"}]
});
assert_eq!(
admin_usage_body_value_from_sources(None, Some(&inline_body)),
Some(inline_body)
);
}
}
pub(super) fn build_admin_usage_detail_payload(
item: &StoredRequestUsageAudit,
users_by_id: &BTreeMap<String, aether_data::repository::users::StoredUserSummary>,
api_key_names: &BTreeMap<String, String>,
auth_user_reader_available: bool,
auth_api_key_reader_available: bool,
provider_key_name: Option<&str>,
include_bodies: bool,
request_body: Value,
@@ -73,6 +155,9 @@ pub(super) fn build_admin_usage_detail_payload(
aether_admin::observability::usage::build_admin_usage_detail_payload(
item,
users_by_id,
api_key_names,
auth_user_reader_available,
auth_api_key_reader_available,
provider_key_name,
include_bodies,
request_body,
@@ -254,7 +339,9 @@ pub(super) async fn build_admin_usage_replay_response(
let same_provider = item.provider_id.as_deref() == Some(target_provider.id.as_str());
let same_endpoint = item.provider_endpoint_id.as_deref() == Some(target_endpoint.id.as_str());
let request_body = admin_usage_resolve_request_preview_body(&item, payload.body_override);
let request_body =
admin_usage_resolve_request_preview_body_for_item(state, &item, payload.body_override)
.await?;
let url = admin_usage_curl_url(state, &target_endpoint, &item);
let headers = admin_usage_curl_headers();

View File

@@ -1,5 +1,5 @@
use super::super::stats::{AdminStatsTimeRange, AdminStatsUsageFilter};
use super::analytics::admin_usage_provider_key_names;
use super::analytics::{admin_usage_api_key_names, admin_usage_provider_key_names};
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::handlers::admin::shared::query_param_value;
use crate::GatewayError;
@@ -85,10 +85,13 @@ pub(super) async fn maybe_build_local_admin_usage_summary_response(
if requested_ids.is_none() && items.len() > 50 {
items.truncate(50);
}
let api_key_names = admin_usage_api_key_names(state, &items).await?;
let provider_key_names = admin_usage_provider_key_names(state, &items).await?;
return Ok(Some(build_admin_usage_active_requests_response(
&items,
&api_key_names,
state.has_auth_api_key_data_reader(),
&provider_key_names,
)));
}
@@ -134,10 +137,30 @@ pub(super) async fn maybe_build_local_admin_usage_summary_response(
Err(detail) => return Ok(Some(admin_usage_bad_request_response(detail))),
};
let user_ids: Vec<String> = usage
.iter()
.filter_map(|item| item.user_id.clone())
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
let users_by_id: BTreeMap<String, aether_data::repository::users::StoredUserSummary> =
state.resolve_auth_user_summaries_by_ids(&user_ids).await?;
let api_key_names = admin_usage_api_key_names(state, &usage).await?;
usage.retain(|item| {
admin_usage_matches_search(item, search.as_deref())
&& admin_usage_matches_username(item, username_filter.as_deref())
&& admin_usage_matches_eq(item.model.as_str(), model_filter.as_deref())
admin_usage_matches_search(
item,
search.as_deref(),
&users_by_id,
&api_key_names,
state.has_auth_user_data_reader(),
state.has_auth_api_key_data_reader(),
) && admin_usage_matches_username(
item,
username_filter.as_deref(),
&users_by_id,
state.has_auth_user_data_reader(),
) && admin_usage_matches_eq(item.model.as_str(), model_filter.as_deref())
&& admin_usage_matches_eq(
item.provider_name.as_str(),
provider_filter.as_deref(),
@@ -153,24 +176,6 @@ pub(super) async fn maybe_build_local_admin_usage_summary_response(
});
let total = usage.len();
let user_ids: Vec<String> = usage
.iter()
.filter_map(|item| item.user_id.clone())
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
let users_by_id: BTreeMap<String, aether_data::repository::users::StoredUserSummary> =
if state.has_user_data_reader() && !user_ids.is_empty() {
state
.list_users_by_ids(&user_ids)
.await?
.into_iter()
.map(|user| (user.id.clone(), user))
.collect()
} else {
BTreeMap::new()
};
let provider_key_names = admin_usage_provider_key_names(state, &usage).await?;
let records = usage
@@ -182,6 +187,9 @@ pub(super) async fn maybe_build_local_admin_usage_summary_response(
return Ok(Some(build_admin_usage_records_response(
&records,
&users_by_id,
&api_key_names,
state.has_auth_user_data_reader(),
state.has_auth_api_key_data_reader(),
&provider_key_names,
total,
limit,