Skip to main content

zerolaunch_plugin_api/services/icon/
icon_cache.rs

1use crate::common::dir_utils::DirUtils;
2use dashmap::{DashMap, DashSet};
3use parking_lot::RwLock;
4use std::path::Path;
5use std::time::{Duration, Instant};
6use tracing::warn;
7
8/// 提取失败有效期:期间同一请求直接回退默认图标,抑制故障站点被反复抓取。
9const ICON_FAILURE_TTL: Duration = Duration::from_secs(600);
10
11/// 图标缓存服务,提供 L1 内存缓存 + L2 文件缓存的原子操作原语。
12/// 本服务是纯缓存工具,不知道图标如何提取,也不知道 IconRequest/CacheLevel 等业务语义。
13/// 业务逻辑由 IconExtractor trait 的默认实现提供,本服务仅提供存取能力。
14pub struct IconCacheService {
15    /// L1: 内存缓存,key 为文件名(如 "abc123.webp"),value 为 WebP 字节数据
16    memory_cache: DashMap<String, Vec<u8>>,
17    /// L2: 文件缓存目录路径
18    cache_dir: RwLock<String>,
19    /// L2: 已缓存的文件名集合(启动时扫描填充,运行时追加)
20    cached_file_hashes: DashSet<String>,
21    /// 提取失败时间表(键同缓存键):仅记录网络类请求,按 TTL 自动失效。
22    failed_at: DashMap<String, Instant>,
23}
24
25impl IconCacheService {
26    /// 创建图标缓存服务实例。
27    /// 参数:cache_dir - L2 文件缓存目录路径。
28    /// 返回:初始化后的 IconCacheService(尚未扫描 L2 缓存,需调用 init())。
29    pub fn new(cache_dir: String) -> Self {
30        Self {
31            memory_cache: DashMap::new(),
32            cache_dir: RwLock::new(cache_dir),
33            cached_file_hashes: DashSet::new(),
34            failed_at: DashMap::new(),
35        }
36    }
37
38    /// 初始化缓存服务,扫描 L2 文件缓存目录填充 cached_file_hashes。
39    /// 参数:无。
40    /// 返回:无。
41    /// 特性:必须在 new() 之后、首次缓存操作之前调用。
42    pub fn init(&self) {
43        let cache_dir = self.cache_dir.read().clone();
44        let hashes = Self::scan_cached_icons(&cache_dir);
45        for hash in hashes {
46            self.cached_file_hashes.insert(hash);
47        }
48    }
49
50    /// 预载 L2 文件缓存到 L1 内存。
51    /// 参数:无。
52    /// 返回:无。
53    /// 特性:并行读取,全部完成后返回;失败条目静默跳过(查询路径会回退到按需读 L2)。
54    pub async fn preload_l1(&self) {
55        let cache_dir = self.cache_dir.read().clone();
56        let keys: Vec<String> = self
57            .cached_file_hashes
58            .iter()
59            .map(|entry| entry.clone())
60            .collect();
61        let mut handles = Vec::with_capacity(keys.len());
62        for key in keys {
63            let dir = cache_dir.clone();
64            let key_clone = key.clone();
65            handles.push(tokio::spawn(async move {
66                let data = tokio::fs::read(Path::new(&dir).join(&key_clone)).await.ok();
67                (key, data)
68            }));
69        }
70        for handle in handles {
71            if let Ok((key, Some(data))) = handle.await {
72                self.memory_cache.insert(key, data);
73            }
74        }
75        let (count, bytes): (usize, usize) = self
76            .memory_cache
77            .iter()
78            .map(|e| (1, e.value().len()))
79            .fold((0, 0), |(c, b), (cc, bb)| (c + cc, b + bb));
80        tracing::info!(
81            "图标缓存预载完成: {} 个, {:.1} KB",
82            count,
83            bytes as f64 / 1024.0
84        );
85    }
86
87    // ===== L1 内存缓存操作 =====
88
89    /// 从 L1 内存缓存获取数据。
90    /// 参数:key - 缓存键(文件名,如 "abc123.webp")。
91    /// 返回:命中返回 Some(数据),未命中返回 None。
92    pub fn get_l1(&self, key: &str) -> Option<Vec<u8>> {
93        self.memory_cache
94            .get(key)
95            .map(|entry| entry.value().clone())
96    }
97
98    /// 写入 L1 内存缓存。
99    /// 参数:key - 缓存键;data - 图标 WebP 数据。
100    /// 返回:无。
101    pub fn set_l1(&self, key: &str, data: Vec<u8>) {
102        self.memory_cache.insert(key.to_string(), data);
103    }
104
105    /// 清空 L1 内存缓存。
106    /// 参数:无。
107    /// 返回:无。
108    pub fn clear_l1(&self) {
109        self.memory_cache.clear();
110    }
111
112    /// 移除 L1 内存缓存中的指定键(用于图标覆盖后强制刷新缓存)。
113    /// 参数:key - 缓存键(文件名,如 "abc123.webp")。
114    /// 返回:无。
115    pub fn remove_l1(&self, key: &str) {
116        self.memory_cache.remove(key);
117    }
118
119    // ===== L2 文件缓存操作 =====
120
121    /// 检查 L2 文件缓存中是否包含指定键。
122    /// 参数:key - 缓存键(文件名)。
123    /// 返回:存在返回 true。
124    pub fn contains_l2(&self, key: &str) -> bool {
125        self.cached_file_hashes.contains(key)
126    }
127
128    // ===== 负缓存(网络类提取失败)=====
129
130    /// 记录一次提取失败。
131    /// 参数:key - 缓存键。返回:无。
132    pub fn mark_failed(&self, key: &str) {
133        self.failed_at.insert(key.to_string(), Instant::now());
134    }
135
136    /// 判断该键在有效期内是否已提取失败(过期条目顺手清理)。
137    /// 参数:key - 缓存键。
138    /// 返回:true 表示有效期内失败过,调用方应直接回退默认图标。
139    pub fn is_recently_failed(&self, key: &str) -> bool {
140        let Some(entry) = self.failed_at.get(key) else {
141            return false;
142        };
143        let elapsed = entry.elapsed();
144        drop(entry);
145        if elapsed < ICON_FAILURE_TTL {
146            return true;
147        }
148        self.failed_at.remove(key);
149        false
150    }
151
152    /// 清除失败记录(提取成功或手动强制刷新后调用)。
153    /// 参数:key - 缓存键。返回:无。
154    pub fn clear_failed(&self, key: &str) {
155        self.failed_at.remove(key);
156    }
157
158    /// 从 L2 文件缓存读取数据。
159    /// 参数:key - 缓存键(文件名)。
160    /// 返回:成功返回 Some(数据),失败或不存在返回 None。
161    pub async fn get_l2(&self, key: &str) -> Option<Vec<u8>> {
162        let cache_dir = self.cache_dir.read().clone();
163        let icon_path = Path::new(&cache_dir).join(key);
164        tokio::fs::read(&icon_path).await.ok()
165    }
166
167    /// 异步写入 L2 文件缓存。
168    /// 参数:key - 缓存键(文件名);data - 图标 WebP 数据。
169    /// 返回:无。
170    /// 特性:写入操作在后台线程执行,不阻塞调用方;同时更新 cached_file_hashes。
171    pub async fn set_l2(&self, key: &str, data: Vec<u8>) {
172        let cache_dir = self.cache_dir.read().clone();
173        let icon_path = Path::new(&cache_dir).join(key);
174        let key_owned = key.to_string();
175
176        if let Err(e) = tokio::fs::write(&icon_path, data).await {
177            warn!("写入图标缓存失败 {:?}: {}", icon_path, e);
178        } else {
179            self.cached_file_hashes.insert(key_owned);
180        }
181    }
182
183    // ===== 管理操作 =====
184
185    /// 更新图标缓存目录路径,同时清空 L1 内存缓存并重新扫描 L2。
186    /// 参数:new_cache_dir - 新的图标文件缓存目录路径。
187    /// 返回:无。
188    pub fn update_cache_dir(&self, new_cache_dir: &str) {
189        self.memory_cache.clear();
190
191        {
192            let mut cache_dir = self.cache_dir.write();
193            *cache_dir = new_cache_dir.to_string();
194        }
195
196        self.cached_file_hashes.clear();
197        self.failed_at.clear();
198        let hashes = Self::scan_cached_icons(new_cache_dir);
199        for hash in hashes {
200            self.cached_file_hashes.insert(hash);
201        }
202    }
203
204    /// 扫描缓存目录,收集当前格式(.webp)文件名集合。
205    /// 参数:cache_dir - 缓存目录路径。
206    /// 返回:当前格式(.webp)文件名集合。
207    fn scan_cached_icons(cache_dir: &str) -> DashSet<String> {
208        let result = DashSet::new();
209        match DirUtils::read_dir_or_create(cache_dir) {
210            Ok(entries) => {
211                for entry in entries.flatten() {
212                    let file_name = entry.file_name();
213                    let file_name = file_name.to_string_lossy();
214                    if !file_name.ends_with(".webp") {
215                        continue;
216                    }
217                    result.insert(file_name.into_owned());
218                }
219            }
220            Err(e) => warn!("扫描图标缓存目录失败: {}", e),
221        }
222        result
223    }
224}