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::{FeedItem, Post, PostListItem, PostStats, Tag};
20#[cfg(feature = "server")]
21use crate::models::user::SessionUser;
22
23// ============================================================================
24// 缓存 TTL 配置
25// ============================================================================
26
27/// 文章列表缓存 TTL:60 秒。
28#[cfg(feature = "server")]
29const TTL_POST_LIST: Duration = Duration::from_secs(60);
30
31/// 标签列表缓存 TTL:300 秒。
32#[cfg(feature = "server")]
33const TTL_TAG_LIST: Duration = Duration::from_secs(300);
34
35/// 单篇文章缓存 TTL:600 秒。
36#[cfg(feature = "server")]
37const TTL_SINGLE_POST: Duration = Duration::from_secs(600);
38
39/// 文章统计缓存 TTL:60 秒。
40#[cfg(feature = "server")]
41const TTL_POST_STATS: Duration = Duration::from_secs(60);
42
43/// 标签下文章列表缓存 TTL:120 秒。
44#[cfg(feature = "server")]
45const TTL_TAG_POSTS: Duration = Duration::from_secs(120);
46
47/// 评论列表缓存 TTL:60 秒。
48#[cfg(feature = "server")]
49const TTL_COMMENTS: Duration = Duration::from_secs(60);
50
51/// 待审核评论数量缓存 TTL:10 秒,因管理后台需要较实时数据。
52#[cfg(feature = "server")]
53const TTL_PENDING_COUNT: Duration = Duration::from_secs(10);
54
55/// 会话用户缓存 TTL:300 秒(5 分钟),短于 DB 会话过期时间。
56#[cfg(feature = "server")]
57const TTL_SESSION: Duration = Duration::from_secs(300);
58
59/// 搜索结果缓存 TTL:10 秒。
60#[cfg(feature = "server")]
61const TTL_SEARCH: Duration = Duration::from_secs(10);
62
63// ============================================================================
64// 缓存 Key 类型
65// ============================================================================
66
67/// 统一的缓存键枚举,每个变体对应一类可缓存数据。
68#[cfg(feature = "server")]
69#[derive(Debug, Clone, Hash, Eq, PartialEq)]
70pub enum CacheKey {
71    /// 已发布文章分页列表。
72    PublishedPosts { page: i32, per_page: i32 },
73    /// 已发布文章总数。
74    TotalPublishedPosts,
75    /// 全部标签。
76    AllTags,
77    /// 按 slug 查询的单篇文章。
78    PostBySlug(String),
79    /// 按标签查询的文章列表(不分页,返回全部)。
80    PostsByTag(String),
81    /// 文章统计信息。
82    PostStats,
83    /// 某篇文章下的评论列表。
84    CommentsByPost { post_id: i32 },
85    /// 待审核评论总数。
86    PendingCommentCount,
87    /// Feed 条目列表(RSS / JSON Feed 共用单键)。
88    Feed,
89    /// 全部友链(前台可见集)。
90    FriendLinks,
91}
92
93// ============================================================================
94// 缓存实例
95// ============================================================================
96
97/// 文章列表缓存类型,值为(文章列表,总数)。
98#[cfg(feature = "server")]
99pub type PostListCache = Cache<CacheKey, (Vec<PostListItem>, i64)>;
100
101/// 标签列表缓存类型。
102#[cfg(feature = "server")]
103pub type TagListCache = Cache<CacheKey, Vec<Tag>>;
104
105/// 单篇文章缓存类型。
106#[cfg(feature = "server")]
107pub type SinglePostCache = Cache<CacheKey, Option<Post>>;
108
109/// 文章统计缓存类型。
110#[cfg(feature = "server")]
111pub type PostStatsCache = Cache<CacheKey, PostStats>;
112
113/// Feed 条目列表缓存类型(RSS / JSON Feed 共用)。
114#[cfg(feature = "server")]
115pub type FeedCache = Cache<CacheKey, Vec<FeedItem>>;
116
117/// 全局文章列表缓存实例,最大容量 100。
118#[cfg(feature = "server")]
119static POST_LIST_CACHE: LazyLock<PostListCache> = LazyLock::new(|| {
120    Cache::builder()
121        .max_capacity(100)
122        .time_to_live(TTL_POST_LIST)
123        .build()
124});
125
126/// 全局标签列表缓存实例,最大容量 50。
127#[cfg(feature = "server")]
128static TAG_LIST_CACHE: LazyLock<TagListCache> = LazyLock::new(|| {
129    Cache::builder()
130        .max_capacity(50)
131        .time_to_live(TTL_TAG_LIST)
132        .build()
133});
134
135/// 友链列表缓存类型。
136#[cfg(feature = "server")]
137pub type FriendLinksCache = Cache<CacheKey, Vec<FriendLink>>;
138
139/// 全局友链列表缓存实例,最大容量 50,TTL 与标签列表一致。
140#[cfg(feature = "server")]
141static FRIEND_LINKS_CACHE: LazyLock<FriendLinksCache> = LazyLock::new(|| {
142    Cache::builder()
143        .max_capacity(50)
144        .time_to_live(TTL_TAG_LIST)
145        .build()
146});
147
148/// 全局单篇文章缓存实例,最大容量 200。
149#[cfg(feature = "server")]
150static SINGLE_POST_CACHE: LazyLock<SinglePostCache> = LazyLock::new(|| {
151    Cache::builder()
152        .max_capacity(200)
153        .time_to_live(TTL_SINGLE_POST)
154        .build()
155});
156
157/// 全局文章统计缓存实例,最大容量 10。
158#[cfg(feature = "server")]
159static POST_STATS_CACHE: LazyLock<PostStatsCache> = LazyLock::new(|| {
160    Cache::builder()
161        .max_capacity(10)
162        .time_to_live(TTL_POST_STATS)
163        .build()
164});
165
166/// 全局 Feed 条目缓存实例,最大容量 10(单键,20 篇全文条目)。
167#[cfg(feature = "server")]
168static FEED_CACHE: LazyLock<FeedCache> = LazyLock::new(|| {
169    Cache::builder()
170        .max_capacity(10)
171        .time_to_live(TTL_SINGLE_POST)
172        .build()
173});
174
175/// 全局标签文章列表缓存实例,最大容量 100。
176#[cfg(feature = "server")]
177static TAG_POSTS_CACHE: LazyLock<PostListCache> = LazyLock::new(|| {
178    Cache::builder()
179        .max_capacity(100)
180        .time_to_live(TTL_TAG_POSTS)
181        .build()
182});
183
184/// 评论列表缓存类型。
185#[cfg(feature = "server")]
186pub type CommentListCache = Cache<CacheKey, Vec<PublicComment>>;
187
188/// 全局评论列表缓存实例,最大容量 200。
189#[cfg(feature = "server")]
190static COMMENT_CACHE: LazyLock<CommentListCache> = LazyLock::new(|| {
191    Cache::builder()
192        .max_capacity(200)
193        .time_to_live(TTL_COMMENTS)
194        .build()
195});
196
197/// 全局待审核评论数量缓存实例,最大容量 10。
198#[cfg(feature = "server")]
199static PENDING_COUNT_CACHE: LazyLock<Cache<CacheKey, i64>> = LazyLock::new(|| {
200    Cache::builder()
201        .max_capacity(10)
202        .time_to_live(TTL_PENDING_COUNT)
203        .build()
204});
205
206/// 会话用户缓存类型。
207#[cfg(feature = "server")]
208pub type SessionCache = Cache<String, SessionUser>;
209
210/// 搜索结果缓存类型。
211#[cfg(feature = "server")]
212pub type SearchCache = Cache<String, (Vec<PostListItem>, i64)>;
213
214/// 全局会话用户缓存实例,最大容量 1000,TTL 5 分钟。
215#[cfg(feature = "server")]
216pub static SESSION_CACHE: LazyLock<SessionCache> = LazyLock::new(|| {
217    Cache::builder()
218        .max_capacity(1000)
219        .time_to_live(TTL_SESSION)
220        .build()
221});
222
223/// 全局搜索结果缓存实例,最大容量 200,TTL 10 秒。
224#[cfg(feature = "server")]
225static SEARCH_CACHE: LazyLock<SearchCache> = LazyLock::new(|| {
226    Cache::builder()
227        .max_capacity(200)
228        .time_to_live(TTL_SEARCH)
229        .build()
230});
231
232// ============================================================================
233// 命中率统计(供系统状态面板展示)
234// ============================================================================
235
236/// 单个缓存的命中/未命中计数。用 AtomicU64 在 get_* 路径上记录。
237#[cfg(feature = "server")]
238pub struct CacheStats {
239    pub name: &'static str,
240    hits: std::sync::atomic::AtomicU64,
241    misses: std::sync::atomic::AtomicU64,
242}
243
244#[cfg(feature = "server")]
245impl CacheStats {
246    pub const fn new(name: &'static str) -> Self {
247        Self {
248            name,
249            hits: std::sync::atomic::AtomicU64::new(0),
250            misses: std::sync::atomic::AtomicU64::new(0),
251        }
252    }
253    fn record_hit(&self) {
254        self.hits.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
255    }
256    fn record_miss(&self) {
257        self.misses
258            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
259    }
260}
261
262// 每个缓存一份统计实例(const,启动时零开销初始化)。
263#[cfg(feature = "server")]
264static POST_LIST_STATS: CacheStats = CacheStats::new("文章列表");
265#[cfg(feature = "server")]
266static TAG_STATS: CacheStats = CacheStats::new("标签");
267#[cfg(feature = "server")]
268static SINGLE_POST_STATS: CacheStats = CacheStats::new("单篇文章");
269#[cfg(feature = "server")]
270static POST_STATS_STATS: CacheStats = CacheStats::new("文章统计");
271#[cfg(feature = "server")]
272static TAG_POSTS_STATS: CacheStats = CacheStats::new("标签文章");
273#[cfg(feature = "server")]
274static COMMENT_STATS: CacheStats = CacheStats::new("评论");
275#[cfg(feature = "server")]
276static PENDING_COUNT_STATS: CacheStats = CacheStats::new("待审评论数");
277#[cfg(feature = "server")]
278static SESSION_STATS: CacheStats = CacheStats::new("会话用户");
279#[cfg(feature = "server")]
280static SEARCH_STATS: CacheStats = CacheStats::new("搜索");
281#[cfg(feature = "server")]
282static FEED_STATS: CacheStats = CacheStats::new("Feed");
283#[cfg(feature = "server")]
284static FRIEND_STATS: CacheStats = CacheStats::new("友链");
285
286/// 缓存统计快照项(序列化给前端展示)。
287#[cfg(feature = "server")]
288#[derive(Debug)]
289pub struct CacheStatSnapshot {
290    pub name: &'static str,
291    pub entry_count: u64,
292    pub hits: u64,
293    pub misses: u64,
294    pub hit_rate: f64,
295}
296
297/// 聚合所有缓存的统计快照(供 get_server_status 调用)。
298#[cfg(feature = "server")]
299pub fn cache_stats() -> Vec<CacheStatSnapshot> {
300    fn snap(stats: &CacheStats, entry_count: u64) -> CacheStatSnapshot {
301        let hits = stats.hits.load(std::sync::atomic::Ordering::Relaxed);
302        let misses = stats.misses.load(std::sync::atomic::Ordering::Relaxed);
303        let total = hits + misses;
304        let hit_rate = if total == 0 {
305            0.0
306        } else {
307            hits as f64 / total as f64
308        };
309        CacheStatSnapshot {
310            name: stats.name,
311            entry_count,
312            hits,
313            misses,
314            hit_rate,
315        }
316    }
317    vec![
318        snap(&POST_LIST_STATS, POST_LIST_CACHE.entry_count()),
319        snap(&TAG_STATS, TAG_LIST_CACHE.entry_count()),
320        snap(&SINGLE_POST_STATS, SINGLE_POST_CACHE.entry_count()),
321        snap(&POST_STATS_STATS, POST_STATS_CACHE.entry_count()),
322        snap(&TAG_POSTS_STATS, TAG_POSTS_CACHE.entry_count()),
323        snap(&COMMENT_STATS, COMMENT_CACHE.entry_count()),
324        snap(&PENDING_COUNT_STATS, PENDING_COUNT_CACHE.entry_count()),
325        snap(&SESSION_STATS, SESSION_CACHE.entry_count()),
326        snap(&SEARCH_STATS, SEARCH_CACHE.entry_count()),
327        snap(&FEED_STATS, FEED_CACHE.entry_count()),
328        snap(&FRIEND_STATS, FRIEND_LINKS_CACHE.entry_count()),
329    ]
330}
331
332// ============================================================================
333// 公共缓存 API
334// ============================================================================
335
336/// 读取文章分页列表缓存。
337#[cfg(feature = "server")]
338pub async fn get_post_list(key: &CacheKey) -> Option<(Vec<PostListItem>, i64)> {
339    let v = POST_LIST_CACHE.get(key).await;
340    if v.is_some() {
341        POST_LIST_STATS.record_hit();
342    } else {
343        POST_LIST_STATS.record_miss();
344    }
345    v
346}
347
348/// 写入文章分页列表缓存。
349#[cfg(feature = "server")]
350pub async fn set_post_list(key: &CacheKey, posts: Vec<PostListItem>, total: i64) {
351    let _ = POST_LIST_CACHE.insert(key.clone(), (posts, total)).await;
352}
353
354/// 读取已发布文章总数缓存。
355#[cfg(feature = "server")]
356pub async fn get_total_published_posts() -> Option<i64> {
357    let v = POST_LIST_CACHE
358        .get(&CacheKey::TotalPublishedPosts)
359        .await
360        .map(|(_, total)| total);
361    if v.is_some() {
362        POST_LIST_STATS.record_hit();
363    } else {
364        POST_LIST_STATS.record_miss();
365    }
366    v
367}
368
369/// 写入已发布文章总数缓存,文章列表部分置空以节省内存。
370#[cfg(feature = "server")]
371pub async fn set_total_published_posts(total: i64) {
372    let _ = POST_LIST_CACHE
373        .insert(CacheKey::TotalPublishedPosts, (vec![], total))
374        .await;
375}
376
377/// 读取全部标签缓存。
378#[cfg(feature = "server")]
379pub async fn get_tag_list() -> Option<Vec<Tag>> {
380    let v = TAG_LIST_CACHE.get(&CacheKey::AllTags).await;
381    if v.is_some() {
382        TAG_STATS.record_hit();
383    } else {
384        TAG_STATS.record_miss();
385    }
386    v
387}
388
389/// 写入全部标签缓存。
390#[cfg(feature = "server")]
391pub async fn set_tag_list(tags: Vec<Tag>) {
392    let _ = TAG_LIST_CACHE.insert(CacheKey::AllTags, tags).await;
393}
394
395/// 读取全部友链缓存(前台可见集)。
396#[cfg(feature = "server")]
397pub async fn get_friend_links() -> Option<Vec<FriendLink>> {
398    let v = FRIEND_LINKS_CACHE.get(&CacheKey::FriendLinks).await;
399    if v.is_some() {
400        FRIEND_STATS.record_hit();
401    } else {
402        FRIEND_STATS.record_miss();
403    }
404    v
405}
406
407/// 写入全部友链缓存。
408#[cfg(feature = "server")]
409pub async fn set_friend_links(links: Vec<FriendLink>) {
410    let _ = FRIEND_LINKS_CACHE
411        .insert(CacheKey::FriendLinks, links)
412        .await;
413}
414
415/// 按 slug 读取单篇文章缓存。
416#[cfg(feature = "server")]
417pub async fn get_post_by_slug(slug: &str) -> Option<Option<Post>> {
418    let v = SINGLE_POST_CACHE
419        .get(&CacheKey::PostBySlug(slug.to_string()))
420        .await;
421    if v.is_some() {
422        SINGLE_POST_STATS.record_hit();
423    } else {
424        SINGLE_POST_STATS.record_miss();
425    }
426    v
427}
428
429/// 按 slug 写入单篇文章缓存,None 表示文章不存在。
430#[cfg(feature = "server")]
431pub async fn set_post_by_slug(slug: &str, post: Option<Post>) {
432    let _ = SINGLE_POST_CACHE
433        .insert(CacheKey::PostBySlug(slug.to_string()), post)
434        .await;
435}
436
437/// 按标签读取文章列表缓存。
438#[cfg(feature = "server")]
439pub async fn get_posts_by_tag(tag: &str) -> Option<(Vec<PostListItem>, i64)> {
440    let v = TAG_POSTS_CACHE
441        .get(&CacheKey::PostsByTag(tag.to_string()))
442        .await;
443    if v.is_some() {
444        TAG_POSTS_STATS.record_hit();
445    } else {
446        TAG_POSTS_STATS.record_miss();
447    }
448    v
449}
450
451/// 按标签写入文章列表缓存。
452#[cfg(feature = "server")]
453pub async fn set_posts_by_tag(tag: &str, posts: Vec<PostListItem>, total: i64) {
454    let _ = TAG_POSTS_CACHE
455        .insert(CacheKey::PostsByTag(tag.to_string()), (posts, total))
456        .await;
457}
458
459/// 读取文章统计缓存。
460#[cfg(feature = "server")]
461pub async fn get_post_stats() -> Option<PostStats> {
462    let v = POST_STATS_CACHE.get(&CacheKey::PostStats).await;
463    if v.is_some() {
464        POST_STATS_STATS.record_hit();
465    } else {
466        POST_STATS_STATS.record_miss();
467    }
468    v
469}
470
471/// 写入文章统计缓存。
472#[cfg(feature = "server")]
473pub async fn set_post_stats(stats: PostStats) {
474    let _ = POST_STATS_CACHE.insert(CacheKey::PostStats, stats).await;
475}
476
477/// 读取 Feed 条目列表缓存。
478#[cfg(feature = "server")]
479pub async fn get_feed() -> Option<Vec<FeedItem>> {
480    let v = FEED_CACHE.get(&CacheKey::Feed).await;
481    if v.is_some() {
482        FEED_STATS.record_hit();
483    } else {
484        FEED_STATS.record_miss();
485    }
486    v
487}
488
489/// 写入 Feed 条目列表缓存。
490#[cfg(feature = "server")]
491pub async fn set_feed(items: Vec<FeedItem>) {
492    let _ = FEED_CACHE.insert(CacheKey::Feed, items).await;
493}
494
495// ============================================================================
496// 缓存失效
497// ============================================================================
498
499/// 清空所有文章分页列表缓存。
500#[cfg(feature = "server")]
501pub fn invalidate_post_lists() {
502    POST_LIST_CACHE.invalidate_all();
503}
504
505/// 清空所有标签缓存。
506#[cfg(feature = "server")]
507pub fn invalidate_all_tags() {
508    TAG_LIST_CACHE.invalidate_all();
509}
510
511/// 清空友链缓存。
512#[cfg(feature = "server")]
513pub fn invalidate_friend_links() {
514    FRIEND_LINKS_CACHE.invalidate_all();
515}
516
517/// 按 slug 失效单篇文章缓存。
518#[cfg(feature = "server")]
519pub async fn invalidate_post_by_slug(slug: &str) {
520    SINGLE_POST_CACHE
521        .invalidate(&CacheKey::PostBySlug(slug.to_string()))
522        .await;
523}
524
525/// 按标签失效文章列表缓存。
526#[cfg(feature = "server")]
527pub async fn invalidate_posts_by_tag(tag: &str) {
528    TAG_POSTS_CACHE
529        .invalidate(&CacheKey::PostsByTag(tag.to_string()))
530        .await;
531}
532
533/// 清空文章统计缓存。
534#[cfg(feature = "server")]
535pub fn invalidate_post_stats() {
536    POST_STATS_CACHE.invalidate_all();
537}
538
539/// 按标签批量失效文章列表缓存。
540#[cfg(feature = "server")]
541pub async fn invalidate_tag_posts_for(tags: &[String]) {
542    let futures: Vec<_> = tags
543        .iter()
544        .map(|tag| invalidate_posts_by_tag(tag))
545        .collect();
546    let _ = futures::future::join_all(futures).await;
547}
548
549/// 清空所有文章相关缓存(列表、标签、单篇、统计、标签文章)。
550///
551/// 这是一个“紧急”使用的全量失效开关,会一次性冲刷所有文章缓存;
552/// 正常写路径应当使用更细粒度的 `invalidate_post_lists` / `invalidate_all_tags` /
553/// `invalidate_post_by_slug` / `invalidate_posts_by_tag` / `invalidate_post_stats` /
554/// `invalidate_tag_posts_for` 等函数,避免不必要的缓存击穿。
555#[cfg(feature = "server")]
556pub fn invalidate_all_post_caches() {
557    POST_LIST_CACHE.invalidate_all();
558    TAG_LIST_CACHE.invalidate_all();
559    SINGLE_POST_CACHE.invalidate_all();
560    POST_STATS_CACHE.invalidate_all();
561    TAG_POSTS_CACHE.invalidate_all();
562    invalidate_feed();
563}
564
565/// 清空 Feed 条目缓存。
566///
567/// 使用同步签名是因为 `moka::Cache::invalidate_all` 为同步操作,
568/// 与 `invalidate_post_stats` 等元数据失效保持一致。
569#[cfg(feature = "server")]
570pub fn invalidate_feed() {
571    FEED_CACHE.invalidate_all();
572}
573
574/// 失效文章「元数据」类缓存:列表、标签、统计、搜索结果、Feed。
575///
576/// 这些在每次文章写操作(创建/更新/删除/恢复/清空回收站)后都需要一起失效。
577/// 单篇正文与标签下文章列表是定向失效(按 slug / tag),不在此处处理,由调用方
578/// 根据实际涉及的 slug/tags 额外调用 `invalidate_post_by_slug` / `invalidate_tag_posts_for`。
579#[cfg(feature = "server")]
580pub fn invalidate_post_metadata() {
581    invalidate_post_lists();
582    invalidate_all_tags();
583    invalidate_post_stats();
584    invalidate_search_results();
585    invalidate_feed();
586}
587
588/// 文章写操作(创建/更新/删除/恢复/清空回收站)后的统一缓存失效序列。
589///
590/// 按影响范围精准失效:先失效文章「元数据」类缓存(列表/标签云/统计/搜索),
591/// 再按 slug 定向失效单篇正文与 SSR 详情页,按标签失效标签下文章列表,
592/// 最后全量刷新公开页 SSR 缓存并递增全局世代号。
593///
594/// - `slugs`:受影响的文章 slug(旧/新均可,用于单篇缓存与 SSR 详情页定向失效)。
595/// - `tags`:受影响的标签名(用于标签下文章列表失效)。
596///
597/// 对于 slug 变更等需要分别处理旧/新 slug 的复杂场景,调用方将涉及的 slug 一并传入即可——
598/// 本函数对 `slugs` 中的每个元素一视同仁。注意:`invalidate_ssr_all_public` 会删除全部
599/// 公开页 SSR 缓存,因此批量场景下逐 slug 的 `invalidate_ssr_route` 仅是先行的定向清理,
600/// 不会改变最终失效结果。
601#[cfg(feature = "server")]
602pub async fn invalidate_for_post_write(slugs: &[String], tags: &[String]) {
603    invalidate_post_metadata();
604    for slug in slugs {
605        invalidate_post_by_slug(slug).await;
606    }
607    invalidate_tag_posts_for(tags).await;
608    for slug in slugs {
609        crate::ssr_cache::invalidate_ssr_route(&format!("/post/{slug}"));
610    }
611    crate::ssr_cache::invalidate_ssr_all_public();
612    crate::ssr_cache::bump_global_generation();
613}
614
615/// 按文章主键读取评论列表缓存。
616#[cfg(feature = "server")]
617pub async fn get_comments_by_post(post_id: i32) -> Option<Vec<PublicComment>> {
618    let v = COMMENT_CACHE
619        .get(&CacheKey::CommentsByPost { post_id })
620        .await;
621    if v.is_some() {
622        COMMENT_STATS.record_hit();
623    } else {
624        COMMENT_STATS.record_miss();
625    }
626    v
627}
628
629/// 按文章主键写入评论列表缓存。
630#[cfg(feature = "server")]
631pub async fn set_comments_by_post(post_id: i32, comments: Vec<PublicComment>) {
632    let _ = COMMENT_CACHE
633        .insert(CacheKey::CommentsByPost { post_id }, comments)
634        .await;
635}
636
637/// 读取待审核评论总数缓存。
638#[cfg(feature = "server")]
639pub async fn get_pending_count() -> Option<i64> {
640    let v = PENDING_COUNT_CACHE
641        .get(&CacheKey::PendingCommentCount)
642        .await;
643    if v.is_some() {
644        PENDING_COUNT_STATS.record_hit();
645    } else {
646        PENDING_COUNT_STATS.record_miss();
647    }
648    v
649}
650
651/// 写入待审核评论总数缓存。
652#[cfg(feature = "server")]
653pub async fn set_pending_count(count: i64) {
654    let _ = PENDING_COUNT_CACHE
655        .insert(CacheKey::PendingCommentCount, count)
656        .await;
657}
658
659/// 规范化搜索查询键:trim、转小写、截断至 200 字符。
660#[cfg(feature = "server")]
661pub fn normalize_search_key(query: &str) -> String {
662    query.trim().to_lowercase().chars().take(200).collect()
663}
664
665/// 读取会话用户缓存。
666#[cfg(feature = "server")]
667pub async fn get_session_user(token_hash: &str) -> Option<SessionUser> {
668    let v = SESSION_CACHE.get(token_hash).await;
669    if v.is_some() {
670        SESSION_STATS.record_hit();
671    } else {
672        SESSION_STATS.record_miss();
673    }
674    v
675}
676
677/// 写入会话用户缓存。
678#[cfg(feature = "server")]
679pub async fn set_session_user(token_hash: &str, user: SessionUser) {
680    let _ = SESSION_CACHE.insert(token_hash.to_string(), user).await;
681}
682
683/// 失效指定会话用户缓存。
684#[cfg(feature = "server")]
685pub async fn invalidate_session_user(token_hash: &str) {
686    SESSION_CACHE.invalidate(token_hash).await;
687}
688
689/// 读取搜索结果缓存。
690#[cfg(feature = "server")]
691pub async fn get_search_results(query: &str) -> Option<(Vec<PostListItem>, i64)> {
692    let v = SEARCH_CACHE.get(&normalize_search_key(query)).await;
693    if v.is_some() {
694        SEARCH_STATS.record_hit();
695    } else {
696        SEARCH_STATS.record_miss();
697    }
698    v
699}
700
701/// 写入搜索结果缓存。
702#[cfg(feature = "server")]
703pub async fn set_search_results(query: &str, posts: Vec<PostListItem>, total: i64) {
704    let _ = SEARCH_CACHE
705        .insert(normalize_search_key(query), (posts, total))
706        .await;
707}
708
709/// 清空所有搜索结果缓存。
710///
711/// 使用同步签名是因为 `moka::Cache::invalidate_all` 为同步操作;
712/// 该函数通常由写路径直接调用,无需额外等待。
713#[cfg(feature = "server")]
714pub fn invalidate_search_results() {
715    SEARCH_CACHE.invalidate_all();
716}
717
718/// 按文章主键失效评论列表缓存。
719#[cfg(feature = "server")]
720pub async fn invalidate_comments_by_post(post_id: i32) {
721    COMMENT_CACHE
722        .invalidate(&CacheKey::CommentsByPost { post_id })
723        .await;
724}
725
726/// 失效待审核评论总数缓存。
727#[cfg(feature = "server")]
728pub async fn invalidate_pending_count() {
729    PENDING_COUNT_CACHE
730        .invalidate(&CacheKey::PendingCommentCount)
731        .await;
732}
733
734/// 全量失效评论缓存(SQL 控制台兜底用:管理员直接改 comments 表时无法定向)。
735///
736/// 正常评论写路径用 [`invalidate_comments_by_post`](按文章定向)+ [`invalidate_pending_count`];
737/// 这里全量清空是 SQL 控制台直改 DB 的兜底——无法从任意 SQL 精确解析受影响的 post_id。
738#[cfg(feature = "server")]
739pub fn invalidate_all_comments() {
740    COMMENT_CACHE.invalidate_all();
741    PENDING_COUNT_CACHE.invalidate_all();
742}
743
744#[cfg(all(test, feature = "server"))]
745mod tests {
746    use super::*;
747    use crate::models::comment::PublicComment;
748    use crate::models::post::PostStatus;
749    use crate::models::user::{SessionUser, UserRole};
750    use serial_test::serial;
751
752    #[test]
753    #[serial]
754    fn cache_key_equality() {
755        let k1 = CacheKey::PublishedPosts {
756            page: 1,
757            per_page: 10,
758        };
759        let k2 = CacheKey::PublishedPosts {
760            page: 1,
761            per_page: 10,
762        };
763        let k3 = CacheKey::PublishedPosts {
764            page: 2,
765            per_page: 10,
766        };
767        assert_eq!(k1, k2);
768        assert_ne!(k1, k3);
769    }
770
771    #[tokio::test]
772    #[serial]
773    async fn post_list_cache_roundtrip() {
774        let key = CacheKey::PublishedPosts {
775            page: 999,
776            per_page: 99,
777        };
778        let posts = vec![PostListItem {
779            id: 1,
780            author_id: 1,
781            title: "List Item".to_string(),
782            slug: "list-item".to_string(),
783            summary: None,
784            status: PostStatus::Published,
785            published_at: None,
786            created_at: chrono::Utc::now(),
787            updated_at: chrono::Utc::now(),
788            deleted_at: None,
789            tags: vec!["rust".to_string()],
790            cover_image: None,
791            reading_time: 1,
792            word_count: 10,
793        }];
794
795        set_post_list(&key, posts.clone(), 1).await;
796        let cached = get_post_list(&key).await;
797
798        assert!(cached.is_some());
799        let (cached_posts, cached_total) = cached.unwrap();
800        assert_eq!(cached_posts.len(), 1);
801        assert_eq!(cached_posts[0].title, "List Item");
802        assert_eq!(cached_total, 1);
803    }
804
805    #[tokio::test]
806    #[serial]
807    async fn tag_list_cache_roundtrip() {
808        let tags = vec![Tag {
809            id: 1,
810            name: "rust".to_string(),
811            post_count: 5,
812        }];
813
814        set_tag_list(tags.clone()).await;
815        let cached = get_tag_list().await;
816
817        assert!(cached.is_some());
818        assert_eq!(cached.unwrap()[0].name, "rust");
819    }
820
821    #[tokio::test]
822    #[serial]
823    async fn single_post_cache_roundtrip() {
824        let post = Some(Post {
825            id: 1,
826            author_id: 1,
827            title: "Test".to_string(),
828            slug: "test".to_string(),
829            summary: None,
830            content_md: "content".to_string(),
831            content_html: None,
832            status: PostStatus::Published,
833            published_at: None,
834            created_at: chrono::Utc::now(),
835            updated_at: chrono::Utc::now(),
836            deleted_at: None,
837            tags: vec![],
838            cover_image: None,
839            reading_time: 1,
840            word_count: 10,
841            toc_html: None,
842            prev_post: None,
843            next_post: None,
844        });
845
846        set_post_by_slug("test", post.clone()).await;
847        let cached = get_post_by_slug("test").await;
848
849        assert!(cached.is_some());
850        assert_eq!(cached.unwrap().unwrap().title, "Test");
851    }
852
853    #[tokio::test]
854    #[serial]
855    async fn post_stats_cache_roundtrip() {
856        let stats = PostStats {
857            total: 10,
858            drafts: 3,
859            published: 7,
860            trash: 2,
861        };
862
863        set_post_stats(stats.clone()).await;
864        let cached = get_post_stats().await;
865
866        assert!(cached.is_some());
867        assert_eq!(cached.unwrap().total, 10);
868    }
869
870    #[tokio::test]
871    #[serial]
872    async fn cache_invalidation_works() {
873        let post = Some(Post {
874            id: 42,
875            author_id: 1,
876            title: "Invalidation Test".to_string(),
877            slug: "invalidation-test".to_string(),
878            summary: None,
879            content_md: "test".to_string(),
880            content_html: None,
881            status: PostStatus::Published,
882            published_at: None,
883            created_at: chrono::Utc::now(),
884            updated_at: chrono::Utc::now(),
885            deleted_at: None,
886            tags: vec![],
887            cover_image: None,
888            reading_time: 1,
889            word_count: 4,
890            toc_html: None,
891            prev_post: None,
892            next_post: None,
893        });
894
895        set_post_by_slug("invalidation-test", post.clone()).await;
896        let cached_before = get_post_by_slug("invalidation-test").await;
897        assert!(cached_before.is_some());
898
899        invalidate_post_by_slug("invalidation-test").await;
900
901        let cached_after = get_post_by_slug("invalidation-test").await;
902        assert!(cached_after.is_none());
903    }
904
905    #[tokio::test]
906    #[serial]
907    async fn comment_cache_roundtrip() {
908        let comments = vec![PublicComment {
909            id: 1,
910            parent_id: None,
911            depth: 0,
912            author_name: "Alice".to_string(),
913            author_url: None,
914            avatar_url: "https://example.com/avatar".to_string(),
915            content_html: Some("<p>Hello</p>".to_string()),
916            created_at: "刚刚".to_string(),
917            created_at_iso: "2026-01-01T00:00:00Z".to_string(),
918        }];
919
920        set_comments_by_post(42, comments.clone()).await;
921        let cached = get_comments_by_post(42).await;
922
923        assert!(cached.is_some());
924        assert_eq!(cached.unwrap().len(), 1);
925    }
926
927    #[tokio::test]
928    #[serial]
929    async fn pending_count_cache_roundtrip() {
930        set_pending_count(7).await;
931        let cached = get_pending_count().await;
932
933        assert!(cached.is_some());
934        assert_eq!(cached.unwrap(), 7);
935    }
936
937    #[tokio::test]
938    #[serial]
939    async fn comment_cache_invalidation() {
940        set_comments_by_post(99, vec![]).await;
941        assert!(get_comments_by_post(99).await.is_some());
942
943        invalidate_comments_by_post(99).await;
944        assert!(get_comments_by_post(99).await.is_none());
945    }
946
947    #[tokio::test]
948    #[serial]
949    async fn pending_count_invalidation() {
950        set_pending_count(3).await;
951        assert!(get_pending_count().await.is_some());
952
953        invalidate_pending_count().await;
954        assert!(get_pending_count().await.is_none());
955    }
956
957    #[tokio::test]
958    #[serial]
959    async fn session_cache_roundtrip() {
960        let user = SessionUser {
961            id: 42,
962            username: "cached_user".to_string(),
963            email: "cached@example.com".to_string(),
964            role: UserRole::Admin,
965            created_at: chrono::Utc::now(),
966            session_generation: 0,
967        };
968        let token_hash = "sha256_token_hash";
969
970        set_session_user(token_hash, user.clone()).await;
971        let cached = get_session_user(token_hash).await;
972
973        assert!(cached.is_some());
974        let cached_user = cached.unwrap();
975        assert_eq!(cached_user.id, user.id);
976        assert_eq!(cached_user.username, user.username);
977        assert_eq!(cached_user.email, user.email);
978        assert_eq!(cached_user.role, user.role);
979
980        invalidate_session_user(token_hash).await;
981        assert!(get_session_user(token_hash).await.is_none());
982    }
983
984    #[test]
985    fn search_key_normalization() {
986        assert_eq!(normalize_search_key("  Rust "), "rust");
987        assert_eq!(normalize_search_key("Rust"), "rust");
988        assert_eq!(normalize_search_key("  rust "), "rust");
989        assert_eq!(normalize_search_key(""), "");
990
991        let long = "a".repeat(250);
992        let normalized = normalize_search_key(&long);
993        assert_eq!(normalized.len(), 200);
994        assert!(normalized.chars().all(|c| c == 'a'));
995
996        // 大小写与空格差异应映射到同一键。
997        assert_eq!(
998            normalize_search_key("  Dioxus Fullstack "),
999            normalize_search_key("dioxus fullstack")
1000        );
1001    }
1002
1003    #[tokio::test]
1004    #[serial]
1005    async fn search_cache_roundtrip() {
1006        let query = "Rust";
1007        let posts = vec![PostListItem {
1008            id: 1,
1009            author_id: 1,
1010            title: "Search Result".to_string(),
1011            slug: "search-result".to_string(),
1012            summary: None,
1013            status: PostStatus::Published,
1014            published_at: None,
1015            created_at: chrono::Utc::now(),
1016            updated_at: chrono::Utc::now(),
1017            deleted_at: None,
1018            tags: vec!["rust".to_string()],
1019            cover_image: None,
1020            reading_time: 1,
1021            word_count: 10,
1022        }];
1023
1024        set_search_results(query, posts.clone(), 1).await;
1025
1026        // 大小写与空格差异应命中同一缓存条目。
1027        let cached = get_search_results(" rust ").await;
1028        assert!(cached.is_some());
1029        let (cached_posts, cached_total) = cached.unwrap();
1030        assert_eq!(cached_posts.len(), 1);
1031        assert_eq!(cached_posts[0].title, "Search Result");
1032        assert_eq!(cached_total, 1);
1033
1034        invalidate_search_results();
1035        assert!(get_search_results(query).await.is_none());
1036    }
1037
1038    #[tokio::test]
1039    #[serial]
1040    async fn search_cache_invalidation() {
1041        set_search_results("tokio", vec![], 0).await;
1042        assert!(get_search_results("tokio").await.is_some());
1043
1044        invalidate_search_results();
1045        assert!(get_search_results("tokio").await.is_none());
1046    }
1047}