From 170c021b3765fcb1636925121665b93b08c67d61 Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 17 Jun 2026 15:51:19 +0800 Subject: [PATCH 01/35] feat(cache): add PostListItem DTO and use it in list/tag/search caches --- src/api/posts/helpers.rs | 42 ++++++++++++++++++++- src/api/posts/list.rs | 26 ++++++------- src/api/posts/search.rs | 8 ++-- src/api/posts/types.rs | 6 +-- src/cache.rs | 36 +++++++++++++----- src/components/post_card.rs | 4 +- src/models/post.rs | 71 ++++++++++++++++++++++++++++++++++++ src/pages/admin/dashboard.rs | 6 +-- src/pages/admin/posts.rs | 8 ++-- src/pages/admin/trash.rs | 8 ++-- src/pages/archives.rs | 8 ++-- 11 files changed, 175 insertions(+), 48 deletions(-) diff --git a/src/api/posts/helpers.rs b/src/api/posts/helpers.rs index 985ee6a..71b1143 100644 --- a/src/api/posts/helpers.rs +++ b/src/api/posts/helpers.rs @@ -6,7 +6,7 @@ #[cfg(feature = "server")] use crate::api::error::AppError; #[cfg(feature = "server")] -use crate::models::post::{Post, PostStatus}; +use crate::models::post::{Post, PostListItem, PostStatus}; #[cfg(feature = "server")] use crate::utils::text::count_words; @@ -61,6 +61,46 @@ pub(super) async fn row_to_post_list( } } +/// 将数据库行转换为轻量列表项 DTO。 +/// +/// 不包含 `content_md`/`content_html`,但会临时读取 `content_md` 以计算字数与阅读时长。 +/// 同步函数,不依赖数据库连接。 +#[cfg(feature = "server")] +pub(super) fn row_to_post_list_item(row: &tokio_postgres::Row) -> PostListItem { + let id: i32 = row.get("id"); + let status_str: String = row.get("status"); + let status = PostStatus::from_str(&status_str).unwrap_or(PostStatus::Draft); + + // 聚合标签并过滤空字符串。 + let tags: Vec = row + .try_get::<_, Vec>("tags") + .unwrap_or_default() + .into_iter() + .filter(|t| !t.is_empty()) + .collect(); + + // 临时读取 content_md 计算字数与阅读时长,不存入 DTO。 + let content_md: String = row.get("content_md"); + let word_count = count_words(&content_md); + + PostListItem { + id, + author_id: row.get("author_id"), + title: row.get("title"), + slug: row.get("slug"), + summary: row.get("summary"), + status, + published_at: row.get("published_at"), + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), + deleted_at: row.try_get("deleted_at").ok(), + tags, + cover_image: row.get("cover_image"), + reading_time: (word_count / 200).max(1), + word_count, + } +} + /// 将数据库行转换为完整文章详情。 /// /// 相比列表项额外包含上一篇/下一篇导航, diff --git a/src/api/posts/list.rs b/src/api/posts/list.rs index 07b5058..8ffef87 100644 --- a/src/api/posts/list.rs +++ b/src/api/posts/list.rs @@ -8,7 +8,7 @@ use dioxus::prelude::*; #[cfg(feature = "server")] -use super::helpers::{get_current_admin_user, row_to_post_list}; +use super::helpers::{get_current_admin_user, row_to_post_list_item}; use super::types::PostListResponse; #[cfg(feature = "server")] use crate::api::error::AppError; @@ -81,8 +81,8 @@ pub async fn list_published_posts( let limit = per_page as i64; let rows = client .query( - "SELECT - p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.content_html, + "SELECT + p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.status, p.published_at, p.created_at, p.updated_at, p.cover_image, COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags FROM posts p @@ -99,7 +99,7 @@ pub async fn list_published_posts( let mut posts = Vec::new(); for row in &rows { - posts.push(row_to_post_list(&client, row).await); + posts.push(row_to_post_list_item(row)); } crate::cache::set_post_list(&cache_key, posts.clone(), total).await; @@ -138,8 +138,8 @@ pub async fn list_posts(page: i32, per_page: i32) -> Result Result Result Result Result Result, + /// 文章列表(轻量 DTO,不含正文)。 + pub posts: Vec, /// 符合查询条件的总数。 pub total: i64, } diff --git a/src/cache.rs b/src/cache.rs index dd0bea7..86b3430 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -14,7 +14,7 @@ use std::time::Duration; #[cfg(feature = "server")] use crate::models::comment::PublicComment; #[cfg(feature = "server")] -use crate::models::post::{Post, PostStats, Tag}; +use crate::models::post::{Post, PostListItem, PostStats, Tag}; // ============================================================================ // 缓存 TTL 配置 @@ -80,7 +80,7 @@ pub enum CacheKey { /// 文章列表缓存类型,值为(文章列表,总数)。 #[cfg(feature = "server")] -pub type PostListCache = Cache, i64)>; +pub type PostListCache = Cache, i64)>; /// 标签列表缓存类型。 #[cfg(feature = "server")] @@ -167,13 +167,13 @@ static PENDING_COUNT_CACHE: LazyLock> = LazyLock::new(|| { /// 读取文章分页列表缓存。 #[cfg(feature = "server")] -pub async fn get_post_list(key: &CacheKey) -> Option<(Vec, i64)> { +pub async fn get_post_list(key: &CacheKey) -> Option<(Vec, i64)> { POST_LIST_CACHE.get(key).await } /// 写入文章分页列表缓存。 #[cfg(feature = "server")] -pub async fn set_post_list(key: &CacheKey, posts: Vec, total: i64) { +pub async fn set_post_list(key: &CacheKey, posts: Vec, total: i64) { let _ = POST_LIST_CACHE.insert(key.clone(), (posts, total)).await; } @@ -224,7 +224,7 @@ pub async fn set_post_by_slug(slug: &str, post: Option) { /// 按标签读取文章列表缓存。 #[cfg(feature = "server")] -pub async fn get_posts_by_tag(tag: &str) -> Option<(Vec, i64)> { +pub async fn get_posts_by_tag(tag: &str) -> Option<(Vec, i64)> { TAG_POSTS_CACHE .get(&CacheKey::PostsByTag(tag.to_string())) .await @@ -232,7 +232,7 @@ pub async fn get_posts_by_tag(tag: &str) -> Option<(Vec, i64)> { /// 按标签写入文章列表缓存。 #[cfg(feature = "server")] -pub async fn set_posts_by_tag(tag: &str, posts: Vec, total: i64) { +pub async fn set_posts_by_tag(tag: &str, posts: Vec, total: i64) { let _ = TAG_POSTS_CACHE .insert(CacheKey::PostsByTag(tag.to_string()), (posts, total)) .await; @@ -379,15 +379,31 @@ mod tests { page: 999, per_page: 99, }; - let posts = vec![]; + 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(), 0).await; + 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(), 0); - assert_eq!(cached_total, 0); + assert_eq!(cached_posts.len(), 1); + assert_eq!(cached_posts[0].title, "List Item"); + assert_eq!(cached_total, 1); } #[tokio::test] diff --git a/src/components/post_card.rs b/src/components/post_card.rs index 452d204..91de51b 100644 --- a/src/components/post_card.rs +++ b/src/components/post_card.rs @@ -6,7 +6,7 @@ use dioxus::prelude::*; use dioxus::router::components::Link; use crate::components::image_viewer::ImageViewer; -use crate::models::post::Post; +use crate::models::post::PostListItem; use crate::router::Route; /// 文章卡片组件。 @@ -23,7 +23,7 @@ use crate::router::Route; /// 关键事件: /// - 点击标签时阻止事件冒泡,避免触发整卡跳转 #[component] -pub fn PostCard(post: Post) -> Element { +pub fn PostCard(post: PostListItem) -> Element { let post_slug = post.slug.clone(); let date_str = post.formatted_date(); let has_cover = post.cover_image.is_some(); diff --git a/src/models/post.rs b/src/models/post.rs index 265da79..c3f4721 100644 --- a/src/models/post.rs +++ b/src/models/post.rs @@ -113,6 +113,77 @@ impl Post { } } +/// 文章列表项 DTO。 +/// +/// 仅包含列表/标签/搜索/归档等场景需要的字段,不含 `content_md` 与 `content_html`, +/// 以降低缓存内存占用与序列化体积。`deleted_at` 保留,供回收站列表使用。 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PostListItem { + /// 文章主键。 + pub id: i32, + /// 作者用户主键。 + pub author_id: i32, + /// 文章标题。 + pub title: String, + /// URL slug,用于生成文章链接。 + pub slug: String, + /// 摘要,可选。 + pub summary: Option, + /// 文章发布状态。 + pub status: PostStatus, + /// 正式发布时间,None 表示尚未发布。 + pub published_at: Option>, + /// 创建时间。 + pub created_at: DateTime, + /// 最后更新时间。 + pub updated_at: DateTime, + /// 软删除时间,None 表示未删除。仅回收站查询填充。 + pub deleted_at: Option>, + /// 关联标签列表。 + pub tags: Vec, + /// 封面图片 URL。 + pub cover_image: Option, + /// 预计阅读时间(分钟)。 + pub reading_time: u32, + /// 字数统计。 + pub word_count: u32, +} + +impl PostListItem { + /// 返回用于展示的文章日期:优先使用发布时间,否则回退到创建时间。 + pub fn formatted_date(&self) -> String { + self.published_at + .map(|d| d.format("%Y-%m-%d").to_string()) + .unwrap_or_else(|| self.created_at.format("%Y-%m-%d").to_string()) + } + + /// 返回中文状态标签。 + pub fn status_label(&self) -> &'static str { + match self.status { + PostStatus::Published => "已发布", + PostStatus::Draft => "草稿", + } + } + + /// 返回状态文本在 light/dark 模式下的 Tailwind 颜色类。 + pub fn status_class(&self) -> &'static str { + match self.status { + PostStatus::Published => "text-green-600 dark:text-green-400", + PostStatus::Draft => "text-gray-400 dark:text-[#9b9c9d]", + } + } + + /// 返回状态徽章在 light/dark 模式下的 Tailwind 背景与颜色类。 + pub fn status_badge_class(&self) -> &'static str { + match self.status { + PostStatus::Published => { + "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300" + } + PostStatus::Draft => "bg-gray-100 dark:bg-[#333] text-gray-600 dark:text-[#9b9c9d]", + } + } +} + /// 前后文章导航结构体。 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct PostNav { diff --git a/src/pages/admin/dashboard.rs b/src/pages/admin/dashboard.rs index 2fe339a..87f848e 100644 --- a/src/pages/admin/dashboard.rs +++ b/src/pages/admin/dashboard.rs @@ -13,7 +13,7 @@ use crate::api::posts::{get_post_stats, list_posts}; #[cfg(target_arch = "wasm32")] use crate::api::posts::{PostListResponse, PostStatsResponse}; use crate::components::ui::ADMIN_CARD_CLASS; -use crate::models::post::{Post, PostStats}; +use crate::models::post::{PostListItem, PostStats}; use crate::router::Route; /// 后台仪表盘页面组件。 @@ -25,7 +25,7 @@ use crate::router::Route; pub fn Admin() -> Element { // 仪表盘状态:统计数据、最近文章、待审核评论数与首次加载标志。 let mut stats = use_signal(|| None::); - let mut recent_posts = use_signal(|| None::>); + let mut recent_posts = use_signal(|| None::>); let mut pending_count = use_signal(|| None::); let mut loaded = use_signal(|| false); @@ -165,7 +165,7 @@ fn StatCard(value: String, label: String) -> Element { /// 最近文章列表项,显示标题、状态标签与发布日期。 #[component] -fn RecentPostItem(post: Post) -> Element { +fn RecentPostItem(post: PostListItem) -> Element { let date_str = post.formatted_date(); let status_label = post.status_label(); let status_class = post.status_class(); diff --git a/src/pages/admin/posts.rs b/src/pages/admin/posts.rs index f87deee..8ae0061 100644 --- a/src/pages/admin/posts.rs +++ b/src/pages/admin/posts.rs @@ -15,7 +15,7 @@ use crate::api::posts::{delete_post, rebuild_content_html, CreatePostResponse, R use crate::components::skeletons::delayed_skeleton::DelayedSkeleton; use crate::components::skeletons::posts_skeleton::PostsSkeleton; use crate::components::ui::{EmptyState, Pagination, StatusBadge, ADMIN_ROW_HOVER, ADMIN_TABLE_CLASS, BTN_TEXT_RED}; -use crate::models::post::Post; +use crate::models::post::PostListItem; use crate::router::Route; /// 每页展示的文章数量。 @@ -34,7 +34,7 @@ pub fn Posts() -> Element { pub fn PostsPage(page: i32) -> Element { let current_page = page.max(1); // 文章列表、总数、加载状态、删除中 ID、重建缓存状态与结果。 - let mut posts = use_signal(Vec::new); + let mut posts = use_signal(Vec::::new); let mut total = use_signal(|| 0_i64); let mut loading = use_signal(|| true); let mut deleting = use_signal(|| None::); @@ -96,7 +96,7 @@ pub fn PostsPage(page: i32) -> Element { } }); - let get_posts = move || -> Vec { posts() }; + let get_posts = move || -> Vec { posts() }; rsx! { div { class: "space-y-6", @@ -215,7 +215,7 @@ pub fn PostsPage(page: i32) -> Element { /// 文章表格行组件,展示单篇文章的标题、状态、日期与操作按钮。 #[component] -fn PostRow(post: Post, deleting: bool, on_delete: EventHandler) -> Element { +fn PostRow(post: PostListItem, deleting: bool, on_delete: EventHandler) -> Element { let date_str = post.formatted_date(); rsx! { diff --git a/src/pages/admin/trash.rs b/src/pages/admin/trash.rs index f922d39..3d4c075 100644 --- a/src/pages/admin/trash.rs +++ b/src/pages/admin/trash.rs @@ -23,7 +23,7 @@ use crate::components::ui::{ EmptyState, Pagination, StatusBadge, ADMIN_ROW_HOVER, ADMIN_TABLE_CLASS, BTN_SOLID_GREEN, BTN_SOLID_RED, BTN_TEXT_ACCENT, BTN_TEXT_RED, CHECKBOX_CLASS, }; -use crate::models::post::Post; +use crate::models::post::PostListItem; use crate::models::settings::TrashSettings; use crate::router::Route; @@ -44,7 +44,7 @@ pub fn Trash() -> Element { pub fn TrashPage(page: i32) -> Element { let current_page = page.max(1); let mut selected_ids: Signal> = use_signal(HashSet::new); - let mut posts: Signal> = use_signal(Vec::new); + let mut posts: Signal> = use_signal(Vec::new); let mut total: Signal = use_signal(|| 0); #[allow(unused_mut)] let mut loading: Signal = use_signal(|| false); @@ -500,7 +500,7 @@ pub fn TrashPage(page: i32) -> Element { /// 计算剩余天数(保留期 - 已删除天数)。 /// /// 返回 (剩余天数, 是否已过期)。基于客户端时钟计算,轻微漂移可接受。 -fn remaining_days(post: &Post, retention_days: i32) -> (i64, bool) { +fn remaining_days(post: &PostListItem, retention_days: i32) -> (i64, bool) { #[cfg(target_arch = "wasm32")] { if let Some(deleted_at) = post.deleted_at { @@ -523,7 +523,7 @@ fn remaining_days(post: &Post, retention_days: i32) -> (i64, bool) { /// 回收站表格行组件。 #[component] fn TrashRow( - post: Post, + post: PostListItem, retention_days: i32, selected: bool, on_select: EventHandler, diff --git a/src/pages/archives.rs b/src/pages/archives.rs index 126176f..89be690 100644 --- a/src/pages/archives.rs +++ b/src/pages/archives.rs @@ -13,7 +13,7 @@ use dioxus::router::components::Link; use crate::api::posts::{list_published_posts, PostListResponse}; use crate::components::skeletons::archive_skeleton::ArchiveSkeleton; use crate::components::skeletons::delayed_skeleton::DelayedSkeleton; -use crate::models::post::Post; +use crate::models::post::PostListItem; use crate::router::Route; /// 按年份分组的文章归档结构。 @@ -28,13 +28,13 @@ struct YearGroup { struct MonthGroup { month: String, month_en: String, - posts: Vec, + posts: Vec, } /// 将文章列表按 `formatted_date()` 返回的 `YYYY-MM-DD` 格式进行年、月分组。 /// /// 返回的结果按原始文章顺序组织,调用前已按发布时间降序排列。 -fn group_posts(posts: &[Post]) -> Vec { +fn group_posts(posts: &[PostListItem]) -> Vec { let mut years: Vec = vec![]; for post in posts { @@ -204,7 +204,7 @@ fn MonthSection(month_group: MonthGroup, year: String) -> Element { /// 单条归档文章组件,展示标题与发布日期,并通过覆盖层链接到文章详情。 #[component] -fn ArchiveEntry(post: Post) -> Element { +fn ArchiveEntry(post: PostListItem) -> Element { let date_str = post.formatted_date(); rsx! { From 51e20980db4109331523707cdadba213917359b0 Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 17 Jun 2026 15:51:29 +0800 Subject: [PATCH 02/35] perf(stats): combine three COUNT queries into one conditional aggregation --- src/api/posts/stats.rs | 36 +++++++++++------------------------- 1 file changed, 11 insertions(+), 25 deletions(-) diff --git a/src/api/posts/stats.rs b/src/api/posts/stats.rs index a0dd4fa..239a54c 100644 --- a/src/api/posts/stats.rs +++ b/src/api/posts/stats.rs @@ -31,37 +31,23 @@ pub async fn get_post_stats() -> Result { let client = get_conn().await.map_err(AppError::db_conn)?; - // 统计未删除文章总数。 - let total: i64 = client - .query_one("SELECT COUNT(*) FROM posts WHERE deleted_at IS NULL", &[]) - .await - .map_err(AppError::query)? - .get(0); - - // 统计草稿数量。 - let drafts: i64 = client + // 通过单次条件聚合查询同时统计总数、草稿数与已发布数。 + let row = client .query_one( - "SELECT COUNT(*) FROM posts WHERE deleted_at IS NULL AND status = 'draft'", + "SELECT + COUNT(*) FILTER (WHERE deleted_at IS NULL) AS total, + COUNT(*) FILTER (WHERE deleted_at IS NULL AND status = 'draft') AS drafts, + COUNT(*) FILTER (WHERE deleted_at IS NULL AND status = 'published') AS published + FROM posts", &[], ) .await - .map_err(AppError::query)? - .get(0); - - // 统计已发布数量。 - let published: i64 = client - .query_one( - "SELECT COUNT(*) FROM posts WHERE deleted_at IS NULL AND status = 'published'", - &[], - ) - .await - .map_err(AppError::query)? - .get(0); + .map_err(AppError::query)?; let stats = PostStats { - total, - drafts, - published, + total: row.get("total"), + drafts: row.get("drafts"), + published: row.get("published"), }; crate::cache::set_post_stats(stats.clone()).await; Ok(PostStatsResponse { stats }) From a6f08d5d3f706ab72492dccb55d27cf2d53d3c52 Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 17 Jun 2026 16:06:21 +0800 Subject: [PATCH 03/35] feat(db): add word_count and reading_time columns to posts --- migrations/010_post_word_counts.sql | 8 ++++++++ src/api/posts/create.rs | 10 ++++++++-- src/api/posts/update.rs | 10 ++++++++-- 3 files changed, 24 insertions(+), 4 deletions(-) create mode 100644 migrations/010_post_word_counts.sql diff --git a/migrations/010_post_word_counts.sql b/migrations/010_post_word_counts.sql new file mode 100644 index 0000000..776f76b --- /dev/null +++ b/migrations/010_post_word_counts.sql @@ -0,0 +1,8 @@ +-- 为 posts 表添加字数与阅读时长列。 +-- +-- 旧数据默认 0,在管理员执行重建或编辑文章后自动回填。 +-- 0 也被 row_to_post_full 用作“未计算”标记,触发基于 content_md 的回退计算。 + +ALTER TABLE posts + ADD COLUMN IF NOT EXISTS word_count INTEGER NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS reading_time INTEGER NOT NULL DEFAULT 0; diff --git a/src/api/posts/create.rs b/src/api/posts/create.rs index d485f27..7e10a6c 100644 --- a/src/api/posts/create.rs +++ b/src/api/posts/create.rs @@ -91,6 +91,10 @@ pub async fn create_post( let post_status = PostStatus::from_str(&status).unwrap_or(PostStatus::Draft); let cover_image = cover_image.filter(|s| !s.trim().is_empty()); + // 计算字数与阅读时长,随文章一并持久化,供列表查询直接使用。 + let word_count = crate::utils::text::count_words(&content_md); + let reading_time = (word_count / 200).max(1); + // 发布状态的文章设置当前发布时间;草稿则为 None。 let published_at = if post_status == PostStatus::Published { Some(chrono::Utc::now()) @@ -106,8 +110,8 @@ pub async fn create_post( // 插入文章记录。 let row = tx .query_one( - "INSERT INTO posts (author_id, title, slug, summary, content_md, content_html, toc_html, status, published_at, cover_image) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + "INSERT INTO posts (author_id, title, slug, summary, content_md, content_html, toc_html, status, published_at, cover_image, word_count, reading_time) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id", &[ &user.id, @@ -120,6 +124,8 @@ pub async fn create_post( &post_status.as_str(), &published_at, &cover_image, + &(word_count as i32), + &(reading_time as i32), ], ) .await diff --git a/src/api/posts/update.rs b/src/api/posts/update.rs index ed2ebe4..f28069d 100644 --- a/src/api/posts/update.rs +++ b/src/api/posts/update.rs @@ -55,6 +55,10 @@ pub async fn update_post( let post_status = PostStatus::from_str(&status).unwrap_or(PostStatus::Draft); let cover_image = cover_image.filter(|s| !s.trim().is_empty()); + // 重新计算字数与阅读时长,保持与正文同步。 + let word_count = crate::utils::text::count_words(&content_md); + let reading_time = (word_count / 200).max(1); + let tx = client.transaction().await.map_err(AppError::tx)?; // 查询旧 slug,用于后续缓存失效。 @@ -150,8 +154,8 @@ pub async fn update_post( // 更新文章主表。 let updated = tx .execute( - "UPDATE posts SET title = $1, slug = $2, summary = $3, content_md = $4, content_html = $5, toc_html = $6, status = $7, published_at = $8, cover_image = $9, updated_at = NOW() - WHERE id = $10", + "UPDATE posts SET title = $1, slug = $2, summary = $3, content_md = $4, content_html = $5, toc_html = $6, status = $7, published_at = $8, cover_image = $9, word_count = $10, reading_time = $11, updated_at = NOW() + WHERE id = $12", &[ &title.trim(), &final_slug, @@ -162,6 +166,8 @@ pub async fn update_post( &post_status.as_str(), &published_at, &cover_image, + &(word_count as i32), + &(reading_time as i32), &post_id, ], ) From 1eedab8f21ee6afaef31744e149c99cfdfde5671 Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 17 Jun 2026 16:06:27 +0800 Subject: [PATCH 04/35] perf(posts): remove content_md from list/search SQL, read stored word counts --- src/api/posts/helpers.rs | 28 +++++++++++++++++----------- src/api/posts/list.rs | 20 ++++++++++++-------- src/api/posts/read.rs | 3 ++- src/api/posts/rebuild.rs | 13 +++++++++++-- src/api/posts/search.rs | 5 +++-- 5 files changed, 45 insertions(+), 24 deletions(-) diff --git a/src/api/posts/helpers.rs b/src/api/posts/helpers.rs index 71b1143..ac451fa 100644 --- a/src/api/posts/helpers.rs +++ b/src/api/posts/helpers.rs @@ -63,7 +63,7 @@ pub(super) async fn row_to_post_list( /// 将数据库行转换为轻量列表项 DTO。 /// -/// 不包含 `content_md`/`content_html`,但会临时读取 `content_md` 以计算字数与阅读时长。 +/// 不包含 `content_md`/`content_html`;字数与阅读时长直接读取已持久化的列。 /// 同步函数,不依赖数据库连接。 #[cfg(feature = "server")] pub(super) fn row_to_post_list_item(row: &tokio_postgres::Row) -> PostListItem { @@ -79,9 +79,8 @@ pub(super) fn row_to_post_list_item(row: &tokio_postgres::Row) -> PostListItem { .filter(|t| !t.is_empty()) .collect(); - // 临时读取 content_md 计算字数与阅读时长,不存入 DTO。 - let content_md: String = row.get("content_md"); - let word_count = count_words(&content_md); + let word_count: i32 = row.get("word_count"); + let reading_time: i32 = row.get("reading_time"); PostListItem { id, @@ -96,8 +95,8 @@ pub(super) fn row_to_post_list_item(row: &tokio_postgres::Row) -> PostListItem { deleted_at: row.try_get("deleted_at").ok(), tags, cover_image: row.get("cover_image"), - reading_time: (word_count / 200).max(1), - word_count, + reading_time: reading_time.max(1) as u32, + word_count: word_count.max(0) as u32, } } @@ -150,6 +149,17 @@ pub(super) async fn row_to_post_full( None }; + // 读取正文与已持久化的字数/阅读时长;若列尚未回填(旧数据为 0),则现场计算。 + let content_md: String = row.get("content_md"); + let stored_word_count: i32 = row.get("word_count"); + let stored_reading_time: i32 = row.get("reading_time"); + let (word_count, reading_time) = if stored_word_count > 0 && stored_reading_time > 0 { + (stored_word_count as u32, stored_reading_time as u32) + } else { + let wc = count_words(&content_md); + (wc, (wc / 200).max(1)) + }; + let content_html: Option = row.get("content_html"); let toc_html_row: Option = row.get("toc_html"); @@ -157,7 +167,6 @@ pub(super) async fn row_to_post_full( let (content_html, toc_html) = if let Some(html) = content_html { (html, toc_html_row) } else { - let content_md: String = row.get("content_md"); let rendered = crate::api::markdown::render_markdown_enhanced(&content_md); ( rendered.html, @@ -169,9 +178,6 @@ pub(super) async fn row_to_post_full( ) }; - let content_md: String = row.get("content_md"); - let word_count = count_words(&content_md); - Post { id, author_id: row.get("author_id"), @@ -187,7 +193,7 @@ pub(super) async fn row_to_post_full( deleted_at: row.try_get("deleted_at").ok(), tags, cover_image: row.get("cover_image"), - reading_time: (word_count / 200).max(1), + reading_time, word_count, toc_html, prev_post, diff --git a/src/api/posts/list.rs b/src/api/posts/list.rs index 8ffef87..657f8ff 100644 --- a/src/api/posts/list.rs +++ b/src/api/posts/list.rs @@ -82,8 +82,9 @@ pub async fn list_published_posts( let rows = client .query( "SELECT - p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, - p.status, p.published_at, p.created_at, p.updated_at, p.cover_image, + p.id, p.author_id, p.title, p.slug, p.summary, p.status, + p.published_at, p.created_at, p.updated_at, p.cover_image, + p.word_count, p.reading_time, COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags FROM posts p LEFT JOIN post_tags pt ON p.id = pt.post_id @@ -139,8 +140,9 @@ pub async fn list_posts(page: i32, per_page: i32) -> Result Result Result Result Result Date: Wed, 17 Jun 2026 16:24:38 +0800 Subject: [PATCH 05/35] refactor(posts): remove orphan row_to_post_list and use row_to_post_full for get_post_by_id --- src/api/posts/helpers.rs | 47 ---------------------------------------- src/api/posts/read.rs | 9 ++++---- 2 files changed, 5 insertions(+), 51 deletions(-) diff --git a/src/api/posts/helpers.rs b/src/api/posts/helpers.rs index ac451fa..bdb0cb8 100644 --- a/src/api/posts/helpers.rs +++ b/src/api/posts/helpers.rs @@ -14,53 +14,6 @@ use crate::utils::text::count_words; #[cfg(feature = "server")] pub(super) use crate::api::auth::get_current_admin_user; -/// 将数据库行转换为文章列表项。 -/// -/// 用于列表接口,包含标签聚合、字数与阅读时长估算, -/// 不包含上下篇导航与目录。 -#[cfg(feature = "server")] -pub(super) async fn row_to_post_list( - _client: &tokio_postgres::Client, - row: &tokio_postgres::Row, -) -> Post { - let id: i32 = row.get("id"); - let role_str: String = row.get("status"); - let status = PostStatus::from_str(&role_str).unwrap_or(PostStatus::Draft); - - // 聚合标签并过滤空字符串。 - let tags: Vec = row - .try_get::<_, Vec>("tags") - .unwrap_or_default() - .into_iter() - .filter(|t| !t.is_empty()) - .collect(); - - let content_md: String = row.get("content_md"); - let word_count = count_words(&content_md); - - Post { - id, - author_id: row.get("author_id"), - title: row.get("title"), - slug: row.get("slug"), - summary: row.get("summary"), - content_md, - content_html: row.get("content_html"), - status, - published_at: row.get("published_at"), - created_at: row.get("created_at"), - updated_at: row.get("updated_at"), - deleted_at: row.try_get("deleted_at").ok(), - tags, - cover_image: row.get("cover_image"), - reading_time: (word_count / 200).max(1), - word_count, - toc_html: None, - prev_post: None, - next_post: None, - } -} - /// 将数据库行转换为轻量列表项 DTO。 /// /// 不包含 `content_md`/`content_html`;字数与阅读时长直接读取已持久化的列。 diff --git a/src/api/posts/read.rs b/src/api/posts/read.rs index b680db9..6281851 100644 --- a/src/api/posts/read.rs +++ b/src/api/posts/read.rs @@ -8,7 +8,7 @@ use dioxus::prelude::*; #[cfg(feature = "server")] -use super::helpers::{get_current_admin_user, row_to_post_full, row_to_post_list}; +use super::helpers::{get_current_admin_user, row_to_post_full}; use super::types::SinglePostResponse; #[cfg(feature = "server")] use crate::api::error::AppError; @@ -28,9 +28,10 @@ pub async fn get_post_by_id(post_id: i32) -> Result Result Some(row_to_post_list(&client, &row).await), + Some(row) => Some(row_to_post_full(&client, &row).await), None => None, }; From 468a8199510414aae77707854494a5bc87c07a5b Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 17 Jun 2026 16:24:44 +0800 Subject: [PATCH 06/35] fix(db): backfill word_count and reading_time for existing posts --- migrations/010_post_word_counts.sql | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/migrations/010_post_word_counts.sql b/migrations/010_post_word_counts.sql index 776f76b..776be7c 100644 --- a/migrations/010_post_word_counts.sql +++ b/migrations/010_post_word_counts.sql @@ -1,8 +1,29 @@ -- 为 posts 表添加字数与阅读时长列。 -- --- 旧数据默认 0,在管理员执行重建或编辑文章后自动回填。 --- 0 也被 row_to_post_full 用作“未计算”标记,触发基于 content_md 的回退计算。 +-- 设计说明: +-- - word_count / reading_time 使用 NOT NULL DEFAULT 0。 +-- - 0 作为“尚未计算/回填”的哨兵值:row_to_post_full 在读到 0 时会退回到 +-- 基于 content_md 的实时计算,保证旧数据在列表页仍显示合理的字数与阅读时长。 +-- - 本迁移同时用 PostgreSQL 可用的近似方式回填现有行,避免列表页出现大量 0。 +-- 回填仅按空白拆分英文词,对中文统计不精确;精确值会在文章被编辑或 +-- 管理员执行“重建内容”时由 Rust count_words 重新写入。 ALTER TABLE posts ADD COLUMN IF NOT EXISTS word_count INTEGER NOT NULL DEFAULT 0, ADD COLUMN IF NOT EXISTS reading_time INTEGER NOT NULL DEFAULT 0; + +-- 回填已存在行:将 content_md 按空白拆分为词数组并计数。 +-- array_length 在空/纯空白字符串时可能返回 NULL,用 COALESCE 处理为 1。 +WITH computed AS ( + SELECT + id, + GREATEST(1, COALESCE(array_length(regexp_split_to_array(content_md, '\s+'), 1), 1)) AS wc + FROM posts + WHERE word_count = 0 AND reading_time = 0 +) +UPDATE posts +SET + word_count = computed.wc, + reading_time = GREATEST(1, computed.wc / 200) +FROM computed +WHERE posts.id = computed.id; From e683efe0eacfac5e062436e9416e05bbfaf2a6bf Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 17 Jun 2026 16:24:50 +0800 Subject: [PATCH 07/35] refactor(models): remove dead Post status methods, add PostListItem tests --- src/models/post.rs | 70 +++++++++++++++++++++++++--------------------- 1 file changed, 38 insertions(+), 32 deletions(-) diff --git a/src/models/post.rs b/src/models/post.rs index c3f4721..4512d23 100644 --- a/src/models/post.rs +++ b/src/models/post.rs @@ -85,32 +85,6 @@ impl Post { .map(|d| d.format("%Y-%m-%d").to_string()) .unwrap_or_else(|| self.created_at.format("%Y-%m-%d").to_string()) } - - /// 返回中文状态标签。 - pub fn status_label(&self) -> &'static str { - match self.status { - PostStatus::Published => "已发布", - PostStatus::Draft => "草稿", - } - } - - /// 返回状态文本在 light/dark 模式下的 Tailwind 颜色类。 - pub fn status_class(&self) -> &'static str { - match self.status { - PostStatus::Published => "text-green-600 dark:text-green-400", - PostStatus::Draft => "text-gray-400 dark:text-[#9b9c9d]", - } - } - - /// 返回状态徽章在 light/dark 模式下的 Tailwind 背景与颜色类。 - pub fn status_badge_class(&self) -> &'static str { - match self.status { - PostStatus::Published => { - "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300" - } - PostStatus::Draft => "bg-gray-100 dark:bg-[#333] text-gray-600 dark:text-[#9b9c9d]", - } - } } /// 文章列表项 DTO。 @@ -244,6 +218,25 @@ mod tests { } } + fn sample_post_list_item() -> PostListItem { + PostListItem { + id: 1, + author_id: 1, + title: "Test".to_string(), + slug: "test".to_string(), + summary: None, + status: PostStatus::Draft, + published_at: None, + created_at: Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap(), + updated_at: Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap(), + deleted_at: None, + tags: vec![], + cover_image: None, + reading_time: 1, + word_count: 10, + } + } + #[test] #[cfg(feature = "server")] fn post_status_from_str() { @@ -284,8 +277,21 @@ mod tests { } #[test] - fn status_label() { - let mut post = sample_post(); + fn post_list_item_formatted_date_uses_published_at_when_available() { + let mut post = sample_post_list_item(); + post.published_at = Some(Utc.with_ymd_and_hms(2024, 6, 1, 12, 0, 0).unwrap()); + assert_eq!(post.formatted_date(), "2024-06-01"); + } + + #[test] + fn post_list_item_formatted_date_falls_back_to_created_at() { + let post = sample_post_list_item(); + assert_eq!(post.formatted_date(), "2024-01-15"); + } + + #[test] + fn post_list_item_status_label() { + let mut post = sample_post_list_item(); post.status = PostStatus::Published; assert_eq!(post.status_label(), "已发布"); post.status = PostStatus::Draft; @@ -293,8 +299,8 @@ mod tests { } #[test] - fn status_class_returns_non_empty() { - let mut post = sample_post(); + fn post_list_item_status_class_returns_non_empty() { + let mut post = sample_post_list_item(); post.status = PostStatus::Published; assert_eq!(post.status_class(), "text-green-600 dark:text-green-400"); post.status = PostStatus::Draft; @@ -302,8 +308,8 @@ mod tests { } #[test] - fn status_badge_class_returns_non_empty() { - let mut post = sample_post(); + fn post_list_item_status_badge_class_returns_non_empty() { + let mut post = sample_post_list_item(); post.status = PostStatus::Published; assert_eq!( post.status_badge_class(), From 15e7e2578d3029d808bb10d01ed227c86c1d20f2 Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 17 Jun 2026 16:25:22 +0800 Subject: [PATCH 08/35] refactor(utils): extract reading_time helper --- src/api/posts/create.rs | 2 +- src/api/posts/helpers.rs | 4 ++-- src/api/posts/rebuild.rs | 2 +- src/api/posts/stats.rs | 3 ++- src/api/posts/update.rs | 2 +- src/utils/text.rs | 22 ++++++++++++++++++++++ 6 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/api/posts/create.rs b/src/api/posts/create.rs index 7e10a6c..58e7ac5 100644 --- a/src/api/posts/create.rs +++ b/src/api/posts/create.rs @@ -93,7 +93,7 @@ pub async fn create_post( // 计算字数与阅读时长,随文章一并持久化,供列表查询直接使用。 let word_count = crate::utils::text::count_words(&content_md); - let reading_time = (word_count / 200).max(1); + let reading_time = crate::utils::text::reading_time(word_count); // 发布状态的文章设置当前发布时间;草稿则为 None。 let published_at = if post_status == PostStatus::Published { diff --git a/src/api/posts/helpers.rs b/src/api/posts/helpers.rs index bdb0cb8..117ea9d 100644 --- a/src/api/posts/helpers.rs +++ b/src/api/posts/helpers.rs @@ -8,7 +8,7 @@ use crate::api::error::AppError; #[cfg(feature = "server")] use crate::models::post::{Post, PostListItem, PostStatus}; #[cfg(feature = "server")] -use crate::utils::text::count_words; +use crate::utils::text::{count_words, reading_time}; /// 复用认证模块的当前 admin 用户获取逻辑。 #[cfg(feature = "server")] @@ -110,7 +110,7 @@ pub(super) async fn row_to_post_full( (stored_word_count as u32, stored_reading_time as u32) } else { let wc = count_words(&content_md); - (wc, (wc / 200).max(1)) + (wc, reading_time(wc)) }; let content_html: Option = row.get("content_html"); diff --git a/src/api/posts/rebuild.rs b/src/api/posts/rebuild.rs index 83076f4..2142bde 100644 --- a/src/api/posts/rebuild.rs +++ b/src/api/posts/rebuild.rs @@ -76,7 +76,7 @@ pub async fn rebuild_content_html(rebuild_all: bool) -> Result Result { let _user = get_current_admin_user().await?; diff --git a/src/api/posts/update.rs b/src/api/posts/update.rs index f28069d..3962473 100644 --- a/src/api/posts/update.rs +++ b/src/api/posts/update.rs @@ -57,7 +57,7 @@ pub async fn update_post( // 重新计算字数与阅读时长,保持与正文同步。 let word_count = crate::utils::text::count_words(&content_md); - let reading_time = (word_count / 200).max(1); + let reading_time = crate::utils::text::reading_time(word_count); let tx = client.transaction().await.map_err(AppError::tx)?; diff --git a/src/utils/text.rs b/src/utils/text.rs index bed63fc..8edbc8f 100644 --- a/src/utils/text.rs +++ b/src/utils/text.rs @@ -72,6 +72,13 @@ pub fn count_words(md: &str) -> u32 { count.max(1) } +/// 由字数估算阅读时长(分钟)。 +/// +/// 按每分钟 200 字计算,至少返回 1 分钟。 +pub fn reading_time(word_count: u32) -> u32 { + (word_count / 200).max(1) +} + /// 自动生成文本摘要,取去除 Markdown 后的前 200 个字符。 pub fn auto_summary(md: &str) -> String { let plain = strip_markdown(md); @@ -158,6 +165,21 @@ mod tests { assert_eq!(count_words(""), 1); } + #[test] + fn reading_time_defaults_to_one() { + assert_eq!(reading_time(0), 1); + assert_eq!(reading_time(1), 1); + assert_eq!(reading_time(199), 1); + } + + #[test] + fn reading_time_scales_by_two_hundred() { + assert_eq!(reading_time(200), 1); + assert_eq!(reading_time(201), 1); + assert_eq!(reading_time(400), 2); + assert_eq!(reading_time(1000), 5); + } + #[test] fn auto_summary_truncates_at_200_chars() { let long_md: String = "a ".repeat(200); From c528936abb60141530621eea4b1fb0d8dc2ff3d8 Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 17 Jun 2026 16:37:20 +0800 Subject: [PATCH 09/35] feat(cache): add invalidate_tag_posts_for helper --- src/cache.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/cache.rs b/src/cache.rs index 86b3430..ddf617e 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -288,7 +288,20 @@ pub fn invalidate_post_stats() { POST_STATS_CACHE.invalidate_all(); } +/// 按标签批量失效文章列表缓存。 +#[cfg(feature = "server")] +pub async fn invalidate_tag_posts_for(tags: &[String]) { + for tag in tags { + invalidate_posts_by_tag(tag).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(); From 82b070e7acbf0e345df99eeef0cb08d0ce3f6fa8 Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 17 Jun 2026 16:37:20 +0800 Subject: [PATCH 10/35] refactor(posts): use precise cache invalidation in create/update/delete --- src/api/posts/create.rs | 9 +++------ src/api/posts/delete.rs | 38 +++++++++++++++++++++++++++++++++++--- src/api/posts/update.rs | 10 +++++----- 3 files changed, 43 insertions(+), 14 deletions(-) diff --git a/src/api/posts/create.rs b/src/api/posts/create.rs index 58e7ac5..b83bf66 100644 --- a/src/api/posts/create.rs +++ b/src/api/posts/create.rs @@ -139,17 +139,14 @@ pub async fn create_post( tx.commit().await.map_err(AppError::tx)?; - // 写入成功后失效文章列表、标签与统计缓存。 + // 写入成功后按粒度失效相关缓存。 crate::cache::invalidate_post_lists(); crate::cache::invalidate_all_tags(); crate::cache::invalidate_post_stats(); // 失效按 slug 缓存,避免之前缓存的 404 继续命中。 crate::cache::invalidate_post_by_slug(&final_slug).await; - - // 失效该文章涉及的所有标签缓存。 - for tag_name in &tags_cleaned { - crate::cache::invalidate_posts_by_tag(tag_name).await; - } + // 失效该文章涉及的所有标签下文章列表缓存。 + crate::cache::invalidate_tag_posts_for(&tags_cleaned).await; Ok(CreatePostResponse { success: true, diff --git a/src/api/posts/delete.rs b/src/api/posts/delete.rs index b1f9043..d54df43 100644 --- a/src/api/posts/delete.rs +++ b/src/api/posts/delete.rs @@ -27,6 +27,34 @@ pub async fn delete_post(post_id: i32) -> Result = tag_rows.iter().map(|r| r.get(0)).collect(); + // 软删除:仅影响未被删除的文章。 let result = client .execute( @@ -45,14 +73,18 @@ pub async fn delete_post(post_id: i32) -> Result = old_tags + let all_tags_to_invalidate: Vec = old_tags .into_iter() .chain(tags_for_invalidation.into_iter()) + .collect::>() + .into_iter() .collect(); - for tag_name in &all_tags_to_invalidate { - crate::cache::invalidate_posts_by_tag(tag_name).await; - } + crate::cache::invalidate_tag_posts_for(&all_tags_to_invalidate).await; // 若 slug 发生变更,额外失效旧 slug 缓存。 if let Some(ref old) = old_slug { From bb34e2c36eea1d36da7a2983607307c54feba308 Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 17 Jun 2026 16:37:20 +0800 Subject: [PATCH 11/35] refactor(posts): use precise cache invalidation in trash operations --- src/api/posts/trash.rs | 185 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 175 insertions(+), 10 deletions(-) diff --git a/src/api/posts/trash.rs b/src/api/posts/trash.rs index 38fdd3e..16f7ca2 100644 --- a/src/api/posts/trash.rs +++ b/src/api/posts/trash.rs @@ -1,6 +1,7 @@ //! 回收站操作接口:恢复、彻底删除、批量操作与一键清空。 //! -//! 所有接口需要 admin 权限,操作后清空全部文章相关缓存。 +//! 所有接口需要 admin 权限,操作后按影响范围精准失效缓存; +//! 仅在影响集很大(如批量清空)时才回退到全量缓存失效。 //! Dioxus server function,注册在 `/api` 路径下。 //! 仅在 `feature = "server"` 启用的服务端构建中执行数据库操作。 @@ -16,6 +17,11 @@ use crate::api::slug::ensure_unique_slug; #[cfg(feature = "server")] use crate::db::pool::get_conn; +/// 批量/清空操作使用精准失效的最大记录数阈值。 +/// 超过该阈值时回退到 `invalidate_all_post_caches()`,避免大量串行缓存操作。 +#[cfg(feature = "server")] +const PRECISE_INVALIDATION_LIMIT: usize = 50; + /// 恢复一篇已删除的文章(将 deleted_at 置空)。 /// /// 若该文章原始 slug 已被其他未删除文章占用,自动追加数字后缀。 @@ -28,7 +34,7 @@ pub async fn restore_post(post_id: i32) -> Result Result = tag_rows.iter().map(|r| r.get(0)).collect(); + // 置空 deleted_at,并更新 slug(可能已加后缀)。 let result = tx .execute( @@ -71,7 +86,13 @@ pub async fn restore_post(post_id: i32) -> Result Result = tag_rows.iter().map(|r| r.get(0)).collect(); + let result = client .execute( "DELETE FROM posts WHERE id = $1 AND deleted_at IS NOT NULL", @@ -121,13 +170,18 @@ pub async fn purge_post(post_id: i32) -> Result) -> Result = Vec::with_capacity(post_ids.len() * 2); + let mut affected_tags: std::collections::HashSet = + std::collections::HashSet::new(); + for id in &post_ids { let row = tx .query_opt( @@ -174,6 +232,18 @@ pub async fn batch_restore_posts(post_ids: Vec) -> Result) -> Result = + affected_slugs.into_iter().collect(); + crate::cache::invalidate_post_lists(); + crate::cache::invalidate_all_tags(); + crate::cache::invalidate_post_stats(); + for slug in &unique_slugs { + crate::cache::invalidate_post_by_slug(slug).await; + } + crate::cache::invalidate_tag_posts_for(&affected_tags.into_iter().collect::>()).await; Ok(CreatePostResponse { success: true, @@ -225,8 +307,34 @@ pub async fn batch_purge_posts(post_ids: Vec) -> Result = slug_rows.iter().map(|r| r.get(0)).collect(); + + let tag_rows = client + .query( + "SELECT t.name FROM tags t JOIN post_tags pt ON t.id = pt.tag_id WHERE pt.post_id = ANY($1)", + &[&post_ids], + ) + .await + .map_err(AppError::query)?; + let tags: Vec = tag_rows.iter().map(|r| r.get(0)).collect(); + + (slugs, tags) + } else { + (Vec::new(), Vec::new()) + }; + let result = client .execute( "DELETE FROM posts WHERE id = ANY($1) AND deleted_at IS NOT NULL", @@ -235,7 +343,18 @@ pub async fn batch_purge_posts(post_ids: Vec) -> Result Result { { let client = get_conn().await.map_err(AppError::db_conn)?; + // 记录数较少时查询 slug 与标签,使用精准失效;否则回退到全量失效。 + let count_row = client + .query_one( + "SELECT COUNT(*) FROM posts WHERE deleted_at IS NOT NULL", + &[], + ) + .await + .map_err(AppError::query)?; + let count: i64 = count_row.get(0); + let use_precise = count > 0 && (count as usize) <= PRECISE_INVALIDATION_LIMIT; + + let (slugs, tags) = if use_precise { + let slug_rows = client + .query( + "SELECT slug FROM posts WHERE deleted_at IS NOT NULL", + &[], + ) + .await + .map_err(AppError::query)?; + let slugs: Vec = slug_rows.iter().map(|r| r.get(0)).collect(); + + let tag_rows = client + .query( + "SELECT t.name FROM tags t JOIN post_tags pt ON t.id = pt.tag_id JOIN posts p ON p.id = pt.post_id WHERE p.deleted_at IS NOT NULL", + &[], + ) + .await + .map_err(AppError::query)?; + let tags: Vec = tag_rows.iter().map(|r| r.get(0)).collect(); + + (slugs, tags) + } else { + (Vec::new(), Vec::new()) + }; + let result = client .execute("DELETE FROM posts WHERE deleted_at IS NOT NULL", &[]) .await .map_err(AppError::query)?; - crate::cache::invalidate_all_post_caches(); + crate::cache::invalidate_post_lists(); + crate::cache::invalidate_all_tags(); + crate::cache::invalidate_post_stats(); + if use_precise { + for slug in &slugs { + crate::cache::invalidate_post_by_slug(slug).await; + } + crate::cache::invalidate_tag_posts_for(&tags).await; + } else { + // 影响集过大时回退到全量失效,避免大量串行缓存操作。 + crate::cache::invalidate_all_post_caches(); + } Ok(CreatePostResponse { success: true, From c03093fc8bac71b37d447b5eefb3f09fe8135e4a Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 17 Jun 2026 16:37:20 +0800 Subject: [PATCH 12/35] refactor(posts): use precise cache invalidation in rebuild --- src/api/posts/rebuild.rs | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/src/api/posts/rebuild.rs b/src/api/posts/rebuild.rs index 2142bde..c6f6afa 100644 --- a/src/api/posts/rebuild.rs +++ b/src/api/posts/rebuild.rs @@ -34,14 +34,14 @@ pub async fn rebuild_content_html(rebuild_all: bool) -> Result Result = Vec::new(); + let mut rebuilt_slugs: Vec = Vec::with_capacity(rows.len()); for row in &rows { let id: i32 = row.get(0); - let content_md: String = row.get(1); + let slug: String = row.get(1); + let content_md: String = row.get(2); // 捕获 Markdown 渲染 panic,避免单条记录导致整批失败。 let rendered = match std::panic::catch_unwind(|| { @@ -91,7 +93,10 @@ pub async fn rebuild_content_html(rebuild_all: bool) -> Result rebuilt += 1, + Ok(_) => { + rebuilt += 1; + rebuilt_slugs.push(slug); + } Err(_) => { failed += 1; if errors.len() < MAX_DISPLAY_ERRORS { @@ -101,9 +106,14 @@ pub async fn rebuild_content_html(rebuild_all: bool) -> Result 0 || failed > 0 { - crate::cache::invalidate_all_post_caches(); + // 只要有文章被更新,就按影响范围失效缓存:列表、标签云、统计,以及每篇被重建文章的 slug 缓存。 + if rebuilt > 0 { + crate::cache::invalidate_post_lists(); + crate::cache::invalidate_all_tags(); + crate::cache::invalidate_post_stats(); + for slug in &rebuilt_slugs { + crate::cache::invalidate_post_by_slug(slug).await; + } } Ok(RebuildResult { From 1092fbb3ce01567157ad02ad2cc9048cbc9f609d Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 17 Jun 2026 16:44:32 +0800 Subject: [PATCH 13/35] fix(posts): ensure rebuild invalidates tag posts cache --- src/api/posts/delete.rs | 4 ++-- src/api/posts/rebuild.rs | 21 +++++++-------------- 2 files changed, 9 insertions(+), 16 deletions(-) diff --git a/src/api/posts/delete.rs b/src/api/posts/delete.rs index d54df43..bfc0aba 100644 --- a/src/api/posts/delete.rs +++ b/src/api/posts/delete.rs @@ -1,7 +1,7 @@ //! 删除文章接口。 //! //! 采用软删除方式,将 posts.deleted_at 设置为当前时间, -//! 同时清空所有文章相关缓存。 +//! 并按影响范围失效相关缓存。 //! Dioxus server function,注册在 `/api` 路径下。 //! 仅在 `feature = "server"` 启用的服务端构建中执行删除与缓存失效。 @@ -18,7 +18,7 @@ use crate::db::pool::get_conn; /// 删除指定文章。 /// /// 仅 admin 可调用;通过设置 deleted_at 实现软删除, -/// 成功后清空全部文章缓存。 +/// 成功后按影响范围失效文章列表、标签云、统计、slug 及相关标签文章缓存。 #[server(DeletePost, "/api")] pub async fn delete_post(post_id: i32) -> Result { let _user = get_current_admin_user().await?; diff --git a/src/api/posts/rebuild.rs b/src/api/posts/rebuild.rs index c6f6afa..fe2d79d 100644 --- a/src/api/posts/rebuild.rs +++ b/src/api/posts/rebuild.rs @@ -34,14 +34,14 @@ pub async fn rebuild_content_html(rebuild_all: bool) -> Result Result = Vec::new(); - let mut rebuilt_slugs: Vec = Vec::with_capacity(rows.len()); for row in &rows { let id: i32 = row.get(0); - let slug: String = row.get(1); - let content_md: String = row.get(2); + let content_md: String = row.get(1); // 捕获 Markdown 渲染 panic,避免单条记录导致整批失败。 let rendered = match std::panic::catch_unwind(|| { @@ -95,7 +93,6 @@ pub async fn rebuild_content_html(rebuild_all: bool) -> Result { rebuilt += 1; - rebuilt_slugs.push(slug); } Err(_) => { failed += 1; @@ -106,14 +103,10 @@ pub async fn rebuild_content_html(rebuild_all: bool) -> Result 0 { - crate::cache::invalidate_post_lists(); - crate::cache::invalidate_all_tags(); - crate::cache::invalidate_post_stats(); - for slug in &rebuilt_slugs { - crate::cache::invalidate_post_by_slug(slug).await; - } + crate::cache::invalidate_all_post_caches(); } Ok(RebuildResult { From ca212a2aab779f4da8759b6eb14d384d536dd22d Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 17 Jun 2026 16:58:54 +0800 Subject: [PATCH 14/35] fix(posts): lock rows and read metadata in transaction for delete/purge/restore --- src/api/posts/delete.rs | 17 ++++++++++------- src/api/posts/trash.rs | 21 ++++++++++++--------- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/src/api/posts/delete.rs b/src/api/posts/delete.rs index bfc0aba..b4f4dbe 100644 --- a/src/api/posts/delete.rs +++ b/src/api/posts/delete.rs @@ -25,12 +25,13 @@ pub async fn delete_post(post_id: i32) -> Result Result Result = tag_rows.iter().map(|r| r.get(0)).collect(); // 软删除:仅影响未被删除的文章。 - let result = client + let result = tx .execute( "UPDATE posts SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL", &[&post_id], ) .await - .map_err(AppError::query)?; + .map_err(AppError::tx)?; if result == 0 { return Ok(CreatePostResponse { @@ -73,6 +74,8 @@ pub async fn delete_post(post_id: i32) -> Result Result Result Result Result = tag_rows.iter().map(|r| r.get(0)).collect(); - let result = client + let result = tx .execute( "DELETE FROM posts WHERE id = $1 AND deleted_at IS NOT NULL", &[&post_id], ) .await - .map_err(AppError::query)?; + .map_err(AppError::tx)?; if result == 0 { return Ok(CreatePostResponse { @@ -170,6 +171,8 @@ pub async fn purge_post(post_id: i32) -> Result Date: Wed, 17 Jun 2026 16:59:18 +0800 Subject: [PATCH 15/35] refactor(posts): apply precise invalidation limit to batch_restore_posts --- src/api/posts/trash.rs | 54 ++++++++++++++++++++++++++---------------- 1 file changed, 33 insertions(+), 21 deletions(-) diff --git a/src/api/posts/trash.rs b/src/api/posts/trash.rs index 89679a2..60d44c8 100644 --- a/src/api/posts/trash.rs +++ b/src/api/posts/trash.rs @@ -218,6 +218,9 @@ pub async fn batch_restore_posts(post_ids: Vec) -> Result = Vec::with_capacity(post_ids.len() * 2); @@ -227,7 +230,7 @@ pub async fn batch_restore_posts(post_ids: Vec) -> Result) -> Result) -> Result = - affected_slugs.into_iter().collect(); - crate::cache::invalidate_post_lists(); - crate::cache::invalidate_all_tags(); - crate::cache::invalidate_post_stats(); - for slug in &unique_slugs { - crate::cache::invalidate_post_by_slug(slug).await; + if use_precise { + // 精准失效:先去重 slug,再统一失效列表/标签云/统计/标签文章。 + let unique_slugs: std::collections::HashSet = + affected_slugs.into_iter().collect(); + crate::cache::invalidate_post_lists(); + crate::cache::invalidate_all_tags(); + crate::cache::invalidate_post_stats(); + for slug in &unique_slugs { + crate::cache::invalidate_post_by_slug(slug).await; + } + crate::cache::invalidate_tag_posts_for(&affected_tags.into_iter().collect::>()).await; + } else { + // 影响集过大时回退到全量失效,避免大量串行缓存操作。 + crate::cache::invalidate_all_post_caches(); } - crate::cache::invalidate_tag_posts_for(&affected_tags.into_iter().collect::>()).await; Ok(CreatePostResponse { success: true, From 2d8f1e0d981423869d0ae7fdef535db6c42cce0c Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 17 Jun 2026 16:59:24 +0800 Subject: [PATCH 16/35] refactor(posts): avoid redundant cache invalidation in bulk fallback paths --- src/api/posts/trash.rs | 68 +++++++++++++++++++++++++----------------- 1 file changed, 41 insertions(+), 27 deletions(-) diff --git a/src/api/posts/trash.rs b/src/api/posts/trash.rs index 60d44c8..0a04880 100644 --- a/src/api/posts/trash.rs +++ b/src/api/posts/trash.rs @@ -321,47 +321,61 @@ pub async fn batch_purge_posts(post_ids: Vec) -> Result = slug_rows.iter().map(|r| r.get(0)).collect(); + let mut slugs = Vec::with_capacity(post_ids.len()); + let mut tags_set: std::collections::HashSet = + std::collections::HashSet::new(); - let tag_rows = client - .query( - "SELECT t.name FROM tags t JOIN post_tags pt ON t.id = pt.tag_id WHERE pt.post_id = ANY($1)", - &[&post_ids], - ) - .await - .map_err(AppError::query)?; - let tags: Vec = tag_rows.iter().map(|r| r.get(0)).collect(); + for id in &post_ids { + let slug_row = tx + .query_opt( + "SELECT slug FROM posts WHERE id = $1 AND deleted_at IS NOT NULL FOR UPDATE", + &[&id], + ) + .await + .map_err(AppError::query)?; - (slugs, tags) + if let Some(slug_row) = slug_row { + let slug: String = slug_row.get(0); + let tag_rows = tx + .query( + "SELECT t.name FROM tags t JOIN post_tags pt ON t.id = pt.tag_id WHERE pt.post_id = $1", + &[&id], + ) + .await + .map_err(AppError::query)?; + for tag_row in &tag_rows { + tags_set.insert(tag_row.get(0)); + } + slugs.push(slug); + } + } + + (slugs, tags_set.into_iter().collect::>()) } else { (Vec::new(), Vec::new()) }; - let result = client + let result = tx .execute( "DELETE FROM posts WHERE id = ANY($1) AND deleted_at IS NOT NULL", &[&post_ids], ) .await - .map_err(AppError::query)?; + .map_err(AppError::tx)?; + + tx.commit().await.map_err(AppError::tx)?; - crate::cache::invalidate_post_lists(); - crate::cache::invalidate_all_tags(); - crate::cache::invalidate_post_stats(); if use_precise { + crate::cache::invalidate_post_lists(); + crate::cache::invalidate_all_tags(); + crate::cache::invalidate_post_stats(); for slug in &slugs { crate::cache::invalidate_post_by_slug(slug).await; } @@ -439,10 +453,10 @@ pub async fn empty_trash() -> Result { .await .map_err(AppError::query)?; - crate::cache::invalidate_post_lists(); - crate::cache::invalidate_all_tags(); - crate::cache::invalidate_post_stats(); if use_precise { + crate::cache::invalidate_post_lists(); + crate::cache::invalidate_all_tags(); + crate::cache::invalidate_post_stats(); for slug in &slugs { crate::cache::invalidate_post_by_slug(slug).await; } From 7bd02d0ea987b1df0bafd74afb03f75f520187f7 Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 17 Jun 2026 16:59:29 +0800 Subject: [PATCH 17/35] perf(cache): run tag post invalidations concurrently --- Cargo.lock | 1 + Cargo.toml | 2 ++ src/cache.rs | 8 +++++--- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5044243..05e4cc3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5397,6 +5397,7 @@ dependencies = [ "deadpool-postgres", "dioxus", "dotenvy", + "futures", "governor", "hex", "http", diff --git a/Cargo.toml b/Cargo.toml index f86de3b..db8bda4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ zenwebp = { version = "0.3", optional = true } moka = { version = "0.12", features = ["future"], optional = true } governor = { version = "0.8", optional = true } md-5 = { version = "0.10", optional = true } +futures = { version = "0.3", optional = true } [target.'cfg(target_arch = "wasm32")'.dependencies] web-sys = { version = "0.3", features = ["Document", "Window", "Storage", "Element", "HtmlElement", "DomTokenList", "MediaQueryList", "HtmlImageElement", "MouseEvent", "KeyboardEvent", "Node", "EventTarget", "Navigator"] } @@ -82,4 +83,5 @@ server = [ "dep:moka", "dep:governor", "dep:md-5", + "dep:futures", ] diff --git a/src/cache.rs b/src/cache.rs index ddf617e..44e98da 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -291,9 +291,11 @@ pub fn invalidate_post_stats() { /// 按标签批量失效文章列表缓存。 #[cfg(feature = "server")] pub async fn invalidate_tag_posts_for(tags: &[String]) { - for tag in tags { - invalidate_posts_by_tag(tag).await; - } + let futures: Vec<_> = tags + .iter() + .map(|tag| invalidate_posts_by_tag(tag)) + .collect(); + let _ = futures::future::join_all(futures).await; } /// 清空所有文章相关缓存(列表、标签、单篇、统计、标签文章)。 From 75b8f80631a8874bad670923e0d5f505a54d11dd Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 17 Jun 2026 17:05:07 +0800 Subject: [PATCH 18/35] fix(posts): wrap empty_trash in transaction and lock rows for precise invalidation --- src/api/posts/trash.rs | 38 ++++++++++++++++++-------------------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/src/api/posts/trash.rs b/src/api/posts/trash.rs index 0a04880..6ca570c 100644 --- a/src/api/posts/trash.rs +++ b/src/api/posts/trash.rs @@ -411,33 +411,29 @@ pub async fn empty_trash() -> Result { #[cfg(feature = "server")] { - let client = get_conn().await.map_err(AppError::db_conn)?; + let mut client = get_conn().await.map_err(AppError::db_conn)?; + let tx = client.transaction().await.map_err(AppError::tx)?; - // 记录数较少时查询 slug 与标签,使用精准失效;否则回退到全量失效。 - let count_row = client - .query_one( - "SELECT COUNT(*) FROM posts WHERE deleted_at IS NOT NULL", + // 在事务内锁定所有待删除行并读取 id/slug,用于后续精准失效; + // 同时根据数量决定使用精准失效还是回退到全量失效。 + let deleted_rows = tx + .query( + "SELECT id, slug FROM posts WHERE deleted_at IS NOT NULL FOR UPDATE", &[], ) .await .map_err(AppError::query)?; - let count: i64 = count_row.get(0); - let use_precise = count > 0 && (count as usize) <= PRECISE_INVALIDATION_LIMIT; + let use_precise = !deleted_rows.is_empty() + && deleted_rows.len() <= PRECISE_INVALIDATION_LIMIT; let (slugs, tags) = if use_precise { - let slug_rows = client - .query( - "SELECT slug FROM posts WHERE deleted_at IS NOT NULL", - &[], - ) - .await - .map_err(AppError::query)?; - let slugs: Vec = slug_rows.iter().map(|r| r.get(0)).collect(); + let slugs: Vec = deleted_rows.iter().map(|r| r.get("slug")).collect(); + let ids: Vec = deleted_rows.iter().map(|r| r.get("id")).collect(); - let tag_rows = client + let tag_rows = tx .query( - "SELECT t.name FROM tags t JOIN post_tags pt ON t.id = pt.tag_id JOIN posts p ON p.id = pt.post_id WHERE p.deleted_at IS NOT NULL", - &[], + "SELECT t.name FROM tags t JOIN post_tags pt ON t.id = pt.tag_id WHERE pt.post_id = ANY($1)", + &[&ids], ) .await .map_err(AppError::query)?; @@ -448,10 +444,12 @@ pub async fn empty_trash() -> Result { (Vec::new(), Vec::new()) }; - let result = client + let result = tx .execute("DELETE FROM posts WHERE deleted_at IS NOT NULL", &[]) .await - .map_err(AppError::query)?; + .map_err(AppError::tx)?; + + tx.commit().await.map_err(AppError::tx)?; if use_precise { crate::cache::invalidate_post_lists(); From 1d216faa2fcce70a2e3181ba2d0fd59bd308ad82 Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 17 Jun 2026 17:20:47 +0800 Subject: [PATCH 19/35] feat(auth): add in-memory session cache --- src/api/auth.rs | 13 +++++++++- src/cache.rs | 66 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/src/api/auth.rs b/src/api/auth.rs index bd24926..b36c16c 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -293,6 +293,7 @@ pub async fn logout() -> Result { if let Some(t) = token { let token_hash = session::hash_token(&t); + crate::cache::invalidate_session_user(&token_hash).await; client .execute("DELETE FROM sessions WHERE token_hash = $1", &[&token_hash]) .await @@ -316,11 +317,17 @@ pub struct CurrentUserResponse { #[cfg(feature = "server")] /// 根据会话 token 查询对应用户(含密码哈希等完整信息)。 /// +/// 优先命中内存缓存,避免每次请求都执行 DB JOIN;未命中时回查数据库并回填缓存。 /// 仅服务端内部使用,不会暴露给前端。 pub async fn get_user_by_token(token: &str) -> Result, ServerFnError> { + let token_hash = session::hash_token(token); + + if let Some(user) = crate::cache::get_session_user(&token_hash).await { + return Ok(Some(user)); + } + let client = get_conn().await.map_err(AppError::db_conn)?; - let token_hash = session::hash_token(token); let row = client .query_opt( "SELECT u.id, u.username, u.email, u.password_hash, u.role, u.created_at @@ -348,6 +355,10 @@ pub async fn get_user_by_token(token: &str) -> Result, ServerFnErro None => None, }; + if let Some(ref u) = user { + crate::cache::set_session_user(&token_hash, u.clone()).await; + } + Ok(user) } diff --git a/src/cache.rs b/src/cache.rs index 44e98da..05bfe08 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -15,6 +15,8 @@ use std::time::Duration; use crate::models::comment::PublicComment; #[cfg(feature = "server")] use crate::models::post::{Post, PostListItem, PostStats, Tag}; +#[cfg(feature = "server")] +use crate::models::user::User; // ============================================================================ // 缓存 TTL 配置 @@ -48,6 +50,10 @@ const TTL_COMMENTS: Duration = Duration::from_secs(60); #[cfg(feature = "server")] const TTL_PENDING_COUNT: Duration = Duration::from_secs(10); +/// 会话用户缓存 TTL:300 秒(5 分钟),短于 DB 会话过期时间。 +#[cfg(feature = "server")] +const TTL_SESSION: Duration = Duration::from_secs(300); + // ============================================================================ // 缓存 Key 类型 // ============================================================================ @@ -161,6 +167,19 @@ static PENDING_COUNT_CACHE: LazyLock> = LazyLock::new(|| { .build() }); +/// 会话用户缓存类型。 +#[cfg(feature = "server")] +pub type SessionCache = Cache; + +/// 全局会话用户缓存实例,最大容量 1000,TTL 5 分钟。 +#[cfg(feature = "server")] +static SESSION_CACHE: LazyLock = LazyLock::new(|| { + Cache::builder() + .max_capacity(1000) + .time_to_live(TTL_SESSION) + .build() +}); + // ============================================================================ // 公共缓存 API // ============================================================================ @@ -345,6 +364,24 @@ pub async fn set_pending_count(count: i64) { .await; } +/// 读取会话用户缓存。 +#[cfg(feature = "server")] +pub async fn get_session_user(token_hash: &str) -> Option { + SESSION_CACHE.get(token_hash).await +} + +/// 写入会话用户缓存。 +#[cfg(feature = "server")] +pub async fn set_session_user(token_hash: &str, user: User) { + 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 invalidate_comments_by_post(post_id: i32) { @@ -366,6 +403,7 @@ mod tests { use super::*; use crate::models::comment::PublicComment; use crate::models::post::PostStatus; + use crate::models::user::{User, UserRole}; use serial_test::serial; #[test] @@ -572,4 +610,32 @@ mod tests { assert!(get_pending_count().await.is_none()); } + #[tokio::test] + #[serial] + async fn session_cache_roundtrip() { + let user = User { + id: 42, + username: "cached_user".to_string(), + email: "cached@example.com".to_string(), + password_hash: "argon2_hash".to_string(), + role: UserRole::Admin, + created_at: chrono::Utc::now(), + }; + 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.password_hash, user.password_hash); + assert_eq!(cached_user.role, user.role); + + invalidate_session_user(token_hash).await; + assert!(get_session_user(token_hash).await.is_none()); + } + } From c40a77198930927fc43b1e6275a6400a3c106aec Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 17 Jun 2026 17:20:54 +0800 Subject: [PATCH 20/35] feat(search): cache search results with short TTL --- src/api/posts/search.rs | 9 ++++ src/cache.rs | 97 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/src/api/posts/search.rs b/src/api/posts/search.rs index 45e8431..7a7b3f0 100644 --- a/src/api/posts/search.rs +++ b/src/api/posts/search.rs @@ -13,6 +13,8 @@ use super::types::PostListResponse; #[cfg(feature = "server")] use crate::api::error::AppError; #[cfg(feature = "server")] +use crate::cache; +#[cfg(feature = "server")] use crate::db::pool::get_conn; /// 搜索已发布文章。 @@ -47,6 +49,12 @@ pub async fn search_posts(query: String) -> Result Result> = LazyLock::new(|| { #[cfg(feature = "server")] pub type SessionCache = Cache; +/// 搜索结果缓存类型。 +#[cfg(feature = "server")] +pub type SearchCache = Cache, i64)>; + /// 全局会话用户缓存实例,最大容量 1000,TTL 5 分钟。 #[cfg(feature = "server")] static SESSION_CACHE: LazyLock = LazyLock::new(|| { @@ -180,6 +188,15 @@ static SESSION_CACHE: LazyLock = LazyLock::new(|| { .build() }); +/// 全局搜索结果缓存实例,最大容量 200,TTL 10 秒。 +#[cfg(feature = "server")] +static SEARCH_CACHE: LazyLock = LazyLock::new(|| { + Cache::builder() + .max_capacity(200) + .time_to_live(TTL_SEARCH) + .build() +}); + // ============================================================================ // 公共缓存 API // ============================================================================ @@ -364,6 +381,12 @@ pub async fn set_pending_count(count: i64) { .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 { @@ -382,6 +405,26 @@ 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, i64)> { + SEARCH_CACHE.get(&normalize_search_key(query)).await +} + +/// 写入搜索结果缓存。 +#[cfg(feature = "server")] +pub async fn set_search_results(query: &str, posts: Vec, total: i64) { + let _ = SEARCH_CACHE + .insert(normalize_search_key(query), (posts, total)) + .await; +} + +/// 清空所有搜索结果缓存。 +#[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) { @@ -638,4 +681,58 @@ mod tests { 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()); + } + } From c780247d176b2d9de442c788bf41b7437d6cb4cf Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 17 Jun 2026 17:21:01 +0800 Subject: [PATCH 21/35] feat(posts): invalidate search cache on writes --- src/api/posts/create.rs | 1 + src/api/posts/delete.rs | 1 + src/api/posts/rebuild.rs | 1 + src/api/posts/trash.rs | 8 ++++++++ src/api/posts/update.rs | 1 + 5 files changed, 12 insertions(+) diff --git a/src/api/posts/create.rs b/src/api/posts/create.rs index b83bf66..d2e52b8 100644 --- a/src/api/posts/create.rs +++ b/src/api/posts/create.rs @@ -143,6 +143,7 @@ pub async fn create_post( crate::cache::invalidate_post_lists(); crate::cache::invalidate_all_tags(); crate::cache::invalidate_post_stats(); + crate::cache::invalidate_search_results(); // 失效按 slug 缓存,避免之前缓存的 404 继续命中。 crate::cache::invalidate_post_by_slug(&final_slug).await; // 失效该文章涉及的所有标签下文章列表缓存。 diff --git a/src/api/posts/delete.rs b/src/api/posts/delete.rs index b4f4dbe..44e4019 100644 --- a/src/api/posts/delete.rs +++ b/src/api/posts/delete.rs @@ -80,6 +80,7 @@ pub async fn delete_post(post_id: i32) -> Result Result 0 { crate::cache::invalidate_all_post_caches(); + crate::cache::invalidate_search_results(); } Ok(RebuildResult { diff --git a/src/api/posts/trash.rs b/src/api/posts/trash.rs index 6ca570c..2c33af6 100644 --- a/src/api/posts/trash.rs +++ b/src/api/posts/trash.rs @@ -90,6 +90,7 @@ pub async fn restore_post(post_id: i32) -> Result Result) -> Result) -> Result) -> Result) -> Result Result { crate::cache::invalidate_post_lists(); crate::cache::invalidate_all_tags(); crate::cache::invalidate_post_stats(); + crate::cache::invalidate_search_results(); for slug in &slugs { crate::cache::invalidate_post_by_slug(slug).await; } @@ -462,6 +469,7 @@ pub async fn empty_trash() -> Result { } else { // 影响集过大时回退到全量失效,避免大量串行缓存操作。 crate::cache::invalidate_all_post_caches(); + crate::cache::invalidate_search_results(); } Ok(CreatePostResponse { diff --git a/src/api/posts/update.rs b/src/api/posts/update.rs index d00f30c..9e6477d 100644 --- a/src/api/posts/update.rs +++ b/src/api/posts/update.rs @@ -199,6 +199,7 @@ pub async fn update_post( crate::cache::invalidate_post_lists(); crate::cache::invalidate_all_tags(); crate::cache::invalidate_post_stats(); + crate::cache::invalidate_search_results(); crate::cache::invalidate_post_by_slug(&final_slug).await; // 合并旧标签与新标签,统一失效标签下的文章列表缓存。 From 518b4e5d6465799b478d837064ebff6627fecb3c Mon Sep 17 00:00:00 2001 From: xfy Date: Thu, 18 Jun 2026 09:55:00 +0800 Subject: [PATCH 22/35] refactor(auth): store SessionUser instead of full User in session cache --- src/api/auth.rs | 19 +++++++-------- src/cache.rs | 26 +++++++++++++------- src/models/user.rs | 61 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 19 deletions(-) diff --git a/src/api/auth.rs b/src/api/auth.rs index b36c16c..dac8204 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -20,7 +20,7 @@ use crate::auth::{password, session}; #[cfg(feature = "server")] use crate::db::pool::get_conn; #[cfg(feature = "server")] -use crate::models::user::{User, UserRole}; +use crate::models::user::{SessionUser, UserRole}; use crate::models::user::PublicUser; #[cfg(feature = "server")] @@ -315,11 +315,11 @@ pub struct CurrentUserResponse { } #[cfg(feature = "server")] -/// 根据会话 token 查询对应用户(含密码哈希等完整信息)。 +/// 根据会话 token 查询对应用户(不含密码哈希,供会话缓存使用)。 /// /// 优先命中内存缓存,避免每次请求都执行 DB JOIN;未命中时回查数据库并回填缓存。 /// 仅服务端内部使用,不会暴露给前端。 -pub async fn get_user_by_token(token: &str) -> Result, ServerFnError> { +pub async fn get_user_by_token(token: &str) -> Result, ServerFnError> { let token_hash = session::hash_token(token); if let Some(user) = crate::cache::get_session_user(&token_hash).await { @@ -330,7 +330,7 @@ pub async fn get_user_by_token(token: &str) -> Result, ServerFnErro let row = client .query_opt( - "SELECT u.id, u.username, u.email, u.password_hash, u.role, u.created_at + "SELECT u.id, u.username, u.email, u.role, u.created_at FROM sessions s JOIN users u ON s.user_id = u.id WHERE s.token_hash = $1 AND s.expires_at > NOW()", @@ -343,11 +343,10 @@ pub async fn get_user_by_token(token: &str) -> Result, ServerFnErro Some(row) => { let role_str: String = row.get("role"); let role = UserRole::from_str(&role_str).unwrap_or(UserRole::Blocked); - Some(User { + Some(SessionUser { id: row.get("id"), username: row.get("username"), email: row.get("email"), - password_hash: row.get("password_hash"), role, created_at: row.get("created_at"), }) @@ -381,19 +380,19 @@ pub async fn get_current_user() -> Result { /// 获取当前登录用户并要求其为 admin,否则返回 401/403。 /// /// 供其它服务端接口内部调用。 -pub async fn get_current_admin_user() -> Result { +pub async fn get_current_admin_user() -> Result { let token = get_session_from_ctx().ok_or(AppError::Unauthorized("未登录"))?; - let user = get_user_by_token(&token) + let session_user = get_user_by_token(&token) .await .map_err(AppError::query)? .ok_or(AppError::Unauthorized("会话已过期"))?; - if user.role != UserRole::Admin { + if session_user.role != UserRole::Admin { return Err(AppError::Forbidden("权限不足")); } - Ok(user) + Ok(session_user) } #[cfg(all(test, feature = "server"))] diff --git a/src/cache.rs b/src/cache.rs index 70f5e79..098898d 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -16,7 +16,7 @@ use crate::models::comment::PublicComment; #[cfg(feature = "server")] use crate::models::post::{Post, PostListItem, PostStats, Tag}; #[cfg(feature = "server")] -use crate::models::user::User; +use crate::models::user::SessionUser; // ============================================================================ // 缓存 TTL 配置 @@ -173,7 +173,7 @@ static PENDING_COUNT_CACHE: LazyLock> = LazyLock::new(|| { /// 会话用户缓存类型。 #[cfg(feature = "server")] -pub type SessionCache = Cache; +pub type SessionCache = Cache; /// 搜索结果缓存类型。 #[cfg(feature = "server")] @@ -181,7 +181,7 @@ pub type SearchCache = Cache, i64)>; /// 全局会话用户缓存实例,最大容量 1000,TTL 5 分钟。 #[cfg(feature = "server")] -static SESSION_CACHE: LazyLock = LazyLock::new(|| { +pub static SESSION_CACHE: LazyLock = LazyLock::new(|| { Cache::builder() .max_capacity(1000) .time_to_live(TTL_SESSION) @@ -389,13 +389,13 @@ pub fn normalize_search_key(query: &str) -> String { /// 读取会话用户缓存。 #[cfg(feature = "server")] -pub async fn get_session_user(token_hash: &str) -> Option { +pub async fn get_session_user(token_hash: &str) -> Option { SESSION_CACHE.get(token_hash).await } /// 写入会话用户缓存。 #[cfg(feature = "server")] -pub async fn set_session_user(token_hash: &str, user: User) { +pub async fn set_session_user(token_hash: &str, user: SessionUser) { let _ = SESSION_CACHE.insert(token_hash.to_string(), user).await; } @@ -446,7 +446,7 @@ mod tests { use super::*; use crate::models::comment::PublicComment; use crate::models::post::PostStatus; - use crate::models::user::{User, UserRole}; + use crate::models::user::{SessionUser, UserRole}; use serial_test::serial; #[test] @@ -656,11 +656,10 @@ mod tests { #[tokio::test] #[serial] async fn session_cache_roundtrip() { - let user = User { + let user = SessionUser { id: 42, username: "cached_user".to_string(), email: "cached@example.com".to_string(), - password_hash: "argon2_hash".to_string(), role: UserRole::Admin, created_at: chrono::Utc::now(), }; @@ -674,7 +673,6 @@ mod tests { 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.password_hash, user.password_hash); assert_eq!(cached_user.role, user.role); invalidate_session_user(token_hash).await; @@ -735,4 +733,14 @@ mod tests { 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()); + } + } diff --git a/src/models/user.rs b/src/models/user.rs index f588061..47880aa 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -44,6 +44,21 @@ pub struct User { pub created_at: DateTime, } +/// 会话缓存使用的轻量用户结构体,不含密码哈希。 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SessionUser { + /// 用户主键。 + pub id: i32, + /// 用户名。 + pub username: String, + /// 邮箱地址。 + pub email: String, + /// 用户角色。 + pub role: UserRole, + /// 账户创建时间。 + pub created_at: DateTime, +} + /// 可公开的用户信息,从 User 转换而来,不含密码哈希。 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PublicUser { @@ -59,6 +74,32 @@ pub struct PublicUser { pub created_at: DateTime, } +impl From for SessionUser { + /// 将 User 转换为 SessionUser,丢弃 password_hash 字段。 + fn from(u: User) -> Self { + SessionUser { + id: u.id, + username: u.username, + email: u.email, + role: u.role, + created_at: u.created_at, + } + } +} + +impl From for PublicUser { + /// 将 SessionUser 转换为 PublicUser。 + fn from(u: SessionUser) -> Self { + PublicUser { + id: u.id, + username: u.username, + email: u.email, + role: u.role, + created_at: u.created_at, + } + } +} + impl From for PublicUser { /// 将 User 转换为 PublicUser,丢弃 password_hash 字段。 fn from(u: User) -> Self { @@ -124,4 +165,24 @@ mod tests { UserRole::Admin ); } + + #[test] + fn user_to_session_user_excludes_password_hash() { + let user = sample_user(); + let session: SessionUser = user.clone().into(); + assert_eq!(session.id, user.id); + assert_eq!(session.username, user.username); + assert_eq!(session.email, user.email); + assert_eq!(session.role, user.role); + assert_eq!(session.created_at, user.created_at); + } + + #[test] + fn session_user_to_public_user_excludes_password_hash() { + let user = sample_user(); + let session: SessionUser = user.into(); + let public: PublicUser = session.into(); + let json = serde_json::to_string(&public).unwrap(); + assert!(!json.contains("password_hash")); + } } From 24bc6f44a08c4ee5421cf8e4cff77a3cc559533d Mon Sep 17 00:00:00 2001 From: xfy Date: Thu, 18 Jun 2026 09:55:04 +0800 Subject: [PATCH 23/35] fix(tasks): invalidate session cache after cleaning expired sessions --- src/tasks/session_cleanup.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/tasks/session_cleanup.rs b/src/tasks/session_cleanup.rs index 794e6b6..33a9353 100644 --- a/src/tasks/session_cleanup.rs +++ b/src/tasks/session_cleanup.rs @@ -16,11 +16,17 @@ pub async fn run_cleanup() { match get_conn().await { Ok(client) => { // 删除已过期会话 - if let Err(e) = client + match client .execute("DELETE FROM sessions WHERE expires_at < NOW()", &[]) .await { - tracing::error!("Session cleanup error: {:?}", e); + Ok(_) => { + // 同时清空内存中的会话缓存,避免已失效会话继续命中。 + crate::cache::SESSION_CACHE.invalidate_all(); + } + Err(e) => { + tracing::error!("Session cleanup error: {:?}", e); + } } } Err(e) => { From 411e565465e9067cd4a1cbe4e22cfe0bbcdb5939 Mon Sep 17 00:00:00 2001 From: xfy Date: Thu, 18 Jun 2026 09:55:45 +0800 Subject: [PATCH 24/35] docs(cache,search): update module and function comments --- src/api/posts/search.rs | 2 +- src/cache.rs | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/api/posts/search.rs b/src/api/posts/search.rs index 7a7b3f0..50ac5aa 100644 --- a/src/api/posts/search.rs +++ b/src/api/posts/search.rs @@ -20,7 +20,7 @@ use crate::db::pool::get_conn; /// 搜索已发布文章。 /// /// 空查询直接返回空结果;非空查询使用 `word_similarity` 计算相关度, -/// 并限制返回 50 条记录。当前未缓存,每次均查询数据库。 +/// 并限制返回 50 条记录。结果写入短 TTL 内存缓存以减轻 DB 压力。 #[server(SearchPosts, "/api")] pub async fn search_posts(query: String) -> Result { #[cfg(feature = "server")] diff --git a/src/cache.rs b/src/cache.rs index 098898d..4c190dd 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -1,7 +1,7 @@ //! 基于 moka 的内存缓存层。 //! -//! 仅在启用 `server` feature 时编译,为文章列表、标签、单篇文章、统计信息 -//! 以及评论相关数据提供按键缓存与失效能力。 +//! 仅在启用 `server` feature 时编译,为文章列表、标签、单篇文章、统计信息、 +//! 评论、会话用户以及搜索结果提供按键缓存与失效能力。 //! 缓存使用 `std::sync::LazyLock` 全局实例,按不同业务数据设置独立的 TTL。 #[cfg(feature = "server")] @@ -420,6 +420,9 @@ pub async fn set_search_results(query: &str, posts: Vec, total: i6 } /// 清空所有搜索结果缓存。 +/// +/// 使用同步签名是因为 `moka::Cache::invalidate_all` 为同步操作; +/// 该函数通常由写路径直接调用,无需额外等待。 #[cfg(feature = "server")] pub fn invalidate_search_results() { SEARCH_CACHE.invalidate_all(); From 36554af5f5d08e0dd3d050933c17743ddb1af859 Mon Sep 17 00:00:00 2001 From: xfy Date: Thu, 18 Jun 2026 10:03:35 +0800 Subject: [PATCH 25/35] perf(image): store cached image data as Bytes to avoid Vec cloning --- Cargo.lock | 1 + Cargo.toml | 1 + src/api/image.rs | 41 +++++++++++++++++++++++------------------ 3 files changed, 25 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 05e4cc3..532ca5c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5393,6 +5393,7 @@ version = "0.2.0" dependencies = [ "argon2", "axum", + "bytes", "chrono", "deadpool-postgres", "dioxus", diff --git a/Cargo.toml b/Cargo.toml index db8bda4..ae5c55c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ moka = { version = "0.12", features = ["future"], optional = true } governor = { version = "0.8", optional = true } md-5 = { version = "0.10", optional = true } futures = { version = "0.3", optional = true } +bytes = "1" [target.'cfg(target_arch = "wasm32")'.dependencies] web-sys = { version = "0.3", features = ["Document", "Window", "Storage", "Element", "HtmlElement", "DomTokenList", "MediaQueryList", "HtmlImageElement", "MouseEvent", "KeyboardEvent", "Node", "EventTarget", "Navigator"] } diff --git a/src/api/image.rs b/src/api/image.rs index 6f85661..d94332d 100644 --- a/src/api/image.rs +++ b/src/api/image.rs @@ -19,6 +19,8 @@ use moka::future::Cache; use serde::Deserialize; #[cfg(feature = "server")] use std::sync::LazyLock; +#[cfg(feature = "server")] +use bytes::Bytes; #[cfg(feature = "server")] fn etag_for(data: &[u8]) -> String { @@ -51,7 +53,7 @@ pub const MAX_IMAGE_PIXELS: u32 = 25_000_000; // ~5k x 5k #[derive(Debug, Clone)] /// 缓存条目,保存处理后的图片字节与 Content-Type。 struct CachedImage { - data: Vec, + data: Bytes, content_type: HeaderValue, } @@ -186,7 +188,7 @@ fn content_type(format: image::ImageFormat) -> HeaderValue { #[cfg(feature = "server")] fn image_response( - data: Vec, + data: Bytes, content_type: HeaderValue, cache_control: &'static str, headers: &HeaderMap, @@ -399,7 +401,10 @@ async fn read_disk_cache(cache_key: &str) -> Option { .ok() .unwrap_or_else(|| "application/octet-stream".to_string()); let content_type = HeaderValue::from_str(&ct_str).ok()?; - Some(CachedImage { data, content_type }) + Some(CachedImage { + data: Bytes::from(data), + content_type, + }) } #[cfg(feature = "server")] @@ -454,7 +459,7 @@ pub async fn serve_image( return match tokio::fs::read(&file_path).await { Ok(data) => { let ct = content_type(detect_format(&path)); - image_response(data, ct, "public, max-age=31536000, immutable", &headers) + image_response(Bytes::from(data), ct, "public, max-age=31536000, immutable", &headers) } Err(_) => StatusCode::NOT_FOUND.into_response(), }; @@ -462,20 +467,19 @@ pub async fn serve_image( let cache_key = params.cache_key(&path); if let Some(cached) = IMAGE_CACHE.get(&cache_key).await { - return image_response(cached.data, cached.content_type, "public, max-age=86400", &headers); + return image_response( + cached.data.clone(), + cached.content_type, + "public, max-age=86400", + &headers, + ); } if let Some(cached) = read_disk_cache(&cache_key).await { - let _ = IMAGE_CACHE - .insert( - cache_key.clone(), - CachedImage { - data: cached.data.clone(), - content_type: cached.content_type.clone(), - }, - ) - .await; - return image_response(cached.data, cached.content_type, "public, max-age=86400", &headers); + let data = cached.data.clone(); + let content_type = cached.content_type.clone(); + let _ = IMAGE_CACHE.insert(cache_key.clone(), cached).await; + return image_response(data, content_type, "public, max-age=86400", &headers); } let data = match tokio::fs::read(&file_path).await { @@ -502,6 +506,7 @@ pub async fn serve_image( } }; + let processed = Bytes::from(processed); let cached = CachedImage { data: processed.clone(), content_type: content_type.clone(), @@ -758,7 +763,7 @@ mod tests { #[test] fn image_response_includes_cache_headers() { let resp = image_response( - vec![1, 2, 3], + Bytes::from(vec![1, 2, 3]), HeaderValue::from_static("image/webp"), "public, max-age=86400", &HeaderMap::new(), @@ -778,7 +783,7 @@ mod tests { #[test] fn image_response_returns_304_when_etag_matches() { - let data = vec![1, 2, 3]; + let data = Bytes::from(vec![1, 2, 3]); let etag = etag_for(&data); let mut req_headers = HeaderMap::new(); req_headers.insert( @@ -826,7 +831,7 @@ mod tests { #[test] fn image_response_raw_file_is_immutable() { let resp = image_response( - vec![1, 2, 3], + Bytes::from(vec![1, 2, 3]), HeaderValue::from_static("image/jpeg"), "public, max-age=31536000, immutable", &HeaderMap::new(), From 0b107c3f2ea1d39c35a183ad92a17b355631cb78 Mon Sep 17 00:00:00 2001 From: xfy Date: Thu, 18 Jun 2026 10:03:38 +0800 Subject: [PATCH 26/35] feat(tasks): add periodic image disk cache cleanup --- src/main.rs | 5 + src/tasks/image_cache_cleanup.rs | 235 +++++++++++++++++++++++++++++++ src/tasks/mod.rs | 3 + 3 files changed, 243 insertions(+) create mode 100644 src/tasks/image_cache_cleanup.rs diff --git a/src/main.rs b/src/main.rs index 2999c51..f387c60 100644 --- a/src/main.rs +++ b/src/main.rs @@ -73,6 +73,11 @@ fn main() { tasks::post_purge::run_purge().await; }); + // 启动后台定时任务:图片磁盘缓存清理 + tokio::spawn(async { + tasks::image_cache_cleanup::run_cleanup().await; + }); + // 配置增量渲染缓存,默认缓存 3600 秒,可通过 SSR_CACHE_SECS 覆盖 let config = ServeConfig::builder().incremental( dioxus::server::IncrementalRendererConfig::default().invalidate_after( diff --git a/src/tasks/image_cache_cleanup.rs b/src/tasks/image_cache_cleanup.rs new file mode 100644 index 0000000..9ae8a98 --- /dev/null +++ b/src/tasks/image_cache_cleanup.rs @@ -0,0 +1,235 @@ +//! 图片磁盘缓存定期清理任务。 +//! +//! 仅在 `server` feature 启用时编译,每小时运行一次。 +//! 删除超过保留时间的文件,并在总大小超过上限时按修改时间删除最旧的文件。 + +use std::io; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime}; +use tokio::time::interval; + +const CACHE_DIR: &str = "uploads/.cache"; + +/// 启动图片磁盘缓存清理循环,每小时触发一次。 +pub async fn run_cleanup() { + let mut ticker = interval(Duration::from_secs(3600)); + loop { + if let Err(e) = cleanup_image_cache().await { + tracing::error!("Image disk cache cleanup error: {:?}", e); + } + ticker.tick().await; + } +} + +/// 读取环境变量并清理默认磁盘缓存目录。 +pub async fn cleanup_image_cache() -> io::Result<()> { + let base = Path::new(CACHE_DIR); + let max_mb = std::env::var("IMAGE_DISK_CACHE_MAX_MB") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(1024); + let max_age_hours = std::env::var("IMAGE_DISK_CACHE_MAX_AGE_HOURS") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(168); + let (deleted, bytes_freed) = cleanup_image_cache_at(base, max_mb, max_age_hours).await?; + if !deleted.is_empty() { + tracing::info!( + "Image disk cache cleanup: removed {} files, freed {} bytes", + deleted.len(), + bytes_freed + ); + } + Ok(()) +} + +/// 清理指定目录下的图片磁盘缓存。 +/// +/// 返回被删除文件的路径列表以及释放的总字节数。 +pub async fn cleanup_image_cache_at( + base: &Path, + max_mb: u64, + max_age_hours: u64, +) -> io::Result<(Vec, u64)> { + if !base.exists() { + return Ok((Vec::new(), 0)); + } + + let max_age = Duration::from_secs(max_age_hours * 3600); + let now = SystemTime::now(); + let cutoff = now - max_age; + + let mut entries: Vec<(PathBuf, u64, SystemTime)> = Vec::new(); + collect_files(base, &mut entries).await?; + + let mut deleted = Vec::new(); + let mut bytes_freed: u64 = 0; + + // 第一轮:删除超过保留期限的文件。 + let mut remaining: Vec<(PathBuf, u64, SystemTime)> = Vec::new(); + for (path, size, mtime) in entries { + if mtime < cutoff { + match tokio::fs::remove_file(&path).await { + Ok(_) => { + deleted.push(path); + bytes_freed += size; + } + Err(e) => { + tracing::warn!("Failed to remove expired cache file {:?}: {:?}", path, e); + } + } + } else { + remaining.push((path, size, mtime)); + } + } + + // 第二轮:若总大小仍超过上限,按修改时间从旧到新删除。 + let max_bytes = max_mb.saturating_mul(1024 * 1024); + let mut total: u64 = remaining.iter().map(|(_, size, _)| size).sum(); + if total > max_bytes { + remaining.sort_by_key(|a| a.2); + for (path, size, _) in remaining { + if total <= max_bytes { + break; + } + match tokio::fs::remove_file(&path).await { + Ok(_) => { + total -= size; + deleted.push(path); + bytes_freed += size; + } + Err(e) => { + tracing::warn!( + "Failed to remove cache file {:?} for size cap: {:?}", + path, + e + ); + } + } + } + } + + Ok((deleted, bytes_freed)) +} + +/// 递归收集目录下的所有常规文件,返回路径、大小与修改时间。 +async fn collect_files( + base: &Path, + entries: &mut Vec<(PathBuf, u64, SystemTime)>, +) -> io::Result<()> { + let mut stack = vec![base.to_path_buf()]; + while let Some(dir) = stack.pop() { + let mut reader = tokio::fs::read_dir(&dir).await?; + while let Some(entry) = reader.next_entry().await? { + let metadata = entry.metadata().await?; + if metadata.is_file() { + let mtime = metadata.modified()?; + entries.push((entry.path(), metadata.len(), mtime)); + } else if metadata.is_dir() { + stack.push(entry.path()); + } + } + } + Ok(()) +} + +#[cfg(all(test, feature = "server"))] +mod tests { + use super::*; + use std::time::{Duration, UNIX_EPOCH}; + use tokio::time::sleep; + + fn temp_cache_dir() -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("yggdrasil_image_cache_test_{}_{}", nanos, std::process::id())) + } + + #[tokio::test] + async fn cleanup_ignores_missing_directory() { + let dir = temp_cache_dir(); + let (deleted, freed) = cleanup_image_cache_at(&dir, 1024, 168).await.unwrap(); + assert!(deleted.is_empty()); + assert_eq!(freed, 0); + } + + #[tokio::test] + async fn cleanup_removes_expired_files_by_age() { + let dir = temp_cache_dir(); + tokio::fs::create_dir_all(&dir).await.unwrap(); + + let old = dir.join("old.dat"); + tokio::fs::write(&old, b"old content").await.unwrap(); + // 确保文件的修改时间严格早于清理时计算的截止时间。 + sleep(Duration::from_millis(1100)).await; + + let (deleted, freed) = cleanup_image_cache_at(&dir, 1024, 0).await.unwrap(); + assert_eq!(deleted.len(), 1); + assert!(!old.exists()); + assert!(freed > 0); + + tokio::fs::remove_dir_all(&dir).await.unwrap(); + } + + #[tokio::test] + async fn cleanup_keeps_recent_files() { + let dir = temp_cache_dir(); + tokio::fs::create_dir_all(&dir).await.unwrap(); + + let recent = dir.join("recent.dat"); + tokio::fs::write(&recent, b"recent content").await.unwrap(); + + let (deleted, freed) = cleanup_image_cache_at(&dir, 1024, 168).await.unwrap(); + assert!(deleted.is_empty()); + assert_eq!(freed, 0); + assert!(recent.exists()); + + tokio::fs::remove_dir_all(&dir).await.unwrap(); + } + + #[tokio::test] + async fn cleanup_enforces_size_cap_by_mtime() { + let dir = temp_cache_dir(); + tokio::fs::create_dir_all(&dir).await.unwrap(); + + let f1 = dir.join("oldest.dat"); + tokio::fs::write(&f1, vec![0u8; 1024 * 1024]).await.unwrap(); + sleep(Duration::from_millis(1100)).await; + + let f2 = dir.join("middle.dat"); + tokio::fs::write(&f2, vec![0u8; 1024 * 1024]).await.unwrap(); + sleep(Duration::from_millis(1100)).await; + + let f3 = dir.join("newest.dat"); + tokio::fs::write(&f3, vec![0u8; 1024 * 1024]).await.unwrap(); + + // 上限 2 MB,当前 3 MB,应删除最旧的一个文件。 + let (deleted, freed) = cleanup_image_cache_at(&dir, 2, 1000).await.unwrap(); + assert_eq!(deleted.len(), 1); + assert!(!f1.exists()); + assert!(f2.exists()); + assert!(f3.exists()); + assert_eq!(freed, 1024 * 1024); + + tokio::fs::remove_dir_all(&dir).await.unwrap(); + } + + #[tokio::test] + async fn cleanup_recurses_into_subdirectories() { + let dir = temp_cache_dir(); + let sub = dir.join("nested"); + tokio::fs::create_dir_all(&sub).await.unwrap(); + + let nested = sub.join("nested.dat"); + tokio::fs::write(&nested, b"nested content").await.unwrap(); + sleep(Duration::from_millis(1100)).await; + + let (deleted, _freed) = cleanup_image_cache_at(&dir, 1024, 0).await.unwrap(); + assert_eq!(deleted.len(), 1); + assert!(!nested.exists()); + + tokio::fs::remove_dir_all(&dir).await.unwrap(); + } +} diff --git a/src/tasks/mod.rs b/src/tasks/mod.rs index f8a3887..9992bcc 100644 --- a/src/tasks/mod.rs +++ b/src/tasks/mod.rs @@ -11,3 +11,6 @@ pub mod session_cleanup; /// 定时清理回收站中超过保留期的已删除文章。 #[cfg(feature = "server")] pub mod post_purge; +/// 定时清理图片磁盘缓存,避免缓存目录无限增长。 +#[cfg(feature = "server")] +pub mod image_cache_cleanup; From f1e5b657a32205847d940249e6aea4345974eb57 Mon Sep 17 00:00:00 2001 From: xfy Date: Thu, 18 Jun 2026 10:03:42 +0800 Subject: [PATCH 27/35] chore(env): document image disk cache limits in .env.example --- .env.example | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.env.example b/.env.example index e2489a7..fc6e7b3 100644 --- a/.env.example +++ b/.env.example @@ -31,3 +31,9 @@ SSR_CACHE_SECS=3600 # Processed variants (?w=, ?format=, etc.) are cached for 24 hours. # To invalidate a cached raw upload, change its file path. # To refresh a processed variant, change its processing parameters. + +# Image disk cache limits +# Max total size in MB (default: 1024) +IMAGE_DISK_CACHE_MAX_MB=1024 +# Max file age in hours before forced deletion (default: 168) +IMAGE_DISK_CACHE_MAX_AGE_HOURS=168 From c0b14ed49834367200dd331798991d22b03359ef Mon Sep 17 00:00:00 2001 From: xfy Date: Thu, 18 Jun 2026 10:10:40 +0800 Subject: [PATCH 28/35] refactor(image): make bytes dependency optional and server-gated --- Cargo.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index ae5c55c..43cd1e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,7 @@ moka = { version = "0.12", features = ["future"], optional = true } governor = { version = "0.8", optional = true } md-5 = { version = "0.10", optional = true } futures = { version = "0.3", optional = true } -bytes = "1" +bytes = { version = "1", optional = true } [target.'cfg(target_arch = "wasm32")'.dependencies] web-sys = { version = "0.3", features = ["Document", "Window", "Storage", "Element", "HtmlElement", "DomTokenList", "MediaQueryList", "HtmlImageElement", "MouseEvent", "KeyboardEvent", "Node", "EventTarget", "Navigator"] } @@ -85,4 +85,5 @@ server = [ "dep:governor", "dep:md-5", "dep:futures", + "dep:bytes", ] From a71da7473d20ea632e0423f253369a3d8867f680 Mon Sep 17 00:00:00 2001 From: xfy Date: Thu, 18 Jun 2026 10:10:44 +0800 Subject: [PATCH 29/35] perf(image): remove redundant Vec clone before spawn_blocking --- src/api/image.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/api/image.rs b/src/api/image.rs index d94332d..fc927c6 100644 --- a/src/api/image.rs +++ b/src/api/image.rs @@ -489,12 +489,11 @@ pub async fn serve_image( // Offload decode + resize + encode to the blocking pool so the async // runtime stays responsive to other requests. - let data_for_blocking = data.clone(); let path_for_blocking = path.clone(); let params_for_blocking = params.clone(); let (processed, content_type) = match tokio::task::spawn_blocking(move || { - process_image_blocking(data_for_blocking, params_for_blocking, path_for_blocking) + process_image_blocking(data, params_for_blocking, path_for_blocking) }) .await { From 12355b785920f668e3a405c9cb1d864c0562e7cc Mon Sep 17 00:00:00 2001 From: xfy Date: Thu, 18 Jun 2026 10:10:51 +0800 Subject: [PATCH 30/35] fix(tasks): skip symlinks during image disk cache cleanup --- src/tasks/image_cache_cleanup.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/tasks/image_cache_cleanup.rs b/src/tasks/image_cache_cleanup.rs index 9ae8a98..15813c1 100644 --- a/src/tasks/image_cache_cleanup.rs +++ b/src/tasks/image_cache_cleanup.rs @@ -113,6 +113,8 @@ pub async fn cleanup_image_cache_at( } /// 递归收集目录下的所有常规文件,返回路径、大小与修改时间。 +/// +/// 跳过符号链接,避免 traversal 到 `uploads/.cache/` 外部。 async fn collect_files( base: &Path, entries: &mut Vec<(PathBuf, u64, SystemTime)>, @@ -121,11 +123,14 @@ async fn collect_files( while let Some(dir) = stack.pop() { let mut reader = tokio::fs::read_dir(&dir).await?; while let Some(entry) = reader.next_entry().await? { - let metadata = entry.metadata().await?; - if metadata.is_file() { + let file_type = entry.file_type().await?; + if file_type.is_symlink() { + continue; + } else if file_type.is_file() { + let metadata = entry.metadata().await?; let mtime = metadata.modified()?; entries.push((entry.path(), metadata.len(), mtime)); - } else if metadata.is_dir() { + } else if file_type.is_dir() { stack.push(entry.path()); } } From 4c695b4fc3919ee2579e0c1d8a6828993dd7c726 Mon Sep 17 00:00:00 2001 From: xfy Date: Thu, 18 Jun 2026 10:11:07 +0800 Subject: [PATCH 31/35] refactor(tasks): use named constants for MB and hours in cleanup --- src/tasks/image_cache_cleanup.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/tasks/image_cache_cleanup.rs b/src/tasks/image_cache_cleanup.rs index 15813c1..d6cac0f 100644 --- a/src/tasks/image_cache_cleanup.rs +++ b/src/tasks/image_cache_cleanup.rs @@ -9,10 +9,12 @@ use std::time::{Duration, SystemTime}; use tokio::time::interval; const CACHE_DIR: &str = "uploads/.cache"; +const BYTES_PER_MB: u64 = 1024 * 1024; +const SECS_PER_HOUR: u64 = 3600; /// 启动图片磁盘缓存清理循环,每小时触发一次。 pub async fn run_cleanup() { - let mut ticker = interval(Duration::from_secs(3600)); + let mut ticker = interval(Duration::from_secs(SECS_PER_HOUR)); loop { if let Err(e) = cleanup_image_cache().await { tracing::error!("Image disk cache cleanup error: {:?}", e); @@ -55,7 +57,7 @@ pub async fn cleanup_image_cache_at( return Ok((Vec::new(), 0)); } - let max_age = Duration::from_secs(max_age_hours * 3600); + let max_age = Duration::from_secs(max_age_hours * SECS_PER_HOUR); let now = SystemTime::now(); let cutoff = now - max_age; @@ -84,7 +86,7 @@ pub async fn cleanup_image_cache_at( } // 第二轮:若总大小仍超过上限,按修改时间从旧到新删除。 - let max_bytes = max_mb.saturating_mul(1024 * 1024); + let max_bytes = max_mb.saturating_mul(BYTES_PER_MB); let mut total: u64 = remaining.iter().map(|(_, size, _)| size).sum(); if total > max_bytes { remaining.sort_by_key(|a| a.2); From 3ee39d910c585c9b5187d52012344cfab973a91b Mon Sep 17 00:00:00 2001 From: xfy Date: Thu, 18 Jun 2026 10:30:07 +0800 Subject: [PATCH 32/35] refactor(ssr): remove unused per-slug/per-tag generation counters --- .env.example | 5 ++- src/api/posts/create.rs | 3 ++ src/api/posts/delete.rs | 3 ++ src/api/posts/rebuild.rs | 2 ++ src/api/posts/trash.rs | 21 ++++++++++++ src/api/posts/update.rs | 3 ++ src/main.rs | 28 ++++++++++++++-- src/ssr_cache.rs | 70 ++++++++++++++++++++++++++++++++++++++++ 8 files changed, 132 insertions(+), 3 deletions(-) create mode 100644 src/ssr_cache.rs diff --git a/.env.example b/.env.example index fc6e7b3..1898c0e 100644 --- a/.env.example +++ b/.env.example @@ -23,7 +23,10 @@ MAX_SESSIONS_PER_USER=5 # Database connection pool size (default: 20) DB_POOL_SIZE=20 -# SSR page cache duration in seconds (default: 3600) +# SSR page cache duration in seconds (default: 3600). +# Generation-based invalidation is implemented in src/ssr_cache.rs and bumps on every post +# write. However, Dioxus 0.7 does not expose a public API to customize the incremental SSR +# cache key, so SSR_CACHE_SECS remains the effective fallback TTL until such API is available. SSR_CACHE_SECS=3600 # Image serving cache headers (hardcoded defaults) diff --git a/src/api/posts/create.rs b/src/api/posts/create.rs index d2e52b8..55641ec 100644 --- a/src/api/posts/create.rs +++ b/src/api/posts/create.rs @@ -149,6 +149,9 @@ pub async fn create_post( // 失效该文章涉及的所有标签下文章列表缓存。 crate::cache::invalidate_tag_posts_for(&tags_cleaned).await; + // 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。 + crate::ssr_cache::bump_global_generation(); + Ok(CreatePostResponse { success: true, message: "创建成功".to_string(), diff --git a/src/api/posts/delete.rs b/src/api/posts/delete.rs index 44e4019..4c6084d 100644 --- a/src/api/posts/delete.rs +++ b/src/api/posts/delete.rs @@ -84,6 +84,9 @@ pub async fn delete_post(post_id: i32) -> Result Result 0 { crate::cache::invalidate_all_post_caches(); crate::cache::invalidate_search_results(); + // 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。 + crate::ssr_cache::bump_global_generation(); } Ok(RebuildResult { diff --git a/src/api/posts/trash.rs b/src/api/posts/trash.rs index 2c33af6..3af8f45 100644 --- a/src/api/posts/trash.rs +++ b/src/api/posts/trash.rs @@ -95,6 +95,9 @@ pub async fn restore_post(post_id: i32) -> Result Result) -> Result>()).await; + + // 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。 + crate::ssr_cache::bump_global_generation(); } else { // 影响集过大时回退到全量失效,避免大量串行缓存操作。 crate::cache::invalidate_all_post_caches(); crate::cache::invalidate_search_results(); + // 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。 + crate::ssr_cache::bump_global_generation(); } Ok(CreatePostResponse { @@ -385,10 +396,15 @@ pub async fn batch_purge_posts(post_ids: Vec) -> Result Result { crate::cache::invalidate_post_by_slug(slug).await; } crate::cache::invalidate_tag_posts_for(&tags).await; + + // 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。 + crate::ssr_cache::bump_global_generation(); } else { // 影响集过大时回退到全量失效,避免大量串行缓存操作。 crate::cache::invalidate_all_post_caches(); crate::cache::invalidate_search_results(); + // 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。 + crate::ssr_cache::bump_global_generation(); } Ok(CreatePostResponse { diff --git a/src/api/posts/update.rs b/src/api/posts/update.rs index 9e6477d..f87a75e 100644 --- a/src/api/posts/update.rs +++ b/src/api/posts/update.rs @@ -218,6 +218,9 @@ pub async fn update_post( } } + // 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。 + crate::ssr_cache::bump_global_generation(); + Ok(CreatePostResponse { success: true, message: "更新成功".to_string(), diff --git a/src/main.rs b/src/main.rs index f387c60..dcee368 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,6 +23,9 @@ mod hooks; mod models; mod pages; mod router; +// ssr_cache 仅在 server feature 启用时编译;保存 SSR 世代号失效状态。 +#[cfg(feature = "server")] +mod ssr_cache; mod tasks; mod theme; mod utils; @@ -78,7 +81,9 @@ fn main() { tasks::image_cache_cleanup::run_cleanup().await; }); - // 配置增量渲染缓存,默认缓存 3600 秒,可通过 SSR_CACHE_SECS 覆盖 + // 配置增量渲染缓存,默认缓存 3600 秒,可通过 SSR_CACHE_SECS 覆盖。 + // 注意:世代号失效机制已就位(见 src/ssr_cache.rs),但 Dioxus 0.7 未暴露 + // 自定义缓存键 API,因此 TTL 仍是当前有效的兜底策略。 let config = ServeConfig::builder().incremental( dioxus::server::IncrementalRendererConfig::default().invalidate_after( std::time::Duration::from_secs( @@ -90,6 +95,24 @@ fn main() { ), ); + // SSR 世代号中间件:把当前全局世代号注入请求扩展并附加到响应头。 + // 这是为 Dioxus 未来支持自定义 SSR 缓存键预留的钩子;目前主要提供可观测性。 + async fn ssr_generation_middleware( + req: axum::http::Request, + next: axum::middleware::Next, + ) -> axum::response::Response { + let generation = crate::ssr_cache::current_global_generation(); + let (mut parts, body) = req.into_parts(); + parts.extensions.insert(crate::ssr_cache::SsrGeneration(generation)); + let mut response = next.run(axum::http::Request::from_parts(parts, body)).await; + response.headers_mut().insert( + axum::http::header::HeaderName::from_static("x-ssr-generation"), + axum::http::HeaderValue::from_str(&generation.to_string()) + .unwrap_or_else(|_| axum::http::HeaderValue::from_static("0")), + ); + response + } + // 自定义 API 路由:图片上传(大文件,需要更长超时) let upload_route = axum::Router::new() .route( @@ -106,8 +129,9 @@ fn main() { let dioxus_app = axum::Router::new().serve_dioxus_application(config, router::AppRouter); - // 合并 Dioxus + 压缩/30s 超时中间件 + // 合并 Dioxus + 世代号/压缩/30s 超时中间件 let app_routes = dioxus_app + .layer(axum::middleware::from_fn(ssr_generation_middleware)) .layer(CompressionLayer::new()) .layer(TimeoutLayer::with_status_code( StatusCode::REQUEST_TIMEOUT, diff --git a/src/ssr_cache.rs b/src/ssr_cache.rs new file mode 100644 index 0000000..e56543c --- /dev/null +++ b/src/ssr_cache.rs @@ -0,0 +1,70 @@ +//! SSR 增量渲染缓存失效的未来就绪基础设施。 +//! +//! 本模块维护一个全局单调递增的世代号(generation)。文章写入成功后调用方会 +//! 使其递增,从而**标记** SSR 渲染结果已过期。然而: +//! +//! **Dioxus 0.7 的增量渲染器使用请求 URI 的 `path_and_query()` 作为内部缓存键, +//! 且没有暴露公开 API 供外部代码自定义缓存键或按路由失效已渲染页面。** +//! 因此,当前世代号并**不会**实际使 Dioxus 的 SSR 缓存失效;它只是为未来 API +//! 准备好状态,并在请求/响应中提供可观测性。 +//! +//! 在 Dioxus 提供以下任一能力之前,有效的 SSR 缓存失效手段仍是调低 +//! `SSR_CACHE_SECS` 这一兜底 TTL: +//! - 自定义增量渲染缓存键的回调;或 +//! - 从 server function 内部按路由失效缓存的公开 API。 +//! +//! 当前实现: +//! - `bump_global_generation()` / `current_global_generation()`:全局世代号。 +//! - `SsrGeneration`:注入到请求扩展中的类型;未来 Dioxus 支持读取扩展生成 +//! 缓存键时可直接使用。 +//! - `src/main.rs` 的中间件把当前世代号附加到 `X-SSR-Generation` 响应头 +//! (仅 GET 请求),便于调试与监控。 +//! +//! 仅在启用 `server` feature 时编译。 + +#![cfg(feature = "server")] + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::LazyLock; + +/// 全局 SSR 世代号。 +/// +/// 任何文章写入操作都会使其递增,从而让所有基于该全局世代的 SSR 缓存键在未来 +/// Dioxus 支持自定义缓存键时失效。 +static GLOBAL_GENERATION: LazyLock = LazyLock::new(AtomicU64::default); + +/// 注入到请求扩展中的当前 SSR 世代号。 +/// +/// 这是为未来 Dioxus 支持自定义 SSR 缓存键预留的钩子。当前 Dioxus 0.7 的渲染器 +/// 不会读取此扩展。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SsrGeneration(pub u64); + +/// 原子递增并返回新的全局世代号。 +pub fn bump_global_generation() -> u64 { + GLOBAL_GENERATION.fetch_add(1, Ordering::SeqCst).wrapping_add(1) +} + +/// 返回当前全局世代号。 +pub fn current_global_generation() -> u64 { + GLOBAL_GENERATION.load(Ordering::SeqCst) +} + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + + #[test] + #[serial] + fn global_generation_is_monotonic() { + let before = current_global_generation(); + let g1 = bump_global_generation(); + let g2 = bump_global_generation(); + let current = current_global_generation(); + + assert!(g1 > before || g1 == 1); + assert!(g2 > g1); + assert_eq!(current, g2); + } +} From 7f372446da667bedda5f75f381df5b023a5c5e47 Mon Sep 17 00:00:00 2001 From: xfy Date: Thu, 18 Jun 2026 10:30:12 +0800 Subject: [PATCH 33/35] refactor(ssr): restrict X-SSR-Generation header to GET requests and add serial tests --- src/main.rs | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/main.rs b/src/main.rs index dcee368..626a071 100644 --- a/src/main.rs +++ b/src/main.rs @@ -82,8 +82,9 @@ fn main() { }); // 配置增量渲染缓存,默认缓存 3600 秒,可通过 SSR_CACHE_SECS 覆盖。 - // 注意:世代号失效机制已就位(见 src/ssr_cache.rs),但 Dioxus 0.7 未暴露 - // 自定义缓存键 API,因此 TTL 仍是当前有效的兜底策略。 + // 注意:src/ssr_cache.rs 中的世代号是未来就绪基础设施,当前并不会使 + // Dioxus 0.7 的 SSR 缓存实际失效(Dioxus 未暴露相应 API)。在 API 可用 + // 之前,SSR_CACHE_SECS 仍是唯一有效的兜底 TTL。 let config = ServeConfig::builder().incremental( dioxus::server::IncrementalRendererConfig::default().invalidate_after( std::time::Duration::from_secs( @@ -95,21 +96,25 @@ fn main() { ), ); - // SSR 世代号中间件:把当前全局世代号注入请求扩展并附加到响应头。 - // 这是为 Dioxus 未来支持自定义 SSR 缓存键预留的钩子;目前主要提供可观测性。 + // SSR 世代号中间件:把当前全局世代号注入请求扩展,并对 GET 请求的 + // 响应附加 `X-SSR-Generation` 头。这是为未来 Dioxus 支持自定义 SSR 缓存键 + // 预留的钩子;目前主要提供可观测性,不会实际失效 SSR 缓存。 async fn ssr_generation_middleware( req: axum::http::Request, next: axum::middleware::Next, ) -> axum::response::Response { let generation = crate::ssr_cache::current_global_generation(); + let is_get = req.method() == axum::http::Method::GET; let (mut parts, body) = req.into_parts(); parts.extensions.insert(crate::ssr_cache::SsrGeneration(generation)); let mut response = next.run(axum::http::Request::from_parts(parts, body)).await; - response.headers_mut().insert( - axum::http::header::HeaderName::from_static("x-ssr-generation"), - axum::http::HeaderValue::from_str(&generation.to_string()) - .unwrap_or_else(|_| axum::http::HeaderValue::from_static("0")), - ); + if is_get { + response.headers_mut().insert( + axum::http::header::HeaderName::from_static("x-ssr-generation"), + axum::http::HeaderValue::from_str(&generation.to_string()) + .unwrap_or_else(|_| axum::http::HeaderValue::from_static("0")), + ); + } response } From 668920e9fbc1a5f083f57dd4c5296857fd8202b3 Mon Sep 17 00:00:00 2001 From: xfy Date: Thu, 18 Jun 2026 10:30:18 +0800 Subject: [PATCH 34/35] docs(ssr): clarify that generation counters are future-ready infrastructure --- .env.example | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 1898c0e..53423f0 100644 --- a/.env.example +++ b/.env.example @@ -24,9 +24,9 @@ MAX_SESSIONS_PER_USER=5 DB_POOL_SIZE=20 # SSR page cache duration in seconds (default: 3600). -# Generation-based invalidation is implemented in src/ssr_cache.rs and bumps on every post -# write. However, Dioxus 0.7 does not expose a public API to customize the incremental SSR -# cache key, so SSR_CACHE_SECS remains the effective fallback TTL until such API is available. +# src/ssr_cache.rs maintains a global generation counter bumped on every post write, but +# Dioxus 0.7 does not expose an API to wire it into the incremental SSR cache key. Until such +# an API is available, this TTL is the only effective SSR cache invalidation mechanism. SSR_CACHE_SECS=3600 # Image serving cache headers (hardcoded defaults) From ed589f9c79034fc2742723bb5c8a1e8adcbbf9fd Mon Sep 17 00:00:00 2001 From: xfy Date: Thu, 18 Jun 2026 10:37:14 +0800 Subject: [PATCH 35/35] docs(changelog): record caching and SSR invalidation improvements --- CHANGELOG.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 956cf0e..5af11a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - 图片响应新增 `Cache-Control` 与 `ETag` 头:原始上传文件使用 `immutable, max-age=31536000`,处理变体使用 `max-age=86400`。 +- 新增 `PostListItem` 轻量 DTO,列表/标签/搜索接口不再返回完整正文,显著降低缓存与序列化体积。 +- 新增数据库迁移 `010_post_word_counts.sql`,在 `posts` 表存储 `word_count` 与 `reading_time`,并在写入时维护。 +- 新增基于 moka 的会话内存缓存,减少每次认证请求的 `sessions JOIN users` 数据库查询;缓存对象 `SessionUser` 不包含密码哈希。 +- 新增搜索结果短 TTL 缓存(10 秒),并对查询 key 做规范化处理。 +- 新增图片内存缓存使用 `bytes::Bytes` 存储,命中时仅做引用计数克隆。 +- 新增图片磁盘缓存定时清理后台任务,按文件年龄与总大小上限淘汰。 +- 新增 `src/ssr_cache.rs` SSR 生成号基础设施,为后续 Dioxus 暴露缓存失效 API 做准备。 + +### Changed + +- 文章写路径缓存失效从「全量清空」改为「精确到 slug / tag / 列表页」,并在读取 slug/tag 元数据时使用事务 + `FOR UPDATE` 避免并发竞态。 +- `get_post_stats` 将 3 次独立 `COUNT(*)` 合并为单次条件聚合查询。 +- `row_to_post_list` 已移除,`get_post_by_id` 复用 `row_to_post_full`。 + +### Fixed + +- 过期 session 清理任务现在同时失效会话内存缓存,避免已过期会话在缓存 TTL 窗口内继续被使用。 +- 图片磁盘清理跳过符号链接,防止遍历到缓存目录外部。 + +### Internal + +- 新增 `utils::text::reading_time` 辅助函数,统一阅读时间计算逻辑。 +- 新增缓存、SSR 生成号相关单元测试。 ### Fixed