fix: 修复账号批量操作快捷多选不生效和今日 Token 不准确 (#328)

* fix(pool): 修复账号批量操作快捷多选不生效

- 解析号池账号列表接口的 quick_selectors 查询参数
- 列表查询复用现有快捷筛选匹配逻辑
- 支持冷却列表与普通列表的快捷筛选后分页
- 更新架构测试中的 pool admin helper 边界断言

* fix(dashboard): 今日 Token 改为按分项汇总口径展示

- /api/dashboard/stats 的 today.tokens 改为 输入+输出+写缓存+读缓存
- “今日 Token”卡片 value 与 subValue 口径保持一致
- 同步更新 dashboard 相关测试断言
This commit is contained in:
AAEE86
2026-04-24 18:36:38 +08:00
committed by GitHub
parent 3cc54deb9c
commit 4618184516
6 changed files with 91 additions and 11 deletions

View File

@@ -35,8 +35,8 @@ pub(crate) use self::batch_shared::{
};
pub(crate) use self::support::{
admin_pool_provider_id_from_path, parse_admin_pool_page, parse_admin_pool_page_size,
parse_admin_pool_search, parse_admin_pool_status_filter, AdminPoolResolveSelectionRequest,
ADMIN_POOL_BANNED_KEY_CLEANUP_EMPTY_MESSAGE,
parse_admin_pool_quick_selectors, parse_admin_pool_search, parse_admin_pool_status_filter,
AdminPoolResolveSelectionRequest, ADMIN_POOL_BANNED_KEY_CLEANUP_EMPTY_MESSAGE,
ADMIN_POOL_PROVIDER_CATALOG_READER_UNAVAILABLE_DETAIL,
ADMIN_POOL_PROVIDER_CATALOG_WRITER_UNAVAILABLE_DETAIL,
};

View File

@@ -1,13 +1,14 @@
use super::{
admin_pool_provider_id_from_path, admin_provider_pool_config, build_admin_pool_error_response,
parse_admin_pool_page, parse_admin_pool_page_size, parse_admin_pool_search,
parse_admin_pool_status_filter, pool_payloads, pool_selection,
parse_admin_pool_page, parse_admin_pool_page_size, parse_admin_pool_quick_selectors,
parse_admin_pool_search, parse_admin_pool_status_filter, pool_payloads, pool_selection,
read_admin_provider_pool_cooldown_key_ids, read_admin_provider_pool_runtime_state,
AdminProviderPoolRuntimeState, ProviderCatalogKeyListOrder, ProviderCatalogKeyListQuery,
ADMIN_POOL_PROVIDER_CATALOG_READER_UNAVAILABLE_DETAIL,
};
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::GatewayError;
use aether_admin::provider::pool as admin_provider_pool_pure;
use axum::{
body::Body,
http,
@@ -53,6 +54,9 @@ pub(super) async fn build_admin_pool_list_keys_response(
}
};
let search = parse_admin_pool_search(query).map(|value| value.to_ascii_lowercase());
let quick_selectors = admin_provider_pool_pure::admin_pool_sanitize_quick_selectors(
parse_admin_pool_quick_selectors(query),
);
let status = match parse_admin_pool_status_filter(query) {
Ok(value) => value,
Err(detail) => {
@@ -93,10 +97,63 @@ pub(super) async fn build_admin_pool_list_keys_response(
};
if let Some(keyword) = search.as_ref() {
keys.retain(|key| {
key.name.to_ascii_lowercase().contains(keyword)
|| key.id.to_ascii_lowercase().contains(keyword)
pool_selection::admin_pool_matches_search(
state,
key,
&provider.provider_type,
Some(keyword),
)
});
}
if !quick_selectors.is_empty() {
keys.retain(|key| {
quick_selectors.iter().all(|selector| {
pool_selection::admin_pool_matches_quick_selector(
state,
key,
&provider.provider_type,
selector,
)
})
});
}
pool_selection::admin_pool_sort_keys(&mut keys);
let total = keys.len();
let keys = keys
.into_iter()
.skip(page_offset)
.take(page_size)
.collect::<Vec<_>>();
(keys, total)
} else if !quick_selectors.is_empty() {
let mut keys = state
.list_provider_catalog_keys_by_provider_ids(std::slice::from_ref(&provider.id))
.await?
.into_iter()
.filter(|key| match status.as_str() {
"active" => key.is_active,
"inactive" => !key.is_active,
_ => true,
})
.filter(|key| {
pool_selection::admin_pool_matches_search(
state,
key,
&provider.provider_type,
search.as_deref(),
)
})
.filter(|key| {
quick_selectors.iter().all(|selector| {
pool_selection::admin_pool_matches_quick_selector(
state,
key,
&provider.provider_type,
selector,
)
})
})
.collect::<Vec<_>>();
pool_selection::admin_pool_sort_keys(&mut keys);
let total = keys.len();
let keys = keys

View File

@@ -60,6 +60,18 @@ pub(crate) fn parse_admin_pool_search(query: Option<&str>) -> Option<String> {
.filter(|value| !value.is_empty())
}
pub(crate) fn parse_admin_pool_quick_selectors(query: Option<&str>) -> Vec<String> {
query_param_value(query, "quick_selectors")
.map(|value| {
value
.split(',')
.map(|item| item.trim().to_string())
.filter(|item| !item.is_empty())
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
pub(crate) fn parse_admin_pool_status_filter(query: Option<&str>) -> Result<String, String> {
let value = query_param_value(query, "status")
.unwrap_or_else(|| "all".to_string())

View File

@@ -213,6 +213,14 @@ fn dashboard_format_today_token_subvalue(totals: &DashboardUsageTotals) -> Strin
)
}
fn dashboard_today_token_value_from_subvalue(totals: &DashboardUsageTotals) -> u64 {
totals
.effective_input_tokens
.saturating_add(totals.output_tokens)
.saturating_add(totals.cache_creation_tokens)
.saturating_add(totals.cache_read_tokens)
}
fn dashboard_parse_tz_offset_minutes(query: Option<&str>) -> Result<i32, String> {
query_param_value(query, "tz_offset_minutes")
.map(|value| {
@@ -1591,9 +1599,10 @@ pub(super) async fn handle_dashboard_stats_get(
"cache_creation": period_totals.cache_creation_tokens,
"cache_read": period_totals.cache_read_tokens,
});
let today_token_value = dashboard_today_token_value_from_subvalue(&today_totals);
let today_payload = json!({
"requests": today_totals.requests,
"tokens": today_totals.total_tokens,
"tokens": today_token_value,
"cost": dashboard_round_f64(today_totals.total_cost_usd, 4),
"actual_cost": dashboard_round_f64(today_totals.actual_total_cost_usd, 4),
"cache_creation_tokens": today_totals.cache_creation_tokens,
@@ -1634,7 +1643,7 @@ pub(super) async fn handle_dashboard_stats_get(
},
{
"name": "今日 Token",
"value": dashboard_format_token_compact(today_totals.total_tokens),
"value": dashboard_format_token_compact(today_token_value),
"subValue": dashboard_format_today_token_subvalue(&today_totals),
"icon": "Zap",
},

View File

@@ -858,6 +858,7 @@ fn admin_provider_pool_admin_mod_stays_thin() {
"struct AdminPoolResolveSelectionRequest",
"fn parse_admin_pool_page(",
"fn parse_admin_pool_page_size(",
"fn parse_admin_pool_quick_selectors(",
"fn parse_admin_pool_search(",
"fn parse_admin_pool_status_filter(",
"fn admin_pool_provider_id_from_path(",
@@ -880,6 +881,7 @@ fn admin_provider_pool_admin_mod_stays_thin() {
"pub(crate) use aether_admin::provider::pool::AdminPoolResolveSelectionRequest;",
"pub(crate) fn parse_admin_pool_page(",
"pub(crate) fn parse_admin_pool_page_size(",
"pub(crate) fn parse_admin_pool_quick_selectors(",
"pub(crate) fn parse_admin_pool_search(",
"pub(crate) fn parse_admin_pool_status_filter(",
"pub(crate) fn admin_pool_provider_id_from_path(",

View File

@@ -157,7 +157,7 @@ async fn gateway_handles_dashboard_stats_locally_without_proxying_upstream() {
assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["today"]["requests"], 1);
assert_eq!(payload["today"]["tokens"], 150);
assert_eq!(payload["today"]["tokens"], 160);
assert_eq!(payload["api_keys"]["total"], 2);
assert_eq!(payload["api_keys"]["active"], 1);
assert_eq!(payload["stats"][3]["subValue"], json!("输入 240 / 输出 60"));
@@ -405,10 +405,10 @@ async fn gateway_handles_admin_dashboard_stats_locally_without_proxying_upstream
assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["today"]["requests"], 2);
assert_eq!(payload["today"]["tokens"], 16_000);
assert_eq!(payload["today"]["tokens"], 17_450);
assert_eq!(payload["today"]["cost"], json!(2.5));
assert_eq!(payload["stats"][0]["value"], json!("2"));
assert_eq!(payload["stats"][1]["value"], json!("16K"));
assert_eq!(payload["stats"][1]["value"], json!("17.4K"));
assert_eq!(
payload["stats"][1]["subValue"],
json!("输入 12.1K / 输出 3.1K · 写缓存 1.25K / 读缓存 1K")