From d22ec298248c2b5c5c58dfb3adb5f4cf36e18d2b Mon Sep 17 00:00:00 2001 From: Zyy Date: Mon, 2 Nov 2026 14:39:44 +0800 Subject: [PATCH] fix(usage): show CPR badges for linked accounts Use current peer summaries for request records and account diagnostics without changing historical snapshots or database schema. Co-authored-by: Codex --- .../gateway-admin/src/model/observability.rs | 4 ++ .../src/use_case/observability/mod.rs | 52 +++++++++++++- .../tests/use_case/observability/mod.rs | 1 + .../src/admin/observability/presenter.rs | 2 + .../src/admin/observability/wire.rs | 2 + .../tests/admin/observability/response.rs | 67 +++++++++++++++++-- .../gateway-api/tests/key_usage/fixtures.rs | 1 + .../src/postgres/observability/mapping.rs | 1 + docs/api.md | 6 +- frontend/src/api/modules/usage.ts | 2 + .../components/account/AccountPlanBadge.vue | 3 +- .../components/usage/UsageRecordsTable.vue | 3 +- .../src/views/theme/fixtures/dashboard.ts | 1 + .../usage/components/UsageDiagnosticCard.vue | 3 +- 14 files changed, 137 insertions(+), 11 deletions(-) diff --git a/backend/crates/gateway-admin/src/model/observability.rs b/backend/crates/gateway-admin/src/model/observability.rs index 02aa76b8f..f1ec86de1 100644 --- a/backend/crates/gateway-admin/src/model/observability.rs +++ b/backend/crates/gateway-admin/src/model/observability.rs @@ -739,6 +739,8 @@ pub struct UsageListRecord { /// 请求选号时冻结的账号套餐 pub provider_account_plan_type: Option, pub provider_account_plan_type_display: Option, + /// 查询时的互联识别结果,缺少当前摘要时为未知 + pub provider_account_upstream_peer: Option, pub provider_account_authentication_kind: Option, pub upstream_model_id: Option, pub upstream_transport: Option, @@ -1439,6 +1441,8 @@ pub struct DiagnosticsItem { pub name: String, pub account_authentication_kind: Option, pub account_plan_type: Option, + /// 仅账号维度补充当前互联识别结果 + pub account_upstream_peer: Option, pub account_plan_type_display: Option, pub request_count: u64, pub success_count: u64, diff --git a/backend/crates/gateway-admin/src/use_case/observability/mod.rs b/backend/crates/gateway-admin/src/use_case/observability/mod.rs index 50bf7d900..389dd9da2 100644 --- a/backend/crates/gateway-admin/src/use_case/observability/mod.rs +++ b/backend/crates/gateway-admin/src/use_case/observability/mod.rs @@ -8,6 +8,7 @@ use std::{ use async_trait::async_trait; use chrono::{DateTime, Duration, Utc}; +use futures::StreamExt as _; use gateway_core::{account::ProviderAccountId, routing::ProviderKind}; use crate::{ @@ -133,7 +134,8 @@ impl DefaultObservabilityService { ) .map_err(|error| map_store_error(error, "dashboard"))?; let runtime_slots = observation.runtime_slots; - self.enrich_list_records(&mut observation.recent_requests); + self.enrich_list_records(&mut observation.recent_requests) + .await; self.enrich_dashboard_quotas(&mut observation.account_usage) .await; for account in &mut observation.account_usage { @@ -259,7 +261,7 @@ impl ObservabilityService for DefaultObservabilityService { .list_usage_records(query) .await .map_err(|error| map_store_error(error, "usage records"))?; - self.enrich_list_records(&mut page.items); + self.enrich_list_records(&mut page.items).await; Ok(page) } @@ -336,6 +338,12 @@ impl ObservabilityService for DefaultObservabilityService { .usage_diagnostics(range, filter, dimension, 100) .await .map_err(|error| map_store_error(error, "usage diagnostics"))?; + let peers = if dimension == DiagnosticDimension::Account { + self.current_upstream_peers(observation.items.iter().map(|item| item.key.as_str())) + .await + } else { + HashMap::new() + }; let items = observation .items .into_iter() @@ -345,6 +353,7 @@ impl ObservabilityService for DefaultObservabilityService { rate_or_zero(item.non_completion_count, item.request_count); let retry_rate = rate_or_zero(item.retried_request_count, item.request_count); DiagnosticsItem { + account_upstream_peer: peers.get(&item.key).copied(), key: item.key, name: item.name, account_authentication_kind: item.account_authentication_kind, @@ -912,6 +921,29 @@ fn usd_cost(costs: &[CurrencyCost]) -> Option { } impl DefaultObservabilityService { + async fn current_upstream_peers<'a>( + &self, + ids: impl Iterator, + ) -> HashMap { + let Some(peer) = self.upstream_info.as_deref() else { + return HashMap::new(); + }; + let ids = ids + .map(str::to_owned) + .collect::>(); + // 同一页重复账号只读取一次摘要,单个账号不存在或读取失败不阻断历史记录 + futures::stream::iter(ids) + .map(|id: String| async move { + let account_id = ProviderAccountId::new(id.clone()).ok()?; + let upstream = peer.read_summary(&account_id).await.ok()??.upstream?; + Some((id, upstream.discovered && upstream.observed_at.is_some())) + }) + .buffer_unordered(8) + .filter_map(|value| async move { value }) + .collect() + .await + } + async fn enrich_dashboard_quotas(&self, accounts: &mut [DashboardAccountUsage]) { if accounts.is_empty() { return; @@ -976,8 +1008,22 @@ impl DefaultObservabilityService { /// 补充当前套餐展示名称,逐条尽力把可校验的总额升级为完整分解;单条脏数据(非法 Provider kind、 /// 不支持的来源或费用规则失败)只保留该条已存的总额,不影响整页返回 - fn enrich_list_records(&self, records: &mut [crate::model::observability::UsageListRecord]) { + async fn enrich_list_records( + &self, + records: &mut [crate::model::observability::UsageListRecord], + ) { + let peers = self + .current_upstream_peers( + records + .iter() + .filter_map(|record| record.provider_account_ref.as_deref()), + ) + .await; for record in records { + record.provider_account_upstream_peer = record + .provider_account_ref + .as_ref() + .and_then(|id| peers.get(id).copied()); record.provider_account_plan_type_display = record.provider_kind.as_deref().and_then(|provider| { self.providers diff --git a/backend/crates/gateway-admin/tests/use_case/observability/mod.rs b/backend/crates/gateway-admin/tests/use_case/observability/mod.rs index 7a6dec205..a213108dd 100644 --- a/backend/crates/gateway-admin/tests/use_case/observability/mod.rs +++ b/backend/crates/gateway-admin/tests/use_case/observability/mod.rs @@ -1479,6 +1479,7 @@ fn total_record( now: DateTime, ) -> UsageListRecord { UsageListRecord { + provider_account_upstream_peer: None, error_code: None, error_message: None, client_status_code: None, diff --git a/backend/crates/gateway-api/src/admin/observability/presenter.rs b/backend/crates/gateway-api/src/admin/observability/presenter.rs index 678bb753f..b36b11ba3 100644 --- a/backend/crates/gateway-api/src/admin/observability/presenter.rs +++ b/backend/crates/gateway-api/src/admin/observability/presenter.rs @@ -271,6 +271,7 @@ pub(crate) fn usage_list_record_view( account_notes: record.provider_account_notes, account_plan_type: record.provider_account_plan_type, account_plan_type_display: record.provider_account_plan_type_display, + account_upstream_peer: record.provider_account_upstream_peer, route: record.endpoint, model, requested_model: record.requested_model_id, @@ -1351,6 +1352,7 @@ pub(crate) fn diagnostics_view( request_count: item.request_count, account_plan_type: item.account_plan_type, account_plan_type_display: item.account_plan_type_display, + account_upstream_peer: item.account_upstream_peer, success_count: item.success_count, error_count: item.error_count, error_rate: item.error_rate, diff --git a/backend/crates/gateway-api/src/admin/observability/wire.rs b/backend/crates/gateway-api/src/admin/observability/wire.rs index 36a64271f..7c9169c18 100644 --- a/backend/crates/gateway-api/src/admin/observability/wire.rs +++ b/backend/crates/gateway-api/src/admin/observability/wire.rs @@ -99,6 +99,7 @@ pub struct UsageListRecordView { pub account_notes: Option, pub account_plan_type: Option, pub account_plan_type_display: Option, + pub account_upstream_peer: Option, pub route: String, pub model: Option, pub requested_model: Option, @@ -849,6 +850,7 @@ pub struct DiagnosticItemView { pub authentication_kind: Option, pub account_plan_type: Option, pub account_plan_type_display: Option, + pub account_upstream_peer: Option, pub request_count: u64, pub success_count: u64, pub error_count: u64, diff --git a/backend/crates/gateway-api/tests/admin/observability/response.rs b/backend/crates/gateway-api/tests/admin/observability/response.rs index 2d1d2508b..61d5dbd97 100644 --- a/backend/crates/gateway-api/tests/admin/observability/response.rs +++ b/backend/crates/gateway-api/tests/admin/observability/response.rs @@ -656,6 +656,23 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() { ); } +fn peer_summary( + discovered: bool, + observed: bool, +) -> gateway_admin::model::provider_credentials::ProviderQuota { + gateway_admin::model::provider_credentials::ProviderQuota { + upstream: Some(gateway_admin::model::upstream_info::ProviderUpstreamInfo { + discovered, + observed_at: observed.then(chrono::Utc::now), + stale: true, + connection_revision: "test-connection".to_owned(), + last_error: None, + pool: None, + }), + ..Default::default() + } +} + #[tokio::test] async fn diagnostics_should_keep_stable_key_and_display_name_contract() { use axum::{ @@ -670,13 +687,17 @@ async fn diagnostics_should_keep_stable_key_and_display_name_contract() { use crate::admin::{AdminTestFixture, AdminTestState}; - for (authentication_kind, plan_type, plan_display) in [ - (Some("oauth"), Some("pro"), Some("Pro")), - (Some("api_key"), None, None), - (None, None, None), + for (authentication_kind, plan_type, plan_display, peer) in [ + (Some("oauth"), Some("pro"), Some("Pro"), None), + (Some("api_key"), None, None, None), + (Some("api_key"), None, None, Some((true, true))), + (Some("api_key"), None, None, Some((true, false))), + (Some("api_key"), None, None, Some((false, true))), + (None, None, None, None), ] { let fixture = AdminTestFixture::new().await; fixture.auth.insert_session("valid-session"); + *fixture.provider_quota.lock().unwrap() = peer.map(|(d, o)| peer_summary(d, o)); *fixture.diagnostics.lock().expect("diagnostics") = DiagnosticsObservation { analytics: Default::default(), total_request_count: 4, @@ -718,6 +739,10 @@ async fn diagnostics_should_keep_stable_key_and_display_name_contract() { .await .expect("diagnostics body"); let value: serde_json::Value = serde_json::from_slice(&body).expect("diagnostics JSON"); + assert_eq!( + value["data"]["items"][0]["accountUpstreamPeer"], + json!(peer.map(|(d, o)| d && o)) + ); assert_eq!(value["data"]["items"][0]["requestShare"], 0.5); assert_eq!(value["data"]["items"][0]["retryCount"], 3); assert_eq!(value["data"]["items"][0]["retryRate"], 0.5); @@ -906,6 +931,7 @@ async fn usage_route_projects_request_facts_and_billing() { .lock() .expect("usage records") .push(UsageListRecord { + provider_account_upstream_peer: None, error_code: None, error_message: None, client_status_code: None, @@ -1131,6 +1157,39 @@ async fn usage_route_projects_request_facts_and_billing() { value["data"]["items"][2]["billing"]["totalAmountDisplay"], "$0.007" ); + + // 互联标识随当前摘要变化,重复请求共享同一账号,既有请求快照保持原值 + for peer in [ + Some((true, true)), + Some((true, false)), + Some((false, true)), + None, + ] { + *fixture.provider_quota.lock().unwrap() = peer.map(|(d, o)| peer_summary(d, o)); + let response = observability::router::() + .with_state(fixture.state()) + .oneshot( + Request::builder() + .uri("/api/admin/usage/records?status=all") + .header(header::COOKIE, "cpr_session=valid-session") + .header("x-request-id", "req_peer_badge") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), 1024 * 1024).await.unwrap(); + let value: serde_json::Value = serde_json::from_slice(&body).unwrap(); + for item in value["data"]["items"].as_array().unwrap() { + assert_eq!( + item["accountUpstreamPeer"], + json!(peer.map(|(d, o)| d && o)) + ); + assert_eq!(item["accountName"], "Snapshot Alpha"); + assert_eq!(item["accountPlanType"], "supergrok_plus"); + } + } } #[tokio::test] diff --git a/backend/crates/gateway-api/tests/key_usage/fixtures.rs b/backend/crates/gateway-api/tests/key_usage/fixtures.rs index b6400ba39..045357255 100644 --- a/backend/crates/gateway-api/tests/key_usage/fixtures.rs +++ b/backend/crates/gateway-api/tests/key_usage/fixtures.rs @@ -92,6 +92,7 @@ pub(super) async fn fixture() -> AdminTestFixture { fn usage_record() -> UsageListRecord { UsageListRecord { + provider_account_upstream_peer: None, error_code: None, error_message: None, client_status_code: None, diff --git a/backend/crates/gateway-store/src/postgres/observability/mapping.rs b/backend/crates/gateway-store/src/postgres/observability/mapping.rs index fc2ce5837..00c46f8f9 100644 --- a/backend/crates/gateway-store/src/postgres/observability/mapping.rs +++ b/backend/crates/gateway-store/src/postgres/observability/mapping.rs @@ -327,6 +327,7 @@ pub(crate) fn usage_list_record_from_row( provider_account_name: get(row, "provider_account_name")?, provider_account_email: get(row, "provider_account_email")?, provider_account_notes: get(row, "provider_account_notes")?, + provider_account_upstream_peer: None, provider_account_plan_type: get(row, "provider_account_plan_type")?, provider_account_plan_type_display: None, provider_account_authentication_kind: get(row, "provider_account_authentication_kind")?, diff --git a/docs/api.md b/docs/api.md index ede8e5d65..3043456d9 100644 --- a/docs/api.md +++ b/docs/api.md @@ -2511,10 +2511,14 @@ Dashboard 返回原始 `asOf` 及 `asOfDisplay`,作为当前数据的查询锚 管理端请求列表及 Dashboard 最近请求中的 `accountNotes` 来自请求保存的账号备注快照,未保存时为 `null` +请求列表、Dashboard 最近请求及账号维度诊断的 `accountUpstreamPeer` 来自账号当前的互联摘要, +已发现且有观测时间时为 `true`,尚未识别成功时为 `false`;摘要缺失、账号已删除或读取失败时为 `null`。 +该字段仅供 CPR 标识展示,不写入历史快照,非账号维度诊断为 `null` + `/api/admin/usage/records` 与 `/api/admin/operations/errors` 的 `clientApiKeyName` 来自请求保存的 Key 名称快照, 未保存时为 `null`,不影响记录返回,不包含密钥原文 -查询不关联当前配置,改名、修改订阅或删除配置不改变已有快照。补写的历史 Key 名称与订阅取自补写时仍存在的配置, +上述快照字段不关联当前配置,改名、修改订阅或删除配置不改变已有快照。补写的历史 Key 名称与订阅取自补写时仍存在的配置, 不保证等于请求发生时的值;无法补齐的字段保持未知 运维错误记录的 `errorDetails` 为受控诊断文本,无详情时为 `null`。 diff --git a/frontend/src/api/modules/usage.ts b/frontend/src/api/modules/usage.ts index 97edde031..1c6c99b68 100644 --- a/frontend/src/api/modules/usage.ts +++ b/frontend/src/api/modules/usage.ts @@ -94,6 +94,7 @@ export interface UsageListRecord { accountNotes: string | null accountPlanType: string | null accountPlanTypeDisplay: string | null + accountUpstreamPeer: boolean | null route: string model: string | null requestedModel: string | null @@ -364,6 +365,7 @@ export interface UsageDiagnosticItem { authenticationKind: string | null accountPlanType: string | null accountPlanTypeDisplay: string | null + accountUpstreamPeer: boolean | null requestCount: number successCount: number errorCount: number diff --git a/frontend/src/components/account/AccountPlanBadge.vue b/frontend/src/components/account/AccountPlanBadge.vue index 6fa0c8d29..f995c222a 100644 --- a/frontend/src/components/account/AccountPlanBadge.vue +++ b/frontend/src/components/account/AccountPlanBadge.vue @@ -11,6 +11,7 @@ const props = withDefaults( planType?: string | null authenticationKind?: string upstream?: AccountUpstream | null + upstreamPeer?: boolean | null planTypeDisplay: string size?: 'xs' | 'sm' | 'md' }>(), @@ -61,7 +62,7 @@ const paletteClass = computed(() => {