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::{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#[cfg(feature = "server")]
66const TTL_SUGGEST: Duration = Duration::from_secs(30);
67
68#[cfg(feature = "server")]
70const TTL_SITE_SETTINGS: Duration = Duration::from_secs(600);
71
72#[cfg(feature = "server")]
75const TTL_SECURITY_SETTINGS: Duration = Duration::from_secs(15);
76
77#[cfg(feature = "server")]
79const TTL_IMAGE_CACHE_SETTINGS: Duration = Duration::from_secs(60);
80
81#[cfg(feature = "server")]
83const TTL_LOG_TARGETS: Duration = Duration::from_secs(60);
84
85#[cfg(feature = "server")]
91#[derive(Debug, Clone, Hash, Eq, PartialEq)]
92pub enum CacheKey {
93 PublishedPosts { page: i32, per_page: i32 },
95 TotalPublishedPosts,
97 AllTags,
99 PostBySlug(String),
101 PostsByTag(String),
103 PostStats,
105 CommentsByPost { post_id: i32 },
107 PendingCommentCount,
109 Feed,
111 FriendLinks,
113 SiteSettings,
115 SecuritySettings,
117 ImageCacheSettings,
119 LogTargets,
121}
122
123#[cfg(feature = "server")]
129pub type PostListCache = Cache<CacheKey, (Vec<PostListItem>, i64)>;
130
131#[cfg(feature = "server")]
133pub type TagListCache = Cache<CacheKey, Vec<Tag>>;
134
135#[cfg(feature = "server")]
137pub type SinglePostCache = Cache<CacheKey, Option<Post>>;
138
139#[cfg(feature = "server")]
141pub type PostStatsCache = Cache<CacheKey, PostStats>;
142
143#[cfg(feature = "server")]
145pub type FeedCache = Cache<CacheKey, std::sync::Arc<crate::api::feed::RenderedFeed>>;
146
147#[cfg(feature = "server")]
149static POST_LIST_CACHE: LazyLock<PostListCache> = LazyLock::new(|| {
150 Cache::builder()
151 .max_capacity(100)
152 .time_to_live(TTL_POST_LIST)
153 .build()
154});
155
156#[cfg(feature = "server")]
157static ARCHIVE_CACHE: LazyLock<Cache<i32, crate::api::posts::ArchiveResponse>> =
158 LazyLock::new(|| {
159 Cache::builder()
160 .max_capacity(100)
161 .time_to_live(TTL_POST_LIST)
162 .build()
163 });
164
165#[cfg(feature = "server")]
166pub async fn get_archive(page: i32) -> Option<crate::api::posts::ArchiveResponse> {
167 ARCHIVE_CACHE.get(&page).await
168}
169
170#[cfg(feature = "server")]
171pub async fn set_archive(page: i32, response: crate::api::posts::ArchiveResponse) {
172 ARCHIVE_CACHE.insert(page, response).await;
173}
174
175#[cfg(feature = "server")]
177static TAG_LIST_CACHE: LazyLock<TagListCache> = LazyLock::new(|| {
178 Cache::builder()
179 .max_capacity(50)
180 .time_to_live(TTL_TAG_LIST)
181 .build()
182});
183
184#[cfg(feature = "server")]
186pub type FriendLinksCache = Cache<CacheKey, Vec<FriendLink>>;
187
188#[cfg(feature = "server")]
190static FRIEND_LINKS_CACHE: LazyLock<FriendLinksCache> = LazyLock::new(|| {
191 Cache::builder()
192 .max_capacity(50)
193 .time_to_live(TTL_TAG_LIST)
194 .build()
195});
196
197#[cfg(feature = "server")]
199static SINGLE_POST_CACHE: LazyLock<SinglePostCache> = LazyLock::new(|| {
200 Cache::builder()
201 .max_capacity(200)
202 .time_to_live(TTL_SINGLE_POST)
203 .build()
204});
205
206#[cfg(feature = "server")]
208static POST_STATS_CACHE: LazyLock<PostStatsCache> = LazyLock::new(|| {
209 Cache::builder()
210 .max_capacity(10)
211 .time_to_live(TTL_POST_STATS)
212 .build()
213});
214
215#[cfg(feature = "server")]
217static FEED_CACHE: LazyLock<FeedCache> = LazyLock::new(|| {
218 Cache::builder()
219 .max_capacity(10)
220 .time_to_live(TTL_SINGLE_POST)
221 .build()
222});
223
224#[cfg(feature = "server")]
226static TAG_POSTS_CACHE: LazyLock<PostListCache> = LazyLock::new(|| {
227 Cache::builder()
228 .max_capacity(100)
229 .time_to_live(TTL_TAG_POSTS)
230 .build()
231});
232
233#[cfg(feature = "server")]
235pub type CommentListCache = Cache<CacheKey, Vec<PublicComment>>;
236
237#[cfg(feature = "server")]
239static COMMENT_CACHE: LazyLock<CommentListCache> = LazyLock::new(|| {
240 Cache::builder()
241 .max_capacity(200)
242 .time_to_live(TTL_COMMENTS)
243 .build()
244});
245
246#[cfg(feature = "server")]
248static PENDING_COUNT_CACHE: LazyLock<Cache<CacheKey, i64>> = LazyLock::new(|| {
249 Cache::builder()
250 .max_capacity(10)
251 .time_to_live(TTL_PENDING_COUNT)
252 .build()
253});
254
255#[cfg(feature = "server")]
257static SITE_SETTINGS_CACHE: LazyLock<Cache<CacheKey, SiteSettings>> = LazyLock::new(|| {
258 Cache::builder()
259 .max_capacity(2)
260 .time_to_live(TTL_SITE_SETTINGS)
261 .build()
262});
263
264#[cfg(feature = "server")]
266static SECURITY_SETTINGS_CACHE: LazyLock<Cache<CacheKey, SecuritySettings>> = LazyLock::new(|| {
267 Cache::builder()
268 .max_capacity(2)
269 .time_to_live(TTL_SECURITY_SETTINGS)
270 .build()
271});
272
273#[cfg(feature = "server")]
275static IMAGE_CACHE_SETTINGS_CACHE: LazyLock<Cache<CacheKey, ImageCacheSettings>> =
276 LazyLock::new(|| {
277 Cache::builder()
278 .max_capacity(2)
279 .time_to_live(TTL_IMAGE_CACHE_SETTINGS)
280 .build()
281 });
282
283#[cfg(feature = "server")]
285static LOG_TARGETS_CACHE: LazyLock<Cache<CacheKey, Vec<String>>> = LazyLock::new(|| {
286 Cache::builder()
287 .max_capacity(2)
288 .time_to_live(TTL_LOG_TARGETS)
289 .build()
290});
291
292#[cfg(feature = "server")]
294pub type SessionCache = Cache<String, SessionUser>;
295
296#[cfg(feature = "server")]
298pub type SearchCache = Cache<String, (Vec<PostListItem>, i64)>;
299
300#[cfg(feature = "server")]
302pub static SESSION_CACHE: LazyLock<SessionCache> = LazyLock::new(|| {
303 Cache::builder()
304 .max_capacity(1000)
305 .time_to_live(TTL_SESSION)
306 .build()
307});
308
309#[cfg(feature = "server")]
311static SEARCH_CACHE: LazyLock<SearchCache> = LazyLock::new(|| {
312 Cache::builder()
313 .max_capacity(200)
314 .time_to_live(TTL_SEARCH)
315 .build()
316});
317
318#[cfg(feature = "server")]
320static SUGGEST_CACHE: LazyLock<Cache<String, Vec<crate::api::posts::PostSuggestion>>> =
321 LazyLock::new(|| {
322 Cache::builder()
323 .max_capacity(500)
324 .time_to_live(TTL_SUGGEST)
325 .build()
326 });
327
328#[cfg(feature = "server")]
330static YEAR_COUNTS_CACHE: LazyLock<Cache<(), Vec<crate::api::posts::YearCount>>> =
331 LazyLock::new(|| {
332 Cache::builder()
333 .max_capacity(1)
334 .time_to_live(TTL_POST_LIST)
335 .build()
336 });
337
338#[cfg(feature = "server")]
339pub async fn get_year_counts() -> Option<Vec<crate::api::posts::YearCount>> {
340 YEAR_COUNTS_CACHE.get(&()).await
341}
342
343#[cfg(feature = "server")]
344pub async fn set_year_counts(counts: Vec<crate::api::posts::YearCount>) {
345 YEAR_COUNTS_CACHE.insert((), counts).await;
346}
347
348#[cfg(feature = "server")]
350static OUTLINE_CACHE: LazyLock<Cache<(), Option<crate::api::posts::PostOutline>>> =
351 LazyLock::new(|| {
352 Cache::builder()
353 .max_capacity(1)
354 .time_to_live(TTL_POST_LIST)
355 .build()
356 });
357
358#[cfg(feature = "server")]
364pub struct CacheStats {
365 pub name: &'static str,
366 hits: std::sync::atomic::AtomicU64,
367 misses: std::sync::atomic::AtomicU64,
368}
369
370#[cfg(feature = "server")]
371impl CacheStats {
372 pub const fn new(name: &'static str) -> Self {
373 Self {
374 name,
375 hits: std::sync::atomic::AtomicU64::new(0),
376 misses: std::sync::atomic::AtomicU64::new(0),
377 }
378 }
379 fn record_hit(&self) {
380 self.hits.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
381 }
382 fn record_miss(&self) {
383 self.misses
384 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
385 }
386}
387
388#[cfg(feature = "server")]
390static POST_LIST_STATS: CacheStats = CacheStats::new("文章列表");
391#[cfg(feature = "server")]
392static TAG_STATS: CacheStats = CacheStats::new("标签");
393#[cfg(feature = "server")]
394static SINGLE_POST_STATS: CacheStats = CacheStats::new("单篇文章");
395#[cfg(feature = "server")]
396static POST_STATS_STATS: CacheStats = CacheStats::new("文章统计");
397#[cfg(feature = "server")]
398static TAG_POSTS_STATS: CacheStats = CacheStats::new("标签文章");
399#[cfg(feature = "server")]
400static COMMENT_STATS: CacheStats = CacheStats::new("评论");
401#[cfg(feature = "server")]
402static PENDING_COUNT_STATS: CacheStats = CacheStats::new("待审评论数");
403#[cfg(feature = "server")]
404static SESSION_STATS: CacheStats = CacheStats::new("会话用户");
405#[cfg(feature = "server")]
406static SEARCH_STATS: CacheStats = CacheStats::new("搜索");
407#[cfg(feature = "server")]
408static SUGGEST_STATS: CacheStats = CacheStats::new("搜索建议");
409#[cfg(feature = "server")]
410static OUTLINE_STATS: CacheStats = CacheStats::new("最新目录");
411#[cfg(feature = "server")]
412static FEED_STATS: CacheStats = CacheStats::new("Feed");
413#[cfg(feature = "server")]
414static FRIEND_STATS: CacheStats = CacheStats::new("友链");
415#[cfg(feature = "server")]
416static SITE_SETTINGS_STATS: CacheStats = CacheStats::new("站点配置");
417
418#[cfg(feature = "server")]
420#[derive(Debug)]
421pub struct CacheStatSnapshot {
422 pub name: &'static str,
423 pub entry_count: u64,
424 pub hits: u64,
425 pub misses: u64,
426 pub hit_rate: f64,
427}
428
429#[cfg(feature = "server")]
431pub fn cache_stats() -> Vec<CacheStatSnapshot> {
432 fn snap(stats: &CacheStats, entry_count: u64) -> CacheStatSnapshot {
433 let hits = stats.hits.load(std::sync::atomic::Ordering::Relaxed);
434 let misses = stats.misses.load(std::sync::atomic::Ordering::Relaxed);
435 let total = hits + misses;
436 let hit_rate = if total == 0 {
437 0.0
438 } else {
439 hits as f64 / total as f64
440 };
441 CacheStatSnapshot {
442 name: stats.name,
443 entry_count,
444 hits,
445 misses,
446 hit_rate,
447 }
448 }
449 vec![
450 snap(&POST_LIST_STATS, POST_LIST_CACHE.entry_count()),
451 snap(&TAG_STATS, TAG_LIST_CACHE.entry_count()),
452 snap(&SINGLE_POST_STATS, SINGLE_POST_CACHE.entry_count()),
453 snap(&POST_STATS_STATS, POST_STATS_CACHE.entry_count()),
454 snap(&TAG_POSTS_STATS, TAG_POSTS_CACHE.entry_count()),
455 snap(&COMMENT_STATS, COMMENT_CACHE.entry_count()),
456 snap(&PENDING_COUNT_STATS, PENDING_COUNT_CACHE.entry_count()),
457 snap(&SESSION_STATS, SESSION_CACHE.entry_count()),
458 snap(&SEARCH_STATS, SEARCH_CACHE.entry_count()),
459 snap(&SUGGEST_STATS, SUGGEST_CACHE.entry_count()),
460 snap(&OUTLINE_STATS, OUTLINE_CACHE.entry_count()),
461 snap(&FEED_STATS, FEED_CACHE.entry_count()),
462 snap(&FRIEND_STATS, FRIEND_LINKS_CACHE.entry_count()),
463 snap(&SITE_SETTINGS_STATS, SITE_SETTINGS_CACHE.entry_count()),
464 ]
465}
466
467#[cfg(any(feature = "server", test))]
473macro_rules! record_hit_miss {
474 ($v:expr, $stats:expr) => {{
475 let v = $v;
476 if v.is_some() {
477 $stats.record_hit();
478 } else {
479 $stats.record_miss();
480 }
481 v
482 }};
483}
484
485#[cfg(feature = "server")]
487pub async fn get_post_list(key: &CacheKey) -> Option<(Vec<PostListItem>, i64)> {
488 record_hit_miss!(POST_LIST_CACHE.get(key).await, POST_LIST_STATS)
489}
490
491#[cfg(feature = "server")]
493pub async fn set_post_list(key: &CacheKey, posts: Vec<PostListItem>, total: i64) {
494 let _ = POST_LIST_CACHE.insert(key.clone(), (posts, total)).await;
495}
496
497#[cfg(feature = "server")]
499pub async fn get_total_published_posts() -> Option<i64> {
500 record_hit_miss!(
501 POST_LIST_CACHE
502 .get(&CacheKey::TotalPublishedPosts)
503 .await
504 .map(|(_, total)| total),
505 POST_LIST_STATS
506 )
507}
508
509#[cfg(feature = "server")]
511pub async fn set_total_published_posts(total: i64) {
512 let _ = POST_LIST_CACHE
513 .insert(CacheKey::TotalPublishedPosts, (vec![], total))
514 .await;
515}
516
517#[cfg(feature = "server")]
519pub async fn get_tag_list() -> Option<Vec<Tag>> {
520 record_hit_miss!(TAG_LIST_CACHE.get(&CacheKey::AllTags).await, TAG_STATS)
521}
522
523#[cfg(feature = "server")]
525pub async fn set_tag_list(tags: Vec<Tag>) {
526 let _ = TAG_LIST_CACHE.insert(CacheKey::AllTags, tags).await;
527}
528
529#[cfg(feature = "server")]
531pub async fn get_friend_links() -> Option<Vec<FriendLink>> {
532 record_hit_miss!(
533 FRIEND_LINKS_CACHE.get(&CacheKey::FriendLinks).await,
534 FRIEND_STATS
535 )
536}
537
538#[cfg(feature = "server")]
540pub async fn set_friend_links(links: Vec<FriendLink>) {
541 let _ = FRIEND_LINKS_CACHE
542 .insert(CacheKey::FriendLinks, links)
543 .await;
544}
545
546#[cfg(feature = "server")]
548pub async fn get_site_settings() -> Option<SiteSettings> {
549 record_hit_miss!(
550 SITE_SETTINGS_CACHE.get(&CacheKey::SiteSettings).await,
551 SITE_SETTINGS_STATS
552 )
553}
554
555#[cfg(feature = "server")]
557pub async fn set_site_settings(settings: SiteSettings) {
558 let _ = SITE_SETTINGS_CACHE
559 .insert(CacheKey::SiteSettings, settings)
560 .await;
561}
562
563#[cfg(feature = "server")]
567pub fn invalidate_site_settings() {
568 SITE_SETTINGS_CACHE.invalidate_all();
569}
570
571#[cfg(feature = "server")]
573pub async fn get_security_settings() -> Option<SecuritySettings> {
574 SECURITY_SETTINGS_CACHE
575 .get(&CacheKey::SecuritySettings)
576 .await
577}
578
579#[cfg(feature = "server")]
581pub async fn set_security_settings(settings: SecuritySettings) {
582 let _ = SECURITY_SETTINGS_CACHE
583 .insert(CacheKey::SecuritySettings, settings)
584 .await;
585}
586
587#[cfg(feature = "server")]
589pub fn invalidate_security_settings() {
590 SECURITY_SETTINGS_CACHE.invalidate_all();
591}
592
593#[cfg(feature = "server")]
595pub async fn get_image_cache_settings() -> Option<ImageCacheSettings> {
596 IMAGE_CACHE_SETTINGS_CACHE
597 .get(&CacheKey::ImageCacheSettings)
598 .await
599}
600
601#[cfg(feature = "server")]
603pub async fn set_image_cache_settings(settings: ImageCacheSettings) {
604 let _ = IMAGE_CACHE_SETTINGS_CACHE
605 .insert(CacheKey::ImageCacheSettings, settings)
606 .await;
607}
608
609#[cfg(feature = "server")]
611pub fn invalidate_image_cache_settings() {
612 IMAGE_CACHE_SETTINGS_CACHE.invalidate_all();
613}
614
615#[cfg(feature = "server")]
617pub async fn get_log_targets() -> Option<Vec<String>> {
618 LOG_TARGETS_CACHE.get(&CacheKey::LogTargets).await
619}
620
621#[cfg(feature = "server")]
623pub async fn set_log_targets(targets: Vec<String>) {
624 let _ = LOG_TARGETS_CACHE
625 .insert(CacheKey::LogTargets, targets)
626 .await;
627}
628
629#[cfg(feature = "server")]
631pub fn invalidate_log_targets() {
632 LOG_TARGETS_CACHE.invalidate_all();
633}
634
635#[cfg(feature = "server")]
637pub async fn get_post_by_slug(slug: &str) -> Option<Option<Post>> {
638 record_hit_miss!(
639 SINGLE_POST_CACHE
640 .get(&CacheKey::PostBySlug(slug.to_string()))
641 .await,
642 SINGLE_POST_STATS
643 )
644}
645
646#[cfg(feature = "server")]
648pub async fn set_post_by_slug(slug: &str, post: Option<Post>) {
649 let _ = SINGLE_POST_CACHE
650 .insert(CacheKey::PostBySlug(slug.to_string()), post)
651 .await;
652}
653
654#[cfg(feature = "server")]
656pub async fn get_posts_by_tag(tag: &str) -> Option<(Vec<PostListItem>, i64)> {
657 record_hit_miss!(
658 TAG_POSTS_CACHE
659 .get(&CacheKey::PostsByTag(tag.to_string()))
660 .await,
661 TAG_POSTS_STATS
662 )
663}
664
665#[cfg(feature = "server")]
667pub async fn set_posts_by_tag(tag: &str, posts: Vec<PostListItem>, total: i64) {
668 let _ = TAG_POSTS_CACHE
669 .insert(CacheKey::PostsByTag(tag.to_string()), (posts, total))
670 .await;
671}
672
673#[cfg(feature = "server")]
675pub async fn get_post_stats() -> Option<PostStats> {
676 record_hit_miss!(
677 POST_STATS_CACHE.get(&CacheKey::PostStats).await,
678 POST_STATS_STATS
679 )
680}
681
682#[cfg(feature = "server")]
684pub async fn set_post_stats(stats: PostStats) {
685 let _ = POST_STATS_CACHE.insert(CacheKey::PostStats, stats).await;
686}
687
688#[cfg(feature = "server")]
690pub async fn get_feed() -> Option<std::sync::Arc<crate::api::feed::RenderedFeed>> {
691 record_hit_miss!(FEED_CACHE.get(&CacheKey::Feed).await, FEED_STATS)
692}
693
694#[cfg(feature = "server")]
696pub async fn set_feed(feed: std::sync::Arc<crate::api::feed::RenderedFeed>) {
697 let _ = FEED_CACHE.insert(CacheKey::Feed, feed).await;
698}
699
700#[cfg(feature = "server")]
706pub fn invalidate_post_lists() {
707 ARCHIVE_CACHE.invalidate_all();
708 POST_LIST_CACHE.invalidate_all();
709 OUTLINE_CACHE.invalidate_all();
710 YEAR_COUNTS_CACHE.invalidate_all();
711}
712
713#[cfg(feature = "server")]
715pub fn invalidate_all_tags() {
716 TAG_LIST_CACHE.invalidate_all();
717}
718
719#[cfg(feature = "server")]
721pub fn invalidate_friend_links() {
722 FRIEND_LINKS_CACHE.invalidate_all();
723}
724
725#[cfg(feature = "server")]
727pub async fn invalidate_post_by_slug(slug: &str) {
728 SINGLE_POST_CACHE
729 .invalidate(&CacheKey::PostBySlug(slug.to_string()))
730 .await;
731}
732
733#[cfg(feature = "server")]
735pub async fn invalidate_posts_by_tag(tag: &str) {
736 TAG_POSTS_CACHE
737 .invalidate(&CacheKey::PostsByTag(tag.to_string()))
738 .await;
739}
740
741#[cfg(feature = "server")]
743pub fn invalidate_post_stats() {
744 POST_STATS_CACHE.invalidate_all();
745}
746
747#[cfg(feature = "server")]
749pub async fn invalidate_tag_posts_for(tags: &[String]) {
750 let futures: Vec<_> = tags
751 .iter()
752 .map(|tag| invalidate_posts_by_tag(tag))
753 .collect();
754 let _ = futures::future::join_all(futures).await;
755}
756
757#[cfg(feature = "server")]
764pub fn invalidate_all_post_caches() {
765 invalidate_post_metadata();
766 SINGLE_POST_CACHE.invalidate_all();
767 TAG_POSTS_CACHE.invalidate_all();
768}
769
770#[cfg(feature = "server")]
772pub fn invalidate_shared_content() {
773 invalidate_all_post_caches();
774 invalidate_all_comments();
775 invalidate_friend_links();
776 invalidate_site_settings();
777 invalidate_security_settings();
778 invalidate_image_cache_settings();
779 crate::ssr_cache::invalidate_ssr_all_public();
780}
781
782#[cfg(feature = "server")]
787pub fn invalidate_feed() {
788 FEED_CACHE.invalidate_all();
789}
790
791#[cfg(feature = "server")]
797pub fn invalidate_post_metadata() {
798 invalidate_post_lists();
799 invalidate_all_tags();
800 invalidate_post_stats();
801 invalidate_search_results();
802 invalidate_suggestions();
803 invalidate_feed();
804}
805
806#[cfg(feature = "server")]
820pub async fn invalidate_for_post_write(slugs: &[String], tags: &[String]) {
821 invalidate_post_metadata();
822 for slug in slugs {
823 invalidate_post_by_slug(slug).await;
824 }
825 invalidate_tag_posts_for(tags).await;
826 for slug in slugs {
827 crate::ssr_cache::invalidate_ssr_route(&format!("/post/{slug}"));
828 crate::ssr_cache::invalidate_post_preview(slug);
829 }
830 crate::ssr_cache::invalidate_ssr_all_public();
831 crate::ssr_cache::bump_global_generation();
832}
833
834#[cfg(feature = "server")]
840pub fn invalidate_for_tag_write() {
841 invalidate_all_post_caches();
842 crate::ssr_cache::invalidate_ssr_all_public();
843 crate::ssr_cache::bump_global_generation();
844}
845
846#[cfg(feature = "server")]
848pub async fn get_comments_by_post(post_id: i32) -> Option<Vec<PublicComment>> {
849 record_hit_miss!(
850 COMMENT_CACHE
851 .get(&CacheKey::CommentsByPost { post_id })
852 .await,
853 COMMENT_STATS
854 )
855}
856
857#[cfg(feature = "server")]
859pub async fn set_comments_by_post(post_id: i32, comments: Vec<PublicComment>) {
860 let _ = COMMENT_CACHE
861 .insert(CacheKey::CommentsByPost { post_id }, comments)
862 .await;
863}
864
865#[cfg(feature = "server")]
867pub async fn get_pending_count() -> Option<i64> {
868 record_hit_miss!(
869 PENDING_COUNT_CACHE
870 .get(&CacheKey::PendingCommentCount)
871 .await,
872 PENDING_COUNT_STATS
873 )
874}
875
876#[cfg(feature = "server")]
878pub async fn set_pending_count(count: i64) {
879 let _ = PENDING_COUNT_CACHE
880 .insert(CacheKey::PendingCommentCount, count)
881 .await;
882}
883
884#[cfg(feature = "server")]
886pub fn normalize_search_key(query: &str) -> String {
887 query.trim().to_lowercase().chars().take(200).collect()
888}
889
890#[cfg(feature = "server")]
892pub async fn get_session_user(token_hash: &str) -> Option<SessionUser> {
893 record_hit_miss!(SESSION_CACHE.get(token_hash).await, SESSION_STATS)
894}
895
896#[cfg(feature = "server")]
898pub async fn set_session_user(token_hash: &str, user: SessionUser) {
899 let _ = SESSION_CACHE.insert(token_hash.to_string(), user).await;
900}
901
902#[cfg(feature = "server")]
904pub async fn invalidate_session_user(token_hash: &str) {
905 SESSION_CACHE.invalidate(token_hash).await;
906}
907
908#[cfg(feature = "server")]
910pub async fn get_search_results(query: &str) -> Option<(Vec<PostListItem>, i64)> {
911 record_hit_miss!(
912 SEARCH_CACHE.get(&normalize_search_key(query)).await,
913 SEARCH_STATS
914 )
915}
916
917#[cfg(feature = "server")]
919pub async fn set_search_results(query: &str, posts: Vec<PostListItem>, total: i64) {
920 let _ = SEARCH_CACHE
921 .insert(normalize_search_key(query), (posts, total))
922 .await;
923}
924
925#[cfg(feature = "server")]
930pub fn invalidate_search_results() {
931 SEARCH_CACHE.invalidate_all();
932}
933
934#[cfg(feature = "server")]
936pub async fn get_suggestions(query: &str) -> Option<Vec<crate::api::posts::PostSuggestion>> {
937 record_hit_miss!(
938 SUGGEST_CACHE.get(&normalize_search_key(query)).await,
939 SUGGEST_STATS
940 )
941}
942
943#[cfg(feature = "server")]
945pub async fn set_suggestions(query: &str, posts: Vec<crate::api::posts::PostSuggestion>) {
946 let _ = SUGGEST_CACHE
947 .insert(normalize_search_key(query), posts)
948 .await;
949}
950
951#[cfg(feature = "server")]
953pub fn invalidate_suggestions() {
954 SUGGEST_CACHE.invalidate_all();
955}
956
957#[cfg(feature = "server")]
959pub async fn get_latest_outline() -> Option<Option<crate::api::posts::PostOutline>> {
960 record_hit_miss!(OUTLINE_CACHE.get(&()).await, OUTLINE_STATS)
961}
962
963#[cfg(feature = "server")]
965pub async fn set_latest_outline(outline: Option<crate::api::posts::PostOutline>) {
966 OUTLINE_CACHE.insert((), outline).await;
967}
968
969#[cfg(feature = "server")]
971pub async fn invalidate_comments_by_post(post_id: i32) {
972 COMMENT_CACHE
973 .invalidate(&CacheKey::CommentsByPost { post_id })
974 .await;
975}
976
977#[cfg(feature = "server")]
979pub async fn invalidate_pending_count() {
980 PENDING_COUNT_CACHE
981 .invalidate(&CacheKey::PendingCommentCount)
982 .await;
983}
984
985#[cfg(feature = "server")]
990pub fn invalidate_all_comments() {
991 COMMENT_CACHE.invalidate_all();
992 PENDING_COUNT_CACHE.invalidate_all();
993}
994
995#[cfg(all(test, feature = "server"))]
996mod tests {
997 use super::*;
998 use crate::models::comment::PublicComment;
999 use crate::models::post::PostStatus;
1000 use crate::models::user::{SessionUser, UserRole};
1001 use serial_test::serial;
1002
1003 #[test]
1004 #[serial]
1005 fn cache_key_equality() {
1006 let k1 = CacheKey::PublishedPosts {
1007 page: 1,
1008 per_page: 10,
1009 };
1010 let k2 = CacheKey::PublishedPosts {
1011 page: 1,
1012 per_page: 10,
1013 };
1014 let k3 = CacheKey::PublishedPosts {
1015 page: 2,
1016 per_page: 10,
1017 };
1018 assert_eq!(k1, k2);
1019 assert_ne!(k1, k3);
1020 }
1021
1022 #[tokio::test]
1023 #[serial]
1024 async fn post_list_cache_roundtrip() {
1025 let key = CacheKey::PublishedPosts {
1026 page: 999,
1027 per_page: 99,
1028 };
1029 let posts = vec![PostListItem {
1030 id: 1,
1031 author_id: 1,
1032 title: "List Item".to_string(),
1033 slug: "list-item".to_string(),
1034 summary: None,
1035 status: PostStatus::Published,
1036 published_at: None,
1037 created_at: chrono::Utc::now(),
1038 updated_at: chrono::Utc::now(),
1039 deleted_at: None,
1040 tags: vec!["rust".to_string()],
1041 cover_image: None,
1042 reading_time: 1,
1043 word_count: 10,
1044 }];
1045
1046 set_post_list(&key, posts.clone(), 1).await;
1047 let cached = get_post_list(&key).await;
1048
1049 assert!(cached.is_some());
1050 let (cached_posts, cached_total) = cached.unwrap();
1051 assert_eq!(cached_posts.len(), 1);
1052 assert_eq!(cached_posts[0].title, "List Item");
1053 assert_eq!(cached_total, 1);
1054 }
1055
1056 #[tokio::test]
1057 #[serial]
1058 async fn tag_list_cache_roundtrip() {
1059 let tags = vec![Tag {
1060 id: 1,
1061 name: "rust".to_string(),
1062 post_count: 5,
1063 }];
1064
1065 set_tag_list(tags.clone()).await;
1066 let cached = get_tag_list().await;
1067
1068 assert!(cached.is_some());
1069 assert_eq!(cached.unwrap()[0].name, "rust");
1070 }
1071
1072 #[tokio::test]
1073 #[serial]
1074 async fn single_post_cache_roundtrip() {
1075 let post = Some(Post {
1076 id: 1,
1077 author_id: 1,
1078 title: "Test".to_string(),
1079 slug: "test".to_string(),
1080 summary: None,
1081 content_md: "content".to_string(),
1082 content_html: None,
1083 status: PostStatus::Published,
1084 published_at: None,
1085 created_at: chrono::Utc::now(),
1086 updated_at: chrono::Utc::now(),
1087 deleted_at: None,
1088 tags: vec![],
1089 cover_image: None,
1090 reading_time: 1,
1091 word_count: 10,
1092 toc_html: None,
1093 prev_post: None,
1094 next_post: None,
1095 });
1096
1097 set_post_by_slug("test", post.clone()).await;
1098 let cached = get_post_by_slug("test").await;
1099
1100 assert!(cached.is_some());
1101 assert_eq!(cached.unwrap().unwrap().title, "Test");
1102 }
1103
1104 #[tokio::test]
1105 #[serial]
1106 async fn post_stats_cache_roundtrip() {
1107 let stats = PostStats {
1108 total: 10,
1109 drafts: 3,
1110 published: 7,
1111 trash: 2,
1112 recent_30d: 4,
1113 monthly_published: Vec::new(),
1114 };
1115
1116 set_post_stats(stats.clone()).await;
1117 let cached = get_post_stats().await;
1118
1119 assert!(cached.is_some());
1120 assert_eq!(cached.unwrap().total, 10);
1121 }
1122
1123 #[tokio::test]
1124 #[serial]
1125 async fn cache_invalidation_works() {
1126 let post = Some(Post {
1127 id: 42,
1128 author_id: 1,
1129 title: "Invalidation Test".to_string(),
1130 slug: "invalidation-test".to_string(),
1131 summary: None,
1132 content_md: "test".to_string(),
1133 content_html: None,
1134 status: PostStatus::Published,
1135 published_at: None,
1136 created_at: chrono::Utc::now(),
1137 updated_at: chrono::Utc::now(),
1138 deleted_at: None,
1139 tags: vec![],
1140 cover_image: None,
1141 reading_time: 1,
1142 word_count: 4,
1143 toc_html: None,
1144 prev_post: None,
1145 next_post: None,
1146 });
1147
1148 set_post_by_slug("invalidation-test", post.clone()).await;
1149 let cached_before = get_post_by_slug("invalidation-test").await;
1150 assert!(cached_before.is_some());
1151
1152 invalidate_post_by_slug("invalidation-test").await;
1153
1154 let cached_after = get_post_by_slug("invalidation-test").await;
1155 assert!(cached_after.is_none());
1156 }
1157
1158 #[tokio::test]
1159 #[serial]
1160 async fn comment_cache_roundtrip() {
1161 let comments = vec![PublicComment {
1162 id: 1,
1163 parent_id: None,
1164 depth: 0,
1165 author_name: "Alice".to_string(),
1166 author_url: None,
1167 avatar_url: "https://example.com/avatar".to_string(),
1168 is_author: false,
1169 content_html: Some("<p>Hello</p>".to_string()),
1170 created_at: "刚刚".to_string(),
1171 created_at_iso: "2026-01-01T00:00:00Z".to_string(),
1172 }];
1173
1174 set_comments_by_post(42, comments.clone()).await;
1175 let cached = get_comments_by_post(42).await;
1176
1177 assert!(cached.is_some());
1178 assert_eq!(cached.unwrap().len(), 1);
1179 }
1180
1181 #[tokio::test]
1182 #[serial]
1183 async fn pending_count_cache_roundtrip() {
1184 set_pending_count(7).await;
1185 let cached = get_pending_count().await;
1186
1187 assert!(cached.is_some());
1188 assert_eq!(cached.unwrap(), 7);
1189 }
1190
1191 #[tokio::test]
1192 #[serial]
1193 async fn comment_cache_invalidation() {
1194 set_comments_by_post(99, vec![]).await;
1195 assert!(get_comments_by_post(99).await.is_some());
1196
1197 invalidate_comments_by_post(99).await;
1198 assert!(get_comments_by_post(99).await.is_none());
1199 }
1200
1201 #[tokio::test]
1202 #[serial]
1203 async fn pending_count_invalidation() {
1204 set_pending_count(3).await;
1205 assert!(get_pending_count().await.is_some());
1206
1207 invalidate_pending_count().await;
1208 assert!(get_pending_count().await.is_none());
1209 }
1210
1211 #[tokio::test]
1212 #[serial]
1213 async fn session_cache_roundtrip() {
1214 let user = SessionUser {
1215 id: 42,
1216 username: "cached_user".to_string(),
1217 email: "cached@example.com".to_string(),
1218 display_name: None,
1219 avatar_url: None,
1220 role: UserRole::Admin,
1221 created_at: chrono::Utc::now(),
1222 session_generation: 0,
1223 };
1224 let token_hash = "sha256_token_hash";
1225
1226 set_session_user(token_hash, user.clone()).await;
1227 let cached = get_session_user(token_hash).await;
1228
1229 assert!(cached.is_some());
1230 let cached_user = cached.unwrap();
1231 assert_eq!(cached_user.id, user.id);
1232 assert_eq!(cached_user.username, user.username);
1233 assert_eq!(cached_user.email, user.email);
1234 assert_eq!(cached_user.role, user.role);
1235
1236 invalidate_session_user(token_hash).await;
1237 assert!(get_session_user(token_hash).await.is_none());
1238 }
1239
1240 #[test]
1241 fn search_key_normalization() {
1242 assert_eq!(normalize_search_key(" Rust "), "rust");
1243 assert_eq!(normalize_search_key("Rust"), "rust");
1244 assert_eq!(normalize_search_key(" rust "), "rust");
1245 assert_eq!(normalize_search_key(""), "");
1246
1247 let long = "a".repeat(250);
1248 let normalized = normalize_search_key(&long);
1249 assert_eq!(normalized.len(), 200);
1250 assert!(normalized.chars().all(|c| c == 'a'));
1251
1252 assert_eq!(
1254 normalize_search_key(" Dioxus Fullstack "),
1255 normalize_search_key("dioxus fullstack")
1256 );
1257 }
1258
1259 #[tokio::test]
1260 #[serial]
1261 async fn search_cache_roundtrip() {
1262 let query = "Rust";
1263 let posts = vec![PostListItem {
1264 id: 1,
1265 author_id: 1,
1266 title: "Search Result".to_string(),
1267 slug: "search-result".to_string(),
1268 summary: None,
1269 status: PostStatus::Published,
1270 published_at: None,
1271 created_at: chrono::Utc::now(),
1272 updated_at: chrono::Utc::now(),
1273 deleted_at: None,
1274 tags: vec!["rust".to_string()],
1275 cover_image: None,
1276 reading_time: 1,
1277 word_count: 10,
1278 }];
1279
1280 set_search_results(query, posts.clone(), 1).await;
1281
1282 let cached = get_search_results(" rust ").await;
1284 assert!(cached.is_some());
1285 let (cached_posts, cached_total) = cached.unwrap();
1286 assert_eq!(cached_posts.len(), 1);
1287 assert_eq!(cached_posts[0].title, "Search Result");
1288 assert_eq!(cached_total, 1);
1289
1290 invalidate_search_results();
1291 assert!(get_search_results(query).await.is_none());
1292 }
1293
1294 #[tokio::test]
1295 #[serial]
1296 async fn full_post_invalidation_also_clears_search_results() {
1297 set_search_results("removed article", vec![], 0).await;
1298 assert!(get_search_results("removed article").await.is_some());
1299 invalidate_all_post_caches();
1300 assert!(get_search_results("removed article").await.is_none());
1301 }
1302
1303 #[tokio::test]
1304 #[serial]
1305 async fn tag_write_invalidation_clears_lists_and_tag_posts() {
1306 let key = CacheKey::PublishedPosts {
1307 page: 998,
1308 per_page: 98,
1309 };
1310 set_post_list(&key, vec![], 0).await;
1311 set_posts_by_tag("renamed-tag", vec![], 0).await;
1312 set_tag_list(vec![]).await;
1313 let generation = crate::ssr_cache::current_global_generation();
1314
1315 invalidate_for_tag_write();
1316
1317 assert!(get_post_list(&key).await.is_none());
1318 assert!(get_posts_by_tag("renamed-tag").await.is_none());
1319 assert!(get_tag_list().await.is_none());
1320 assert!(crate::ssr_cache::current_global_generation() > generation);
1321 }
1322
1323 #[tokio::test]
1324 #[serial]
1325 async fn search_cache_invalidation() {
1326 set_search_results("tokio", vec![], 0).await;
1327 assert!(get_search_results("tokio").await.is_some());
1328
1329 invalidate_search_results();
1330 assert!(get_search_results("tokio").await.is_none());
1331 }
1332}