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/// 按 slug 读取单篇文章缓存。
562#[cfg(feature = "server")]
563pub async fn get_post_by_slug(slug: &str) -> Option<Option<Post>> {
564    record_hit_miss!(
565        SINGLE_POST_CACHE
566            .get(&CacheKey::PostBySlug(slug.to_string()))
567            .await,
568        SINGLE_POST_STATS
569    )
570}
571
572/// 按 slug 写入单篇文章缓存,None 表示文章不存在。
573#[cfg(feature = "server")]
574pub async fn set_post_by_slug(slug: &str, post: Option<Post>) {
575    let _ = SINGLE_POST_CACHE
576        .insert(CacheKey::PostBySlug(slug.to_string()), post)
577        .await;
578}
579
580/// 按标签读取文章列表缓存。
581#[cfg(feature = "server")]
582pub async fn get_posts_by_tag(tag: &str) -> Option<(Vec<PostListItem>, i64)> {
583    record_hit_miss!(
584        TAG_POSTS_CACHE
585            .get(&CacheKey::PostsByTag(tag.to_string()))
586            .await,
587        TAG_POSTS_STATS
588    )
589}
590
591/// 按标签写入文章列表缓存。
592#[cfg(feature = "server")]
593pub async fn set_posts_by_tag(tag: &str, posts: Vec<PostListItem>, total: i64) {
594    let _ = TAG_POSTS_CACHE
595        .insert(CacheKey::PostsByTag(tag.to_string()), (posts, total))
596        .await;
597}
598
599/// 读取文章统计缓存。
600#[cfg(feature = "server")]
601pub async fn get_post_stats() -> Option<PostStats> {
602    record_hit_miss!(
603        POST_STATS_CACHE.get(&CacheKey::PostStats).await,
604        POST_STATS_STATS
605    )
606}
607
608/// 写入文章统计缓存。
609#[cfg(feature = "server")]
610pub async fn set_post_stats(stats: PostStats) {
611    let _ = POST_STATS_CACHE.insert(CacheKey::PostStats, stats).await;
612}
613
614/// 读取 Feed 条目列表缓存。
615#[cfg(feature = "server")]
616pub async fn get_feed() -> Option<Vec<FeedItem>> {
617    record_hit_miss!(FEED_CACHE.get(&CacheKey::Feed).await, FEED_STATS)
618}
619
620/// 写入 Feed 条目列表缓存。
621#[cfg(feature = "server")]
622pub async fn set_feed(items: Vec<FeedItem>) {
623    let _ = FEED_CACHE.insert(CacheKey::Feed, items).await;
624}
625
626// ============================================================================
627// 缓存失效
628// ============================================================================
629
630/// 清空所有文章分页列表缓存。
631#[cfg(feature = "server")]
632pub fn invalidate_post_lists() {
633    POST_LIST_CACHE.invalidate_all();
634}
635
636/// 清空所有标签缓存。
637#[cfg(feature = "server")]
638pub fn invalidate_all_tags() {
639    TAG_LIST_CACHE.invalidate_all();
640}
641
642/// 清空友链缓存。
643#[cfg(feature = "server")]
644pub fn invalidate_friend_links() {
645    FRIEND_LINKS_CACHE.invalidate_all();
646}
647
648/// 按 slug 失效单篇文章缓存。
649#[cfg(feature = "server")]
650pub async fn invalidate_post_by_slug(slug: &str) {
651    SINGLE_POST_CACHE
652        .invalidate(&CacheKey::PostBySlug(slug.to_string()))
653        .await;
654}
655
656/// 按标签失效文章列表缓存。
657#[cfg(feature = "server")]
658pub async fn invalidate_posts_by_tag(tag: &str) {
659    TAG_POSTS_CACHE
660        .invalidate(&CacheKey::PostsByTag(tag.to_string()))
661        .await;
662}
663
664/// 清空文章统计缓存。
665#[cfg(feature = "server")]
666pub fn invalidate_post_stats() {
667    POST_STATS_CACHE.invalidate_all();
668}
669
670/// 按标签批量失效文章列表缓存。
671#[cfg(feature = "server")]
672pub async fn invalidate_tag_posts_for(tags: &[String]) {
673    let futures: Vec<_> = tags
674        .iter()
675        .map(|tag| invalidate_posts_by_tag(tag))
676        .collect();
677    let _ = futures::future::join_all(futures).await;
678}
679
680/// 清空所有文章相关缓存(列表、标签、单篇、统计、标签文章)。
681///
682/// 这是一个“紧急”使用的全量失效开关,会一次性冲刷所有文章缓存;
683/// 正常写路径应当使用更细粒度的 `invalidate_post_lists` / `invalidate_all_tags` /
684/// `invalidate_post_by_slug` / `invalidate_posts_by_tag` / `invalidate_post_stats` /
685/// `invalidate_tag_posts_for` 等函数,避免不必要的缓存击穿。
686#[cfg(feature = "server")]
687pub fn invalidate_all_post_caches() {
688    POST_LIST_CACHE.invalidate_all();
689    TAG_LIST_CACHE.invalidate_all();
690    SINGLE_POST_CACHE.invalidate_all();
691    POST_STATS_CACHE.invalidate_all();
692    TAG_POSTS_CACHE.invalidate_all();
693    invalidate_feed();
694}
695
696/// 清空 Feed 条目缓存。
697///
698/// 使用同步签名是因为 `moka::Cache::invalidate_all` 为同步操作,
699/// 与 `invalidate_post_stats` 等元数据失效保持一致。
700#[cfg(feature = "server")]
701pub fn invalidate_feed() {
702    FEED_CACHE.invalidate_all();
703}
704
705/// 失效文章「元数据」类缓存:列表、标签、统计、搜索结果、Feed。
706///
707/// 这些在每次文章写操作(创建/更新/删除/恢复/清空回收站)后都需要一起失效。
708/// 单篇正文与标签下文章列表是定向失效(按 slug / tag),不在此处处理,由调用方
709/// 根据实际涉及的 slug/tags 额外调用 `invalidate_post_by_slug` / `invalidate_tag_posts_for`。
710#[cfg(feature = "server")]
711pub fn invalidate_post_metadata() {
712    invalidate_post_lists();
713    invalidate_all_tags();
714    invalidate_post_stats();
715    invalidate_search_results();
716    invalidate_feed();
717}
718
719/// 文章写操作(创建/更新/删除/恢复/清空回收站)后的统一缓存失效序列。
720///
721/// 按影响范围精准失效:先失效文章「元数据」类缓存(列表/标签云/统计/搜索),
722/// 再按 slug 定向失效单篇正文与 SSR 详情页,按标签失效标签下文章列表,
723/// 最后全量刷新公开页 SSR 缓存并递增全局世代号。
724///
725/// - `slugs`:受影响的文章 slug(旧/新均可,用于单篇缓存与 SSR 详情页定向失效)。
726/// - `tags`:受影响的标签名(用于标签下文章列表失效)。
727///
728/// 对于 slug 变更等需要分别处理旧/新 slug 的复杂场景,调用方将涉及的 slug 一并传入即可——
729/// 本函数对 `slugs` 中的每个元素一视同仁。注意:`invalidate_ssr_all_public` 会删除全部
730/// 公开页 SSR 缓存,因此批量场景下逐 slug 的 `invalidate_ssr_route` 仅是先行的定向清理,
731/// 不会改变最终失效结果。
732#[cfg(feature = "server")]
733pub async fn invalidate_for_post_write(slugs: &[String], tags: &[String]) {
734    invalidate_post_metadata();
735    for slug in slugs {
736        invalidate_post_by_slug(slug).await;
737    }
738    invalidate_tag_posts_for(tags).await;
739    for slug in slugs {
740        crate::ssr_cache::invalidate_ssr_route(&format!("/post/{slug}"));
741        crate::ssr_cache::invalidate_post_preview(slug);
742    }
743    crate::ssr_cache::invalidate_ssr_all_public();
744    crate::ssr_cache::bump_global_generation();
745}
746
747/// 按文章主键读取评论列表缓存。
748#[cfg(feature = "server")]
749pub async fn get_comments_by_post(post_id: i32) -> Option<Vec<PublicComment>> {
750    record_hit_miss!(
751        COMMENT_CACHE
752            .get(&CacheKey::CommentsByPost { post_id })
753            .await,
754        COMMENT_STATS
755    )
756}
757
758/// 按文章主键写入评论列表缓存。
759#[cfg(feature = "server")]
760pub async fn set_comments_by_post(post_id: i32, comments: Vec<PublicComment>) {
761    let _ = COMMENT_CACHE
762        .insert(CacheKey::CommentsByPost { post_id }, comments)
763        .await;
764}
765
766/// 读取待审核评论总数缓存。
767#[cfg(feature = "server")]
768pub async fn get_pending_count() -> Option<i64> {
769    record_hit_miss!(
770        PENDING_COUNT_CACHE
771            .get(&CacheKey::PendingCommentCount)
772            .await,
773        PENDING_COUNT_STATS
774    )
775}
776
777/// 写入待审核评论总数缓存。
778#[cfg(feature = "server")]
779pub async fn set_pending_count(count: i64) {
780    let _ = PENDING_COUNT_CACHE
781        .insert(CacheKey::PendingCommentCount, count)
782        .await;
783}
784
785/// 规范化搜索查询键:trim、转小写、截断至 200 字符。
786#[cfg(feature = "server")]
787pub fn normalize_search_key(query: &str) -> String {
788    query.trim().to_lowercase().chars().take(200).collect()
789}
790
791/// 读取会话用户缓存。
792#[cfg(feature = "server")]
793pub async fn get_session_user(token_hash: &str) -> Option<SessionUser> {
794    record_hit_miss!(SESSION_CACHE.get(token_hash).await, SESSION_STATS)
795}
796
797/// 写入会话用户缓存。
798#[cfg(feature = "server")]
799pub async fn set_session_user(token_hash: &str, user: SessionUser) {
800    let _ = SESSION_CACHE.insert(token_hash.to_string(), user).await;
801}
802
803/// 失效指定会话用户缓存。
804#[cfg(feature = "server")]
805pub async fn invalidate_session_user(token_hash: &str) {
806    SESSION_CACHE.invalidate(token_hash).await;
807}
808
809/// 读取搜索结果缓存。
810#[cfg(feature = "server")]
811pub async fn get_search_results(query: &str) -> Option<(Vec<PostListItem>, i64)> {
812    record_hit_miss!(
813        SEARCH_CACHE.get(&normalize_search_key(query)).await,
814        SEARCH_STATS
815    )
816}
817
818/// 写入搜索结果缓存。
819#[cfg(feature = "server")]
820pub async fn set_search_results(query: &str, posts: Vec<PostListItem>, total: i64) {
821    let _ = SEARCH_CACHE
822        .insert(normalize_search_key(query), (posts, total))
823        .await;
824}
825
826/// 清空所有搜索结果缓存。
827///
828/// 使用同步签名是因为 `moka::Cache::invalidate_all` 为同步操作;
829/// 该函数通常由写路径直接调用,无需额外等待。
830#[cfg(feature = "server")]
831pub fn invalidate_search_results() {
832    SEARCH_CACHE.invalidate_all();
833}
834
835/// 按文章主键失效评论列表缓存。
836#[cfg(feature = "server")]
837pub async fn invalidate_comments_by_post(post_id: i32) {
838    COMMENT_CACHE
839        .invalidate(&CacheKey::CommentsByPost { post_id })
840        .await;
841}
842
843/// 失效待审核评论总数缓存。
844#[cfg(feature = "server")]
845pub async fn invalidate_pending_count() {
846    PENDING_COUNT_CACHE
847        .invalidate(&CacheKey::PendingCommentCount)
848        .await;
849}
850
851/// 全量失效评论缓存(SQL 控制台兜底用:管理员直接改 comments 表时无法定向)。
852///
853/// 正常评论写路径用 [`invalidate_comments_by_post`](按文章定向)+ [`invalidate_pending_count`];
854/// 这里全量清空是 SQL 控制台直改 DB 的兜底——无法从任意 SQL 精确解析受影响的 post_id。
855#[cfg(feature = "server")]
856pub fn invalidate_all_comments() {
857    COMMENT_CACHE.invalidate_all();
858    PENDING_COUNT_CACHE.invalidate_all();
859}
860
861#[cfg(all(test, feature = "server"))]
862mod tests {
863    use super::*;
864    use crate::models::comment::PublicComment;
865    use crate::models::post::PostStatus;
866    use crate::models::user::{SessionUser, UserRole};
867    use serial_test::serial;
868
869    #[test]
870    #[serial]
871    fn cache_key_equality() {
872        let k1 = CacheKey::PublishedPosts {
873            page: 1,
874            per_page: 10,
875        };
876        let k2 = CacheKey::PublishedPosts {
877            page: 1,
878            per_page: 10,
879        };
880        let k3 = CacheKey::PublishedPosts {
881            page: 2,
882            per_page: 10,
883        };
884        assert_eq!(k1, k2);
885        assert_ne!(k1, k3);
886    }
887
888    #[tokio::test]
889    #[serial]
890    async fn post_list_cache_roundtrip() {
891        let key = CacheKey::PublishedPosts {
892            page: 999,
893            per_page: 99,
894        };
895        let posts = vec![PostListItem {
896            id: 1,
897            author_id: 1,
898            title: "List Item".to_string(),
899            slug: "list-item".to_string(),
900            summary: None,
901            status: PostStatus::Published,
902            published_at: None,
903            created_at: chrono::Utc::now(),
904            updated_at: chrono::Utc::now(),
905            deleted_at: None,
906            tags: vec!["rust".to_string()],
907            cover_image: None,
908            reading_time: 1,
909            word_count: 10,
910        }];
911
912        set_post_list(&key, posts.clone(), 1).await;
913        let cached = get_post_list(&key).await;
914
915        assert!(cached.is_some());
916        let (cached_posts, cached_total) = cached.unwrap();
917        assert_eq!(cached_posts.len(), 1);
918        assert_eq!(cached_posts[0].title, "List Item");
919        assert_eq!(cached_total, 1);
920    }
921
922    #[tokio::test]
923    #[serial]
924    async fn tag_list_cache_roundtrip() {
925        let tags = vec![Tag {
926            id: 1,
927            name: "rust".to_string(),
928            post_count: 5,
929        }];
930
931        set_tag_list(tags.clone()).await;
932        let cached = get_tag_list().await;
933
934        assert!(cached.is_some());
935        assert_eq!(cached.unwrap()[0].name, "rust");
936    }
937
938    #[tokio::test]
939    #[serial]
940    async fn single_post_cache_roundtrip() {
941        let post = Some(Post {
942            id: 1,
943            author_id: 1,
944            title: "Test".to_string(),
945            slug: "test".to_string(),
946            summary: None,
947            content_md: "content".to_string(),
948            content_html: None,
949            status: PostStatus::Published,
950            published_at: None,
951            created_at: chrono::Utc::now(),
952            updated_at: chrono::Utc::now(),
953            deleted_at: None,
954            tags: vec![],
955            cover_image: None,
956            reading_time: 1,
957            word_count: 10,
958            toc_html: None,
959            prev_post: None,
960            next_post: None,
961        });
962
963        set_post_by_slug("test", post.clone()).await;
964        let cached = get_post_by_slug("test").await;
965
966        assert!(cached.is_some());
967        assert_eq!(cached.unwrap().unwrap().title, "Test");
968    }
969
970    #[tokio::test]
971    #[serial]
972    async fn post_stats_cache_roundtrip() {
973        let stats = PostStats {
974            total: 10,
975            drafts: 3,
976            published: 7,
977            trash: 2,
978            recent_30d: 4,
979            activity_30d: vec![0, 1, 3],
980        };
981
982        set_post_stats(stats.clone()).await;
983        let cached = get_post_stats().await;
984
985        assert!(cached.is_some());
986        assert_eq!(cached.unwrap().total, 10);
987    }
988
989    #[tokio::test]
990    #[serial]
991    async fn cache_invalidation_works() {
992        let post = Some(Post {
993            id: 42,
994            author_id: 1,
995            title: "Invalidation Test".to_string(),
996            slug: "invalidation-test".to_string(),
997            summary: None,
998            content_md: "test".to_string(),
999            content_html: None,
1000            status: PostStatus::Published,
1001            published_at: None,
1002            created_at: chrono::Utc::now(),
1003            updated_at: chrono::Utc::now(),
1004            deleted_at: None,
1005            tags: vec![],
1006            cover_image: None,
1007            reading_time: 1,
1008            word_count: 4,
1009            toc_html: None,
1010            prev_post: None,
1011            next_post: None,
1012        });
1013
1014        set_post_by_slug("invalidation-test", post.clone()).await;
1015        let cached_before = get_post_by_slug("invalidation-test").await;
1016        assert!(cached_before.is_some());
1017
1018        invalidate_post_by_slug("invalidation-test").await;
1019
1020        let cached_after = get_post_by_slug("invalidation-test").await;
1021        assert!(cached_after.is_none());
1022    }
1023
1024    #[tokio::test]
1025    #[serial]
1026    async fn comment_cache_roundtrip() {
1027        let comments = vec![PublicComment {
1028            id: 1,
1029            parent_id: None,
1030            depth: 0,
1031            author_name: "Alice".to_string(),
1032            author_url: None,
1033            avatar_url: "https://example.com/avatar".to_string(),
1034            is_author: false,
1035            content_html: Some("<p>Hello</p>".to_string()),
1036            created_at: "刚刚".to_string(),
1037            created_at_iso: "2026-01-01T00:00:00Z".to_string(),
1038        }];
1039
1040        set_comments_by_post(42, comments.clone()).await;
1041        let cached = get_comments_by_post(42).await;
1042
1043        assert!(cached.is_some());
1044        assert_eq!(cached.unwrap().len(), 1);
1045    }
1046
1047    #[tokio::test]
1048    #[serial]
1049    async fn pending_count_cache_roundtrip() {
1050        set_pending_count(7).await;
1051        let cached = get_pending_count().await;
1052
1053        assert!(cached.is_some());
1054        assert_eq!(cached.unwrap(), 7);
1055    }
1056
1057    #[tokio::test]
1058    #[serial]
1059    async fn comment_cache_invalidation() {
1060        set_comments_by_post(99, vec![]).await;
1061        assert!(get_comments_by_post(99).await.is_some());
1062
1063        invalidate_comments_by_post(99).await;
1064        assert!(get_comments_by_post(99).await.is_none());
1065    }
1066
1067    #[tokio::test]
1068    #[serial]
1069    async fn pending_count_invalidation() {
1070        set_pending_count(3).await;
1071        assert!(get_pending_count().await.is_some());
1072
1073        invalidate_pending_count().await;
1074        assert!(get_pending_count().await.is_none());
1075    }
1076
1077    #[tokio::test]
1078    #[serial]
1079    async fn session_cache_roundtrip() {
1080        let user = SessionUser {
1081            id: 42,
1082            username: "cached_user".to_string(),
1083            email: "cached@example.com".to_string(),
1084            display_name: None,
1085            avatar_url: None,
1086            role: UserRole::Admin,
1087            created_at: chrono::Utc::now(),
1088            session_generation: 0,
1089        };
1090        let token_hash = "sha256_token_hash";
1091
1092        set_session_user(token_hash, user.clone()).await;
1093        let cached = get_session_user(token_hash).await;
1094
1095        assert!(cached.is_some());
1096        let cached_user = cached.unwrap();
1097        assert_eq!(cached_user.id, user.id);
1098        assert_eq!(cached_user.username, user.username);
1099        assert_eq!(cached_user.email, user.email);
1100        assert_eq!(cached_user.role, user.role);
1101
1102        invalidate_session_user(token_hash).await;
1103        assert!(get_session_user(token_hash).await.is_none());
1104    }
1105
1106    #[test]
1107    fn search_key_normalization() {
1108        assert_eq!(normalize_search_key("  Rust "), "rust");
1109        assert_eq!(normalize_search_key("Rust"), "rust");
1110        assert_eq!(normalize_search_key("  rust "), "rust");
1111        assert_eq!(normalize_search_key(""), "");
1112
1113        let long = "a".repeat(250);
1114        let normalized = normalize_search_key(&long);
1115        assert_eq!(normalized.len(), 200);
1116        assert!(normalized.chars().all(|c| c == 'a'));
1117
1118        // 大小写与空格差异应映射到同一键。
1119        assert_eq!(
1120            normalize_search_key("  Dioxus Fullstack "),
1121            normalize_search_key("dioxus fullstack")
1122        );
1123    }
1124
1125    #[tokio::test]
1126    #[serial]
1127    async fn search_cache_roundtrip() {
1128        let query = "Rust";
1129        let posts = vec![PostListItem {
1130            id: 1,
1131            author_id: 1,
1132            title: "Search Result".to_string(),
1133            slug: "search-result".to_string(),
1134            summary: None,
1135            status: PostStatus::Published,
1136            published_at: None,
1137            created_at: chrono::Utc::now(),
1138            updated_at: chrono::Utc::now(),
1139            deleted_at: None,
1140            tags: vec!["rust".to_string()],
1141            cover_image: None,
1142            reading_time: 1,
1143            word_count: 10,
1144        }];
1145
1146        set_search_results(query, posts.clone(), 1).await;
1147
1148        // 大小写与空格差异应命中同一缓存条目。
1149        let cached = get_search_results(" rust ").await;
1150        assert!(cached.is_some());
1151        let (cached_posts, cached_total) = cached.unwrap();
1152        assert_eq!(cached_posts.len(), 1);
1153        assert_eq!(cached_posts[0].title, "Search Result");
1154        assert_eq!(cached_total, 1);
1155
1156        invalidate_search_results();
1157        assert!(get_search_results(query).await.is_none());
1158    }
1159
1160    #[tokio::test]
1161    #[serial]
1162    async fn search_cache_invalidation() {
1163        set_search_results("tokio", vec![], 0).await;
1164        assert!(get_search_results("tokio").await.is_some());
1165
1166        invalidate_search_results();
1167        assert!(get_search_results("tokio").await.is_none());
1168    }
1169}