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 tracing::warn;
6
7/// 失败记录文件名(位于 L2 图标缓存目录内)。
8const FAILURES_FILE: &str = "failures.json";
9
10/// 图标缓存服务,提供 L1 内存缓存 + L2 文件缓存的原子操作原语。
11/// 本服务是纯缓存工具,不知道图标如何提取,也不知道 IconRequest/CacheLevel 等业务语义。
12/// 业务逻辑由 IconExtractor trait 的默认实现提供,本服务仅提供存取能力。
13pub struct IconCacheService {
14    /// L1: 内存缓存,key 为文件名(如 "abc123.webp"),value 为 WebP 字节数据
15    memory_cache: DashMap<String, Vec<u8>>,
16    /// L2: 文件缓存目录路径
17    cache_dir: RwLock<String>,
18    /// L2: 已缓存的文件名集合(启动时扫描填充,运行时追加)
19    cached_file_hashes: DashSet<String>,
20    /// 提取失败记录(键同缓存键):命中即跳过联网抓取,直到手动重试。
21    /// 与图标字节同目录持久化为 `failures.json`,重启后仍生效。
22    failures: DashSet<String>,
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            failures: DashSet::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        self.load_failures();
49    }
50
51    /// 预载 L2 文件缓存到 L1 内存。
52    /// 参数:无。
53    /// 返回:无。
54    /// 特性:并行读取,全部完成后返回;失败条目静默跳过(查询路径会回退到按需读 L2)。
55    pub async fn preload_l1(&self) {
56        let cache_dir = self.cache_dir.read().clone();
57        let keys: Vec<String> = self
58            .cached_file_hashes
59            .iter()
60            .map(|entry| entry.clone())
61            .collect();
62        let mut handles = Vec::with_capacity(keys.len());
63        for key in keys {
64            let dir = cache_dir.clone();
65            let key_clone = key.clone();
66            handles.push(tokio::spawn(async move {
67                let data = tokio::fs::read(Path::new(&dir).join(&key_clone)).await.ok();
68                (key, data)
69            }));
70        }
71        for handle in handles {
72            if let Ok((key, Some(data))) = handle.await {
73                self.memory_cache.insert(key, data);
74            }
75        }
76        let (count, bytes): (usize, usize) = self
77            .memory_cache
78            .iter()
79            .map(|e| (1, e.value().len()))
80            .fold((0, 0), |(c, b), (cc, bb)| (c + cc, b + bb));
81        tracing::info!(
82            "图标缓存预载完成: {} 个, {:.1} KB",
83            count,
84            bytes as f64 / 1024.0
85        );
86    }
87
88    // ===== L1 内存缓存操作 =====
89
90    /// 从 L1 内存缓存获取数据。
91    /// 参数:key - 缓存键(文件名,如 "abc123.webp")。
92    /// 返回:命中返回 Some(数据),未命中返回 None。
93    pub fn get_l1(&self, key: &str) -> Option<Vec<u8>> {
94        self.memory_cache
95            .get(key)
96            .map(|entry| entry.value().clone())
97    }
98
99    /// 写入 L1 内存缓存。
100    /// 参数:key - 缓存键;data - 图标 WebP 数据。
101    /// 返回:无。
102    pub fn set_l1(&self, key: &str, data: Vec<u8>) {
103        self.memory_cache.insert(key.to_string(), data);
104    }
105
106    /// 清空 L1 内存缓存。
107    /// 参数:无。
108    /// 返回:无。
109    pub fn clear_l1(&self) {
110        self.memory_cache.clear();
111    }
112
113    /// 移除 L1 内存缓存中的指定键(用于图标覆盖后强制刷新缓存)。
114    /// 参数:key - 缓存键(文件名,如 "abc123.webp")。
115    /// 返回:无。
116    pub fn remove_l1(&self, key: &str) {
117        self.memory_cache.remove(key);
118    }
119
120    // ===== L2 文件缓存操作 =====
121
122    /// 检查 L2 文件缓存中是否包含指定键。
123    /// 参数:key - 缓存键(文件名)。
124    /// 返回:存在返回 true。
125    pub fn contains_l2(&self, key: &str) -> bool {
126        self.cached_file_hashes.contains(key)
127    }
128
129    // ===== 失败记录(网络类提取失败)=====
130
131    /// 从 L2 目录读取失败记录填充内存表。
132    /// 参数:无。
133    /// 返回:无。
134    /// 特性:文件不存在或解析失败时按空表处理。
135    fn load_failures(&self) {
136        self.failures.clear();
137        let path = Path::new(&*self.cache_dir.read()).join(FAILURES_FILE);
138        let bytes = match std::fs::read(&path) {
139            Ok(bytes) => bytes,
140            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
141            Err(e) => {
142                warn!("读取图标失败记录失败: {}", e);
143                return;
144            }
145        };
146        match serde_json::from_slice::<Vec<String>>(&bytes) {
147            Ok(keys) => {
148                for key in keys {
149                    self.failures.insert(key);
150                }
151            }
152            Err(e) => warn!("解析图标失败记录失败: {}", e),
153        }
154    }
155
156    /// 将失败记录写入 L2 目录;仅在集合变化时调用,文件很小。
157    /// 参数:无。
158    /// 返回:无。
159    fn save_failures(&self) {
160        let path = Path::new(&*self.cache_dir.read()).join(FAILURES_FILE);
161        let mut keys: Vec<String> = self.failures.iter().map(|entry| entry.clone()).collect();
162        keys.sort();
163        match serde_json::to_vec(&keys) {
164            Ok(bytes) => {
165                if let Err(e) = std::fs::write(&path, bytes) {
166                    warn!("写入图标失败记录失败: {}", e);
167                }
168            }
169            Err(e) => warn!("序列化图标失败记录失败: {}", e),
170        }
171    }
172
173    /// 判断该键是否已记录失败。
174    /// 参数:key - 缓存键。
175    /// 返回:true 表示命中失败记录,调用方应直接回退默认图标、不再联网。
176    pub fn should_skip(&self, key: &str) -> bool {
177        self.failures.contains(key)
178    }
179
180    /// 记录一次提取失败并立即落盘。
181    /// 参数:key - 缓存键。
182    /// 返回:无。
183    pub fn mark_failed(&self, key: &str) {
184        if self.failures.insert(key.to_string()) {
185            self.save_failures();
186        }
187    }
188
189    /// 清除单条失败记录;记录不存在时不写盘。
190    /// 参数:key - 缓存键。
191    /// 返回:无。
192    pub fn clear_failed(&self, key: &str) {
193        if self.failures.remove(key).is_some() {
194            self.save_failures();
195        }
196    }
197
198    /// 清空全部失败记录(设置页整体重试前调用);表为空时不写盘。
199    /// 参数:无。
200    /// 返回:无。
201    pub fn clear_all_failed(&self) {
202        if self.failures.is_empty() {
203            return;
204        }
205        self.failures.clear();
206        self.save_failures();
207    }
208
209    /// 从 L2 文件缓存读取数据。
210    /// 参数:key - 缓存键(文件名)。
211    /// 返回:成功返回 Some(数据),失败或不存在返回 None。
212    pub async fn get_l2(&self, key: &str) -> Option<Vec<u8>> {
213        let cache_dir = self.cache_dir.read().clone();
214        let icon_path = Path::new(&cache_dir).join(key);
215        tokio::fs::read(&icon_path).await.ok()
216    }
217
218    /// 异步写入 L2 文件缓存。
219    /// 参数:key - 缓存键(文件名);data - 图标 WebP 数据。
220    /// 返回:无。
221    /// 特性:写入操作在后台线程执行,不阻塞调用方;同时更新 cached_file_hashes。
222    pub async fn set_l2(&self, key: &str, data: Vec<u8>) {
223        let cache_dir = self.cache_dir.read().clone();
224        let icon_path = Path::new(&cache_dir).join(key);
225        let key_owned = key.to_string();
226
227        if let Err(e) = tokio::fs::write(&icon_path, data).await {
228            warn!("写入图标缓存失败 {:?}: {}", icon_path, e);
229        } else {
230            self.cached_file_hashes.insert(key_owned);
231        }
232    }
233
234    // ===== 管理操作 =====
235
236    /// 更新图标缓存目录路径,同时清空 L1 内存缓存并重新扫描 L2。
237    /// 参数:new_cache_dir - 新的图标文件缓存目录路径。
238    /// 返回:无。
239    pub fn update_cache_dir(&self, new_cache_dir: &str) {
240        self.memory_cache.clear();
241
242        {
243            let mut cache_dir = self.cache_dir.write();
244            *cache_dir = new_cache_dir.to_string();
245        }
246
247        self.cached_file_hashes.clear();
248        let hashes = Self::scan_cached_icons(new_cache_dir);
249        for hash in hashes {
250            self.cached_file_hashes.insert(hash);
251        }
252        self.load_failures();
253    }
254
255    /// 扫描缓存目录,收集当前格式(.webp)文件名集合。
256    /// 参数:cache_dir - 缓存目录路径。
257    /// 返回:当前格式(.webp)文件名集合。
258    fn scan_cached_icons(cache_dir: &str) -> DashSet<String> {
259        let result = DashSet::new();
260        match DirUtils::read_dir_or_create(cache_dir) {
261            Ok(entries) => {
262                for entry in entries.flatten() {
263                    let file_name = entry.file_name();
264                    let file_name = file_name.to_string_lossy();
265                    if !file_name.ends_with(".webp") {
266                        continue;
267                    }
268                    result.insert(file_name.into_owned());
269                }
270            }
271            Err(e) => warn!("扫描图标缓存目录失败: {}", e),
272        }
273        result
274    }
275}