yggdrasil/src/cache.rs
xfy 65a7b1226f style: 全项目格式规范化(cargo fmt)+ Docker daemon 断连容错
- 全项目 .rs 统一 cargo fmt 排版(换行/缩进/参数分行/导入排序)
- infra/docker: DOCKER_CLIENT 连接失败不 panic,降级返回 None
  (博客不依赖 Docker,panic=abort 下会导致整个进程崩溃)
- infra/mod: 去除多余空行
- database/mod: 模块声明按字母序重新排序
2026-07-15 17:22:06 +08:00

942 lines
29 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 基于 moka 的内存缓存层。
//!
//! 仅在启用 `server` feature 时编译,为文章列表、标签、单篇文章、统计信息、
//! 评论、会话用户以及搜索结果提供按键缓存与失效能力。
//! 缓存使用 `std::sync::LazyLock` 全局实例,按不同业务数据设置独立的 TTL。
#[cfg(feature = "server")]
use moka::future::Cache;
#[cfg(feature = "server")]
use std::sync::LazyLock;
#[cfg(feature = "server")]
use std::time::Duration;
#[cfg(feature = "server")]
use crate::models::comment::PublicComment;
#[cfg(feature = "server")]
use crate::models::post::{Post, PostListItem, PostStats, Tag};
#[cfg(feature = "server")]
use crate::models::user::SessionUser;
// ============================================================================
// 缓存 TTL 配置
// ============================================================================
/// 文章列表缓存 TTL60 秒。
#[cfg(feature = "server")]
const TTL_POST_LIST: Duration = Duration::from_secs(60);
/// 标签列表缓存 TTL300 秒。
#[cfg(feature = "server")]
const TTL_TAG_LIST: Duration = Duration::from_secs(300);
/// 单篇文章缓存 TTL600 秒。
#[cfg(feature = "server")]
const TTL_SINGLE_POST: Duration = Duration::from_secs(600);
/// 文章统计缓存 TTL60 秒。
#[cfg(feature = "server")]
const TTL_POST_STATS: Duration = Duration::from_secs(60);
/// 标签下文章列表缓存 TTL120 秒。
#[cfg(feature = "server")]
const TTL_TAG_POSTS: Duration = Duration::from_secs(120);
/// 评论列表缓存 TTL60 秒。
#[cfg(feature = "server")]
const TTL_COMMENTS: Duration = Duration::from_secs(60);
/// 待审核评论数量缓存 TTL10 秒,因管理后台需要较实时数据。
#[cfg(feature = "server")]
const TTL_PENDING_COUNT: Duration = Duration::from_secs(10);
/// 会话用户缓存 TTL300 秒5 分钟),短于 DB 会话过期时间。
#[cfg(feature = "server")]
const TTL_SESSION: Duration = Duration::from_secs(300);
/// 搜索结果缓存 TTL10 秒。
#[cfg(feature = "server")]
const TTL_SEARCH: Duration = Duration::from_secs(10);
// ============================================================================
// 缓存 Key 类型
// ============================================================================
/// 统一的缓存键枚举,每个变体对应一类可缓存数据。
#[cfg(feature = "server")]
#[derive(Debug, Clone, Hash, Eq, PartialEq)]
pub enum CacheKey {
/// 已发布文章分页列表。
PublishedPosts { page: i32, per_page: i32 },
/// 已发布文章总数。
TotalPublishedPosts,
/// 全部标签。
AllTags,
/// 按 slug 查询的单篇文章。
PostBySlug(String),
/// 按标签查询的文章列表(不分页,返回全部)。
PostsByTag(String),
/// 按标签查询的分页文章列表。
PostsByTagPage {
tag: String,
page: i32,
per_page: i32,
},
/// 文章统计信息。
PostStats,
/// 某篇文章下的评论列表。
CommentsByPost { post_id: i32 },
/// 待审核评论总数。
PendingCommentCount,
}
// ============================================================================
// 缓存实例
// ============================================================================
/// 文章列表缓存类型,值为(文章列表,总数)。
#[cfg(feature = "server")]
pub type PostListCache = Cache<CacheKey, (Vec<PostListItem>, i64)>;
/// 标签列表缓存类型。
#[cfg(feature = "server")]
pub type TagListCache = Cache<CacheKey, Vec<Tag>>;
/// 单篇文章缓存类型。
#[cfg(feature = "server")]
pub type SinglePostCache = Cache<CacheKey, Option<Post>>;
/// 文章统计缓存类型。
#[cfg(feature = "server")]
pub type PostStatsCache = Cache<CacheKey, PostStats>;
/// 全局文章列表缓存实例,最大容量 100。
#[cfg(feature = "server")]
static POST_LIST_CACHE: LazyLock<PostListCache> = LazyLock::new(|| {
Cache::builder()
.max_capacity(100)
.time_to_live(TTL_POST_LIST)
.build()
});
/// 全局标签列表缓存实例,最大容量 50。
#[cfg(feature = "server")]
static TAG_LIST_CACHE: LazyLock<TagListCache> = LazyLock::new(|| {
Cache::builder()
.max_capacity(50)
.time_to_live(TTL_TAG_LIST)
.build()
});
/// 全局单篇文章缓存实例,最大容量 200。
#[cfg(feature = "server")]
static SINGLE_POST_CACHE: LazyLock<SinglePostCache> = LazyLock::new(|| {
Cache::builder()
.max_capacity(200)
.time_to_live(TTL_SINGLE_POST)
.build()
});
/// 全局文章统计缓存实例,最大容量 10。
#[cfg(feature = "server")]
static POST_STATS_CACHE: LazyLock<PostStatsCache> = LazyLock::new(|| {
Cache::builder()
.max_capacity(10)
.time_to_live(TTL_POST_STATS)
.build()
});
/// 全局标签文章列表缓存实例,最大容量 100。
#[cfg(feature = "server")]
static TAG_POSTS_CACHE: LazyLock<PostListCache> = LazyLock::new(|| {
Cache::builder()
.max_capacity(100)
.time_to_live(TTL_TAG_POSTS)
.build()
});
/// 评论列表缓存类型。
#[cfg(feature = "server")]
pub type CommentListCache = Cache<CacheKey, Vec<PublicComment>>;
/// 全局评论列表缓存实例,最大容量 200。
#[cfg(feature = "server")]
static COMMENT_CACHE: LazyLock<CommentListCache> = LazyLock::new(|| {
Cache::builder()
.max_capacity(200)
.time_to_live(TTL_COMMENTS)
.build()
});
/// 全局待审核评论数量缓存实例,最大容量 10。
#[cfg(feature = "server")]
static PENDING_COUNT_CACHE: LazyLock<Cache<CacheKey, i64>> = LazyLock::new(|| {
Cache::builder()
.max_capacity(10)
.time_to_live(TTL_PENDING_COUNT)
.build()
});
/// 会话用户缓存类型。
#[cfg(feature = "server")]
pub type SessionCache = Cache<String, SessionUser>;
/// 搜索结果缓存类型。
#[cfg(feature = "server")]
pub type SearchCache = Cache<String, (Vec<PostListItem>, i64)>;
/// 全局会话用户缓存实例,最大容量 1000TTL 5 分钟。
#[cfg(feature = "server")]
pub static SESSION_CACHE: LazyLock<SessionCache> = LazyLock::new(|| {
Cache::builder()
.max_capacity(1000)
.time_to_live(TTL_SESSION)
.build()
});
/// 全局搜索结果缓存实例,最大容量 200TTL 10 秒。
#[cfg(feature = "server")]
static SEARCH_CACHE: LazyLock<SearchCache> = LazyLock::new(|| {
Cache::builder()
.max_capacity(200)
.time_to_live(TTL_SEARCH)
.build()
});
// ============================================================================
// 命中率统计(供系统状态面板展示)
// ============================================================================
/// 单个缓存的命中/未命中计数。用 AtomicU64 在 get_* 路径上记录。
#[cfg(feature = "server")]
pub struct CacheStats {
pub name: &'static str,
hits: std::sync::atomic::AtomicU64,
misses: std::sync::atomic::AtomicU64,
}
#[cfg(feature = "server")]
impl CacheStats {
pub const fn new(name: &'static str) -> Self {
Self {
name,
hits: std::sync::atomic::AtomicU64::new(0),
misses: std::sync::atomic::AtomicU64::new(0),
}
}
fn record_hit(&self) {
self.hits.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
fn record_miss(&self) {
self.misses
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
}
// 每个缓存一份统计实例const启动时零开销初始化
#[cfg(feature = "server")]
static POST_LIST_STATS: CacheStats = CacheStats::new("文章列表");
#[cfg(feature = "server")]
static TAG_STATS: CacheStats = CacheStats::new("标签");
#[cfg(feature = "server")]
static SINGLE_POST_STATS: CacheStats = CacheStats::new("单篇文章");
#[cfg(feature = "server")]
static POST_STATS_STATS: CacheStats = CacheStats::new("文章统计");
#[cfg(feature = "server")]
static TAG_POSTS_STATS: CacheStats = CacheStats::new("标签文章");
#[cfg(feature = "server")]
static COMMENT_STATS: CacheStats = CacheStats::new("评论");
#[cfg(feature = "server")]
static PENDING_COUNT_STATS: CacheStats = CacheStats::new("待审评论数");
#[cfg(feature = "server")]
static SESSION_STATS: CacheStats = CacheStats::new("会话用户");
#[cfg(feature = "server")]
static SEARCH_STATS: CacheStats = CacheStats::new("搜索");
/// 缓存统计快照项(序列化给前端展示)。
#[cfg(feature = "server")]
#[derive(Debug)]
pub struct CacheStatSnapshot {
pub name: &'static str,
pub entry_count: u64,
pub hits: u64,
pub misses: u64,
pub hit_rate: f64,
}
/// 聚合所有缓存的统计快照(供 get_server_status 调用)。
#[cfg(feature = "server")]
pub fn cache_stats() -> Vec<CacheStatSnapshot> {
fn snap(stats: &CacheStats, entry_count: u64) -> CacheStatSnapshot {
let hits = stats.hits.load(std::sync::atomic::Ordering::Relaxed);
let misses = stats.misses.load(std::sync::atomic::Ordering::Relaxed);
let total = hits + misses;
let hit_rate = if total == 0 {
0.0
} else {
hits as f64 / total as f64
};
CacheStatSnapshot {
name: stats.name,
entry_count,
hits,
misses,
hit_rate,
}
}
vec![
snap(&POST_LIST_STATS, POST_LIST_CACHE.entry_count()),
snap(&TAG_STATS, TAG_LIST_CACHE.entry_count()),
snap(&SINGLE_POST_STATS, SINGLE_POST_CACHE.entry_count()),
snap(&POST_STATS_STATS, POST_STATS_CACHE.entry_count()),
snap(&TAG_POSTS_STATS, TAG_POSTS_CACHE.entry_count()),
snap(&COMMENT_STATS, COMMENT_CACHE.entry_count()),
snap(&PENDING_COUNT_STATS, PENDING_COUNT_CACHE.entry_count()),
snap(&SESSION_STATS, SESSION_CACHE.entry_count()),
snap(&SEARCH_STATS, SEARCH_CACHE.entry_count()),
]
}
// ============================================================================
// 公共缓存 API
// ============================================================================
/// 读取文章分页列表缓存。
#[cfg(feature = "server")]
pub async fn get_post_list(key: &CacheKey) -> Option<(Vec<PostListItem>, i64)> {
let v = POST_LIST_CACHE.get(key).await;
if v.is_some() {
POST_LIST_STATS.record_hit();
} else {
POST_LIST_STATS.record_miss();
}
v
}
/// 写入文章分页列表缓存。
#[cfg(feature = "server")]
pub async fn set_post_list(key: &CacheKey, posts: Vec<PostListItem>, total: i64) {
let _ = POST_LIST_CACHE.insert(key.clone(), (posts, total)).await;
}
/// 读取已发布文章总数缓存。
#[cfg(feature = "server")]
pub async fn get_total_published_posts() -> Option<i64> {
let v = POST_LIST_CACHE
.get(&CacheKey::TotalPublishedPosts)
.await
.map(|(_, total)| total);
if v.is_some() {
POST_LIST_STATS.record_hit();
} else {
POST_LIST_STATS.record_miss();
}
v
}
/// 写入已发布文章总数缓存,文章列表部分置空以节省内存。
#[cfg(feature = "server")]
pub async fn set_total_published_posts(total: i64) {
let _ = POST_LIST_CACHE
.insert(CacheKey::TotalPublishedPosts, (vec![], total))
.await;
}
/// 读取全部标签缓存。
#[cfg(feature = "server")]
pub async fn get_tag_list() -> Option<Vec<Tag>> {
let v = TAG_LIST_CACHE.get(&CacheKey::AllTags).await;
if v.is_some() {
TAG_STATS.record_hit();
} else {
TAG_STATS.record_miss();
}
v
}
/// 写入全部标签缓存。
#[cfg(feature = "server")]
pub async fn set_tag_list(tags: Vec<Tag>) {
let _ = TAG_LIST_CACHE.insert(CacheKey::AllTags, tags).await;
}
/// 按 slug 读取单篇文章缓存。
#[cfg(feature = "server")]
pub async fn get_post_by_slug(slug: &str) -> Option<Option<Post>> {
let v = SINGLE_POST_CACHE
.get(&CacheKey::PostBySlug(slug.to_string()))
.await;
if v.is_some() {
SINGLE_POST_STATS.record_hit();
} else {
SINGLE_POST_STATS.record_miss();
}
v
}
/// 按 slug 写入单篇文章缓存None 表示文章不存在。
#[cfg(feature = "server")]
pub async fn set_post_by_slug(slug: &str, post: Option<Post>) {
let _ = SINGLE_POST_CACHE
.insert(CacheKey::PostBySlug(slug.to_string()), post)
.await;
}
/// 按标签读取文章列表缓存。
#[cfg(feature = "server")]
pub async fn get_posts_by_tag(tag: &str) -> Option<(Vec<PostListItem>, i64)> {
let v = TAG_POSTS_CACHE
.get(&CacheKey::PostsByTag(tag.to_string()))
.await;
if v.is_some() {
TAG_POSTS_STATS.record_hit();
} else {
TAG_POSTS_STATS.record_miss();
}
v
}
/// 按标签写入文章列表缓存。
#[cfg(feature = "server")]
pub async fn set_posts_by_tag(tag: &str, posts: Vec<PostListItem>, total: i64) {
let _ = TAG_POSTS_CACHE
.insert(CacheKey::PostsByTag(tag.to_string()), (posts, total))
.await;
}
/// 按标签+分页读取文章列表缓存。
#[cfg(feature = "server")]
pub async fn get_posts_by_tag_paged(key: &CacheKey) -> Option<(Vec<PostListItem>, i64)> {
let v = TAG_POSTS_CACHE.get(key).await;
if v.is_some() {
TAG_POSTS_STATS.record_hit();
} else {
TAG_POSTS_STATS.record_miss();
}
v
}
/// 按标签+分页写入文章列表缓存。
#[cfg(feature = "server")]
pub async fn set_posts_by_tag_paged(key: &CacheKey, posts: Vec<PostListItem>, total: i64) {
let _ = TAG_POSTS_CACHE.insert(key.clone(), (posts, total)).await;
}
/// 读取文章统计缓存。
#[cfg(feature = "server")]
pub async fn get_post_stats() -> Option<PostStats> {
let v = POST_STATS_CACHE.get(&CacheKey::PostStats).await;
if v.is_some() {
POST_STATS_STATS.record_hit();
} else {
POST_STATS_STATS.record_miss();
}
v
}
/// 写入文章统计缓存。
#[cfg(feature = "server")]
pub async fn set_post_stats(stats: PostStats) {
let _ = POST_STATS_CACHE.insert(CacheKey::PostStats, stats).await;
}
// ============================================================================
// 缓存失效
// ============================================================================
/// 清空所有文章分页列表缓存。
#[cfg(feature = "server")]
pub fn invalidate_post_lists() {
POST_LIST_CACHE.invalidate_all();
}
/// 清空所有标签缓存。
#[cfg(feature = "server")]
pub fn invalidate_all_tags() {
TAG_LIST_CACHE.invalidate_all();
}
/// 按 slug 失效单篇文章缓存。
#[cfg(feature = "server")]
pub async fn invalidate_post_by_slug(slug: &str) {
SINGLE_POST_CACHE
.invalidate(&CacheKey::PostBySlug(slug.to_string()))
.await;
}
/// 按标签失效文章列表缓存。
#[cfg(feature = "server")]
pub async fn invalidate_posts_by_tag(tag: &str) {
TAG_POSTS_CACHE
.invalidate(&CacheKey::PostsByTag(tag.to_string()))
.await;
}
/// 清空文章统计缓存。
#[cfg(feature = "server")]
pub fn invalidate_post_stats() {
POST_STATS_CACHE.invalidate_all();
}
/// 按标签批量失效文章列表缓存。
#[cfg(feature = "server")]
pub async fn invalidate_tag_posts_for(tags: &[String]) {
let futures: Vec<_> = tags
.iter()
.map(|tag| invalidate_posts_by_tag(tag))
.collect();
let _ = futures::future::join_all(futures).await;
}
/// 清空所有文章相关缓存(列表、标签、单篇、统计、标签文章)。
///
/// 这是一个“紧急”使用的全量失效开关,会一次性冲刷所有文章缓存;
/// 正常写路径应当使用更细粒度的 `invalidate_post_lists` / `invalidate_all_tags` /
/// `invalidate_post_by_slug` / `invalidate_posts_by_tag` / `invalidate_post_stats` /
/// `invalidate_tag_posts_for` 等函数,避免不必要的缓存击穿。
#[cfg(feature = "server")]
pub fn invalidate_all_post_caches() {
POST_LIST_CACHE.invalidate_all();
TAG_LIST_CACHE.invalidate_all();
SINGLE_POST_CACHE.invalidate_all();
POST_STATS_CACHE.invalidate_all();
TAG_POSTS_CACHE.invalidate_all();
}
/// 失效文章「元数据」类缓存:列表、标签、统计、搜索结果。
///
/// 这四项在每次文章写操作(创建/更新/删除/恢复/清空回收站)后都需要一起失效。
/// 单篇正文与标签下文章列表是定向失效(按 slug / tag不在此处处理由调用方
/// 根据实际涉及的 slug/tags 额外调用 `invalidate_post_by_slug` / `invalidate_tag_posts_for`。
#[cfg(feature = "server")]
pub fn invalidate_post_metadata() {
invalidate_post_lists();
invalidate_all_tags();
invalidate_post_stats();
invalidate_search_results();
}
/// 按文章主键读取评论列表缓存。
#[cfg(feature = "server")]
pub async fn get_comments_by_post(post_id: i32) -> Option<Vec<PublicComment>> {
let v = COMMENT_CACHE
.get(&CacheKey::CommentsByPost { post_id })
.await;
if v.is_some() {
COMMENT_STATS.record_hit();
} else {
COMMENT_STATS.record_miss();
}
v
}
/// 按文章主键写入评论列表缓存。
#[cfg(feature = "server")]
pub async fn set_comments_by_post(post_id: i32, comments: Vec<PublicComment>) {
let _ = COMMENT_CACHE
.insert(CacheKey::CommentsByPost { post_id }, comments)
.await;
}
/// 读取待审核评论总数缓存。
#[cfg(feature = "server")]
pub async fn get_pending_count() -> Option<i64> {
let v = PENDING_COUNT_CACHE
.get(&CacheKey::PendingCommentCount)
.await;
if v.is_some() {
PENDING_COUNT_STATS.record_hit();
} else {
PENDING_COUNT_STATS.record_miss();
}
v
}
/// 写入待审核评论总数缓存。
#[cfg(feature = "server")]
pub async fn set_pending_count(count: i64) {
let _ = PENDING_COUNT_CACHE
.insert(CacheKey::PendingCommentCount, count)
.await;
}
/// 规范化搜索查询键trim、转小写、截断至 200 字符。
#[cfg(feature = "server")]
pub fn normalize_search_key(query: &str) -> String {
query.trim().to_lowercase().chars().take(200).collect()
}
/// 读取会话用户缓存。
#[cfg(feature = "server")]
pub async fn get_session_user(token_hash: &str) -> Option<SessionUser> {
let v = SESSION_CACHE.get(token_hash).await;
if v.is_some() {
SESSION_STATS.record_hit();
} else {
SESSION_STATS.record_miss();
}
v
}
/// 写入会话用户缓存。
#[cfg(feature = "server")]
pub async fn set_session_user(token_hash: &str, user: SessionUser) {
let _ = SESSION_CACHE.insert(token_hash.to_string(), user).await;
}
/// 失效指定会话用户缓存。
#[cfg(feature = "server")]
pub async fn invalidate_session_user(token_hash: &str) {
SESSION_CACHE.invalidate(token_hash).await;
}
/// 读取搜索结果缓存。
#[cfg(feature = "server")]
pub async fn get_search_results(query: &str) -> Option<(Vec<PostListItem>, i64)> {
let v = SEARCH_CACHE.get(&normalize_search_key(query)).await;
if v.is_some() {
SEARCH_STATS.record_hit();
} else {
SEARCH_STATS.record_miss();
}
v
}
/// 写入搜索结果缓存。
#[cfg(feature = "server")]
pub async fn set_search_results(query: &str, posts: Vec<PostListItem>, total: i64) {
let _ = SEARCH_CACHE
.insert(normalize_search_key(query), (posts, total))
.await;
}
/// 清空所有搜索结果缓存。
///
/// 使用同步签名是因为 `moka::Cache::invalidate_all` 为同步操作;
/// 该函数通常由写路径直接调用,无需额外等待。
#[cfg(feature = "server")]
pub fn invalidate_search_results() {
SEARCH_CACHE.invalidate_all();
}
/// 按文章主键失效评论列表缓存。
#[cfg(feature = "server")]
pub async fn invalidate_comments_by_post(post_id: i32) {
COMMENT_CACHE
.invalidate(&CacheKey::CommentsByPost { post_id })
.await;
}
/// 失效待审核评论总数缓存。
#[cfg(feature = "server")]
pub async fn invalidate_pending_count() {
PENDING_COUNT_CACHE
.invalidate(&CacheKey::PendingCommentCount)
.await;
}
#[cfg(all(test, feature = "server"))]
mod tests {
use super::*;
use crate::models::comment::PublicComment;
use crate::models::post::PostStatus;
use crate::models::user::{SessionUser, UserRole};
use serial_test::serial;
#[test]
#[serial]
fn cache_key_equality() {
let k1 = CacheKey::PublishedPosts {
page: 1,
per_page: 10,
};
let k2 = CacheKey::PublishedPosts {
page: 1,
per_page: 10,
};
let k3 = CacheKey::PublishedPosts {
page: 2,
per_page: 10,
};
assert_eq!(k1, k2);
assert_ne!(k1, k3);
}
#[tokio::test]
#[serial]
async fn post_list_cache_roundtrip() {
let key = CacheKey::PublishedPosts {
page: 999,
per_page: 99,
};
let posts = vec![PostListItem {
id: 1,
author_id: 1,
title: "List Item".to_string(),
slug: "list-item".to_string(),
summary: None,
status: PostStatus::Published,
published_at: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
deleted_at: None,
tags: vec!["rust".to_string()],
cover_image: None,
reading_time: 1,
word_count: 10,
}];
set_post_list(&key, posts.clone(), 1).await;
let cached = get_post_list(&key).await;
assert!(cached.is_some());
let (cached_posts, cached_total) = cached.unwrap();
assert_eq!(cached_posts.len(), 1);
assert_eq!(cached_posts[0].title, "List Item");
assert_eq!(cached_total, 1);
}
#[tokio::test]
#[serial]
async fn tag_list_cache_roundtrip() {
let tags = vec![Tag {
id: 1,
name: "rust".to_string(),
post_count: 5,
}];
set_tag_list(tags.clone()).await;
let cached = get_tag_list().await;
assert!(cached.is_some());
assert_eq!(cached.unwrap()[0].name, "rust");
}
#[tokio::test]
#[serial]
async fn single_post_cache_roundtrip() {
let post = Some(Post {
id: 1,
author_id: 1,
title: "Test".to_string(),
slug: "test".to_string(),
summary: None,
content_md: "content".to_string(),
content_html: None,
status: PostStatus::Published,
published_at: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
deleted_at: None,
tags: vec![],
cover_image: None,
reading_time: 1,
word_count: 10,
toc_html: None,
prev_post: None,
next_post: None,
});
set_post_by_slug("test", post.clone()).await;
let cached = get_post_by_slug("test").await;
assert!(cached.is_some());
assert_eq!(cached.unwrap().unwrap().title, "Test");
}
#[tokio::test]
#[serial]
async fn post_stats_cache_roundtrip() {
let stats = PostStats {
total: 10,
drafts: 3,
published: 7,
trash: 2,
};
set_post_stats(stats.clone()).await;
let cached = get_post_stats().await;
assert!(cached.is_some());
assert_eq!(cached.unwrap().total, 10);
}
#[tokio::test]
#[serial]
async fn cache_invalidation_works() {
let post = Some(Post {
id: 42,
author_id: 1,
title: "Invalidation Test".to_string(),
slug: "invalidation-test".to_string(),
summary: None,
content_md: "test".to_string(),
content_html: None,
status: PostStatus::Published,
published_at: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
deleted_at: None,
tags: vec![],
cover_image: None,
reading_time: 1,
word_count: 4,
toc_html: None,
prev_post: None,
next_post: None,
});
set_post_by_slug("invalidation-test", post.clone()).await;
let cached_before = get_post_by_slug("invalidation-test").await;
assert!(cached_before.is_some());
invalidate_post_by_slug("invalidation-test").await;
let cached_after = get_post_by_slug("invalidation-test").await;
assert!(cached_after.is_none());
}
#[tokio::test]
#[serial]
async fn comment_cache_roundtrip() {
let comments = vec![PublicComment {
id: 1,
parent_id: None,
depth: 0,
author_name: "Alice".to_string(),
author_url: None,
avatar_url: "https://example.com/avatar".to_string(),
content_html: Some("<p>Hello</p>".to_string()),
created_at: "刚刚".to_string(),
created_at_iso: "2026-01-01T00:00:00Z".to_string(),
}];
set_comments_by_post(42, comments.clone()).await;
let cached = get_comments_by_post(42).await;
assert!(cached.is_some());
assert_eq!(cached.unwrap().len(), 1);
}
#[tokio::test]
#[serial]
async fn pending_count_cache_roundtrip() {
set_pending_count(7).await;
let cached = get_pending_count().await;
assert!(cached.is_some());
assert_eq!(cached.unwrap(), 7);
}
#[tokio::test]
#[serial]
async fn comment_cache_invalidation() {
set_comments_by_post(99, vec![]).await;
assert!(get_comments_by_post(99).await.is_some());
invalidate_comments_by_post(99).await;
assert!(get_comments_by_post(99).await.is_none());
}
#[tokio::test]
#[serial]
async fn pending_count_invalidation() {
set_pending_count(3).await;
assert!(get_pending_count().await.is_some());
invalidate_pending_count().await;
assert!(get_pending_count().await.is_none());
}
#[tokio::test]
#[serial]
async fn session_cache_roundtrip() {
let user = SessionUser {
id: 42,
username: "cached_user".to_string(),
email: "cached@example.com".to_string(),
role: UserRole::Admin,
created_at: chrono::Utc::now(),
session_generation: 0,
};
let token_hash = "sha256_token_hash";
set_session_user(token_hash, user.clone()).await;
let cached = get_session_user(token_hash).await;
assert!(cached.is_some());
let cached_user = cached.unwrap();
assert_eq!(cached_user.id, user.id);
assert_eq!(cached_user.username, user.username);
assert_eq!(cached_user.email, user.email);
assert_eq!(cached_user.role, user.role);
invalidate_session_user(token_hash).await;
assert!(get_session_user(token_hash).await.is_none());
}
#[test]
fn search_key_normalization() {
assert_eq!(normalize_search_key(" Rust "), "rust");
assert_eq!(normalize_search_key("Rust"), "rust");
assert_eq!(normalize_search_key(" rust "), "rust");
assert_eq!(normalize_search_key(""), "");
let long = "a".repeat(250);
let normalized = normalize_search_key(&long);
assert_eq!(normalized.len(), 200);
assert!(normalized.chars().all(|c| c == 'a'));
// 大小写与空格差异应映射到同一键。
assert_eq!(
normalize_search_key(" Dioxus Fullstack "),
normalize_search_key("dioxus fullstack")
);
}
#[tokio::test]
#[serial]
async fn search_cache_roundtrip() {
let query = "Rust";
let posts = vec![PostListItem {
id: 1,
author_id: 1,
title: "Search Result".to_string(),
slug: "search-result".to_string(),
summary: None,
status: PostStatus::Published,
published_at: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
deleted_at: None,
tags: vec!["rust".to_string()],
cover_image: None,
reading_time: 1,
word_count: 10,
}];
set_search_results(query, posts.clone(), 1).await;
// 大小写与空格差异应命中同一缓存条目。
let cached = get_search_results(" rust ").await;
assert!(cached.is_some());
let (cached_posts, cached_total) = cached.unwrap();
assert_eq!(cached_posts.len(), 1);
assert_eq!(cached_posts[0].title, "Search Result");
assert_eq!(cached_total, 1);
invalidate_search_results();
assert!(get_search_results(query).await.is_none());
}
#[tokio::test]
#[serial]
async fn search_cache_invalidation() {
set_search_results("tokio", vec![], 0).await;
assert!(get_search_results("tokio").await.is_some());
invalidate_search_results();
assert!(get_search_results("tokio").await.is_none());
}
}