mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
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:
@@ -106,6 +106,12 @@ pub(super) async fn build_admin_create_api_key_response(
|
||||
allowed_models,
|
||||
rate_limit,
|
||||
concurrent_limit: 5,
|
||||
force_capabilities: None,
|
||||
is_active: true,
|
||||
expires_at_unix_secs: None,
|
||||
auto_delete_on_expiry: false,
|
||||
total_requests: 0,
|
||||
total_cost_usd: 0.0,
|
||||
},
|
||||
)
|
||||
.await?
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -18,6 +18,23 @@ pub(crate) fn normalize_auth_type(value: Option<&str>) -> Result<String, String>
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_pool_advanced_config(
|
||||
value: Option<serde_json::Value>,
|
||||
) -> Result<Option<serde_json::Value>, String> {
|
||||
let Some(value) = value else {
|
||||
return Ok(None);
|
||||
};
|
||||
match value {
|
||||
serde_json::Value::Null => Ok(None),
|
||||
// `pool_advanced: {}` still means "enable pool mode with defaults".
|
||||
serde_json::Value::Object(map) => Ok(Some(serde_json::Value::Object(map))),
|
||||
// Backward compatibility for older boolean payloads.
|
||||
serde_json::Value::Bool(true) => Ok(Some(serde_json::json!({}))),
|
||||
serde_json::Value::Bool(false) => Ok(None),
|
||||
_ => Err("pool_advanced 必须是 JSON 对象".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_vertex_api_formats(
|
||||
provider_type: &str,
|
||||
auth_type: &str,
|
||||
@@ -46,3 +63,29 @@ pub(crate) fn validate_vertex_api_formats(
|
||||
allowed.join(", ")
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::normalize_pool_advanced_config;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn normalize_pool_advanced_preserves_empty_object() {
|
||||
assert_eq!(
|
||||
normalize_pool_advanced_config(Some(json!({}))).expect("empty object should normalize"),
|
||||
Some(json!({}))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_pool_advanced_accepts_legacy_booleans() {
|
||||
assert_eq!(
|
||||
normalize_pool_advanced_config(Some(json!(true))).expect("true should normalize"),
|
||||
Some(json!({}))
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_pool_advanced_config(Some(json!(false))).expect("false should normalize"),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ use crate::handlers::admin::provider::shared::payloads::AdminProviderCreateReque
|
||||
use crate::handlers::admin::provider::shared::support::{
|
||||
normalize_provider_billing_type, parse_optional_rfc3339_unix_secs,
|
||||
};
|
||||
use crate::handlers::admin::provider::write::normalize::normalize_provider_type_input;
|
||||
use crate::handlers::admin::provider::write::normalize::{
|
||||
normalize_pool_advanced_config, normalize_provider_type_input,
|
||||
};
|
||||
use crate::handlers::admin::request::AdminAppState;
|
||||
use crate::handlers::admin::shared::normalize_json_object;
|
||||
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogProvider;
|
||||
@@ -118,7 +120,7 @@ pub(crate) async fn build_admin_create_provider_record(
|
||||
let mut config_map = normalize_json_object(payload.config, "config")?
|
||||
.and_then(|value| value.as_object().cloned())
|
||||
.unwrap_or_default();
|
||||
if let Some(value) = normalize_json_object(payload.pool_advanced, "pool_advanced")? {
|
||||
if let Some(value) = normalize_pool_advanced_config(payload.pool_advanced)? {
|
||||
config_map.insert("pool_advanced".to_string(), value);
|
||||
}
|
||||
if let Some(value) = normalize_json_object(payload.failover_rules, "failover_rules")? {
|
||||
|
||||
@@ -2,7 +2,9 @@ use crate::handlers::admin::provider::shared::payloads::AdminProviderUpdatePatch
|
||||
use crate::handlers::admin::provider::shared::support::{
|
||||
normalize_provider_billing_type, parse_optional_rfc3339_unix_secs,
|
||||
};
|
||||
use crate::handlers::admin::provider::write::normalize::normalize_provider_type_input;
|
||||
use crate::handlers::admin::provider::write::normalize::{
|
||||
normalize_pool_advanced_config, normalize_provider_type_input,
|
||||
};
|
||||
use crate::handlers::admin::request::AdminAppState;
|
||||
use crate::handlers::admin::shared::normalize_json_object;
|
||||
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogProvider;
|
||||
@@ -252,7 +254,7 @@ pub(crate) async fn build_admin_update_provider_record(
|
||||
if fields.is_null("pool_advanced") {
|
||||
config_map.remove("pool_advanced");
|
||||
} else {
|
||||
let value = normalize_json_object(payload.pool_advanced, "pool_advanced")?
|
||||
let value = normalize_pool_advanced_config(payload.pool_advanced)?
|
||||
.ok_or_else(|| "pool_advanced 必须是 JSON 对象".to_string())?;
|
||||
config_map.insert("pool_advanced".to_string(), value);
|
||||
}
|
||||
|
||||
@@ -84,6 +84,10 @@ impl<'a> AdminAppState<'a> {
|
||||
self.app.has_user_data_reader()
|
||||
}
|
||||
|
||||
pub(crate) fn has_auth_user_data_reader(&self) -> bool {
|
||||
self.app.has_auth_user_data_reader()
|
||||
}
|
||||
|
||||
pub(crate) fn has_proxy_node_reader(&self) -> bool {
|
||||
self.app.has_proxy_node_reader()
|
||||
}
|
||||
|
||||
@@ -54,6 +54,17 @@ impl<'a> AdminAppState<'a> {
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_request_usage_body_ref(
|
||||
&self,
|
||||
body_ref: &str,
|
||||
) -> Result<Option<serde_json::Value>, GatewayError> {
|
||||
self.app
|
||||
.data
|
||||
.resolve_request_usage_body_ref(body_ref)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn list_admin_usage_for_range(
|
||||
&self,
|
||||
time_range: &crate::handlers::admin::observability::AdminStatsTimeRange,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,16 @@ impl<'a> AdminAppState<'a> {
|
||||
self.app.list_users_by_ids(user_ids).await
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_auth_user_summaries_by_ids(
|
||||
&self,
|
||||
user_ids: &[String],
|
||||
) -> Result<
|
||||
std::collections::BTreeMap<String, aether_data::repository::users::StoredUserSummary>,
|
||||
GatewayError,
|
||||
> {
|
||||
self.app.resolve_auth_user_summaries_by_ids(user_ids).await
|
||||
}
|
||||
|
||||
pub(crate) async fn list_export_users_page(
|
||||
&self,
|
||||
query: &aether_data::repository::users::UserExportListQuery,
|
||||
@@ -103,6 +113,17 @@ impl<'a> AdminAppState<'a> {
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn initialize_auth_api_key_wallet(
|
||||
&self,
|
||||
api_key_id: &str,
|
||||
initial_gift_usd: f64,
|
||||
unlimited: bool,
|
||||
) -> Result<Option<aether_data::repository::wallet::StoredWalletSnapshot>, GatewayError> {
|
||||
self.app
|
||||
.initialize_auth_api_key_wallet(api_key_id, initial_gift_usd, unlimited)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn update_local_auth_user_profile(
|
||||
&self,
|
||||
user_id: &str,
|
||||
@@ -165,10 +186,94 @@ impl<'a> AdminAppState<'a> {
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn update_auth_api_key_wallet_limit_mode(
|
||||
&self,
|
||||
api_key_id: &str,
|
||||
limit_mode: &str,
|
||||
) -> Result<Option<aether_data::repository::wallet::StoredWalletSnapshot>, GatewayError> {
|
||||
self.app
|
||||
.update_auth_api_key_wallet_limit_mode(api_key_id, limit_mode)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn update_auth_user_wallet_snapshot(
|
||||
&self,
|
||||
user_id: &str,
|
||||
balance: f64,
|
||||
gift_balance: f64,
|
||||
limit_mode: &str,
|
||||
currency: &str,
|
||||
status: &str,
|
||||
total_recharged: f64,
|
||||
total_consumed: f64,
|
||||
total_refunded: f64,
|
||||
total_adjusted: f64,
|
||||
updated_at_unix_secs: Option<u64>,
|
||||
) -> Result<Option<aether_data::repository::wallet::StoredWalletSnapshot>, GatewayError> {
|
||||
self.app
|
||||
.update_auth_user_wallet_snapshot(
|
||||
user_id,
|
||||
balance,
|
||||
gift_balance,
|
||||
limit_mode,
|
||||
currency,
|
||||
status,
|
||||
total_recharged,
|
||||
total_consumed,
|
||||
total_refunded,
|
||||
total_adjusted,
|
||||
updated_at_unix_secs,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn update_auth_api_key_wallet_snapshot(
|
||||
&self,
|
||||
api_key_id: &str,
|
||||
balance: f64,
|
||||
gift_balance: f64,
|
||||
limit_mode: &str,
|
||||
currency: &str,
|
||||
status: &str,
|
||||
total_recharged: f64,
|
||||
total_consumed: f64,
|
||||
total_refunded: f64,
|
||||
total_adjusted: f64,
|
||||
updated_at_unix_secs: Option<u64>,
|
||||
) -> Result<Option<aether_data::repository::wallet::StoredWalletSnapshot>, GatewayError> {
|
||||
self.app
|
||||
.update_auth_api_key_wallet_snapshot(
|
||||
api_key_id,
|
||||
balance,
|
||||
gift_balance,
|
||||
limit_mode,
|
||||
currency,
|
||||
status,
|
||||
total_recharged,
|
||||
total_consumed,
|
||||
total_refunded,
|
||||
total_adjusted,
|
||||
updated_at_unix_secs,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn count_active_admin_users(&self) -> Result<u64, GatewayError> {
|
||||
self.app.count_active_admin_users().await
|
||||
}
|
||||
|
||||
pub(crate) async fn update_user_model_capability_settings(
|
||||
&self,
|
||||
user_id: &str,
|
||||
settings: Option<serde_json::Value>,
|
||||
) -> Result<Option<serde_json::Value>, GatewayError> {
|
||||
self.app
|
||||
.update_user_model_capability_settings(user_id, settings)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn count_user_pending_refunds(
|
||||
&self,
|
||||
user_id: &str,
|
||||
@@ -236,6 +341,24 @@ impl<'a> AdminAppState<'a> {
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_auth_api_key_snapshots_by_ids(
|
||||
&self,
|
||||
api_key_ids: &[String],
|
||||
) -> Result<Vec<aether_data::repository::auth::StoredAuthApiKeySnapshot>, GatewayError> {
|
||||
self.app
|
||||
.resolve_auth_api_key_snapshots_by_ids(api_key_ids)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_auth_api_key_names_by_ids(
|
||||
&self,
|
||||
api_key_ids: &[String],
|
||||
) -> Result<std::collections::BTreeMap<String, String>, GatewayError> {
|
||||
self.app
|
||||
.resolve_auth_api_key_names_by_ids(api_key_ids)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn list_auth_api_key_export_records_by_user_ids(
|
||||
&self,
|
||||
user_ids: &[String],
|
||||
@@ -339,6 +462,18 @@ impl<'a> AdminAppState<'a> {
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn set_user_api_key_active(
|
||||
&self,
|
||||
user_id: &str,
|
||||
api_key_id: &str,
|
||||
is_active: bool,
|
||||
) -> Result<Option<aether_data::repository::auth::StoredAuthApiKeyExportRecord>, GatewayError>
|
||||
{
|
||||
self.app
|
||||
.set_user_api_key_active(user_id, api_key_id, is_active)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn set_user_api_key_locked(
|
||||
&self,
|
||||
user_id: &str,
|
||||
@@ -362,6 +497,18 @@ impl<'a> AdminAppState<'a> {
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn set_user_api_key_force_capabilities(
|
||||
&self,
|
||||
user_id: &str,
|
||||
api_key_id: &str,
|
||||
force_capabilities: Option<serde_json::Value>,
|
||||
) -> Result<Option<aether_data::repository::auth::StoredAuthApiKeyExportRecord>, GatewayError>
|
||||
{
|
||||
self.app
|
||||
.set_user_api_key_force_capabilities(user_id, api_key_id, force_capabilities)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_user_api_key(
|
||||
&self,
|
||||
user_id: &str,
|
||||
|
||||
@@ -137,11 +137,46 @@ pub(super) async fn maybe_build_local_admin_core_system_response(
|
||||
)));
|
||||
}
|
||||
|
||||
if decision.route_kind.as_deref() == Some("users_import")
|
||||
&& request_method == http::Method::POST
|
||||
&& request_path == "/api/admin/system/users/import"
|
||||
{
|
||||
let Some(request_body) = request_body else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": "请求数据验证失败" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
return Ok(Some(
|
||||
match state
|
||||
.import_admin_system_users(
|
||||
request_body,
|
||||
decision
|
||||
.admin_principal
|
||||
.as_ref()
|
||||
.map(|principal| principal.user_id.as_str()),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Ok(payload) => attach_admin_audit_response(
|
||||
Json(payload).into_response(),
|
||||
"admin_system_users_imported",
|
||||
"import_system_users",
|
||||
"system_users_import",
|
||||
"global",
|
||||
),
|
||||
Err((status, payload)) => (status, Json(payload)).into_response(),
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
if matches!(
|
||||
decision.route_kind.as_deref(),
|
||||
Some(
|
||||
"users_import"
|
||||
| "smtp_test"
|
||||
"smtp_test"
|
||||
| "cleanup"
|
||||
| "purge_config"
|
||||
| "purge_users"
|
||||
|
||||
@@ -120,8 +120,17 @@ pub(crate) async fn build_admin_create_user_api_key_response(
|
||||
key_hash: hash_admin_user_api_key(&plaintext_key),
|
||||
key_encrypted: Some(key_encrypted),
|
||||
name: Some(name.clone()),
|
||||
allowed_providers: None,
|
||||
allowed_api_formats: None,
|
||||
allowed_models: None,
|
||||
rate_limit,
|
||||
concurrent_limit: 5,
|
||||
force_capabilities: None,
|
||||
is_active: true,
|
||||
expires_at_unix_secs: None,
|
||||
auto_delete_on_expiry: false,
|
||||
total_requests: 0,
|
||||
total_cost_usd: 0.0,
|
||||
})
|
||||
.await?
|
||||
else {
|
||||
|
||||
Reference in New Issue
Block a user