Files
CC-Switch/src-tauri/src/services/usage_cache.rs
T
Jason 473f21971d feat(usage): add official subscription quota template with unified tier rendering
Changes:
- Add official_subscription template type for Claude/Codex/Gemini
- Replace implicit 'category=official auto-query' with explicit opt-in template
- Default disabled; users enable via usage script modal with configurable interval
- Unify tier→label mapping across subscription and script paths via labeled_tier_parts()
- Fix tray rendering: week aliases (seven_day/opus/sonnet) now use highest utilization
- Add depth guard: official_subscription checks enabled flag in query_provider_usage_inner
- Add cache invalidation symmetry: invalidate_subscription() for disabled providers
- i18n: add templateOfficialSubscription + hint in zh/en/ja/zh-TW

Backend (Rust):
- provider.rs: add TEMPLATE_TYPE_OFFICIAL_SUBSCRIPTION branch, flatten SubscriptionQuota→UsageData
- tray.rs: extract labeled_tier_parts() shared by both summary functions, use max_by for multi-alias groups
- usage_cache.rs: add invalidate_subscription() method
- Test coverage: add week-alias highest-utilization tests for both paths

Frontend (TypeScript):
- UsageScriptModal: add official_subscription to templates, auto-detect for official providers
- ProviderCard: gate useUsageQuery with !isOfficialSubscriptionUsage, pass autoQueryInterval to footer
- SubscriptionQuotaFooter: accept autoQueryInterval prop, default 0 (disabled)
- constants.ts: add TEMPLATE_TYPES.OFFICIAL_SUBSCRIPTION

Fixes tier rendering regression where:
- Claude/Codex: seven_day was missed (only weekly_limit matched) → lost 7-day window in tray
- Gemini: gemini_pro/flash/flash_lite fell through to fallback → leaked machine names
- Multi-window (opus+sonnet): find() took first, not worst → underestimated utilization and emoji color

All tests pass (cargo test + cargo clippy clean).
2026-06-05 19:03:40 +08:00

157 lines
4.7 KiB
Rust

//! 托盘展示用的用量缓存(进程内、写穿式)。
//!
//! 各 usage 查询命令成功时写入;系统托盘构建菜单时读取。不持久化,
//! 进程重启即空,由下一次自动查询或托盘悬停触发的刷新重新填充。
use std::collections::HashMap;
use std::sync::RwLock;
use crate::app_config::AppType;
use crate::provider::UsageResult;
use crate::services::subscription::SubscriptionQuota;
#[derive(Default)]
pub struct UsageCache {
subscription: RwLock<HashMap<AppType, SubscriptionQuota>>,
script: RwLock<HashMap<(AppType, String), UsageResult>>,
}
impl UsageCache {
pub fn new() -> Self {
Self::default()
}
pub fn put_subscription(&self, app_type: AppType, quota: SubscriptionQuota) {
if let Ok(mut w) = self.subscription.write() {
w.insert(app_type, quota);
}
}
pub fn put_script(&self, app_type: AppType, provider_id: String, result: UsageResult) {
if let Ok(mut w) = self.script.write() {
w.insert((app_type, provider_id), result);
}
}
/// 以借用形式暴露订阅快照,避免托盘每次重建时深拷贝整个 `SubscriptionQuota`。
pub fn with_subscription<R>(
&self,
app_type: &AppType,
f: impl FnOnce(&SubscriptionQuota) -> R,
) -> Option<R> {
self.subscription
.read()
.ok()
.and_then(|r| r.get(app_type).map(f))
}
/// 以借用形式暴露脚本型用量结果,同上。
pub fn with_script<R>(
&self,
app_type: &AppType,
provider_id: &str,
f: impl FnOnce(&UsageResult) -> R,
) -> Option<R> {
self.script
.read()
.ok()
.and_then(|r| r.get(&(app_type.clone(), provider_id.to_string())).map(f))
}
pub fn invalidate_script(&self, app_type: &AppType, provider_id: &str) {
// 热路径会对每个禁用脚本的 provider 在托盘重建时调用一次:先走读锁
// `contains_key` 快速放行"本来就不在缓存里"的常见情况,避免无谓的写锁升级。
let key = (app_type.clone(), provider_id.to_string());
if !self.script.read().is_ok_and(|r| r.contains_key(&key)) {
return;
}
if let Ok(mut w) = self.script.write() {
w.remove(&key);
}
}
pub fn invalidate_subscription(&self, app_type: &AppType) {
if !self
.subscription
.read()
.is_ok_and(|r| r.contains_key(app_type))
{
return;
}
if let Ok(mut w) = self.subscription.write() {
w.remove(app_type);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::services::subscription::CredentialStatus;
fn fake_quota() -> SubscriptionQuota {
SubscriptionQuota {
tool: "claude".to_string(),
credential_status: CredentialStatus::Valid,
credential_message: None,
success: true,
tiers: vec![],
extra_usage: None,
error: None,
queried_at: Some(0),
}
}
fn fake_result() -> UsageResult {
UsageResult {
success: true,
data: None,
error: None,
}
}
#[test]
fn subscription_round_trip() {
let cache = UsageCache::new();
assert!(cache
.with_subscription(&AppType::Claude, |q| q.success)
.is_none());
cache.put_subscription(AppType::Claude, fake_quota());
let got = cache
.with_subscription(&AppType::Claude, |q| q.success)
.unwrap();
assert!(got);
assert!(cache
.with_subscription(&AppType::Codex, |q| q.success)
.is_none());
}
#[test]
fn script_round_trip_and_invalidate() {
let cache = UsageCache::new();
assert!(cache
.with_script(&AppType::Codex, "pid", |r| r.success)
.is_none());
cache.put_script(AppType::Codex, "pid".to_string(), fake_result());
assert!(cache
.with_script(&AppType::Codex, "pid", |r| r.success)
.is_some());
cache.invalidate_script(&AppType::Codex, "pid");
assert!(cache
.with_script(&AppType::Codex, "pid", |r| r.success)
.is_none());
}
#[test]
fn script_keys_isolated_by_app_type() {
let cache = UsageCache::new();
cache.put_script(AppType::Claude, "same".to_string(), fake_result());
assert!(cache
.with_script(&AppType::Claude, "same", |r| r.success)
.is_some());
assert!(cache
.with_script(&AppType::Codex, "same", |r| r.success)
.is_none());
}
}