Skip to main content

yggdrasil/
cache.rs

1//! 基于 moka 的内存缓存层。
2//!
3//! 仅在启用 `server` feature 时编译,为文章列表、标签、单篇文章、统计信息、
4//! 评论、会话用户以及搜索结果提供按键缓存与失效能力。
5//! 缓存使用 `std::sync::LazyLock` 全局实例,按不同业务数据设置独立的 TTL。
6
7#[cfg(feature = "server")]
8use moka::future::Cache;
9#[cfg(feature = "server")]
10use std::sync::LazyLock;
11#[cfg(feature = "server")]
12use std::time::Duration;
13
14#[cfg(feature = "server")]
15use crate::models::comment::PublicComment;
16#[cfg(feature = "server")]
17use crate::models::friend_link::FriendLink;
18#[cfg(feature = "server")]
19use crate::models::post::{Post, PostListItem, PostStats, Tag};
20#[cfg(feature = "server")]
21use crate::models::settings::{ImageCacheSettings, SecuritySettings, SiteSettings};
22#[cfg(feature = "server")]
23use crate::models::user::SessionUser;
24
25// ============================================================================
26// 缓存 TTL 配置
27// ============================================================================
28
29/// 文章列表缓存 TTL:60 秒。
30#[cfg(feature = "server")]
31const TTL_POST_LIST: Duration = Duration::from_secs(60);
32
33/// 标签列表缓存 TTL:300 秒。
34#[cfg(feature = "server")]
35const TTL_TAG_LIST: Duration = Duration::from_secs(300);
36
37/// 单篇文章缓存 TTL:600 秒。
38#[cfg(feature = "server")]
39const TTL_SINGLE_POST: Duration = Duration::from_secs(600);
40
41/// 文章统计缓存 TTL:60 秒。
42#[cfg(feature = "server")]
43const TTL_POST_STATS: Duration = Duration::from_secs(60);
44
45/// 标签下文章列表缓存 TTL:120 秒。
46#[cfg(feature = "server")]
47const TTL_TAG_POSTS: Duration = Duration::from_secs(120);
48
49/// 评论列表缓存 TTL:60 秒。
50#[cfg(feature = "server")]
51const TTL_COMMENTS: Duration = Duration::from_secs(60);
52
53/// 待审核评论数量缓存 TTL:10 秒,因管理后台需要较实时数据。
54#[cfg(feature = "server")]
55const TTL_PENDING_COUNT: Duration = Duration::from_secs(10);
56
57/// 会话用户缓存 TTL:300 秒(5 分钟),短于 DB 会话过期时间。
58#[cfg(feature = "server")]
59const TTL_SESSION: Duration = Duration::from_secs(300);
60
61/// 搜索结果缓存 TTL:10 秒。
62#[cfg(feature = "server")]
63const TTL_SEARCH: Duration = Duration::from_secs(10);
64/// 搜索面板文章建议的缓存时长;文章写操作时整体失效。
65#[cfg(feature = "server")]
66const TTL_SUGGEST: Duration = Duration::from_secs(30);
67
68/// 站点配置缓存 TTL:600 秒。配置极少变更,但前台页脚每页读取,缓存兜底 DB。
69#[cfg(feature = "server")]
70const TTL_SITE_SETTINGS: Duration = Duration::from_secs(600);
71
72/// 安全配置缓存 TTL:15 秒。安全相关(CSRF/cookie/代理/会话),面板修改后
73/// 需尽快全链路生效,TTL 仅作 DB 兜底而非主失效手段(面板保存会主动失效)。
74#[cfg(feature = "server")]
75const TTL_SECURITY_SETTINGS: Duration = Duration::from_secs(15);
76
77/// 图片磁盘缓存配置 TTL:60 秒。仅清理任务每小时读取一次,TTL 纯为 DB 兜底。
78#[cfg(feature = "server")]
79const TTL_IMAGE_CACHE_SETTINGS: Duration = Duration::from_secs(60);
80
81/// 日志 target 去重列表缓存 TTL:60 秒。DISTINCT 全表扫不值得每开页面跑一次。
82#[cfg(feature = "server")]
83const TTL_LOG_TARGETS: Duration = Duration::from_secs(60);
84
85// ============================================================================
86// 缓存 Key 类型
87// ============================================================================
88
89/// 统一的缓存键枚举,每个变体对应一类可缓存数据。
90#[cfg(feature = "server")]
91#[derive(Debug, Clone, Hash, Eq, PartialEq)]
92pub enum CacheKey {
93    /// 已发布文章分页列表。
94    PublishedPosts { page: i32, per_page: i32 },
95    /// 已发布文章总数。
96    TotalPublishedPosts,
97    /// 全部标签。
98    AllTags,
99    /// 按 slug 查询的单篇文章。
100    PostBySlug(String),
101    /// 按标签查询的文章列表(不分页,返回全部)。
102    PostsByTag(String),
103    /// 文章统计信息。
104    PostStats,
105    /// 某篇文章下的评论列表。
106    CommentsByPost { post_id: i32 },
107    /// 待审核评论总数。
108    PendingCommentCount,
109    /// Feed 条目列表(RSS / JSON Feed 共用单键)。
110    Feed,
111    /// 全部友链(前台可见集)。
112    FriendLinks,
113    /// 站点公开配置(页脚 GitHub 链接等,单键)。
114    SiteSettings,
115    /// 安全配置(即时生效层,单键)。
116    SecuritySettings,
117    /// 图片磁盘缓存配置(即时生效层,单键)。
118    ImageCacheSettings,
119    /// 日志 target 去重列表(日志查看器筛选项,单键)。
120    LogTargets,
121}
122
123// ============================================================================
124// 缓存实例
125// ============================================================================
126
127/// 文章列表缓存类型,值为(文章列表,总数)。
128#[cfg(feature = "server")]
129pub type PostListCache = Cache<CacheKey, (Vec<PostListItem>, i64)>;
130
131/// 标签列表缓存类型。
132#[cfg(feature = "server")]
133pub type TagListCache = Cache<CacheKey, Vec<Tag>>;
134
135/// 单篇文章缓存类型。
136#[cfg(feature = "server")]
137pub type SinglePostCache = Cache<CacheKey, Option<Post>>;
138
139/// 文章统计缓存类型。
140#[cfg(feature = "server")]
141pub type PostStatsCache = Cache<CacheKey, PostStats>;
142
143/// Feed 成品缓存类型:RSS / JSON Feed 两份渲染好的文档连同预压缩变体。
144#[cfg(feature = "server")]
145pub type FeedCache = Cache<CacheKey, std::sync::Arc<crate::api::feed::RenderedFeed>>;
146
147/// 全局文章列表缓存实例,最大容量 100。
148#[cfg(feature = "server")]
149static POST_LIST_CACHE: LazyLock<PostListCache> = LazyLock::new(|| {
150    Cache::builder()
151        .max_capacity(100)
152        .time_to_live(TTL_POST_LIST)
153        .build()
154});
155
156#[cfg(feature = "server")]
157static ARCHIVE_CACHE: LazyLock<Cache<i32, crate::api::posts::ArchiveResponse>> =
158    LazyLock::new(|| {
159        Cache::builder()
160            .max_capacity(100)
161            .time_to_live(TTL_POST_LIST)
162            .build()
163    });
164
165#[cfg(feature = "server")]
166pub async fn get_archive(page: i32) -> Option<crate::api::posts::ArchiveResponse> {
167    ARCHIVE_CACHE.get(&page).await
168}
169
170#[cfg(feature = "server")]
171pub async fn set_archive(page: i32, response: crate::api::posts::ArchiveResponse) {
172    ARCHIVE_CACHE.insert(page, response).await;
173}
174
175/// 全局标签列表缓存实例,最大容量 50。
176#[cfg(feature = "server")]
177static TAG_LIST_CACHE: LazyLock<TagListCache> = LazyLock::new(|| {
178    Cache::builder()
179        .max_capacity(50)
180        .time_to_live(TTL_TAG_LIST)
181        .build()
182});
183
184/// 友链列表缓存类型。
185#[cfg(feature = "server")]
186pub type FriendLinksCache = Cache<CacheKey, Vec<FriendLink>>;
187
188/// 全局友链列表缓存实例,最大容量 50,TTL 与标签列表一致。
189#[cfg(feature = "server")]
190static FRIEND_LINKS_CACHE: LazyLock<FriendLinksCache> = LazyLock::new(|| {
191    Cache::builder()
192        .max_capacity(50)
193        .time_to_live(TTL_TAG_LIST)
194        .build()
195});
196
197/// 全局单篇文章缓存实例,最大容量 200。
198#[cfg(feature = "server")]
199static SINGLE_POST_CACHE: LazyLock<SinglePostCache> = LazyLock::new(|| {
200    Cache::builder()
201        .max_capacity(200)
202        .time_to_live(TTL_SINGLE_POST)
203        .build()
204});
205
206/// 全局文章统计缓存实例,最大容量 10。
207#[cfg(feature = "server")]
208static POST_STATS_CACHE: LazyLock<PostStatsCache> = LazyLock::new(|| {
209    Cache::builder()
210        .max_capacity(10)
211        .time_to_live(TTL_POST_STATS)
212        .build()
213});
214
215/// 全局 Feed 条目缓存实例,最大容量 10(单键,20 篇全文条目)。
216#[cfg(feature = "server")]
217static FEED_CACHE: LazyLock<FeedCache> = LazyLock::new(|| {
218    Cache::builder()
219        .max_capacity(10)
220        .time_to_live(TTL_SINGLE_POST)
221        .build()
222});
223
224/// 全局标签文章列表缓存实例,最大容量 100。
225#[cfg(feature = "server")]
226static TAG_POSTS_CACHE: LazyLock<PostListCache> = LazyLock::new(|| {
227    Cache::builder()
228        .max_capacity(100)
229        .time_to_live(TTL_TAG_POSTS)
230        .build()
231});
232
233/// 评论列表缓存类型。
234#[cfg(feature = "server")]
235pub type CommentListCache = Cache<CacheKey, Vec<PublicComment>>;
236
237/// 全局评论列表缓存实例,最大容量 200。
238#[cfg(feature = "server")]
239static COMMENT_CACHE: LazyLock<CommentListCache> = LazyLock::new(|| {
240    Cache::builder()
241        .max_capacity(200)
242        .time_to_live(TTL_COMMENTS)
243        .build()
244});
245
246/// 全局待审核评论数量缓存实例,最大容量 10。
247#[cfg(feature = "server")]
248static PENDING_COUNT_CACHE: LazyLock<Cache<CacheKey, i64>> = LazyLock::new(|| {
249    Cache::builder()
250        .max_capacity(10)
251        .time_to_live(TTL_PENDING_COUNT)
252        .build()
253});
254
255/// 全局站点配置缓存实例(单键 `SiteSettings`),最大容量 2。
256#[cfg(feature = "server")]
257static SITE_SETTINGS_CACHE: LazyLock<Cache<CacheKey, SiteSettings>> = LazyLock::new(|| {
258    Cache::builder()
259        .max_capacity(2)
260        .time_to_live(TTL_SITE_SETTINGS)
261        .build()
262});
263
264/// 全局安全配置缓存实例(单键 `SecuritySettings`),最大容量 2。
265#[cfg(feature = "server")]
266static SECURITY_SETTINGS_CACHE: LazyLock<Cache<CacheKey, SecuritySettings>> = LazyLock::new(|| {
267    Cache::builder()
268        .max_capacity(2)
269        .time_to_live(TTL_SECURITY_SETTINGS)
270        .build()
271});
272
273/// 全局图片磁盘缓存配置实例(单键 `ImageCacheSettings`),最大容量 2。
274#[cfg(feature = "server")]
275static IMAGE_CACHE_SETTINGS_CACHE: LazyLock<Cache<CacheKey, ImageCacheSettings>> =
276    LazyLock::new(|| {
277        Cache::builder()
278            .max_capacity(2)
279            .time_to_live(TTL_IMAGE_CACHE_SETTINGS)
280            .build()
281    });
282
283/// 全局日志 target 去重列表缓存实例(单键 `LogTargets`),最大容量 2。
284#[cfg(feature = "server")]
285static LOG_TARGETS_CACHE: LazyLock<Cache<CacheKey, Vec<String>>> = LazyLock::new(|| {
286    Cache::builder()
287        .max_capacity(2)
288        .time_to_live(TTL_LOG_TARGETS)
289        .build()
290});
291
292/// 会话用户缓存类型。
293#[cfg(feature = "server")]
294pub type SessionCache = Cache<String, SessionUser>;
295
296/// 搜索结果缓存类型。
297#[cfg(feature = "server")]
298pub type SearchCache = Cache<String, (Vec<PostListItem>, i64)>;
299
300/// 全局会话用户缓存实例,最大容量 1000,TTL 5 分钟。
301#[cfg(feature = "server")]
302pub static SESSION_CACHE: LazyLock<SessionCache> = LazyLock::new(|| {
303    Cache::builder()
304        .max_capacity(1000)
305        .time_to_live(TTL_SESSION)
306        .build()
307});
308
309/// 全局搜索结果缓存实例,最大容量 200,TTL 10 秒。
310#[cfg(feature = "server")]
311static SEARCH_CACHE: LazyLock<SearchCache> = LazyLock::new(|| {
312    Cache::builder()
313        .max_capacity(200)
314        .time_to_live(TTL_SEARCH)
315        .build()
316});
317
318/// 搜索面板文章建议缓存:键为规范化查询,最大 500 条,TTL 30 秒。
319#[cfg(feature = "server")]
320static SUGGEST_CACHE: LazyLock<Cache<String, Vec<crate::api::posts::PostSuggestion>>> =
321    LazyLock::new(|| {
322        Cache::builder()
323            .max_capacity(500)
324            .time_to_live(TTL_SUGGEST)
325            .build()
326    });
327
328/// 关于页每年篇数缓存:单键,随文章列表一起失效。
329#[cfg(feature = "server")]
330static YEAR_COUNTS_CACHE: LazyLock<Cache<(), Vec<crate::api::posts::YearCount>>> =
331    LazyLock::new(|| {
332        Cache::builder()
333            .max_capacity(1)
334            .time_to_live(TTL_POST_LIST)
335            .build()
336    });
337
338#[cfg(feature = "server")]
339pub async fn get_year_counts() -> Option<Vec<crate::api::posts::YearCount>> {
340    YEAR_COUNTS_CACHE.get(&()).await
341}
342
343#[cfg(feature = "server")]
344pub async fn set_year_counts(counts: Vec<crate::api::posts::YearCount>) {
345    YEAR_COUNTS_CACHE.insert((), counts).await;
346}
347
348/// 首页最新一篇目录预览缓存:单键,TTL 与文章列表一致,随文章列表一起失效。
349#[cfg(feature = "server")]
350static OUTLINE_CACHE: LazyLock<Cache<(), Option<crate::api::posts::PostOutline>>> =
351    LazyLock::new(|| {
352        Cache::builder()
353            .max_capacity(1)
354            .time_to_live(TTL_POST_LIST)
355            .build()
356    });
357
358// ============================================================================
359// 命中率统计(供系统状态面板展示)
360// ============================================================================
361
362/// 单个缓存的命中/未命中计数。用 AtomicU64 在 get_* 路径上记录。
363#[cfg(feature = "server")]
364pub struct CacheStats {
365    pub name: &'static str,
366    hits: std::sync::atomic::AtomicU64,
367    misses: std::sync::atomic::AtomicU64,
368}
369
370#[cfg(feature = "server")]
371impl CacheStats {
372    pub const fn new(name: &'static str) -> Self {
373        Self {
374            name,
375            hits: std::sync::atomic::AtomicU64::new(0),
376            misses: std::sync::atomic::AtomicU64::new(0),
377        }
378    }
379    fn record_hit(&self) {
380        self.hits.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
381    }
382    fn record_miss(&self) {
383        self.misses
384            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
385    }
386}
387
388// 每个缓存一份统计实例(const,启动时零开销初始化)。
389#[cfg(feature = "server")]
390static POST_LIST_STATS: CacheStats = CacheStats::new("文章列表");
391#[cfg(feature = "server")]
392static TAG_STATS: CacheStats = CacheStats::new("标签");
393#[cfg(feature = "server")]
394static SINGLE_POST_STATS: CacheStats = CacheStats::new("单篇文章");
395#[cfg(feature = "server")]
396static POST_STATS_STATS: CacheStats = CacheStats::new("文章统计");
397#[cfg(feature = "server")]
398static TAG_POSTS_STATS: CacheStats = CacheStats::new("标签文章");
399#[cfg(feature = "server")]
400static COMMENT_STATS: CacheStats = CacheStats::new("评论");
401#[cfg(feature = "server")]
402static PENDING_COUNT_STATS: CacheStats = CacheStats::new("待审评论数");
403#[cfg(feature = "server")]
404static SESSION_STATS: CacheStats = CacheStats::new("会话用户");
405#[cfg(feature = "server")]
406static SEARCH_STATS: CacheStats = CacheStats::new("搜索");
407#[cfg(feature = "server")]
408static SUGGEST_STATS: CacheStats = CacheStats::new("搜索建议");
409#[cfg(feature = "server")]
410static OUTLINE_STATS: CacheStats = CacheStats::new("最新目录");
411#[cfg(feature = "server")]
412static FEED_STATS: CacheStats = CacheStats::new("Feed");
413#[cfg(feature = "server")]
414static FRIEND_STATS: CacheStats = CacheStats::new("友链");
415#[cfg(feature = "server")]
416static SITE_SETTINGS_STATS: CacheStats = CacheStats::new("站点配置");
417
418/// 缓存统计快照项(序列化给前端展示)。
419#[cfg(feature = "server")]
420#[derive(Debug)]
421pub struct CacheStatSnapshot {
422    pub name: &'static str,
423    pub entry_count: u64,
424    pub hits: u64,
425    pub misses: u64,
426    pub hit_rate: f64,
427}
428
429/// 聚合所有缓存的统计快照(供 get_server_status 调用)。
430#[cfg(feature = "server")]
431pub fn cache_stats() -> Vec<CacheStatSnapshot> {
432    fn snap(stats: &CacheStats, entry_count: u64) -> CacheStatSnapshot {
433        let hits = stats.hits.load(std::sync::atomic::Ordering::Relaxed);
434        let misses = stats.misses.load(std::sync::atomic::Ordering::Relaxed);
435        let total = hits + misses;
436        let hit_rate = if total == 0 {
437            0.0
438        } else {
439            hits as f64 / total as f64
440        };
441        CacheStatSnapshot {
442            name: stats.name,
443            entry_count,
444            hits,
445            misses,
446            hit_rate,
447        }
448    }
449    vec![
450        snap(&POST_LIST_STATS, POST_LIST_CACHE.entry_count()),
451        snap(&TAG_STATS, TAG_LIST_CACHE.entry_count()),
452        snap(&SINGLE_POST_STATS, SINGLE_POST_CACHE.entry_count()),
453        snap(&POST_STATS_STATS, POST_STATS_CACHE.entry_count()),
454        snap(&TAG_POSTS_STATS, TAG_POSTS_CACHE.entry_count()),
455        snap(&COMMENT_STATS, COMMENT_CACHE.entry_count()),
456        snap(&PENDING_COUNT_STATS, PENDING_COUNT_CACHE.entry_count()),
457        snap(&SESSION_STATS, SESSION_CACHE.entry_count()),
458        snap(&SEARCH_STATS, SEARCH_CACHE.entry_count()),
459        snap(&SUGGEST_STATS, SUGGEST_CACHE.entry_count()),
460        snap(&OUTLINE_STATS, OUTLINE_CACHE.entry_count()),
461        snap(&FEED_STATS, FEED_CACHE.entry_count()),
462        snap(&FRIEND_STATS, FRIEND_LINKS_CACHE.entry_count()),
463        snap(&SITE_SETTINGS_STATS, SITE_SETTINGS_CACHE.entry_count()),
464    ]
465}
466
467// ============================================================================
468// 公共缓存 API
469// ============================================================================
470
471/// 记录缓存命中/未命中统计,返回原值。
472#[cfg(any(feature = "server", test))]
473macro_rules! record_hit_miss {
474    ($v:expr, $stats:expr) => {{
475        let v = $v;
476        if v.is_some() {
477            $stats.record_hit();
478        } else {
479            $stats.record_miss();
480        }
481        v
482    }};
483}
484
485/// 读取文章分页列表缓存。
486#[cfg(feature = "server")]
487pub async fn get_post_list(key: &CacheKey) -> Option<(Vec<PostListItem>, i64)> {
488    record_hit_miss!(POST_LIST_CACHE.get(key).await, POST_LIST_STATS)
489}
490
491/// 写入文章分页列表缓存。
492#[cfg(feature = "server")]
493pub async fn set_post_list(key: &CacheKey, posts: Vec<PostListItem>, total: i64) {
494    let _ = POST_LIST_CACHE.insert(key.clone(), (posts, total)).await;
495}
496
497/// 读取已发布文章总数缓存。
498#[cfg(feature = "server")]
499pub async fn get_total_published_posts() -> Option<i64> {
500    record_hit_miss!(
501        POST_LIST_CACHE
502            .get(&CacheKey::TotalPublishedPosts)
503            .await
504            .map(|(_, total)| total),
505        POST_LIST_STATS
506    )
507}
508
509/// 写入已发布文章总数缓存,文章列表部分置空以节省内存。
510#[cfg(feature = "server")]
511pub async fn set_total_published_posts(total: i64) {
512    let _ = POST_LIST_CACHE
513        .insert(CacheKey::TotalPublishedPosts, (vec![], total))
514        .await;
515}
516
517/// 读取全部标签缓存。
518#[cfg(feature = "server")]
519pub async fn get_tag_list() -> Option<Vec<Tag>> {
520    record_hit_miss!(TAG_LIST_CACHE.get(&CacheKey::AllTags).await, TAG_STATS)
521}
522
523/// 写入全部标签缓存。
524#[cfg(feature = "server")]
525pub async fn set_tag_list(tags: Vec<Tag>) {
526    let _ = TAG_LIST_CACHE.insert(CacheKey::AllTags, tags).await;
527}
528
529/// 读取全部友链缓存(前台可见集)。
530#[cfg(feature = "server")]
531pub async fn get_friend_links() -> Option<Vec<FriendLink>> {
532    record_hit_miss!(
533        FRIEND_LINKS_CACHE.get(&CacheKey::FriendLinks).await,
534        FRIEND_STATS
535    )
536}
537
538/// 写入全部友链缓存。
539#[cfg(feature = "server")]
540pub async fn set_friend_links(links: Vec<FriendLink>) {
541    let _ = FRIEND_LINKS_CACHE
542        .insert(CacheKey::FriendLinks, links)
543        .await;
544}
545
546/// 读取站点配置缓存。
547#[cfg(feature = "server")]
548pub async fn get_site_settings() -> Option<SiteSettings> {
549    record_hit_miss!(
550        SITE_SETTINGS_CACHE.get(&CacheKey::SiteSettings).await,
551        SITE_SETTINGS_STATS
552    )
553}
554
555/// 写入站点配置缓存。
556#[cfg(feature = "server")]
557pub async fn set_site_settings(settings: SiteSettings) {
558    let _ = SITE_SETTINGS_CACHE
559        .insert(CacheKey::SiteSettings, settings)
560        .await;
561}
562
563/// 失效站点配置缓存。
564///
565/// 使用同步签名是因为 `moka::Cache::invalidate_all` 为同步操作。
566#[cfg(feature = "server")]
567pub fn invalidate_site_settings() {
568    SITE_SETTINGS_CACHE.invalidate_all();
569}
570
571/// 读取安全配置缓存。
572#[cfg(feature = "server")]
573pub async fn get_security_settings() -> Option<SecuritySettings> {
574    SECURITY_SETTINGS_CACHE
575        .get(&CacheKey::SecuritySettings)
576        .await
577}
578
579/// 写入安全配置缓存。
580#[cfg(feature = "server")]
581pub async fn set_security_settings(settings: SecuritySettings) {
582    let _ = SECURITY_SETTINGS_CACHE
583        .insert(CacheKey::SecuritySettings, settings)
584        .await;
585}
586
587/// 失效安全配置缓存(面板保存后调用)。
588#[cfg(feature = "server")]
589pub fn invalidate_security_settings() {
590    SECURITY_SETTINGS_CACHE.invalidate_all();
591}
592
593/// 读取图片磁盘缓存配置缓存。
594#[cfg(feature = "server")]
595pub async fn get_image_cache_settings() -> Option<ImageCacheSettings> {
596    IMAGE_CACHE_SETTINGS_CACHE
597        .get(&CacheKey::ImageCacheSettings)
598        .await
599}
600
601/// 写入图片磁盘缓存配置缓存。
602#[cfg(feature = "server")]
603pub async fn set_image_cache_settings(settings: ImageCacheSettings) {
604    let _ = IMAGE_CACHE_SETTINGS_CACHE
605        .insert(CacheKey::ImageCacheSettings, settings)
606        .await;
607}
608
609/// 失效图片磁盘缓存配置缓存(面板保存后调用)。
610#[cfg(feature = "server")]
611pub fn invalidate_image_cache_settings() {
612    IMAGE_CACHE_SETTINGS_CACHE.invalidate_all();
613}
614
615/// 读取日志 target 去重列表缓存。
616#[cfg(feature = "server")]
617pub async fn get_log_targets() -> Option<Vec<String>> {
618    LOG_TARGETS_CACHE.get(&CacheKey::LogTargets).await
619}
620
621/// 写入日志 target 去重列表缓存。
622#[cfg(feature = "server")]
623pub async fn set_log_targets(targets: Vec<String>) {
624    let _ = LOG_TARGETS_CACHE
625        .insert(CacheKey::LogTargets, targets)
626        .await;
627}
628
629/// 清空日志 target 缓存。
630#[cfg(feature = "server")]
631pub fn invalidate_log_targets() {
632    LOG_TARGETS_CACHE.invalidate_all();
633}
634
635/// 按 slug 读取单篇文章缓存。
636#[cfg(feature = "server")]
637pub async fn get_post_by_slug(slug: &str) -> Option<Option<Post>> {
638    record_hit_miss!(
639        SINGLE_POST_CACHE
640            .get(&CacheKey::PostBySlug(slug.to_string()))
641            .await,
642        SINGLE_POST_STATS
643    )
644}
645
646/// 按 slug 写入单篇文章缓存,None 表示文章不存在。
647#[cfg(feature = "server")]
648pub async fn set_post_by_slug(slug: &str, post: Option<Post>) {
649    let _ = SINGLE_POST_CACHE
650        .insert(CacheKey::PostBySlug(slug.to_string()), post)
651        .await;
652}
653
654/// 按标签读取文章列表缓存。
655#[cfg(feature = "server")]
656pub async fn get_posts_by_tag(tag: &str) -> Option<(Vec<PostListItem>, i64)> {
657    record_hit_miss!(
658        TAG_POSTS_CACHE
659            .get(&CacheKey::PostsByTag(tag.to_string()))
660            .await,
661        TAG_POSTS_STATS
662    )
663}
664
665/// 按标签写入文章列表缓存。
666#[cfg(feature = "server")]
667pub async fn set_posts_by_tag(tag: &str, posts: Vec<PostListItem>, total: i64) {
668    let _ = TAG_POSTS_CACHE
669        .insert(CacheKey::PostsByTag(tag.to_string()), (posts, total))
670        .await;
671}
672
673/// 读取文章统计缓存。
674#[cfg(feature = "server")]
675pub async fn get_post_stats() -> Option<PostStats> {
676    record_hit_miss!(
677        POST_STATS_CACHE.get(&CacheKey::PostStats).await,
678        POST_STATS_STATS
679    )
680}
681
682/// 写入文章统计缓存。
683#[cfg(feature = "server")]
684pub async fn set_post_stats(stats: PostStats) {
685    let _ = POST_STATS_CACHE.insert(CacheKey::PostStats, stats).await;
686}
687
688/// 读取 Feed 成品缓存。
689#[cfg(feature = "server")]
690pub async fn get_feed() -> Option<std::sync::Arc<crate::api::feed::RenderedFeed>> {
691    record_hit_miss!(FEED_CACHE.get(&CacheKey::Feed).await, FEED_STATS)
692}
693
694/// 写入 Feed 成品缓存。
695#[cfg(feature = "server")]
696pub async fn set_feed(feed: std::sync::Arc<crate::api::feed::RenderedFeed>) {
697    let _ = FEED_CACHE.insert(CacheKey::Feed, feed).await;
698}
699
700// ============================================================================
701// 缓存失效
702// ============================================================================
703
704/// 清空所有文章分页列表缓存,以及随列表失效的首页目录预览与每年篇数。
705#[cfg(feature = "server")]
706pub fn invalidate_post_lists() {
707    ARCHIVE_CACHE.invalidate_all();
708    POST_LIST_CACHE.invalidate_all();
709    OUTLINE_CACHE.invalidate_all();
710    YEAR_COUNTS_CACHE.invalidate_all();
711}
712
713/// 清空所有标签缓存。
714#[cfg(feature = "server")]
715pub fn invalidate_all_tags() {
716    TAG_LIST_CACHE.invalidate_all();
717}
718
719/// 清空友链缓存。
720#[cfg(feature = "server")]
721pub fn invalidate_friend_links() {
722    FRIEND_LINKS_CACHE.invalidate_all();
723}
724
725/// 按 slug 失效单篇文章缓存。
726#[cfg(feature = "server")]
727pub async fn invalidate_post_by_slug(slug: &str) {
728    SINGLE_POST_CACHE
729        .invalidate(&CacheKey::PostBySlug(slug.to_string()))
730        .await;
731}
732
733/// 按标签失效文章列表缓存。
734#[cfg(feature = "server")]
735pub async fn invalidate_posts_by_tag(tag: &str) {
736    TAG_POSTS_CACHE
737        .invalidate(&CacheKey::PostsByTag(tag.to_string()))
738        .await;
739}
740
741/// 清空文章统计缓存。
742#[cfg(feature = "server")]
743pub fn invalidate_post_stats() {
744    POST_STATS_CACHE.invalidate_all();
745}
746
747/// 按标签批量失效文章列表缓存。
748#[cfg(feature = "server")]
749pub async fn invalidate_tag_posts_for(tags: &[String]) {
750    let futures: Vec<_> = tags
751        .iter()
752        .map(|tag| invalidate_posts_by_tag(tag))
753        .collect();
754    let _ = futures::future::join_all(futures).await;
755}
756
757/// 清空所有文章相关缓存(列表、标签、单篇、统计、标签文章)。
758///
759/// 这是一个“紧急”使用的全量失效开关,会一次性冲刷所有文章缓存;
760/// 正常写路径应当使用更细粒度的 `invalidate_post_lists` / `invalidate_all_tags` /
761/// `invalidate_post_by_slug` / `invalidate_posts_by_tag` / `invalidate_post_stats` /
762/// `invalidate_tag_posts_for` 等函数,避免不必要的缓存击穿。
763#[cfg(feature = "server")]
764pub fn invalidate_all_post_caches() {
765    invalidate_post_metadata();
766    SINGLE_POST_CACHE.invalidate_all();
767    TAG_POSTS_CACHE.invalidate_all();
768}
769
770/// 数据库内容版本变化时清除本节点的派生数据;不再向数据库发通知,避免回路。
771#[cfg(feature = "server")]
772pub fn invalidate_shared_content() {
773    invalidate_all_post_caches();
774    invalidate_all_comments();
775    invalidate_friend_links();
776    invalidate_site_settings();
777    invalidate_security_settings();
778    invalidate_image_cache_settings();
779    crate::ssr_cache::invalidate_ssr_all_public();
780}
781
782/// 清空 Feed 条目缓存。
783///
784/// 使用同步签名是因为 `moka::Cache::invalidate_all` 为同步操作,
785/// 与 `invalidate_post_stats` 等元数据失效保持一致。
786#[cfg(feature = "server")]
787pub fn invalidate_feed() {
788    FEED_CACHE.invalidate_all();
789}
790
791/// 失效文章「元数据」类缓存:列表、标签、统计、搜索结果、Feed。
792///
793/// 这些在每次文章写操作(创建/更新/删除/恢复/清空回收站)后都需要一起失效。
794/// 单篇正文与标签下文章列表是定向失效(按 slug / tag),不在此处处理,由调用方
795/// 根据实际涉及的 slug/tags 额外调用 `invalidate_post_by_slug` / `invalidate_tag_posts_for`。
796#[cfg(feature = "server")]
797pub fn invalidate_post_metadata() {
798    invalidate_post_lists();
799    invalidate_all_tags();
800    invalidate_post_stats();
801    invalidate_search_results();
802    invalidate_suggestions();
803    invalidate_feed();
804}
805
806/// 文章写操作(创建/更新/删除/恢复/清空回收站)后的统一缓存失效序列。
807///
808/// 按影响范围精准失效:先失效文章「元数据」类缓存(列表/标签云/统计/搜索),
809/// 再按 slug 定向失效单篇正文与 SSR 详情页,按标签失效标签下文章列表,
810/// 最后全量刷新公开页 SSR 缓存并递增全局世代号。
811///
812/// - `slugs`:受影响的文章 slug(旧/新均可,用于单篇缓存与 SSR 详情页定向失效)。
813/// - `tags`:受影响的标签名(用于标签下文章列表失效)。
814///
815/// 对于 slug 变更等需要分别处理旧/新 slug 的复杂场景,调用方将涉及的 slug 一并传入即可——
816/// 本函数对 `slugs` 中的每个元素一视同仁。注意:`invalidate_ssr_all_public` 会删除全部
817/// 公开页 SSR 缓存,因此批量场景下逐 slug 的 `invalidate_ssr_route` 仅是先行的定向清理,
818/// 不会改变最终失效结果。
819#[cfg(feature = "server")]
820pub async fn invalidate_for_post_write(slugs: &[String], tags: &[String]) {
821    invalidate_post_metadata();
822    for slug in slugs {
823        invalidate_post_by_slug(slug).await;
824    }
825    invalidate_tag_posts_for(tags).await;
826    for slug in slugs {
827        crate::ssr_cache::invalidate_ssr_route(&format!("/post/{slug}"));
828        crate::ssr_cache::invalidate_post_preview(slug);
829    }
830    crate::ssr_cache::invalidate_ssr_all_public();
831    crate::ssr_cache::bump_global_generation();
832}
833
834/// 标签写操作(创建/重命名)后的统一缓存失效序列。
835///
836/// 标签名会嵌在列表、单篇、标签下文章列表、搜索与 Feed 中,且重命名会让旧名
837/// 下的缓存键失效但新名键缺席,无法按 slug/标签精准定位,因此全量冲刷文章类缓存,
838/// 并刷新公开页 SSR 缓存、递增全局世代号。
839#[cfg(feature = "server")]
840pub fn invalidate_for_tag_write() {
841    invalidate_all_post_caches();
842    crate::ssr_cache::invalidate_ssr_all_public();
843    crate::ssr_cache::bump_global_generation();
844}
845
846/// 按文章主键读取评论列表缓存。
847#[cfg(feature = "server")]
848pub async fn get_comments_by_post(post_id: i32) -> Option<Vec<PublicComment>> {
849    record_hit_miss!(
850        COMMENT_CACHE
851            .get(&CacheKey::CommentsByPost { post_id })
852            .await,
853        COMMENT_STATS
854    )
855}
856
857/// 按文章主键写入评论列表缓存。
858#[cfg(feature = "server")]
859pub async fn set_comments_by_post(post_id: i32, comments: Vec<PublicComment>) {
860    let _ = COMMENT_CACHE
861        .insert(CacheKey::CommentsByPost { post_id }, comments)
862        .await;
863}
864
865/// 读取待审核评论总数缓存。
866#[cfg(feature = "server")]
867pub async fn get_pending_count() -> Option<i64> {
868    record_hit_miss!(
869        PENDING_COUNT_CACHE
870            .get(&CacheKey::PendingCommentCount)
871            .await,
872        PENDING_COUNT_STATS
873    )
874}
875
876/// 写入待审核评论总数缓存。
877#[cfg(feature = "server")]
878pub async fn set_pending_count(count: i64) {
879    let _ = PENDING_COUNT_CACHE
880        .insert(CacheKey::PendingCommentCount, count)
881        .await;
882}
883
884/// 规范化搜索查询键:trim、转小写、截断至 200 字符。
885#[cfg(feature = "server")]
886pub fn normalize_search_key(query: &str) -> String {
887    query.trim().to_lowercase().chars().take(200).collect()
888}
889
890/// 读取会话用户缓存。
891#[cfg(feature = "server")]
892pub async fn get_session_user(token_hash: &str) -> Option<SessionUser> {
893    record_hit_miss!(SESSION_CACHE.get(token_hash).await, SESSION_STATS)
894}
895
896/// 写入会话用户缓存。
897#[cfg(feature = "server")]
898pub async fn set_session_user(token_hash: &str, user: SessionUser) {
899    let _ = SESSION_CACHE.insert(token_hash.to_string(), user).await;
900}
901
902/// 失效指定会话用户缓存。
903#[cfg(feature = "server")]
904pub async fn invalidate_session_user(token_hash: &str) {
905    SESSION_CACHE.invalidate(token_hash).await;
906}
907
908/// 读取搜索结果缓存。
909#[cfg(feature = "server")]
910pub async fn get_search_results(query: &str) -> Option<(Vec<PostListItem>, i64)> {
911    record_hit_miss!(
912        SEARCH_CACHE.get(&normalize_search_key(query)).await,
913        SEARCH_STATS
914    )
915}
916
917/// 写入搜索结果缓存。
918#[cfg(feature = "server")]
919pub async fn set_search_results(query: &str, posts: Vec<PostListItem>, total: i64) {
920    let _ = SEARCH_CACHE
921        .insert(normalize_search_key(query), (posts, total))
922        .await;
923}
924
925/// 清空所有搜索结果缓存。
926///
927/// 使用同步签名是因为 `moka::Cache::invalidate_all` 为同步操作;
928/// 该函数通常由写路径直接调用,无需额外等待。
929#[cfg(feature = "server")]
930pub fn invalidate_search_results() {
931    SEARCH_CACHE.invalidate_all();
932}
933
934/// 读取搜索面板文章建议缓存。
935#[cfg(feature = "server")]
936pub async fn get_suggestions(query: &str) -> Option<Vec<crate::api::posts::PostSuggestion>> {
937    record_hit_miss!(
938        SUGGEST_CACHE.get(&normalize_search_key(query)).await,
939        SUGGEST_STATS
940    )
941}
942
943/// 写入搜索面板文章建议缓存。
944#[cfg(feature = "server")]
945pub async fn set_suggestions(query: &str, posts: Vec<crate::api::posts::PostSuggestion>) {
946    let _ = SUGGEST_CACHE
947        .insert(normalize_search_key(query), posts)
948        .await;
949}
950
951/// 清空搜索面板文章建议缓存。
952#[cfg(feature = "server")]
953pub fn invalidate_suggestions() {
954    SUGGEST_CACHE.invalidate_all();
955}
956
957/// 读取首页最新一篇目录预览缓存。外层 `None` 表示未命中。
958#[cfg(feature = "server")]
959pub async fn get_latest_outline() -> Option<Option<crate::api::posts::PostOutline>> {
960    record_hit_miss!(OUTLINE_CACHE.get(&()).await, OUTLINE_STATS)
961}
962
963/// 写入首页最新一篇目录预览缓存。
964#[cfg(feature = "server")]
965pub async fn set_latest_outline(outline: Option<crate::api::posts::PostOutline>) {
966    OUTLINE_CACHE.insert((), outline).await;
967}
968
969/// 按文章主键失效评论列表缓存。
970#[cfg(feature = "server")]
971pub async fn invalidate_comments_by_post(post_id: i32) {
972    COMMENT_CACHE
973        .invalidate(&CacheKey::CommentsByPost { post_id })
974        .await;
975}
976
977/// 失效待审核评论总数缓存。
978#[cfg(feature = "server")]
979pub async fn invalidate_pending_count() {
980    PENDING_COUNT_CACHE
981        .invalidate(&CacheKey::PendingCommentCount)
982        .await;
983}
984
985/// 全量失效评论缓存(SQL 控制台兜底用:管理员直接改 comments 表时无法定向)。
986///
987/// 正常评论写路径用 [`invalidate_comments_by_post`](按文章定向)+ [`invalidate_pending_count`];
988/// 这里全量清空是 SQL 控制台直改 DB 的兜底——无法从任意 SQL 精确解析受影响的 post_id。
989#[cfg(feature = "server")]
990pub fn invalidate_all_comments() {
991    COMMENT_CACHE.invalidate_all();
992    PENDING_COUNT_CACHE.invalidate_all();
993}
994
995#[cfg(all(test, feature = "server"))]
996mod tests {
997    use super::*;
998    use crate::models::comment::PublicComment;
999    use crate::models::post::PostStatus;
1000    use crate::models::user::{SessionUser, UserRole};
1001    use serial_test::serial;
1002
1003    #[test]
1004    #[serial]
1005    fn cache_key_equality() {
1006        let k1 = CacheKey::PublishedPosts {
1007            page: 1,
1008            per_page: 10,
1009        };
1010        let k2 = CacheKey::PublishedPosts {
1011            page: 1,
1012            per_page: 10,
1013        };
1014        let k3 = CacheKey::PublishedPosts {
1015            page: 2,
1016            per_page: 10,
1017        };
1018        assert_eq!(k1, k2);
1019        assert_ne!(k1, k3);
1020    }
1021
1022    #[tokio::test]
1023    #[serial]
1024    async fn post_list_cache_roundtrip() {
1025        let key = CacheKey::PublishedPosts {
1026            page: 999,
1027            per_page: 99,
1028        };
1029        let posts = vec![PostListItem {
1030            id: 1,
1031            author_id: 1,
1032            title: "List Item".to_string(),
1033            slug: "list-item".to_string(),
1034            summary: None,
1035            status: PostStatus::Published,
1036            published_at: None,
1037            created_at: chrono::Utc::now(),
1038            updated_at: chrono::Utc::now(),
1039            deleted_at: None,
1040            tags: vec!["rust".to_string()],
1041            cover_image: None,
1042            reading_time: 1,
1043            word_count: 10,
1044        }];
1045
1046        set_post_list(&key, posts.clone(), 1).await;
1047        let cached = get_post_list(&key).await;
1048
1049        assert!(cached.is_some());
1050        let (cached_posts, cached_total) = cached.unwrap();
1051        assert_eq!(cached_posts.len(), 1);
1052        assert_eq!(cached_posts[0].title, "List Item");
1053        assert_eq!(cached_total, 1);
1054    }
1055
1056    #[tokio::test]
1057    #[serial]
1058    async fn tag_list_cache_roundtrip() {
1059        let tags = vec![Tag {
1060            id: 1,
1061            name: "rust".to_string(),
1062            post_count: 5,
1063        }];
1064
1065        set_tag_list(tags.clone()).await;
1066        let cached = get_tag_list().await;
1067
1068        assert!(cached.is_some());
1069        assert_eq!(cached.unwrap()[0].name, "rust");
1070    }
1071
1072    #[tokio::test]
1073    #[serial]
1074    async fn single_post_cache_roundtrip() {
1075        let post = Some(Post {
1076            id: 1,
1077            author_id: 1,
1078            title: "Test".to_string(),
1079            slug: "test".to_string(),
1080            summary: None,
1081            content_md: "content".to_string(),
1082            content_html: None,
1083            status: PostStatus::Published,
1084            published_at: None,
1085            created_at: chrono::Utc::now(),
1086            updated_at: chrono::Utc::now(),
1087            deleted_at: None,
1088            tags: vec![],
1089            cover_image: None,
1090            reading_time: 1,
1091            word_count: 10,
1092            toc_html: None,
1093            prev_post: None,
1094            next_post: None,
1095        });
1096
1097        set_post_by_slug("test", post.clone()).await;
1098        let cached = get_post_by_slug("test").await;
1099
1100        assert!(cached.is_some());
1101        assert_eq!(cached.unwrap().unwrap().title, "Test");
1102    }
1103
1104    #[tokio::test]
1105    #[serial]
1106    async fn post_stats_cache_roundtrip() {
1107        let stats = PostStats {
1108            total: 10,
1109            drafts: 3,
1110            published: 7,
1111            trash: 2,
1112            recent_30d: 4,
1113            monthly_published: Vec::new(),
1114        };
1115
1116        set_post_stats(stats.clone()).await;
1117        let cached = get_post_stats().await;
1118
1119        assert!(cached.is_some());
1120        assert_eq!(cached.unwrap().total, 10);
1121    }
1122
1123    #[tokio::test]
1124    #[serial]
1125    async fn cache_invalidation_works() {
1126        let post = Some(Post {
1127            id: 42,
1128            author_id: 1,
1129            title: "Invalidation Test".to_string(),
1130            slug: "invalidation-test".to_string(),
1131            summary: None,
1132            content_md: "test".to_string(),
1133            content_html: None,
1134            status: PostStatus::Published,
1135            published_at: None,
1136            created_at: chrono::Utc::now(),
1137            updated_at: chrono::Utc::now(),
1138            deleted_at: None,
1139            tags: vec![],
1140            cover_image: None,
1141            reading_time: 1,
1142            word_count: 4,
1143            toc_html: None,
1144            prev_post: None,
1145            next_post: None,
1146        });
1147
1148        set_post_by_slug("invalidation-test", post.clone()).await;
1149        let cached_before = get_post_by_slug("invalidation-test").await;
1150        assert!(cached_before.is_some());
1151
1152        invalidate_post_by_slug("invalidation-test").await;
1153
1154        let cached_after = get_post_by_slug("invalidation-test").await;
1155        assert!(cached_after.is_none());
1156    }
1157
1158    #[tokio::test]
1159    #[serial]
1160    async fn comment_cache_roundtrip() {
1161        let comments = vec![PublicComment {
1162            id: 1,
1163            parent_id: None,
1164            depth: 0,
1165            author_name: "Alice".to_string(),
1166            author_url: None,
1167            avatar_url: "https://example.com/avatar".to_string(),
1168            is_author: false,
1169            content_html: Some("<p>Hello</p>".to_string()),
1170            created_at: "刚刚".to_string(),
1171            created_at_iso: "2026-01-01T00:00:00Z".to_string(),
1172        }];
1173
1174        set_comments_by_post(42, comments.clone()).await;
1175        let cached = get_comments_by_post(42).await;
1176
1177        assert!(cached.is_some());
1178        assert_eq!(cached.unwrap().len(), 1);
1179    }
1180
1181    #[tokio::test]
1182    #[serial]
1183    async fn pending_count_cache_roundtrip() {
1184        set_pending_count(7).await;
1185        let cached = get_pending_count().await;
1186
1187        assert!(cached.is_some());
1188        assert_eq!(cached.unwrap(), 7);
1189    }
1190
1191    #[tokio::test]
1192    #[serial]
1193    async fn comment_cache_invalidation() {
1194        set_comments_by_post(99, vec![]).await;
1195        assert!(get_comments_by_post(99).await.is_some());
1196
1197        invalidate_comments_by_post(99).await;
1198        assert!(get_comments_by_post(99).await.is_none());
1199    }
1200
1201    #[tokio::test]
1202    #[serial]
1203    async fn pending_count_invalidation() {
1204        set_pending_count(3).await;
1205        assert!(get_pending_count().await.is_some());
1206
1207        invalidate_pending_count().await;
1208        assert!(get_pending_count().await.is_none());
1209    }
1210
1211    #[tokio::test]
1212    #[serial]
1213    async fn session_cache_roundtrip() {
1214        let user = SessionUser {
1215            id: 42,
1216            username: "cached_user".to_string(),
1217            email: "cached@example.com".to_string(),
1218            display_name: None,
1219            avatar_url: None,
1220            role: UserRole::Admin,
1221            created_at: chrono::Utc::now(),
1222            session_generation: 0,
1223        };
1224        let token_hash = "sha256_token_hash";
1225
1226        set_session_user(token_hash, user.clone()).await;
1227        let cached = get_session_user(token_hash).await;
1228
1229        assert!(cached.is_some());
1230        let cached_user = cached.unwrap();
1231        assert_eq!(cached_user.id, user.id);
1232        assert_eq!(cached_user.username, user.username);
1233        assert_eq!(cached_user.email, user.email);
1234        assert_eq!(cached_user.role, user.role);
1235
1236        invalidate_session_user(token_hash).await;
1237        assert!(get_session_user(token_hash).await.is_none());
1238    }
1239
1240    #[test]
1241    fn search_key_normalization() {
1242        assert_eq!(normalize_search_key("  Rust "), "rust");
1243        assert_eq!(normalize_search_key("Rust"), "rust");
1244        assert_eq!(normalize_search_key("  rust "), "rust");
1245        assert_eq!(normalize_search_key(""), "");
1246
1247        let long = "a".repeat(250);
1248        let normalized = normalize_search_key(&long);
1249        assert_eq!(normalized.len(), 200);
1250        assert!(normalized.chars().all(|c| c == 'a'));
1251
1252        // 大小写与空格差异应映射到同一键。
1253        assert_eq!(
1254            normalize_search_key("  Dioxus Fullstack "),
1255            normalize_search_key("dioxus fullstack")
1256        );
1257    }
1258
1259    #[tokio::test]
1260    #[serial]
1261    async fn search_cache_roundtrip() {
1262        let query = "Rust";
1263        let posts = vec![PostListItem {
1264            id: 1,
1265            author_id: 1,
1266            title: "Search Result".to_string(),
1267            slug: "search-result".to_string(),
1268            summary: None,
1269            status: PostStatus::Published,
1270            published_at: None,
1271            created_at: chrono::Utc::now(),
1272            updated_at: chrono::Utc::now(),
1273            deleted_at: None,
1274            tags: vec!["rust".to_string()],
1275            cover_image: None,
1276            reading_time: 1,
1277            word_count: 10,
1278        }];
1279
1280        set_search_results(query, posts.clone(), 1).await;
1281
1282        // 大小写与空格差异应命中同一缓存条目。
1283        let cached = get_search_results(" rust ").await;
1284        assert!(cached.is_some());
1285        let (cached_posts, cached_total) = cached.unwrap();
1286        assert_eq!(cached_posts.len(), 1);
1287        assert_eq!(cached_posts[0].title, "Search Result");
1288        assert_eq!(cached_total, 1);
1289
1290        invalidate_search_results();
1291        assert!(get_search_results(query).await.is_none());
1292    }
1293
1294    #[tokio::test]
1295    #[serial]
1296    async fn full_post_invalidation_also_clears_search_results() {
1297        set_search_results("removed article", vec![], 0).await;
1298        assert!(get_search_results("removed article").await.is_some());
1299        invalidate_all_post_caches();
1300        assert!(get_search_results("removed article").await.is_none());
1301    }
1302
1303    #[tokio::test]
1304    #[serial]
1305    async fn tag_write_invalidation_clears_lists_and_tag_posts() {
1306        let key = CacheKey::PublishedPosts {
1307            page: 998,
1308            per_page: 98,
1309        };
1310        set_post_list(&key, vec![], 0).await;
1311        set_posts_by_tag("renamed-tag", vec![], 0).await;
1312        set_tag_list(vec![]).await;
1313        let generation = crate::ssr_cache::current_global_generation();
1314
1315        invalidate_for_tag_write();
1316
1317        assert!(get_post_list(&key).await.is_none());
1318        assert!(get_posts_by_tag("renamed-tag").await.is_none());
1319        assert!(get_tag_list().await.is_none());
1320        assert!(crate::ssr_cache::current_global_generation() > generation);
1321    }
1322
1323    #[tokio::test]
1324    #[serial]
1325    async fn search_cache_invalidation() {
1326        set_search_results("tokio", vec![], 0).await;
1327        assert!(get_search_results("tokio").await.is_some());
1328
1329        invalidate_search_results();
1330        assert!(get_search_results("tokio").await.is_none());
1331    }
1332}