diff --git a/backend/crates/gateway-api/src/admin/accounts.rs b/backend/crates/gateway-api/src/admin/accounts.rs index 515a6c8bb..43795dd68 100644 --- a/backend/crates/gateway-api/src/admin/accounts.rs +++ b/backend/crates/gateway-api/src/admin/accounts.rs @@ -16,7 +16,7 @@ use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use chrono::{DateTime, FixedOffset, Utc}; use futures::{Stream, StreamExt as _}; use gateway_admin::model::{ - AdminError as AdminServiceError, AdminErrorKind, PageSize, + AdminError as AdminServiceError, PageSize, accounts::{ AccountConnectionTestEvent as DomainConnectionTestEvent, AccountCost, AccountListQuery, AccountModelUsage, AccountSort, AccountSortField, AccountStatus as DomainAccountStatus, @@ -37,6 +37,7 @@ use gateway_core::{ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; +use super::presenter::{format_compact_number, format_number}; use super::{ AdminAuth, AdminEnvelope, AdminError, AdminResponse, AdminSessionState, PageMeta, WireValidationError, @@ -1468,7 +1469,7 @@ fn quota_local_usage(usage: &AccountUsage) -> Value { "imageRequestCount": usage.image_request_count, "imageRequestFailedCount": usage.image_request_failed_count, "totalTokens": total_tokens, - "totalTokensDisplay": format_tokens(total_tokens), + "totalTokensDisplay": format_compact_number(total_tokens), "requestBuckets": usage.request_buckets.iter().map(|bucket| serde_json::json!({ "bucketStart": bucket.bucket_start, "requestCount": bucket.request_count, @@ -1601,7 +1602,7 @@ fn account_currency_cost_view(cost: &AccountCost) -> CurrencyCostView { } fn display_optional_tokens(value: Option) -> String { - value.map_or_else(|| "—".to_owned(), format_tokens) + value.map_or_else(|| "—".to_owned(), format_compact_number) } fn empty_account_usage() -> AccountUsageView { @@ -1719,37 +1720,6 @@ fn account_status_name(status: DomainAccountStatus) -> &'static str { } } -fn format_number(value: u64) -> String { - let text = value.to_string(); - let mut output = String::with_capacity(text.len() + text.len() / 3); - for (index, character) in text.chars().rev().enumerate() { - if index > 0 && index % 3 == 0 { - output.push(','); - } - output.push(character); - } - output.chars().rev().collect() -} - -fn format_tokens(value: u64) -> String { - if value < 1_000 { - return format_number(value); - } - for (suffix, threshold) in [ - ("P", 1_000_000_000_000_000_u64), - ("T", 1_000_000_000_000_u64), - ("B", 1_000_000_000_u64), - ("M", 1_000_000_u64), - ("K", 1_000_u64), - ] { - if value >= threshold { - let scaled = value as f64 / threshold as f64; - return format!("{scaled:.1}{suffix}").replace(".0", ""); - } - } - format_number(value) -} - fn relative_time(value: DateTime, now: DateTime) -> String { let elapsed = now.signed_duration_since(value); if elapsed.num_seconds() < 0 { @@ -1787,16 +1757,5 @@ fn map_wire_error(error: WireValidationError) -> AdminError { } fn map_service_error(error: AdminServiceError) -> AdminError { - match error.kind() { - AdminErrorKind::Invalid => AdminError::bad_request(error.to_string()), - AdminErrorKind::Unauthorized => AdminError::admin_session_required(), - AdminErrorKind::NotFound => AdminError::not_found(error.to_string()), - AdminErrorKind::Conflict => AdminError::conflict(error.to_string()), - AdminErrorKind::RateLimited => AdminError::too_many_login_attempts(), - AdminErrorKind::BadGateway => AdminError::bad_gateway(error.to_string()), - AdminErrorKind::Unavailable => { - AdminError::service_unavailable("Account directory unavailable") - } - AdminErrorKind::Internal => AdminError::internal(error.to_string()), - } + super::wire::map_admin_service_error(error, "Account directory unavailable") } diff --git a/backend/crates/gateway-api/src/admin/mod.rs b/backend/crates/gateway-api/src/admin/mod.rs index 7e78713f6..1734c557e 100644 --- a/backend/crates/gateway-api/src/admin/mod.rs +++ b/backend/crates/gateway-api/src/admin/mod.rs @@ -12,6 +12,7 @@ pub mod auth; pub mod backups; pub mod client_keys; pub mod observability; +pub mod presenter; pub mod settings; pub mod system; pub mod wire; diff --git a/backend/crates/gateway-api/src/admin/observability.rs b/backend/crates/gateway-api/src/admin/observability.rs index 4424ea202..f441d36e6 100644 --- a/backend/crates/gateway-api/src/admin/observability.rs +++ b/backend/crates/gateway-api/src/admin/observability.rs @@ -10,14 +10,15 @@ use axum::{ routing::get, }; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; -use chrono::{DateTime, Duration, Timelike as _, Utc}; +use chrono::{DateTime, Duration, Utc}; use gateway_admin::model::{PageSize as DomainPageSize, observability as domain}; use serde::{Deserialize, Serialize}; use serde_json::Value; +use super::presenter::{format_compact_number, format_number}; use super::{ - AdminAuth, AdminEnvelope, AdminError, AdminResponse, AdminSessionState, WireValidationError, - wire::map_admin_service_error, + AdminAuth, AdminEnvelope, AdminError, AdminResponse, AdminSessionState, PageMeta, + WireValidationError, wire::map_admin_service_error, }; /// 观测列表默认页大小。 @@ -329,7 +330,8 @@ fn dashboard_range( end: Option<&str>, ) -> Result { let end = parse_datetime(end)?.unwrap_or_else(Utc::now); - let start = parse_datetime(start)?.unwrap_or_else(|| china_day_start(end) - Duration::days(1)); + let start = + parse_datetime(start)?.unwrap_or_else(|| domain::china_day_start(end) - Duration::days(1)); domain::TimeRange::new(start, end).map_err(|_| WireValidationError::new("timeRange")) } @@ -338,7 +340,7 @@ fn dashboard_today_range( end: Option<&str>, ) -> Result { let end = parse_datetime(end)?.unwrap_or_else(Utc::now); - let start = parse_datetime(start)?.unwrap_or_else(|| china_day_start(end)); + let start = parse_datetime(start)?.unwrap_or_else(|| domain::china_day_start(end)); domain::TimeRange::new(start, end).map_err(|_| WireValidationError::new("timeRange")) } @@ -481,29 +483,6 @@ pub struct CursorWire { pub stable_id: String, } -/// 观测列表分页元数据。 -#[derive(Debug, Clone, Copy, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct PageMeta { - pub page: u32, - pub page_size: u16, - pub total: u64, - pub total_pages: u32, -} - -impl PageMeta { - /// 由应用层已校验的分页事实构造响应元数据。 - #[must_use] - pub const fn new(page: u32, page_size: u16, total: u64, total_pages: u32) -> Self { - Self { - page, - page_size, - total, - total_pages, - } - } -} - /// 观测列表响应数据。 #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] @@ -1443,37 +1422,6 @@ where Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data))) } -fn format_number(value: u64) -> String { - let text = value.to_string(); - let mut output = String::with_capacity(text.len() + text.len() / 3); - for (index, character) in text.chars().rev().enumerate() { - if index > 0 && index % 3 == 0 { - output.push(','); - } - output.push(character); - } - output.chars().rev().collect() -} - -fn format_compact_number(value: u64) -> String { - if value < 1_000 { - return format_number(value); - } - for (suffix, threshold) in [ - ("P", 1_000_000_000_000_000_u64), - ("T", 1_000_000_000_000_u64), - ("B", 1_000_000_000_u64), - ("M", 1_000_000_u64), - ("K", 1_000_u64), - ] { - if value >= threshold { - let scaled = value as f64 / threshold as f64; - return format!("{scaled:.1}{suffix}").replace(".0", ""); - } - } - format_number(value) -} - fn display_duration(value: Option) -> String { let Some(value) = value.and_then(|value| i64::try_from(value).ok()) else { return "—".to_owned(); @@ -1497,12 +1445,6 @@ fn display_rate(value: f64) -> String { } } -fn china_day_start(value: DateTime) -> DateTime { - const CHINA_OFFSET_SECONDS: i64 = 8 * 60 * 60; - let elapsed = (value.timestamp() + CHINA_OFFSET_SECONDS).rem_euclid(24 * 60 * 60); - value - Duration::seconds(elapsed) - Duration::nanoseconds(i64::from(value.nanosecond())) -} - fn china_datetime(value: &DateTime) -> String { (*value + Duration::hours(8)) .format("%Y-%m-%d %H:%M:%S") @@ -1908,7 +1850,7 @@ fn page_meta(page: u32, page_size: u16, total: u64) -> PageMeta { let total_pages = total.saturating_add(page_size_u64 - 1) / page_size_u64; PageMeta::new( page, - page_size, + u32::from(page_size), total, u32::try_from(total_pages).unwrap_or(u32::MAX), ) diff --git a/backend/crates/gateway-api/src/admin/presenter.rs b/backend/crates/gateway-api/src/admin/presenter.rs new file mode 100644 index 000000000..e8838a3c3 --- /dev/null +++ b/backend/crates/gateway-api/src/admin/presenter.rs @@ -0,0 +1,39 @@ +//! Admin API 的展示层辅助:数值格式化。 +//! +//! 按 docs/project-redundancy-boundary-audit.md BE-02 的目标,display 字段最终 +//! 迁移到前端;过渡期统一由本模块提供,避免 accounts/observability 各自维护。 + +/// 千分位格式化。 +#[must_use] +pub fn format_number(value: u64) -> String { + let text = value.to_string(); + let mut output = String::with_capacity(text.len() + text.len() / 3); + for (index, character) in text.chars().rev().enumerate() { + if index > 0 && index % 3 == 0 { + output.push(','); + } + output.push(character); + } + output.chars().rev().collect() +} + +/// 紧凑格式化:≥1000 时用 K/M/B/T/P 后缀,否则千分位。 +#[must_use] +pub fn format_compact_number(value: u64) -> String { + if value < 1_000 { + return format_number(value); + } + for (suffix, threshold) in [ + ("P", 1_000_000_000_000_000_u64), + ("T", 1_000_000_000_000_u64), + ("B", 1_000_000_000_u64), + ("M", 1_000_000_u64), + ("K", 1_000_u64), + ] { + if value >= threshold { + let scaled = value as f64 / threshold as f64; + return format!("{scaled:.1}{suffix}").replace(".0", ""); + } + } + format_number(value) +} diff --git a/backend/crates/gateway-api/tests/admin/observability.rs b/backend/crates/gateway-api/tests/admin/observability.rs index 2e35984ff..2fe9a0f32 100644 --- a/backend/crates/gateway-api/tests/admin/observability.rs +++ b/backend/crates/gateway-api/tests/admin/observability.rs @@ -66,11 +66,12 @@ mod query { mod response { use chrono::{TimeZone, Utc}; use gateway_admin::model::observability::DesktopReleaseStatus; + use gateway_api::admin::PageMeta; use gateway_api::admin::observability::{ BillingView, CostCoverageView, CursorWire, DashboardAccountRequestBucketView, DashboardAccountUsageView, DashboardDesktopReleaseStatusView, DashboardWireAttributeView, - DashboardWireProfileView, DashboardWireTargetView, PageData, PageMeta, TokenDetailsView, - TrendData, TrendKind, TrendPointView, TrendSummaryView, + DashboardWireProfileView, DashboardWireTargetView, PageData, TokenDetailsView, TrendData, + TrendKind, TrendPointView, TrendSummaryView, }; use serde_json::json; diff --git a/backend/crates/gateway-api/tests/architecture.rs b/backend/crates/gateway-api/tests/architecture.rs index 284ea6c9d..39a54ae0b 100644 --- a/backend/crates/gateway-api/tests/architecture.rs +++ b/backend/crates/gateway-api/tests/architecture.rs @@ -39,6 +39,7 @@ fn source_tree_should_match_frozen_machine_manifest() { "src/admin/client_keys.rs", "src/admin/mod.rs", "src/admin/observability.rs", + "src/admin/presenter.rs", "src/admin/settings.rs", "src/admin/system.rs", "src/admin/wire.rs",