diff --git a/.env.example b/.env.example index 34b2afe..e8d475d 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). +# 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 # Compression algorithms for HTTP responses. @@ -36,3 +39,9 @@ COMPRESSION_ALGORITHMS=gzip,brotli,deflate,zstd # 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 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 diff --git a/Cargo.lock b/Cargo.lock index 5044243..532ca5c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5393,10 +5393,12 @@ version = "0.2.0" dependencies = [ "argon2", "axum", + "bytes", "chrono", "deadpool-postgres", "dioxus", "dotenvy", + "futures", "governor", "hex", "http", diff --git a/Cargo.toml b/Cargo.toml index f86de3b..43cd1e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,8 @@ 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 } +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"] } @@ -82,4 +84,6 @@ server = [ "dep:moka", "dep:governor", "dep:md-5", + "dep:futures", + "dep:bytes", ] diff --git a/migrations/010_post_word_counts.sql b/migrations/010_post_word_counts.sql new file mode 100644 index 0000000..776be7c --- /dev/null +++ b/migrations/010_post_word_counts.sql @@ -0,0 +1,29 @@ +-- 为 posts 表添加字数与阅读时长列。 +-- +-- 设计说明: +-- - 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; diff --git a/src/api/auth.rs b/src/api/auth.rs index bd24926..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")] @@ -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 @@ -314,16 +315,22 @@ 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 { + 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 + "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()", @@ -336,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"), }) @@ -348,6 +354,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) } @@ -370,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/api/image.rs b/src/api/image.rs index 6f85661..fc927c6 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 { @@ -485,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 { @@ -502,6 +505,7 @@ pub async fn serve_image( } }; + let processed = Bytes::from(processed); let cached = CachedImage { data: processed.clone(), content_type: content_type.clone(), @@ -758,7 +762,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 +782,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 +830,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(), diff --git a/src/api/posts/create.rs b/src/api/posts/create.rs index d485f27..55641ec 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 = crate::utils::text::reading_time(word_count); + // 发布状态的文章设置当前发布时间;草稿则为 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 @@ -133,17 +139,18 @@ 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(); + crate::cache::invalidate_search_results(); // 失效按 slug 缓存,避免之前缓存的 404 继续命中。 crate::cache::invalidate_post_by_slug(&final_slug).await; + // 失效该文章涉及的所有标签下文章列表缓存。 + crate::cache::invalidate_tag_posts_for(&tags_cleaned).await; - // 失效该文章涉及的所有标签缓存。 - for tag_name in &tags_cleaned { - crate::cache::invalidate_posts_by_tag(tag_name).await; - } + // 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。 + crate::ssr_cache::bump_global_generation(); Ok(CreatePostResponse { success: true, diff --git a/src/api/posts/delete.rs b/src/api/posts/delete.rs index b1f9043..4c6084d 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,23 +18,52 @@ 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?; #[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 slug_row = tx + .query_opt( + "SELECT slug FROM posts WHERE id = $1 AND deleted_at IS NULL FOR UPDATE", + &[&post_id], + ) + .await + .map_err(AppError::query)?; + + let Some(slug_row) = slug_row else { + return Ok(CreatePostResponse { + success: false, + message: "文章不存在".to_string(), + post_id: None, + slug: None, + }); + }; + 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", + &[&post_id], + ) + .await + .map_err(AppError::query)?; + let tags: Vec = 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 { @@ -45,14 +74,24 @@ pub async fn delete_post(post_id: i32) -> Result Post { +pub(super) fn row_to_post_list_item(row: &tokio_postgres::Row) -> PostListItem { 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 status_str: String = row.get("status"); + let status = PostStatus::from_str(&status_str).unwrap_or(PostStatus::Draft); // 聚合标签并过滤空字符串。 let tags: Vec = row @@ -35,17 +32,15 @@ pub(super) async fn row_to_post_list( .filter(|t| !t.is_empty()) .collect(); - 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"); - Post { + PostListItem { 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"), @@ -53,11 +48,8 @@ pub(super) async fn row_to_post_list( 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, + reading_time: reading_time.max(1) as u32, + word_count: word_count.max(0) as u32, } } @@ -110,6 +102,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, reading_time(wc)) + }; + let content_html: Option = row.get("content_html"); let toc_html_row: Option = row.get("toc_html"); @@ -117,7 +120,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, @@ -129,9 +131,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"), @@ -147,7 +146,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 07b5058..657f8ff 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,9 +81,10 @@ 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, - p.status, p.published_at, p.created_at, p.updated_at, p.cover_image, + "SELECT + 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 @@ -99,7 +100,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,9 +139,10 @@ pub async fn list_posts(page: i32, per_page: i32) -> Result Result Result Result Result Result Some(row_to_post_list(&client, &row).await), + Some(row) => Some(row_to_post_full(&client, &row).await), None => None, }; @@ -72,9 +73,10 @@ pub async fn get_post_by_slug(slug: String) -> Result Result rebuilt += 1, + Ok(_) => { + rebuilt += 1; + } Err(_) => { failed += 1; if errors.len() < MAX_DISPLAY_ERRORS { @@ -92,9 +103,13 @@ pub async fn rebuild_content_html(rebuild_all: bool) -> Result 0 || failed > 0 { + // 重建会修改 word_count / reading_time 等列表项字段,批量影响列表、标签云、 + // 标签文章及单篇缓存;这里使用全量失效作为务实的回退策略。 + if rebuilt > 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/search.rs b/src/api/posts/search.rs index 6704da1..50ac5aa 100644 --- a/src/api/posts/search.rs +++ b/src/api/posts/search.rs @@ -8,17 +8,19 @@ use dioxus::prelude::*; #[cfg(feature = "server")] -use super::helpers::row_to_post_list; +use super::helpers::row_to_post_list_item; 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; /// 搜索已发布文章。 /// /// 空查询直接返回空结果;非空查询使用 `word_similarity` 计算相关度, -/// 并限制返回 50 条记录。当前未缓存,每次均查询数据库。 +/// 并限制返回 50 条记录。结果写入短 TTL 内存缓存以减轻 DB 压力。 #[server(SearchPosts, "/api")] pub async fn search_posts(query: String) -> Result { #[cfg(feature = "server")] @@ -47,6 +49,12 @@ pub async fn search_posts(query: String) -> Result Result Result Result { let _user = get_current_admin_user().await?; @@ -31,37 +32,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 }) diff --git a/src/api/posts/trash.rs b/src/api/posts/trash.rs index 38fdd3e..3af8f45 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,10 +34,10 @@ 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,17 @@ pub async fn restore_post(post_id: i32) -> Result Result = tag_rows.iter().map(|r| r.get(0)).collect(); + + 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 { @@ -121,13 +175,24 @@ 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( - "SELECT slug FROM posts WHERE id = $1 AND deleted_at IS NOT NULL", + "SELECT slug FROM posts WHERE id = $1 AND deleted_at IS NOT NULL FOR UPDATE", &[&id], ) .await @@ -174,6 +246,20 @@ 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(); + crate::cache::invalidate_search_results(); + 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; + + // 递增 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 { success: true, @@ -224,18 +336,76 @@ pub async fn batch_purge_posts(post_ids: Vec) -> Result = + std::collections::HashSet::new(); + + 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)?; + + 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 = 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)?; - crate::cache::invalidate_all_post_caches(); + tx.commit().await.map_err(AppError::tx)?; + + if use_precise { + 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; + } + 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 { success: true, @@ -263,14 +433,65 @@ 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)?; - let result = client - .execute("DELETE 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 use_precise = !deleted_rows.is_empty() + && deleted_rows.len() <= PRECISE_INVALIDATION_LIMIT; - crate::cache::invalidate_all_post_caches(); + let (slugs, tags) = if use_precise { + 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 = tx + .query( + "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)?; + let tags: Vec = tag_rows.iter().map(|r| r.get(0)).collect(); + + (slugs, tags) + } else { + (Vec::new(), Vec::new()) + }; + + let result = tx + .execute("DELETE FROM posts WHERE deleted_at IS NOT NULL", &[]) + .await + .map_err(AppError::tx)?; + + tx.commit().await.map_err(AppError::tx)?; + + if use_precise { + 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; + } + 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 { success: true, diff --git a/src/api/posts/types.rs b/src/api/posts/types.rs index 4f1e118..5a8d913 100644 --- a/src/api/posts/types.rs +++ b/src/api/posts/types.rs @@ -1,6 +1,6 @@ //! 文章 API 的请求与响应数据结构。 -use crate::models::post::{Post, PostStats, Tag}; +use crate::models::post::{Post, PostListItem, PostStats, Tag}; /// 创建/更新/删除文章的统一响应结构。 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] @@ -18,8 +18,8 @@ pub struct CreatePostResponse { /// 文章列表响应。 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct PostListResponse { - /// 文章列表。 - pub posts: Vec, + /// 文章列表(轻量 DTO,不含正文)。 + pub posts: Vec, /// 符合查询条件的总数。 pub total: i64, } diff --git a/src/api/posts/update.rs b/src/api/posts/update.rs index ed2ebe4..f87a75e 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 = crate::utils::text::reading_time(word_count); + 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, ], ) @@ -192,17 +198,18 @@ pub async fn update_post( // 失效文章列表、标签、当前 slug 与统计缓存。 crate::cache::invalidate_post_lists(); crate::cache::invalidate_all_tags(); - crate::cache::invalidate_post_by_slug(&final_slug).await; crate::cache::invalidate_post_stats(); + crate::cache::invalidate_search_results(); + crate::cache::invalidate_post_by_slug(&final_slug).await; // 合并旧标签与新标签,统一失效标签下的文章列表缓存。 - let all_tags_to_invalidate: std::collections::HashSet = 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 { @@ -211,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/cache.rs b/src/cache.rs index dd0bea7..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")] @@ -14,7 +14,9 @@ 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}; +#[cfg(feature = "server")] +use crate::models::user::SessionUser; // ============================================================================ // 缓存 TTL 配置 @@ -48,6 +50,14 @@ 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); + +/// 搜索结果缓存 TTL:10 秒。 +#[cfg(feature = "server")] +const TTL_SEARCH: Duration = Duration::from_secs(10); + // ============================================================================ // 缓存 Key 类型 // ============================================================================ @@ -80,7 +90,7 @@ pub enum CacheKey { /// 文章列表缓存类型,值为(文章列表,总数)。 #[cfg(feature = "server")] -pub type PostListCache = Cache, i64)>; +pub type PostListCache = Cache, i64)>; /// 标签列表缓存类型。 #[cfg(feature = "server")] @@ -161,19 +171,45 @@ static PENDING_COUNT_CACHE: LazyLock> = LazyLock::new(|| { .build() }); +/// 会话用户缓存类型。 +#[cfg(feature = "server")] +pub type SessionCache = Cache; + +/// 搜索结果缓存类型。 +#[cfg(feature = "server")] +pub type SearchCache = Cache, i64)>; + +/// 全局会话用户缓存实例,最大容量 1000,TTL 5 分钟。 +#[cfg(feature = "server")] +pub static SESSION_CACHE: LazyLock = LazyLock::new(|| { + Cache::builder() + .max_capacity(1000) + .time_to_live(TTL_SESSION) + .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 // ============================================================================ /// 读取文章分页列表缓存。 #[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 +260,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 +268,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; @@ -288,7 +324,22 @@ pub fn invalidate_post_stats() { POST_STATS_CACHE.invalidate_all(); } +/// 按标签批量失效文章列表缓存。 +#[cfg(feature = "server")] +pub async fn invalidate_tag_posts_for(tags: &[String]) { + let futures: Vec<_> = tags + .iter() + .map(|tag| invalidate_posts_by_tag(tag)) + .collect(); + let _ = futures::future::join_all(futures).await; +} + /// 清空所有文章相关缓存(列表、标签、单篇、统计、标签文章)。 +/// +/// 这是一个“紧急”使用的全量失效开关,会一次性冲刷所有文章缓存; +/// 正常写路径应当使用更细粒度的 `invalidate_post_lists` / `invalidate_all_tags` / +/// `invalidate_post_by_slug` / `invalidate_posts_by_tag` / `invalidate_post_stats` / +/// `invalidate_tag_posts_for` 等函数,避免不必要的缓存击穿。 #[cfg(feature = "server")] pub fn invalidate_all_post_caches() { POST_LIST_CACHE.invalidate_all(); @@ -330,6 +381,53 @@ 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 { + SESSION_CACHE.get(token_hash).await +} + +/// 写入会话用户缓存。 +#[cfg(feature = "server")] +pub async fn set_session_user(token_hash: &str, user: SessionUser) { + let _ = SESSION_CACHE.insert(token_hash.to_string(), user).await; +} + +/// 失效指定会话用户缓存。 +#[cfg(feature = "server")] +pub async fn invalidate_session_user(token_hash: &str) { + SESSION_CACHE.invalidate(token_hash).await; +} + +/// 读取搜索结果缓存。 +#[cfg(feature = "server")] +pub async fn get_search_results(query: &str) -> Option<(Vec, 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; +} + +/// 清空所有搜索结果缓存。 +/// +/// 使用同步签名是因为 `moka::Cache::invalidate_all` 为同步操作; +/// 该函数通常由写路径直接调用,无需额外等待。 +#[cfg(feature = "server")] +pub fn invalidate_search_results() { + SEARCH_CACHE.invalidate_all(); +} + /// 按文章主键失效评论列表缓存。 #[cfg(feature = "server")] pub async fn invalidate_comments_by_post(post_id: i32) { @@ -351,6 +449,7 @@ mod tests { use super::*; use crate::models::comment::PublicComment; use crate::models::post::PostStatus; + use crate::models::user::{SessionUser, UserRole}; use serial_test::serial; #[test] @@ -379,15 +478,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] @@ -541,4 +656,94 @@ mod tests { assert!(get_pending_count().await.is_none()); } + #[tokio::test] + #[serial] + async fn session_cache_roundtrip() { + let user = SessionUser { + id: 42, + username: "cached_user".to_string(), + email: "cached@example.com".to_string(), + role: UserRole::Admin, + created_at: chrono::Utc::now(), + }; + let token_hash = "sha256_token_hash"; + + set_session_user(token_hash, user.clone()).await; + let cached = get_session_user(token_hash).await; + + assert!(cached.is_some()); + let cached_user = cached.unwrap(); + assert_eq!(cached_user.id, user.id); + assert_eq!(cached_user.username, user.username); + assert_eq!(cached_user.email, user.email); + assert_eq!(cached_user.role, user.role); + + invalidate_session_user(token_hash).await; + assert!(get_session_user(token_hash).await.is_none()); + } + + #[test] + fn search_key_normalization() { + assert_eq!(normalize_search_key(" Rust "), "rust"); + assert_eq!(normalize_search_key("Rust"), "rust"); + assert_eq!(normalize_search_key(" rust "), "rust"); + assert_eq!(normalize_search_key(""), ""); + + let long = "a".repeat(250); + let normalized = normalize_search_key(&long); + assert_eq!(normalized.len(), 200); + assert!(normalized.chars().all(|c| c == 'a')); + + // 大小写与空格差异应映射到同一键。 + assert_eq!( + normalize_search_key(" Dioxus Fullstack "), + normalize_search_key("dioxus fullstack") + ); + } + + #[tokio::test] + #[serial] + async fn search_cache_roundtrip() { + let query = "Rust"; + let posts = vec![PostListItem { + id: 1, + author_id: 1, + title: "Search Result".to_string(), + slug: "search-result".to_string(), + summary: None, + status: PostStatus::Published, + published_at: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + deleted_at: None, + tags: vec!["rust".to_string()], + cover_image: None, + reading_time: 1, + word_count: 10, + }]; + + set_search_results(query, posts.clone(), 1).await; + + // 大小写与空格差异应命中同一缓存条目。 + let cached = get_search_results(" rust ").await; + assert!(cached.is_some()); + let (cached_posts, cached_total) = cached.unwrap(); + assert_eq!(cached_posts.len(), 1); + assert_eq!(cached_posts[0].title, "Search Result"); + assert_eq!(cached_total, 1); + + invalidate_search_results(); + assert!(get_search_results(query).await.is_none()); + } + + #[tokio::test] + #[serial] + async fn search_cache_invalidation() { + set_search_results("tokio", vec![], 0).await; + assert!(get_search_results("tokio").await.is_some()); + + invalidate_search_results(); + assert!(get_search_results("tokio").await.is_none()); + } + } 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/main.rs b/src/main.rs index 467e03a..c440869 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; @@ -225,7 +228,15 @@ fn main() { tasks::post_purge::run_purge().await; }); - // 配置增量渲染缓存,默认缓存 3600 秒,可通过 SSR_CACHE_SECS 覆盖 + // 启动后台定时任务:图片磁盘缓存清理 + tokio::spawn(async { + tasks::image_cache_cleanup::run_cleanup().await; + }); + + // 配置增量渲染缓存,默认缓存 3600 秒,可通过 SSR_CACHE_SECS 覆盖。 + // 注意: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( @@ -237,6 +248,28 @@ fn main() { ), ); + // 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; + 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 + } + // 自定义 API 路由:图片上传(大文件,需要更长超时) let upload_route = axum::Router::new() .route( @@ -253,8 +286,10 @@ fn main() { let dioxus_app = axum::Router::new().serve_dioxus_application(config, router::AppRouter); - // 合并 Dioxus + 缓存头/可选压缩/30s 超时中间件 - let mut app_routes = dioxus_app.layer(axum::middleware::from_fn(add_cache_control)); + // 合并 Dioxus + 世代号/缓存头/可选压缩/30s 超时中间件 + let mut app_routes = dioxus_app + .layer(axum::middleware::from_fn(ssr_generation_middleware)) + .layer(axum::middleware::from_fn(add_cache_control)); if let Some(layer) = compression_layer_from_env() { app_routes = app_routes.layer(layer); } diff --git a/src/models/post.rs b/src/models/post.rs index 265da79..4512d23 100644 --- a/src/models/post.rs +++ b/src/models/post.rs @@ -85,6 +85,51 @@ impl Post { .map(|d| d.format("%Y-%m-%d").to_string()) .unwrap_or_else(|| self.created_at.format("%Y-%m-%d").to_string()) } +} + +/// 文章列表项 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 { @@ -173,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() { @@ -213,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; @@ -222,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; @@ -231,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(), 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")); + } } 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! { 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); + } +} diff --git a/src/tasks/image_cache_cleanup.rs b/src/tasks/image_cache_cleanup.rs new file mode 100644 index 0000000..d6cac0f --- /dev/null +++ b/src/tasks/image_cache_cleanup.rs @@ -0,0 +1,242 @@ +//! 图片磁盘缓存定期清理任务。 +//! +//! 仅在 `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"; +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(SECS_PER_HOUR)); + 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 * SECS_PER_HOUR); + 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(BYTES_PER_MB); + 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)) +} + +/// 递归收集目录下的所有常规文件,返回路径、大小与修改时间。 +/// +/// 跳过符号链接,避免 traversal 到 `uploads/.cache/` 外部。 +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 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 file_type.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; 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) => { 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);