fix(overview): 成本分析“提供商用量”展示提供商名称而非 ID

成本分析页「提供商用量」表格的“提供商”列渲染接口返回的 label,而后端在
UsageAnalyticsView::Breakdown + group_by=provider 时把 label 直接写成了
分组键 provider_id,页面因此显示 provider-1 这类内部编号。

- label 改为按「providers.name → 使用记录里的 provider_name 快照 →
  provider_id 兜底」解析;分组键 id 仍是 provider_id,“查看”链接、按
  提供商匹配的已登记支出列、时间序列图表均不受影响。
- 空白与 unknown/unknow/pending 不当作名称;provider_id 为空时保持空标签,
  前端沿用“未归属提供商”文案。
- 内存仓储(无数据库部署与测试)同步同一口径,避免两种实现表现不一致。
- 新增用例:内存侧 overview_provider_breakdown_labels_rows_with_provider_name,
  SQL 侧 provider_breakdown_labels_resolve_catalog_name_before_recorded_name_and_id。
This commit is contained in:
AAEE86
2026-10-09 08:53:04 +08:00
parent 911c7f8875
commit 180da72b69
5 changed files with 178 additions and 3 deletions
@@ -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)
@@ -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'"));
}
@@ -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<String> {
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(),
@@ -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::<Vec<_>>();
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::*;