diff --git a/backend/crates/gateway-store/src/postgres/account_groups.rs b/backend/crates/gateway-store/src/postgres/account_groups.rs index e822fabe0..da8ed6b17 100644 --- a/backend/crates/gateway-store/src/postgres/account_groups.rs +++ b/backend/crates/gateway-store/src/postgres/account_groups.rs @@ -529,6 +529,8 @@ async fn group_costs( return Ok(BTreeMap::new()); } let completed_usage = completed_usage_fact_predicate("mr"); + // 用量按实际完成请求的账号统计:账号属于多个分组时计入每个所属分组, + // 不再按 Client Key 绑定分组快照归属,避免多分组 Key 的费用重复出现在未承接请求的分组上。 let statement = format!( "with requested_groups(group_id) as ( select unnest($1::text[]) @@ -541,8 +543,10 @@ async fn group_costs( coalesce(sum(mr.cost_amount), 0)::text as retained_total_usd from requested_groups cross join runtime_settings settings + left join account_group_accounts membership + on membership.account_group_id = requested_groups.group_id left join model_requests mr - on mr.routing_group_refs @> array[requested_groups.group_id]::text[] + on mr.provider_account_ref = membership.provider_account_id and mr.started_at >= now() - make_interval(days => settings.usage_retention_days::int) and {completed_usage} and mr.cost_currency = 'USD' diff --git a/backend/crates/gateway-store/tests/postgres/account_groups.rs b/backend/crates/gateway-store/tests/postgres/account_groups.rs index d5daa06f6..34863d481 100644 --- a/backend/crates/gateway-store/tests/postgres/account_groups.rs +++ b/backend/crates/gateway-store/tests/postgres/account_groups.rs @@ -89,14 +89,24 @@ async fn groups_aggregate_cross_provider_members_and_key_bindings_without_multip .await .expect("create scoped client key"); } + // 多分组 Key 的请求费用只计入实际承接账号所属的分组,Key 绑定的其他分组不重复计费。 seed_group_cost_snapshot( &database.pool, - "req_historical_empty_group", + "req_dual_group_key", "acct_group_openai", - EMPTY_GROUP, + &[MIXED_GROUP, EMPTY_GROUP], "1.5", ) .await; + // 归属跟随完成请求的账号,而不是 Client Key 绑定的分组快照。 + seed_group_cost_snapshot( + &database.pool, + "req_account_attribution", + "acct_group_xai", + &[EMPTY_GROUP], + "2.25", + ) + .await; let page = groups .list_account_groups(AccountGroupListQuery { @@ -138,14 +148,14 @@ async fn groups_aggregate_cross_provider_members_and_key_bindings_without_multip assert_eq!(mixed.account_summary.total, 0); assert_eq!(mixed.capacity.used_slots, None); assert_eq!(mixed.capacity.total_slots, Some(0)); - assert_eq!(mixed.usage.today_usd.as_str(), "0"); - assert_eq!(mixed.usage.retained_total_usd.as_str(), "0"); + assert_eq!(mixed.usage.today_usd.as_str(), "3.75"); + assert_eq!(mixed.usage.retained_total_usd.as_str(), "3.75"); let empty = by_id.get(EMPTY_GROUP).expect("empty group"); assert_eq!(empty.member_count, 0); assert!(empty.provider_counts.is_empty()); assert_eq!(empty.client_key_count, 1); - assert_eq!(empty.usage.today_usd.as_str(), "1.5"); - assert_eq!(empty.usage.retained_total_usd.as_str(), "1.5"); + assert_eq!(empty.usage.today_usd.as_str(), "0"); + assert_eq!(empty.usage.retained_total_usd.as_str(), "0"); let all_key = keys .reveal_client_key(&client_key_id("key_all_accounts")) @@ -272,6 +282,7 @@ async fn group_costs_should_include_statusless_websocket_but_reject_statusless_h ) .await .expect("create statusless cost group"); + assign_accounts(&database.pool, EMPTY_GROUP, &["acct_group_statusless"]).await; for (request_id, cost_amount) in [ ("req_group_http_success", "1.5"), ("req_group_statusless_websocket", "2"), @@ -281,7 +292,7 @@ async fn group_costs_should_include_statusless_websocket_but_reject_statusless_h &database.pool, request_id, "acct_group_statusless", - EMPTY_GROUP, + &[EMPTY_GROUP], cost_amount, ) .await; @@ -393,9 +404,13 @@ async fn seed_group_cost_snapshot( pool: &sqlx::PgPool, request_id: &str, account_id: &str, - historical_group_id: &str, + routing_group_ids: &[&str], cost_amount: &str, ) { + let routing_group_refs: Vec = routing_group_ids + .iter() + .map(|id| (*id).to_owned()) + .collect(); sqlx::query( "insert into model_requests ( id, client_api_key_ref, config_revision, protocol, operation, endpoint, @@ -411,16 +426,16 @@ async fn seed_group_cost_snapshot( 'sent', now(), 'succeeded', 200, 200, 10, 'provider_reported', $4::numeric, 'USD', now() - interval '1 minute', now() + interval '5 minutes', now(), - 'groups', array[$3]::text[], jsonb_build_array($3::text) + 'groups', $3::text[], to_jsonb($3::text[]) )", ) .bind(request_id) .bind(account_id) - .bind(historical_group_id) + .bind(routing_group_refs) .bind(cost_amount) .execute(pool) .await - .expect("seed historical group cost snapshot"); + .expect("seed group cost snapshot"); } async fn current_revision(pool: &sqlx::PgPool) -> u64 {