1#[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#[cfg(feature = "server")]
31const TTL_POST_LIST: Duration = Duration::from_secs(60);
32
33#[cfg(feature = "server")]
35const TTL_TAG_LIST: Duration = Duration::from_secs(300);
36
37#[cfg(feature = "server")]
39const TTL_SINGLE_POST: Duration = Duration::from_secs(600);
40
41#[cfg(feature = "server")]
43const TTL_POST_STATS: Duration = Duration::from_secs(60);
44
45#[cfg(feature = "server")]
47const TTL_TAG_POSTS: Duration = Duration::from_secs(120);
48
49#[cfg(feature = "server")]
51const TTL_COMMENTS: Duration = Duration::from_secs(60);
52
53#[cfg(feature = "server")]
55const TTL_PENDING_COUNT: Duration = Duration::from_secs(10);
56
57#[cfg(feature = "server")]
59const TTL_SESSION: Duration = Duration::from_secs(300);
60
61#[cfg(feature = "server")]
63const TTL_SEARCH: Duration = Duration::from_secs(10);
64
65#[cfg(feature = "server")]
67const TTL_SITE_SETTINGS: Duration = Duration::from_secs(600);
68
69#[cfg(feature = "server")]
72const TTL_SECURITY_SETTINGS: Duration = Duration::from_secs(15);
73
74#[cfg(feature = "server")]
76const TTL_IMAGE_CACHE_SETTINGS: Duration = Duration::from_secs(60);
77
78#[cfg(feature = "server")]
80const TTL_LOG_TARGETS: Duration = Duration::from_secs(60);
81
82#[cfg(feature = "server")]
88#[derive(Debug, Clone, Hash, Eq, PartialEq)]
89pub enum CacheKey {
90 PublishedPosts { page: i32, per_page: i32 },
92 TotalPublishedPosts,
94 AllTags,
96 PostBySlug(String),
98 PostsByTag(String),
100 PostStats,
102 CommentsByPost { post_id: i32 },
104 PendingCommentCount,
106 Feed,
108 FriendLinks,
110 SiteSettings,
112 SecuritySettings,
114 ImageCacheSettings,
116 LogTargets,
118}
119
120#[cfg(feature = "server")]
126pub type PostListCache = Cache<CacheKey, (Vec<PostListItem>, i64)>;
127
128#[cfg(feature = "server")]
130pub type TagListCache = Cache<CacheKey, Vec<Tag>>;
131
132#[cfg(feature = "server")]
134pub type SinglePostCache = Cache<CacheKey, Option<Post>>;
135
136#[cfg(feature = "server")]
138pub type PostStatsCache = Cache<CacheKey, PostStats>;
139
140#[cfg(feature = "server")]
142pub type FeedCache = Cache<CacheKey, Vec<FeedItem>>;
143
144#[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#[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#[cfg(feature = "server")]
164pub type FriendLinksCache = Cache<CacheKey, Vec<FriendLink>>;
165
166#[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#[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#[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#[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#[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#[cfg(feature = "server")]
213pub type CommentListCache = Cache<CacheKey, Vec<PublicComment>>;
214
215#[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#[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#[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#[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#[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#[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#[cfg(feature = "server")]
272pub type SessionCache = Cache<String, SessionUser>;
273
274#[cfg(feature = "server")]
276pub type SearchCache = Cache<String, (Vec<PostListItem>, i64)>;
277
278#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[cfg(feature = "server")]
499pub fn invalidate_site_settings() {
500 SITE_SETTINGS_CACHE.invalidate_all();
501}
502
503#[cfg(feature = "server")]
505pub async fn get_security_settings() -> Option<SecuritySettings> {
506 SECURITY_SETTINGS_CACHE
507 .get(&CacheKey::SecuritySettings)
508 .await
509}
510
511#[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#[cfg(feature = "server")]
521pub fn invalidate_security_settings() {
522 SECURITY_SETTINGS_CACHE.invalidate_all();
523}
524
525#[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#[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#[cfg(feature = "server")]
543pub fn invalidate_image_cache_settings() {
544 IMAGE_CACHE_SETTINGS_CACHE.invalidate_all();
545}
546
547#[cfg(feature = "server")]
549pub async fn get_log_targets() -> Option<Vec<String>> {
550 LOG_TARGETS_CACHE.get(&CacheKey::LogTargets).await
551}
552
553#[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#[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#[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#[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#[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#[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#[cfg(feature = "server")]
610pub async fn set_post_stats(stats: PostStats) {
611 let _ = POST_STATS_CACHE.insert(CacheKey::PostStats, stats).await;
612}
613
614#[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#[cfg(feature = "server")]
622pub async fn set_feed(items: Vec<FeedItem>) {
623 let _ = FEED_CACHE.insert(CacheKey::Feed, items).await;
624}
625
626#[cfg(feature = "server")]
632pub fn invalidate_post_lists() {
633 POST_LIST_CACHE.invalidate_all();
634}
635
636#[cfg(feature = "server")]
638pub fn invalidate_all_tags() {
639 TAG_LIST_CACHE.invalidate_all();
640}
641
642#[cfg(feature = "server")]
644pub fn invalidate_friend_links() {
645 FRIEND_LINKS_CACHE.invalidate_all();
646}
647
648#[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#[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#[cfg(feature = "server")]
666pub fn invalidate_post_stats() {
667 POST_STATS_CACHE.invalidate_all();
668}
669
670#[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#[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#[cfg(feature = "server")]
701pub fn invalidate_feed() {
702 FEED_CACHE.invalidate_all();
703}
704
705#[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#[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#[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#[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#[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#[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#[cfg(feature = "server")]
787pub fn normalize_search_key(query: &str) -> String {
788 query.trim().to_lowercase().chars().take(200).collect()
789}
790
791#[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#[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#[cfg(feature = "server")]
805pub async fn invalidate_session_user(token_hash: &str) {
806 SESSION_CACHE.invalidate(token_hash).await;
807}
808
809#[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#[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#[cfg(feature = "server")]
831pub fn invalidate_search_results() {
832 SEARCH_CACHE.invalidate_all();
833}
834
835#[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#[cfg(feature = "server")]
845pub async fn invalidate_pending_count() {
846 PENDING_COUNT_CACHE
847 .invalidate(&CacheKey::PendingCommentCount)
848 .await;
849}
850
851#[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 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 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}