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