//! 托盘展示用的用量缓存(进程内、写穿式)。 //! //! 各 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>, script: RwLock>, } 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( &self, app_type: &AppType, f: impl FnOnce(&SubscriptionQuota) -> R, ) -> Option { self.subscription .read() .ok() .and_then(|r| r.get(app_type).map(f)) } /// 以借用形式暴露脚本型用量结果,同上。 pub fn with_script( &self, app_type: &AppType, provider_id: &str, f: impl FnOnce(&UsageResult) -> R, ) -> Option { 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); } } } #[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()); } }