diff --git a/.agents/notes/implemented/bug-fix/2026-10-09-provider-usage-label-shows-id.md b/.agents/notes/implemented/bug-fix/2026-10-09-provider-usage-label-shows-id.md new file mode 100644 index 000000000..b2e2e1683 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-09-provider-usage-label-shows-id.md @@ -0,0 +1,47 @@ +# Agent Note: 成本分析“提供商用量”展示提供商名称 + +Status: implemented + +## Problem + +成本分析页面(`frontend/src/views/admin/CostAnalysis.vue` → `data-provider-usage`)的“提供商”列直接渲染接口返回的 `label`。而后端 `UsageAnalyticsView::Breakdown` + `group_by=provider` 的分组键是 `provider_id`,同一段 SQL 又把 `label` 写成 `group_id::text`,于是页面显示 `provider-1` 这类内部 ID,管理员无法判断是哪个提供商;同一份数据的 CSV 导出 `label` 列也只有 ID。 + +不修的话,这个缺陷不会自愈:`id` 必须继续是 `provider_id`(“查看”链接按它跳转用量明细、已登记支出按它匹配),所以只能修 `label` 的取值,而不是换分组键。故障面覆盖两套实现:PostgreSQL 适配器与内存仓储(无数据库部署与单元测试走后者)。 + +## Decision + +`Breakdown` 提供商分组下,行的 `id` 保持 `provider_id` 不变,`label` 解析为提供商名称,解析顺序: + +1. 提供商目录 `providers.name`(当前名称,随改名刷新); +2. 使用记录里的 `provider_name` 快照(历史行、目录已删的提供商); +3. 原始 `provider_id`(最后的兜底,避免出现空列)。 + +配套边界,两套实现一致: + +- 空白字符串与历史占位值 `unknown` / `unknow` / `pending` 不当作名称展示; +- `provider_id` 为空表示这条记录无法归属,`label` 保持 `null`,由前端沿用既有的“未归属提供商”文案; +- 只有明细分组(`Breakdown`)这么做;时间序列 / Performance / DashboardCharts 仍用 `provider_id` 作标签,`provider_rows` 与 `provider_timeline_rows` 的既有口径不变; +- 内存仓储没有提供商目录,只走第 2、3 步,分组键与标签规则与 PostgreSQL 侧保持一致。 + +PostgreSQL 侧的落地方式:`grouped` CTE 用 `max(provider_name) AS provider_name` 带出名称快照,最外层 `FROM page LEFT JOIN public.providers AS provider_catalog ON provider_catalog.id = page.group_id`,标签表达式固定在 `ANALYTICS_PROVIDER_LABEL_SQL`;`to_jsonb(page)` 需要额外减去 `provider_name`,否则它会被塞进 `metrics`。 + +前端不改:`label` 为空时已经回退到“未归属提供商”,`id` 与 `label` 的职责在页面里本来就是分开的。 + +## Alternatives considered + +- **前端用已加载的财务账户列表(`providerFinanceApi.accounts`)把 ID 映射成名称** — 改动最小且不动后端,但只对已登记财务账户的提供商有效,未登记账户的提供商依旧显示 ID;缺陷被藏在展示层,导出的 CSV 仍然只有 ID。否。 +- **只取使用记录里的 `provider_name` 快照(与 `provider_rows`、`provider_timeline_rows` 的 `max(provider_name)` 保持一致)** — 无需 JOIN,SQL 更短;但提供商在目录里改名后,历史报表仍显示旧名称,与用量审计聚合页的解析顺序不一致。目录优先、快照兜底。 +- **把分组键换成 `provider_name`(照搬用量审计聚合的 `legacy_name` 回退)** — 能让无法归属的历史行按名称分桶,但会改变 `total`、分页口径和“查看”链接语义,并让按 `provider_id` 匹配的已登记支出列错位。否。 + +## Consequences + +- **收益**:页面的“提供商”列与 CSV 的 `label` 列显示可读名称,且与用量审计聚合(`USAGE_RESOLVED_PROVIDER_DISPLAY_NAME_SQL`)保持同一套解析顺序,跨页面观感一致。 +- **代价与已知上限**:明细查询多了一次 `providers` 的 LEFT JOIN(发生在 `LIMIT` 之后的 page 上,最多 `limit` 行,按主键关联);查询结果依赖 `providers` 表可见;提供商被删除后回退到快照名,历史报表不会因目录改名而重写既有数据。 +- **重访信号**:如果将来要求“同名历史提供商合并成一行”(即 `legacy_name` 归并),那是分组键变更,需要另开一篇笔记并同步改动 `total` / 分页 / 前端链接。 + +## Verification + +- 内存实现:`cargo test -p aether-data --lib` → `repository::usage::memory::tests::overview_provider_breakdown_labels_rows_with_provider_name`(覆盖名称展示、占位值回退、无法归属三种分支)。 +- SQL 片段解析顺序:`cargo test -p aether-data-postgres --lib` → `usage::tests::provider_breakdown_labels_resolve_catalog_name_before_recorded_name_and_id`。 +- 展示层:`frontend/src/features/overview/__tests__/costs.spec.ts` 已断言“提供商用量”区块渲染 `Provider One`。 +- 取数端到端(真实 SQL 执行)需要 `AETHER_TEST_DATABASE_URL` 的 live 测试环境;本环境无可用 PostgreSQL,未执行 `crates/aether-data/adapters/postgres/src/usage/analytics_tests.rs` 中的 live 用例。 diff --git a/crates/aether-data/adapters/postgres/src/usage/analytics.rs b/crates/aether-data/adapters/postgres/src/usage/analytics.rs index 2c4080500..3b134e98f 100644 --- a/crates/aether-data/adapters/postgres/src/usage/analytics.rs +++ b/crates/aether-data/adapters/postgres/src/usage/analytics.rs @@ -40,6 +40,20 @@ count(*) FILTER (WHERE actor_user_id IS NOT NULL)::bigint AS trusted_attribution count(*) FILTER (WHERE status = 'failed' AND failure_origin IS NOT NULL AND failure_origin <> 'unknown')::bigint AS classified_failure_count "#; +// 提供商分组的展示标签:分组键仍然是 provider_id,但页面上要展示“提供商名称”。 +// 解析顺序与用量审计聚合保持一致:提供商目录中的当前名称 → 使用记录里的名称快照 → 原始 provider_id。 +// 'unknown' / 'unknow' / 'pending' 是历史占位值,不能当成名称展示; +// provider_id 为空说明这条记录本身无法归属,保持空标签让前端显示“未归属提供商”。 +pub(super) const ANALYTICS_PROVIDER_LABEL_SQL: &str = r#"COALESCE( + NULLIF(BTRIM(provider_catalog.name), ''), + CASE + WHEN page.group_id IS NULL THEN NULL + WHEN lower(BTRIM(COALESCE(page.provider_name, ''))) IN ('', 'unknown', 'unknow', 'pending') THEN NULL + ELSE BTRIM(page.provider_name) + END, + page.group_id::text + )"#; + pub(super) fn dashboard_total_metrics_sql() -> &'static str { r#"count(*)::bigint AS request_count, COALESCE(sum(total_tokens),0)::bigint AS total_tokens, @@ -359,6 +373,10 @@ impl SqlxUsageReadRepository { | UsageAnalyticsView::DashboardCharts | UsageAnalyticsView::Breakdown => { let timeseries = query.view != UsageAnalyticsView::Breakdown; + // 提供商分组的行需要额外带出名称快照,并在最外层关联提供商目录解析展示名。 + // 只有明细分组(Breakdown)才需要,时间序列仍然直接用 provider_id 作为标签。 + let provider_labels = + !timeseries && query.group_by == UsageAnalyticsGroupBy::Provider; let group = if timeseries { let granularity = match query.granularity { UsageAnalyticsGranularity::Hour => "hour", @@ -393,6 +411,11 @@ impl SqlxUsageReadRepository { .push(", grouped AS (SELECT ") .push(group) .push(" AS group_id, ") + .push(if provider_labels { + "max(provider_name) AS provider_name, " + } else { + "" + }) .push(&metrics_sql) .push(if timeseries { " FROM dated GROUP BY " @@ -414,9 +437,22 @@ impl SqlxUsageReadRepository { .push(", group_id ASC NULLS LAST"); } builder.push(" LIMIT ").push_bind(if timeseries { 10_001 } else { i64::from(query.limit) }).push(" OFFSET ").push_bind(if timeseries { 0 } else { query.offset as i64 }) - .push(") SELECT (SELECT count(*) FROM grouped) AS total, COALESCE(jsonb_agg(jsonb_build_object('id', group_id::text, 'label', group_id::text, 'bucket_start', ") + .push(") SELECT (SELECT count(*) FROM grouped) AS total, COALESCE(jsonb_agg(jsonb_build_object('id', page.group_id::text, 'label', ") + .push(if provider_labels { ANALYTICS_PROVIDER_LABEL_SQL } else { "page.group_id::text" }) + .push(", 'bucket_start', ") .push(if timeseries { "to_char(group_id AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"')" } else { "NULL" }) - .push(", 'metrics', to_jsonb(page) - 'group_id')), '[]'::jsonb) AS items FROM page"); + .push(", 'metrics', to_jsonb(page) - 'group_id'") + .push(if provider_labels { + " - 'provider_name'" + } else { + "" + }) + .push(")), '[]'::jsonb) AS items FROM page") + .push(if provider_labels { + " LEFT JOIN public.providers AS provider_catalog ON provider_catalog.id = page.group_id" + } else { + "" + }); let row = builder .build() .fetch_one(&mut *tx) diff --git a/crates/aether-data/adapters/postgres/src/usage/tests.rs b/crates/aether-data/adapters/postgres/src/usage/tests.rs index 33a1b01e3..4286eb0e9 100644 --- a/crates/aether-data/adapters/postgres/src/usage/tests.rs +++ b/crates/aether-data/adapters/postgres/src/usage/tests.rs @@ -5414,3 +5414,21 @@ fn attach_usage_settlement_pricing_snapshot_metadata_adds_missing_values_without }) ); } + +#[test] +fn provider_breakdown_labels_resolve_catalog_name_before_recorded_name_and_id() { + let sql = super::analytics::ANALYTICS_PROVIDER_LABEL_SQL; + let catalog = sql + .find("provider_catalog.name") + .expect("provider catalog name"); + let recorded = sql + .find("page.provider_name") + .expect("recorded provider name snapshot"); + let raw_id = sql + .find("page.group_id::text") + .expect("raw provider id fallback"); + // 成本分析“提供商”列的解析顺序:目录名称 → 使用记录里的名称快照 → 原始 provider_id。 + assert!(catalog < recorded && recorded < raw_id); + // 历史占位值不能被当成提供商名称展示。 + assert!(sql.contains("'unknown', 'unknow', 'pending'")); +} diff --git a/crates/aether-data/runtime/src/repository/usage/memory/analytics.rs b/crates/aether-data/runtime/src/repository/usage/memory/analytics.rs index a709e3b7f..51c004b1d 100644 --- a/crates/aether-data/runtime/src/repository/usage/memory/analytics.rs +++ b/crates/aether-data/runtime/src/repository/usage/memory/analytics.rs @@ -59,6 +59,27 @@ fn available(row: &StoredRequestUsageAudit, key: &str) -> bool { .and_then(serde_json::Value::as_bool) != Some(false) } +/// 提供商明细分组的展示名:分组键依旧是 provider_id(保证与 PostgreSQL 实现一致), +/// 只是把展示标签换成使用记录里的提供商名称快照,名称缺失或为历史占位值时回退到 provider_id。 +/// provider_id 为空说明无法归属,保持 None 让前端显示“未归属提供商”。 +fn provider_display_label( + rows: &[&StoredRequestUsageAudit], + group_id: Option<&str>, +) -> Option { + let id = group_id?; + rows.iter() + .map(|row| row.provider_name.trim()) + .filter(|name| { + !name.is_empty() + && !matches!( + name.to_ascii_lowercase().as_str(), + "unknown" | "unknow" | "pending" + ) + }) + .max() + .map(str::to_owned) + .or_else(|| Some(id.to_owned())) +} // The legacy audit contract stores epoch seconds despite its historical field name. fn usage_started_ms(row: &StoredRequestUsageAudit) -> u64 { row.created_at_unix_ms.saturating_mul(1000) @@ -656,10 +677,17 @@ impl InMemoryUsageReadRepository { }; groups.entry(group).or_default().push(row); } + // 提供商明细分组需要单独解析展示名,其余分组仍然用分组键本身作为标签。 + let provider_breakdown = query.view == UsageAnalyticsView::Breakdown + && query.group_by == UsageAnalyticsGroupBy::Provider; let mut grouped = groups .into_iter() .map(|(id, rows)| UsageAnalyticsRow { - label: id.clone(), + label: if provider_breakdown { + provider_display_label(&rows, id.as_deref()) + } else { + id.clone() + }, bucket_start: (query.view != UsageAnalyticsView::Breakdown) .then(|| id.clone()) .flatten(), diff --git a/crates/aether-data/runtime/src/repository/usage/memory/tests.rs b/crates/aether-data/runtime/src/repository/usage/memory/tests.rs index bb2d94955..08a3931c6 100644 --- a/crates/aether-data/runtime/src/repository/usage/memory/tests.rs +++ b/crates/aether-data/runtime/src/repository/usage/memory/tests.rs @@ -160,6 +160,52 @@ async fn overview_model_performance_merges_provider_samples_without_pagination() assert_eq!(filtered.model_rows, vec![result.model_rows[0].clone()]); } +#[tokio::test] +async fn overview_provider_breakdown_labels_rows_with_provider_name() { + use aether_data_contracts::repository::usage::*; + let at = chrono::DateTime::parse_from_rfc3339("2026-09-12T10:05:00Z").unwrap(); + // 同一 provider_id 的两条记录,展示标签应解析成提供商名称而不是 provider_id。 + let mut first = sample_usage("provider-label-1", at.timestamp()); + first.provider_id = Some("provider-1".into()); + first.provider_name = "Provider One".into(); + let mut second = sample_usage("provider-label-2", at.timestamp()); + second.provider_id = Some("provider-1".into()); + second.provider_name = "Provider One".into(); + // 名称为历史占位值时回退到 provider_id。 + let mut unnamed = sample_usage("provider-label-3", at.timestamp()); + unnamed.provider_id = Some("provider-2".into()); + unnamed.provider_name = "unknown".into(); + // provider_id 为空说明无法归属,标签保持为空,由前端显示“未归属提供商”。 + let mut unattributed = sample_usage("provider-label-4", at.timestamp()); + unattributed.provider_id = None; + unattributed.provider_name = "legacy".into(); + let repo = InMemoryUsageReadRepository::seed([first, second, unnamed, unattributed]); + let result = repo + .query_usage_analytics(&UsageAnalyticsQuery { + from_unix_ms: (at - chrono::Duration::minutes(5)).timestamp_millis() as u64, + to_unix_ms: (at + chrono::Duration::minutes(55)).timestamp_millis() as u64, + timezone: "UTC".into(), + view: UsageAnalyticsView::Breakdown, + group_by: UsageAnalyticsGroupBy::Provider, + limit: 25, + descending: true, + ..Default::default() + }) + .await + .unwrap(); + assert_eq!(result.total, 3); + let rows = result + .rows + .iter() + .map(|row| (row.id.as_deref(), row.label.as_deref())) + .collect::>(); + assert!(rows.contains(&(Some("provider-1"), Some("Provider One")))); + assert!(rows.contains(&(Some("provider-2"), Some("provider-2")))); + assert!(rows.contains(&(None, None))); + // 明细分组不填充 bucket_start。 + assert!(result.rows.iter().all(|row| row.bucket_start.is_none())); +} + #[tokio::test] async fn overview_memory_chart_hour_buckets_are_utc_in_half_hour_zones() { use aether_data_contracts::repository::usage::*;