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

@@ -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?

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,

View File

@@ -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
);
}
}

View File

@@ -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")? {

View File

@@ -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);
}

View File

@@ -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()
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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"

View File

@@ -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 {

View File

@@ -31,8 +31,9 @@ use crate::control::{
GatewayPublicRequestContext,
};
use crate::executor::{
maybe_execute_stream_request, maybe_execute_sync_request,
record_failed_usage_for_exhausted_request, LocalExecutionRequestOutcome,
build_local_execution_runtime_miss_context, maybe_execute_stream_request,
maybe_execute_sync_request, record_failed_usage_for_exhausted_request,
record_failed_usage_for_runtime_miss_request, LocalExecutionRequestOutcome,
};
use crate::frontdoor_loop_guard::{
frontdoor_self_loop_public_ai_path, request_has_execution_runtime_loop_guard,
@@ -46,7 +47,7 @@ use crate::headers::{extract_or_generate_trace_id, should_skip_request_header};
use crate::router::RequestAdmissionError;
use crate::{
AppState, FrontdoorUserRpmOutcome, GatewayError, GatewayFallbackMetricKind,
GatewayFallbackReason,
GatewayFallbackReason, LocalExecutionRuntimeMissDiagnostic,
};
use axum::body::{to_bytes, Body, Bytes};
use axum::extract::{ConnectInfo, Request, State};
@@ -1013,9 +1014,16 @@ pub(crate) async fn proxy_request(
LocalExecutionRequestOutcome::NoPath => {}
}
}
let local_execution_runtime_miss_detail =
local_execution_runtime_miss_detail(control_decision)
.unwrap_or("AI public execution runtime miss did not match a Rust execution path");
let local_execution_runtime_miss_diagnostic =
state.take_local_execution_runtime_miss_diagnostic(&trace_id);
let local_execution_runtime_miss_detail = local_execution_runtime_miss_detail(
control_decision,
local_execution_runtime_miss_diagnostic.as_ref(),
stream_request,
)
.unwrap_or_else(|| {
"AI public execution runtime miss did not match a Rust execution path".to_string()
});
state.record_fallback_metric(
GatewayFallbackMetricKind::LocalExecutionRuntimeMiss,
control_decision,
@@ -1023,38 +1031,95 @@ pub(crate) async fn proxy_request(
Some(EXECUTION_PATH_LOCAL_EXECUTION_RUNTIME_MISS),
GatewayFallbackReason::LocalExecutionPathRequired,
);
let local_execution_runtime_miss_diagnostic =
state.take_local_execution_runtime_miss_diagnostic(&trace_id);
if let Some(diagnostic) = local_execution_runtime_miss_diagnostic.as_ref() {
warn!(
trace_id = %trace_id,
local_execution_runtime_miss_reason = %diagnostic.reason,
route_family = diagnostic.route_family.as_deref().unwrap_or_default(),
route_kind = diagnostic.route_kind.as_deref().unwrap_or_default(),
public_path = diagnostic.public_path.as_deref().unwrap_or_default(),
plan_kind = diagnostic.plan_kind.as_deref().unwrap_or_default(),
requested_model = diagnostic.requested_model.as_deref().unwrap_or_default(),
candidate_count = diagnostic.candidate_count.unwrap_or(0),
skipped_candidate_count = diagnostic.skipped_candidate_count.unwrap_or(0),
skip_reasons = diagnostic.skip_reasons_summary().unwrap_or_default(),
"gateway local execution runtime miss"
);
}
let local_execution_runtime_miss_context =
build_local_execution_runtime_miss_context(&state, &trace_id, control_decision).await;
warn!(
trace_id = %trace_id,
local_execution_runtime_miss_reason = local_execution_runtime_miss_diagnostic
.as_ref()
.map(|value| value.reason.as_str())
.unwrap_or("unknown"),
route_family = local_execution_runtime_miss_diagnostic
.as_ref()
.and_then(|value| value.route_family.as_deref())
.or_else(|| control_decision.and_then(|value| value.route_family.as_deref()))
.unwrap_or_default(),
route_kind = local_execution_runtime_miss_diagnostic
.as_ref()
.and_then(|value| value.route_kind.as_deref())
.or_else(|| control_decision.and_then(|value| value.route_kind.as_deref()))
.unwrap_or_default(),
public_path = local_execution_runtime_miss_diagnostic
.as_ref()
.and_then(|value| value.public_path.as_deref())
.filter(|value| !value.trim().is_empty())
.map(str::to_string)
.or_else(|| control_decision.map(GatewayControlDecision::proxy_path_and_query))
.unwrap_or_default(),
plan_kind = local_execution_runtime_miss_diagnostic
.as_ref()
.and_then(|value| value.plan_kind.as_deref())
.unwrap_or_default(),
requested_model = local_execution_runtime_miss_diagnostic
.as_ref()
.and_then(|value| value.requested_model.as_deref())
.unwrap_or_default(),
candidate_count = local_execution_runtime_miss_diagnostic
.as_ref()
.and_then(|value| value.candidate_count)
.unwrap_or(0),
persisted_candidate_count = local_execution_runtime_miss_context.persisted_candidate_count(),
skipped_candidate_count = local_execution_runtime_miss_diagnostic
.as_ref()
.and_then(|value| value.skipped_candidate_count)
.unwrap_or(0),
skip_reasons = local_execution_runtime_miss_diagnostic
.as_ref()
.and_then(|value| value.skip_reasons_summary())
.unwrap_or_default(),
auth_user_id = local_execution_runtime_miss_context
.auth_user_id
.as_deref()
.unwrap_or_default(),
auth_api_key_id = local_execution_runtime_miss_context
.auth_api_key_id
.as_deref()
.unwrap_or_default(),
auth_api_key_name = local_execution_runtime_miss_context
.auth_api_key_name
.as_deref()
.unwrap_or_default(),
request_candidates = local_execution_runtime_miss_context
.candidate_summary()
.unwrap_or_default(),
"gateway local execution runtime miss"
);
if let Some(exhaustion) = local_execution_exhaustion {
record_failed_usage_for_exhausted_request(
&state,
exhaustion,
&started_at,
local_execution_runtime_miss_detail,
local_execution_runtime_miss_detail.as_str(),
local_execution_runtime_miss_diagnostic.as_ref(),
)
.await;
} else {
record_failed_usage_for_runtime_miss_request(
&state,
&trace_id,
&started_at,
local_execution_runtime_miss_detail.as_str(),
control_decision,
local_execution_runtime_miss_diagnostic.as_ref(),
&local_execution_runtime_miss_context,
)
.await;
}
let mut response = build_local_http_error_response(
&trace_id,
control_decision,
http::StatusCode::SERVICE_UNAVAILABLE,
local_execution_runtime_miss_detail,
local_execution_runtime_miss_detail.as_str(),
)?;
if let Some(diagnostic) = local_execution_runtime_miss_diagnostic {
if !diagnostic.reason.trim().is_empty() {
@@ -1095,6 +1160,41 @@ pub(crate) async fn proxy_request(
fn local_execution_runtime_miss_detail(
decision: Option<&GatewayControlDecision>,
diagnostic: Option<&LocalExecutionRuntimeMissDiagnostic>,
stream_request: bool,
) -> Option<String> {
if let Some(detail) = local_execution_runtime_miss_model_detail(diagnostic, stream_request) {
return Some(detail);
}
local_execution_runtime_miss_route_detail(decision).map(ToOwned::to_owned)
}
fn local_execution_runtime_miss_model_detail(
diagnostic: Option<&LocalExecutionRuntimeMissDiagnostic>,
stream_request: bool,
) -> Option<String> {
let diagnostic = diagnostic?;
if !matches!(
diagnostic.reason.as_str(),
"candidate_list_empty" | "all_candidates_skipped"
) {
return None;
}
let requested_model = diagnostic
.requested_model
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())?;
let request_mode = if stream_request { "流式" } else { "同步" };
Some(format!(
"没有可用的提供商支持模型 {requested_model}{request_mode}请求"
))
}
fn local_execution_runtime_miss_route_detail(
decision: Option<&GatewayControlDecision>,
) -> Option<&'static str> {
let decision = decision?;
if decision.route_class.as_deref() != Some("ai_public") {
@@ -1121,6 +1221,60 @@ fn local_execution_runtime_miss_detail(
}
}
#[cfg(test)]
mod tests {
use super::{
local_execution_runtime_miss_detail, GatewayControlDecision,
LocalExecutionRuntimeMissDiagnostic,
};
#[test]
fn runtime_miss_detail_returns_model_specific_stream_message_when_candidates_are_unavailable() {
let decision = GatewayControlDecision::synthetic(
"/v1/chat/completions",
Some("ai_public".to_string()),
Some("openai".to_string()),
Some("chat".to_string()),
Some("openai:chat".to_string()),
);
let diagnostic = LocalExecutionRuntimeMissDiagnostic {
reason: "candidate_list_empty".to_string(),
requested_model: Some("gpt-5.4".to_string()),
..LocalExecutionRuntimeMissDiagnostic::default()
};
let detail = local_execution_runtime_miss_detail(Some(&decision), Some(&diagnostic), true);
assert_eq!(
detail.as_deref(),
Some("没有可用的提供商支持模型 gpt-5.4 的流式请求")
);
}
#[test]
fn runtime_miss_detail_falls_back_to_route_default_when_reason_is_not_model_unavailable() {
let decision = GatewayControlDecision::synthetic(
"/v1/messages",
Some("ai_public".to_string()),
Some("claude".to_string()),
Some("chat".to_string()),
Some("claude:chat".to_string()),
);
let diagnostic = LocalExecutionRuntimeMissDiagnostic {
reason: "missing_auth_context".to_string(),
requested_model: Some("claude-sonnet-4-5".to_string()),
..LocalExecutionRuntimeMissDiagnostic::default()
};
let detail = local_execution_runtime_miss_detail(Some(&decision), Some(&diagnostic), false);
assert_eq!(
detail.as_deref(),
Some("Claude messages execution runtime miss did not match a Rust execution path")
);
}
}
#[path = "finalize.rs"]
mod finalize;

View File

@@ -1060,23 +1060,21 @@ pub(super) async fn handle_dashboard_recent_requests_get(
.into_iter()
.collect::<Vec<_>>();
let users_by_id: BTreeMap<String, aether_data::repository::users::StoredUserSummary> =
if state.has_user_data_reader() && !user_ids.is_empty() {
match state.list_users_by_ids(&user_ids).await {
Ok(value) => value
.into_iter()
.map(|user| (user.id.clone(), user))
.collect(),
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("dashboard user lookup failed: {err:?}"),
false,
)
}
match state.resolve_auth_user_summaries_by_ids(&user_ids).await {
Ok(value) => value,
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("dashboard user lookup failed: {err:?}"),
false,
)
}
} else {
BTreeMap::new()
};
let mut usernames_by_id: BTreeMap<String, String> = users_by_id
.iter()
.filter(|(_, user)| !user.username.trim().is_empty())
.map(|(user_id, user)| (user_id.clone(), user.username.clone()))
.collect();
let requests = usage
.into_iter()
@@ -1084,9 +1082,13 @@ pub(super) async fn handle_dashboard_recent_requests_get(
let username = item
.user_id
.as_ref()
.and_then(|user_id| users_by_id.get(user_id))
.map(|user| user.username.clone())
.or(item.username.clone())
.and_then(|user_id| usernames_by_id.get(user_id))
.cloned()
.or_else(|| {
(!state.has_auth_user_data_reader())
.then(|| item.username.clone())
.flatten()
})
.unwrap_or_else(|| "Unknown".to_string());
json!({
"id": item.id,

View File

@@ -530,8 +530,17 @@ pub(super) async fn handle_users_me_api_key_create(
key_hash: hash_users_me_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,
};
let Some(created) = (match state.create_user_api_key(record).await {
Ok(value) => value,

View File

@@ -145,32 +145,56 @@ fn users_me_usage_cache_hit_rate(total_input_context: u64, cache_read_tokens: u6
}
}
fn users_me_usage_matches_search(item: &StoredRequestUsageAudit, search: Option<&str>) -> bool {
fn users_me_usage_api_key_name(
item: &StoredRequestUsageAudit,
api_key_names: &BTreeMap<String, String>,
auth_api_key_reader_available: bool,
) -> Option<String> {
item.api_key_id
.as_ref()
.and_then(|api_key_id| api_key_names.get(api_key_id))
.cloned()
.or_else(|| {
(!auth_api_key_reader_available)
.then(|| item.api_key_name.clone())
.flatten()
})
}
fn users_me_usage_matches_search(
item: &StoredRequestUsageAudit,
search: Option<&str>,
api_key_names: &BTreeMap<String, String>,
auth_api_key_reader_available: bool,
) -> bool {
let Some(search) = search.map(str::trim).filter(|value| !value.is_empty()) else {
return true;
};
let model = item.model.to_ascii_lowercase();
let api_key_name = item
.api_key_name
.as_deref()
.unwrap_or_default()
.to_ascii_lowercase();
let api_key_name =
users_me_usage_api_key_name(item, api_key_names, auth_api_key_reader_available)
.as_deref()
.unwrap_or_default()
.to_ascii_lowercase();
search.split_whitespace().all(|keyword| {
let keyword = keyword.to_ascii_lowercase();
model.contains(&keyword) || api_key_name.contains(&keyword)
})
}
fn build_users_me_usage_api_key_payload(item: &StoredRequestUsageAudit) -> serde_json::Value {
fn build_users_me_usage_api_key_payload(
item: &StoredRequestUsageAudit,
api_key_names: &BTreeMap<String, String>,
auth_api_key_reader_available: bool,
) -> serde_json::Value {
let api_key_name =
users_me_usage_api_key_name(item, api_key_names, auth_api_key_reader_available);
match item.api_key_id.as_deref() {
Some(api_key_id) => json!({
"id": api_key_id,
"name": item.api_key_name.clone(),
"display": item
.api_key_name
.clone()
.unwrap_or_else(|| api_key_id.to_string()),
"name": api_key_name.clone(),
"display": api_key_name.unwrap_or_else(|| api_key_id.to_string()),
}),
None => serde_json::Value::Null,
}
@@ -179,7 +203,14 @@ fn build_users_me_usage_api_key_payload(item: &StoredRequestUsageAudit) -> serde
fn build_users_me_usage_record_payload(
item: &StoredRequestUsageAudit,
include_actual_cost: bool,
api_key_names: &BTreeMap<String, String>,
auth_api_key_reader_available: bool,
) -> serde_json::Value {
let input_price_per_1m = item.settlement_input_price_per_1m();
let output_price_per_1m = item.settlement_output_price_per_1m();
let cache_creation_price_per_1m = item.settlement_cache_creation_price_per_1m();
let cache_read_price_per_1m = item.settlement_cache_read_price_per_1m();
let rate_multiplier = item.settlement_rate_multiplier();
let mut payload = json!({
"id": item.id,
"model": item.model,
@@ -203,11 +234,15 @@ fn build_users_me_usage_record_payload(
"cache_read_input_tokens": item.cache_read_input_tokens,
"status_code": item.status_code,
"error_message": item.error_message,
"input_price_per_1m": serde_json::Value::Null,
"output_price_per_1m": item.output_price_per_1m,
"cache_creation_price_per_1m": serde_json::Value::Null,
"cache_read_price_per_1m": serde_json::Value::Null,
"api_key": build_users_me_usage_api_key_payload(item),
"input_price_per_1m": input_price_per_1m,
"output_price_per_1m": output_price_per_1m,
"cache_creation_price_per_1m": cache_creation_price_per_1m,
"cache_read_price_per_1m": cache_read_price_per_1m,
"api_key": build_users_me_usage_api_key_payload(
item,
api_key_names,
auth_api_key_reader_available,
),
});
if item.target_model.is_some() {
@@ -215,7 +250,7 @@ fn build_users_me_usage_record_payload(
}
if include_actual_cost {
payload["actual_cost"] = json!(round_to(item.actual_total_cost_usd, 6));
payload["rate_multiplier"] = serde_json::Value::Null;
payload["rate_multiplier"] = json!(rate_multiplier);
}
payload
}
@@ -233,7 +268,7 @@ fn build_users_me_usage_active_payload(item: &StoredRequestUsageAudit) -> serde_
"cache_read_input_tokens": item.cache_read_input_tokens,
"cost": round_to(item.total_cost_usd, 6),
"actual_cost": round_to(item.actual_total_cost_usd, 6),
"rate_multiplier": serde_json::Value::Null,
"rate_multiplier": item.settlement_rate_multiplier(),
"response_time_ms": item.response_time_ms,
"first_byte_time_ms": item.first_byte_time_ms,
"api_format": item.api_format,
@@ -675,6 +710,30 @@ pub(super) async fn handle_users_me_usage_get(
)
}
};
let api_key_names = if state.has_auth_api_key_data_reader() {
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() {
BTreeMap::new()
} else {
match state.resolve_auth_api_key_names_by_ids(&api_key_ids).await {
Ok(value) => value,
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("user api key name lookup failed: {err:?}"),
false,
)
}
}
}
} else {
BTreeMap::new()
};
let summary_items = usage
.iter()
@@ -738,7 +797,14 @@ pub(super) async fn handle_users_me_usage_get(
let mut records = usage
.into_iter()
.filter(|item| users_me_usage_matches_search(item, search.as_deref()))
.filter(|item| {
users_me_usage_matches_search(
item,
search.as_deref(),
&api_key_names,
state.has_auth_api_key_data_reader(),
)
})
.collect::<Vec<_>>();
records.sort_by(|left, right| {
right
@@ -751,7 +817,14 @@ pub(super) async fn handle_users_me_usage_get(
.into_iter()
.skip(offset)
.take(limit)
.map(|item| build_users_me_usage_record_payload(&item, include_actual_cost))
.map(|item| {
build_users_me_usage_record_payload(
&item,
include_actual_cost,
&api_key_names,
state.has_auth_api_key_data_reader(),
)
})
.collect::<Vec<_>>();
let wallet = state

View File

@@ -245,6 +245,7 @@ pub(crate) fn admin_proxy_local_requires_buffered_body(
| (Some("provider_oauth_manage"), http::Method::POST, Some("device_authorize"))
| (Some("provider_oauth_manage"), http::Method::POST, Some("device_poll"))
| (Some("system_manage"), http::Method::POST, Some("config_import"))
| (Some("system_manage"), http::Method::POST, Some("users_import"))
| (Some("system_manage"), http::Method::PUT, Some("settings_set"))
| (Some("system_manage"), http::Method::PUT, Some("config_set"))
| (Some("system_manage"), http::Method::PUT, Some("email_template_set"))