diff --git a/backend/crates/gateway-admin/src/model/provider_credentials.rs b/backend/crates/gateway-admin/src/model/provider_credentials.rs index 970068f21..9ae60961d 100644 --- a/backend/crates/gateway-admin/src/model/provider_credentials.rs +++ b/backend/crates/gateway-admin/src/model/provider_credentials.rs @@ -631,25 +631,74 @@ impl ProviderQuota { /// 这里只解释跨 Provider 共享的窗口语义,绝不读取 Provider 私有 JSON。 #[must_use] pub fn representative_used_percent(&self) -> Option { + if self.limit_reached { + return Some(100.0); + } + self.representative_used_window() + .map(|(_, used_percent)| used_percent) + } + + /// 将已确认的账号级额度耗尽事实投影到展示窗口。 + /// + /// Provider 已指出具体触顶窗口时只归一化这些窗口;否则归一化 Dashboard + /// 同样会选择的代表窗口,避免把多个独立额度窗口全部伪造成已用尽。 + pub fn apply_limit_reached_display(&mut self) { + if !self.limit_reached { + return; + } + let reached_windows = self + .windows + .iter() + .enumerate() + .filter_map(|(index, window)| window.limit_reached.then_some(index)) + .collect::>(); + if !reached_windows.is_empty() { + for index in reached_windows { + self.windows[index].used_percent = Some(100.0); + } + return; + } + let representative = self + .representative_used_window() + .map(|(index, _)| index) + .or_else(|| { + self.windows + .iter() + .enumerate() + .min_by_key(|(index, window)| (quota_usage_priority(window), *index)) + .map(|(index, _)| index) + }); + if let Some(index) = representative { + self.windows[index].used_percent = Some(100.0); + self.windows[index].limit_reached = true; + } + } + + fn representative_used_window(&self) -> Option<(usize, f64)> { self.windows .iter() + .enumerate() .filter_map(|window| { + let (index, window) = window; let used_percent = window .used_percent .filter(|value| value.is_finite()) .map(|value| value.clamp(0.0, 100.0))?; - Some((quota_usage_priority(window), used_percent)) + Some((index, quota_usage_priority(window), used_percent)) }) - .fold(None::<(u8, f64)>, |selected, candidate| match selected { - Some(current) - if current.0 < candidate.0 - || (current.0 == candidate.0 && current.1 >= candidate.1) => - { + .fold( + None::<(usize, u8, f64)>, + |selected, candidate| match selected { Some(current) - } - _ => Some(candidate), - }) - .map(|(_, used_percent)| used_percent) + if current.1 < candidate.1 + || (current.1 == candidate.1 && current.2 >= candidate.2) => + { + Some(current) + } + _ => Some(candidate), + }, + ) + .map(|(index, _, used_percent)| (index, used_percent)) } } diff --git a/backend/crates/gateway-admin/tests/main.rs b/backend/crates/gateway-admin/tests/main.rs index 735ff1fe1..87161f895 100644 --- a/backend/crates/gateway-admin/tests/main.rs +++ b/backend/crates/gateway-admin/tests/main.rs @@ -135,6 +135,50 @@ fn representative_quota_should_prefer_short_window_and_highest_usage() { assert_eq!(quota.representative_used_percent(), Some(45.0)); } +#[test] +fn exhausted_quota_should_project_full_usage_to_only_the_representative_window() { + let mut quota = ProviderQuota { + observed_at: None, + refresh_token_expires_at: None, + windows: vec![ + quota_window("monthly", Some(2_592_000), Some(99.0)), + quota_window("shortTerm", Some(604_800), Some(80.0)), + quota_window("shortTerm", Some(18_000), Some(30.0)), + quota_window("shortTerm", Some(14_400), Some(95.0)), + ], + limit_reached: true, + provider_data: None, + }; + + assert_eq!(quota.representative_used_percent(), Some(100.0)); + quota.apply_limit_reached_display(); + + assert_eq!(quota.windows[0].used_percent, Some(99.0)); + assert_eq!(quota.windows[1].used_percent, Some(80.0)); + assert_eq!(quota.windows[2].used_percent, Some(30.0)); + assert_eq!(quota.windows[3].used_percent, Some(100.0)); + assert!(quota.windows[3].limit_reached); +} + +#[test] +fn exhausted_quota_should_preserve_the_provider_identified_reached_window() { + let mut reached = quota_window("monthly", Some(2_592_000), Some(98.0)); + reached.limit_reached = true; + let mut quota = ProviderQuota { + observed_at: None, + refresh_token_expires_at: None, + windows: vec![reached, quota_window("shortTerm", Some(18_000), Some(95.0))], + limit_reached: true, + provider_data: None, + }; + + quota.apply_limit_reached_display(); + + assert_eq!(quota.windows[0].used_percent, Some(100.0)); + assert_eq!(quota.windows[1].used_percent, Some(95.0)); + assert!(!quota.windows[1].limit_reached); +} + fn quota_window( group: &str, window_seconds: Option, diff --git a/backend/crates/gateway-api/src/admin/accounts.rs b/backend/crates/gateway-api/src/admin/accounts.rs index 4d40cbed5..4f83a5cd8 100644 --- a/backend/crates/gateway-api/src/admin/accounts.rs +++ b/backend/crates/gateway-api/src/admin/accounts.rs @@ -1574,10 +1574,11 @@ fn account_view(item: AccountDirectoryItem, now: DateTime) -> AccountView { } fn account_quota_view( - quota: ProviderQuota, + mut quota: ProviderQuota, rate_limited_until: Option>, now: DateTime, ) -> (AccountQuotaView, Option) { + quota.apply_limit_reached_display(); let refresh_token_expires_at = quota .refresh_token_expires_at .map(|value| china_rfc3339(&value));