Compare commits

...

36 Commits

Author SHA1 Message Date
xfy
306da3cf83 Merge caching and SSR invalidation improvements
Some checks failed
CI / check (push) Failing after 24m9s
CI / build (push) Has been skipped
2026-06-18 10:43:40 +08:00
xfy
ed589f9c79 docs(changelog): record caching and SSR invalidation improvements 2026-06-18 10:37:14 +08:00
xfy
668920e9fb docs(ssr): clarify that generation counters are future-ready infrastructure 2026-06-18 10:30:18 +08:00
xfy
7f372446da refactor(ssr): restrict X-SSR-Generation header to GET requests and add serial tests 2026-06-18 10:30:12 +08:00
xfy
3ee39d910c refactor(ssr): remove unused per-slug/per-tag generation counters 2026-06-18 10:30:07 +08:00
xfy
4c695b4fc3 refactor(tasks): use named constants for MB and hours in cleanup 2026-06-18 10:11:07 +08:00
xfy
12355b7859 fix(tasks): skip symlinks during image disk cache cleanup 2026-06-18 10:10:51 +08:00
xfy
a71da7473d perf(image): remove redundant Vec clone before spawn_blocking 2026-06-18 10:10:44 +08:00
xfy
c0b14ed498 refactor(image): make bytes dependency optional and server-gated 2026-06-18 10:10:40 +08:00
xfy
f1e5b657a3 chore(env): document image disk cache limits in .env.example 2026-06-18 10:03:42 +08:00
xfy
0b107c3f2e feat(tasks): add periodic image disk cache cleanup 2026-06-18 10:03:38 +08:00
xfy
36554af5f5 perf(image): store cached image data as Bytes to avoid Vec cloning 2026-06-18 10:03:35 +08:00
xfy
411e565465 docs(cache,search): update module and function comments 2026-06-18 09:55:45 +08:00
xfy
24bc6f44a0 fix(tasks): invalidate session cache after cleaning expired sessions 2026-06-18 09:55:04 +08:00
xfy
518b4e5d64 refactor(auth): store SessionUser instead of full User in session cache 2026-06-18 09:55:00 +08:00
xfy
c780247d17 feat(posts): invalidate search cache on writes 2026-06-17 17:21:01 +08:00
xfy
c40a771989 feat(search): cache search results with short TTL 2026-06-17 17:20:54 +08:00
xfy
1d216faa2f feat(auth): add in-memory session cache 2026-06-17 17:20:47 +08:00
xfy
75b8f80631 fix(posts): wrap empty_trash in transaction and lock rows for precise invalidation 2026-06-17 17:05:07 +08:00
xfy
7bd02d0ea9 perf(cache): run tag post invalidations concurrently 2026-06-17 16:59:29 +08:00
xfy
2d8f1e0d98 refactor(posts): avoid redundant cache invalidation in bulk fallback paths 2026-06-17 16:59:24 +08:00
xfy
c77f751377 refactor(posts): apply precise invalidation limit to batch_restore_posts 2026-06-17 16:59:18 +08:00
xfy
ca212a2aab fix(posts): lock rows and read metadata in transaction for delete/purge/restore 2026-06-17 16:58:54 +08:00
xfy
1092fbb3ce fix(posts): ensure rebuild invalidates tag posts cache 2026-06-17 16:44:32 +08:00
xfy
c03093fc8b refactor(posts): use precise cache invalidation in rebuild 2026-06-17 16:37:20 +08:00
xfy
bb34e2c36e refactor(posts): use precise cache invalidation in trash operations 2026-06-17 16:37:20 +08:00
xfy
82b070e7ac refactor(posts): use precise cache invalidation in create/update/delete 2026-06-17 16:37:20 +08:00
xfy
c528936abb feat(cache): add invalidate_tag_posts_for helper 2026-06-17 16:37:20 +08:00
xfy
15e7e2578d refactor(utils): extract reading_time helper 2026-06-17 16:25:22 +08:00
xfy
e683efe0ea refactor(models): remove dead Post status methods, add PostListItem tests 2026-06-17 16:24:50 +08:00
xfy
468a819951 fix(db): backfill word_count and reading_time for existing posts 2026-06-17 16:24:44 +08:00
xfy
9f41ea7354 refactor(posts): remove orphan row_to_post_list and use row_to_post_full for get_post_by_id 2026-06-17 16:24:38 +08:00
xfy
1eedab8f21 perf(posts): remove content_md from list/search SQL, read stored word counts 2026-06-17 16:06:27 +08:00
xfy
a6f08d5d3f feat(db): add word_count and reading_time columns to posts 2026-06-17 16:06:21 +08:00
xfy
51e20980db perf(stats): combine three COUNT queries into one conditional aggregation 2026-06-17 15:51:29 +08:00
xfy
170c021b37 feat(cache): add PostListItem DTO and use it in list/tag/search caches 2026-06-17 15:51:19 +08:00
32 changed files with 1299 additions and 203 deletions

View File

@ -23,7 +23,10 @@ MAX_SESSIONS_PER_USER=5
# Database connection pool size (default: 20) # Database connection pool size (default: 20)
DB_POOL_SIZE=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 SSR_CACHE_SECS=3600
# Compression algorithms for HTTP responses. # 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. # Processed variants (?w=, ?format=, etc.) are cached for 24 hours.
# To invalidate a cached raw upload, change its file path. # To invalidate a cached raw upload, change its file path.
# To refresh a processed variant, change its processing parameters. # 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

View File

@ -10,6 +10,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- 图片响应新增 `Cache-Control``ETag` 头:原始上传文件使用 `immutable, max-age=31536000`,处理变体使用 `max-age=86400` - 图片响应新增 `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 ### Fixed

2
Cargo.lock generated
View File

@ -5393,10 +5393,12 @@ version = "0.2.0"
dependencies = [ dependencies = [
"argon2", "argon2",
"axum", "axum",
"bytes",
"chrono", "chrono",
"deadpool-postgres", "deadpool-postgres",
"dioxus", "dioxus",
"dotenvy", "dotenvy",
"futures",
"governor", "governor",
"hex", "hex",
"http", "http",

View File

@ -36,6 +36,8 @@ zenwebp = { version = "0.3", optional = true }
moka = { version = "0.12", features = ["future"], optional = true } moka = { version = "0.12", features = ["future"], optional = true }
governor = { version = "0.8", optional = true } governor = { version = "0.8", optional = true }
md-5 = { version = "0.10", 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] [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"] } 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:moka",
"dep:governor", "dep:governor",
"dep:md-5", "dep:md-5",
"dep:futures",
"dep:bytes",
] ]

View File

@ -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;

View File

@ -20,7 +20,7 @@ use crate::auth::{password, session};
#[cfg(feature = "server")] #[cfg(feature = "server")]
use crate::db::pool::get_conn; use crate::db::pool::get_conn;
#[cfg(feature = "server")] #[cfg(feature = "server")]
use crate::models::user::{User, UserRole}; use crate::models::user::{SessionUser, UserRole};
use crate::models::user::PublicUser; use crate::models::user::PublicUser;
#[cfg(feature = "server")] #[cfg(feature = "server")]
@ -293,6 +293,7 @@ pub async fn logout() -> Result<AuthResponse, ServerFnError> {
if let Some(t) = token { if let Some(t) = token {
let token_hash = session::hash_token(&t); let token_hash = session::hash_token(&t);
crate::cache::invalidate_session_user(&token_hash).await;
client client
.execute("DELETE FROM sessions WHERE token_hash = $1", &[&token_hash]) .execute("DELETE FROM sessions WHERE token_hash = $1", &[&token_hash])
.await .await
@ -314,16 +315,22 @@ pub struct CurrentUserResponse {
} }
#[cfg(feature = "server")] #[cfg(feature = "server")]
/// 根据会话 token 查询对应用户(含密码哈希等完整信息)。 /// 根据会话 token 查询对应用户(不含密码哈希,供会话缓存使用)。
/// ///
/// 优先命中内存缓存,避免每次请求都执行 DB JOIN未命中时回查数据库并回填缓存。
/// 仅服务端内部使用,不会暴露给前端。 /// 仅服务端内部使用,不会暴露给前端。
pub async fn get_user_by_token(token: &str) -> Result<Option<User>, ServerFnError> { pub async fn get_user_by_token(token: &str) -> Result<Option<SessionUser>, 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 client = get_conn().await.map_err(AppError::db_conn)?;
let token_hash = session::hash_token(token);
let row = client let row = client
.query_opt( .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 FROM sessions s
JOIN users u ON s.user_id = u.id JOIN users u ON s.user_id = u.id
WHERE s.token_hash = $1 AND s.expires_at > NOW()", WHERE s.token_hash = $1 AND s.expires_at > NOW()",
@ -336,11 +343,10 @@ pub async fn get_user_by_token(token: &str) -> Result<Option<User>, ServerFnErro
Some(row) => { Some(row) => {
let role_str: String = row.get("role"); let role_str: String = row.get("role");
let role = UserRole::from_str(&role_str).unwrap_or(UserRole::Blocked); let role = UserRole::from_str(&role_str).unwrap_or(UserRole::Blocked);
Some(User { Some(SessionUser {
id: row.get("id"), id: row.get("id"),
username: row.get("username"), username: row.get("username"),
email: row.get("email"), email: row.get("email"),
password_hash: row.get("password_hash"),
role, role,
created_at: row.get("created_at"), created_at: row.get("created_at"),
}) })
@ -348,6 +354,10 @@ pub async fn get_user_by_token(token: &str) -> Result<Option<User>, ServerFnErro
None => None, None => None,
}; };
if let Some(ref u) = user {
crate::cache::set_session_user(&token_hash, u.clone()).await;
}
Ok(user) Ok(user)
} }
@ -370,19 +380,19 @@ pub async fn get_current_user() -> Result<CurrentUserResponse, ServerFnError> {
/// 获取当前登录用户并要求其为 admin否则返回 401/403。 /// 获取当前登录用户并要求其为 admin否则返回 401/403。
/// ///
/// 供其它服务端接口内部调用。 /// 供其它服务端接口内部调用。
pub async fn get_current_admin_user() -> Result<User, AppError> { pub async fn get_current_admin_user() -> Result<SessionUser, AppError> {
let token = get_session_from_ctx().ok_or(AppError::Unauthorized("未登录"))?; 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 .await
.map_err(AppError::query)? .map_err(AppError::query)?
.ok_or(AppError::Unauthorized("会话已过期"))?; .ok_or(AppError::Unauthorized("会话已过期"))?;
if user.role != UserRole::Admin { if session_user.role != UserRole::Admin {
return Err(AppError::Forbidden("权限不足")); return Err(AppError::Forbidden("权限不足"));
} }
Ok(user) Ok(session_user)
} }
#[cfg(all(test, feature = "server"))] #[cfg(all(test, feature = "server"))]

View File

@ -19,6 +19,8 @@ use moka::future::Cache;
use serde::Deserialize; use serde::Deserialize;
#[cfg(feature = "server")] #[cfg(feature = "server")]
use std::sync::LazyLock; use std::sync::LazyLock;
#[cfg(feature = "server")]
use bytes::Bytes;
#[cfg(feature = "server")] #[cfg(feature = "server")]
fn etag_for(data: &[u8]) -> String { 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)] #[derive(Debug, Clone)]
/// 缓存条目,保存处理后的图片字节与 Content-Type。 /// 缓存条目,保存处理后的图片字节与 Content-Type。
struct CachedImage { struct CachedImage {
data: Vec<u8>, data: Bytes,
content_type: HeaderValue, content_type: HeaderValue,
} }
@ -186,7 +188,7 @@ fn content_type(format: image::ImageFormat) -> HeaderValue {
#[cfg(feature = "server")] #[cfg(feature = "server")]
fn image_response( fn image_response(
data: Vec<u8>, data: Bytes,
content_type: HeaderValue, content_type: HeaderValue,
cache_control: &'static str, cache_control: &'static str,
headers: &HeaderMap, headers: &HeaderMap,
@ -399,7 +401,10 @@ async fn read_disk_cache(cache_key: &str) -> Option<CachedImage> {
.ok() .ok()
.unwrap_or_else(|| "application/octet-stream".to_string()); .unwrap_or_else(|| "application/octet-stream".to_string());
let content_type = HeaderValue::from_str(&ct_str).ok()?; 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")] #[cfg(feature = "server")]
@ -454,7 +459,7 @@ pub async fn serve_image(
return match tokio::fs::read(&file_path).await { return match tokio::fs::read(&file_path).await {
Ok(data) => { Ok(data) => {
let ct = content_type(detect_format(&path)); 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(), Err(_) => StatusCode::NOT_FOUND.into_response(),
}; };
@ -462,20 +467,19 @@ pub async fn serve_image(
let cache_key = params.cache_key(&path); let cache_key = params.cache_key(&path);
if let Some(cached) = IMAGE_CACHE.get(&cache_key).await { 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 { if let Some(cached) = read_disk_cache(&cache_key).await {
let _ = IMAGE_CACHE let data = cached.data.clone();
.insert( let content_type = cached.content_type.clone();
cache_key.clone(), let _ = IMAGE_CACHE.insert(cache_key.clone(), cached).await;
CachedImage { return image_response(data, content_type, "public, max-age=86400", &headers);
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 = match tokio::fs::read(&file_path).await { 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 // Offload decode + resize + encode to the blocking pool so the async
// runtime stays responsive to other requests. // runtime stays responsive to other requests.
let data_for_blocking = data.clone();
let path_for_blocking = path.clone(); let path_for_blocking = path.clone();
let params_for_blocking = params.clone(); let params_for_blocking = params.clone();
let (processed, content_type) = let (processed, content_type) =
match tokio::task::spawn_blocking(move || { 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 .await
{ {
@ -502,6 +505,7 @@ pub async fn serve_image(
} }
}; };
let processed = Bytes::from(processed);
let cached = CachedImage { let cached = CachedImage {
data: processed.clone(), data: processed.clone(),
content_type: content_type.clone(), content_type: content_type.clone(),
@ -758,7 +762,7 @@ mod tests {
#[test] #[test]
fn image_response_includes_cache_headers() { fn image_response_includes_cache_headers() {
let resp = image_response( let resp = image_response(
vec![1, 2, 3], Bytes::from(vec![1, 2, 3]),
HeaderValue::from_static("image/webp"), HeaderValue::from_static("image/webp"),
"public, max-age=86400", "public, max-age=86400",
&HeaderMap::new(), &HeaderMap::new(),
@ -778,7 +782,7 @@ mod tests {
#[test] #[test]
fn image_response_returns_304_when_etag_matches() { 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 etag = etag_for(&data);
let mut req_headers = HeaderMap::new(); let mut req_headers = HeaderMap::new();
req_headers.insert( req_headers.insert(
@ -826,7 +830,7 @@ mod tests {
#[test] #[test]
fn image_response_raw_file_is_immutable() { fn image_response_raw_file_is_immutable() {
let resp = image_response( let resp = image_response(
vec![1, 2, 3], Bytes::from(vec![1, 2, 3]),
HeaderValue::from_static("image/jpeg"), HeaderValue::from_static("image/jpeg"),
"public, max-age=31536000, immutable", "public, max-age=31536000, immutable",
&HeaderMap::new(), &HeaderMap::new(),

View File

@ -91,6 +91,10 @@ pub async fn create_post(
let post_status = PostStatus::from_str(&status).unwrap_or(PostStatus::Draft); let post_status = PostStatus::from_str(&status).unwrap_or(PostStatus::Draft);
let cover_image = cover_image.filter(|s| !s.trim().is_empty()); 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。 // 发布状态的文章设置当前发布时间;草稿则为 None。
let published_at = if post_status == PostStatus::Published { let published_at = if post_status == PostStatus::Published {
Some(chrono::Utc::now()) Some(chrono::Utc::now())
@ -106,8 +110,8 @@ pub async fn create_post(
// 插入文章记录。 // 插入文章记录。
let row = tx let row = tx
.query_one( .query_one(
"INSERT INTO posts (author_id, title, slug, summary, content_md, content_html, toc_html, status, published_at, cover_image) "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) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING id", RETURNING id",
&[ &[
&user.id, &user.id,
@ -120,6 +124,8 @@ pub async fn create_post(
&post_status.as_str(), &post_status.as_str(),
&published_at, &published_at,
&cover_image, &cover_image,
&(word_count as i32),
&(reading_time as i32),
], ],
) )
.await .await
@ -133,17 +139,18 @@ pub async fn create_post(
tx.commit().await.map_err(AppError::tx)?; tx.commit().await.map_err(AppError::tx)?;
// 写入成功后失效文章列表、标签与统计缓存。 // 写入成功后按粒度失效相关缓存。
crate::cache::invalidate_post_lists(); crate::cache::invalidate_post_lists();
crate::cache::invalidate_all_tags(); crate::cache::invalidate_all_tags();
crate::cache::invalidate_post_stats(); crate::cache::invalidate_post_stats();
crate::cache::invalidate_search_results();
// 失效按 slug 缓存,避免之前缓存的 404 继续命中。 // 失效按 slug 缓存,避免之前缓存的 404 继续命中。
crate::cache::invalidate_post_by_slug(&final_slug).await; crate::cache::invalidate_post_by_slug(&final_slug).await;
// 失效该文章涉及的所有标签下文章列表缓存。
crate::cache::invalidate_tag_posts_for(&tags_cleaned).await;
// 失效该文章涉及的所有标签缓存。 // 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。
for tag_name in &tags_cleaned { crate::ssr_cache::bump_global_generation();
crate::cache::invalidate_posts_by_tag(tag_name).await;
}
Ok(CreatePostResponse { Ok(CreatePostResponse {
success: true, success: true,

View File

@ -1,7 +1,7 @@
//! 删除文章接口。 //! 删除文章接口。
//! //!
//! 采用软删除方式,将 posts.deleted_at 设置为当前时间, //! 采用软删除方式,将 posts.deleted_at 设置为当前时间,
//! 同时清空所有文章相关缓存。 //! 并按影响范围失效相关缓存。
//! Dioxus server function注册在 `/api` 路径下。 //! Dioxus server function注册在 `/api` 路径下。
//! 仅在 `feature = "server"` 启用的服务端构建中执行删除与缓存失效。 //! 仅在 `feature = "server"` 启用的服务端构建中执行删除与缓存失效。
@ -18,23 +18,52 @@ use crate::db::pool::get_conn;
/// 删除指定文章。 /// 删除指定文章。
/// ///
/// 仅 admin 可调用;通过设置 deleted_at 实现软删除, /// 仅 admin 可调用;通过设置 deleted_at 实现软删除,
/// 成功后清空全部文章缓存。 /// 成功后按影响范围失效文章列表、标签云、统计、slug 及相关标签文章缓存。
#[server(DeletePost, "/api")] #[server(DeletePost, "/api")]
pub async fn delete_post(post_id: i32) -> Result<CreatePostResponse, ServerFnError> { pub async fn delete_post(post_id: i32) -> Result<CreatePostResponse, ServerFnError> {
let _user = get_current_admin_user().await?; let _user = get_current_admin_user().await?;
#[cfg(feature = "server")] #[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<String> = tag_rows.iter().map(|r| r.get(0)).collect();
// 软删除:仅影响未被删除的文章。 // 软删除:仅影响未被删除的文章。
let result = client let result = tx
.execute( .execute(
"UPDATE posts SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL", "UPDATE posts SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL",
&[&post_id], &[&post_id],
) )
.await .await
.map_err(AppError::query)?; .map_err(AppError::tx)?;
if result == 0 { if result == 0 {
return Ok(CreatePostResponse { return Ok(CreatePostResponse {
@ -45,14 +74,24 @@ pub async fn delete_post(post_id: i32) -> Result<CreatePostResponse, ServerFnErr
}); });
} }
// 删除后所有文章相关缓存均失效。 tx.commit().await.map_err(AppError::tx)?;
crate::cache::invalidate_all_post_caches();
// 删除后按影响范围精准失效缓存。
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(&slug).await;
crate::cache::invalidate_tag_posts_for(&tags).await;
// 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。
crate::ssr_cache::bump_global_generation();
Ok(CreatePostResponse { Ok(CreatePostResponse {
success: true, success: true,
message: "删除成功".to_string(), message: "删除成功".to_string(),
post_id: Some(post_id), post_id: Some(post_id),
slug: None, slug: Some(slug),
}) })
} }

View File

@ -6,26 +6,23 @@
#[cfg(feature = "server")] #[cfg(feature = "server")]
use crate::api::error::AppError; use crate::api::error::AppError;
#[cfg(feature = "server")] #[cfg(feature = "server")]
use crate::models::post::{Post, PostStatus}; use crate::models::post::{Post, PostListItem, PostStatus};
#[cfg(feature = "server")] #[cfg(feature = "server")]
use crate::utils::text::count_words; use crate::utils::text::{count_words, reading_time};
/// 复用认证模块的当前 admin 用户获取逻辑。 /// 复用认证模块的当前 admin 用户获取逻辑。
#[cfg(feature = "server")] #[cfg(feature = "server")]
pub(super) use crate::api::auth::get_current_admin_user; pub(super) use crate::api::auth::get_current_admin_user;
/// 将数据库行转换为文章列表项 /// 将数据库行转换为轻量列表项 DTO
/// ///
/// 用于列表接口,包含标签聚合、字数与阅读时长估算, /// 不包含 `content_md`/`content_html`;字数与阅读时长直接读取已持久化的列。
/// 不包含上下篇导航与目录 /// 同步函数,不依赖数据库连接
#[cfg(feature = "server")] #[cfg(feature = "server")]
pub(super) async fn row_to_post_list( pub(super) fn row_to_post_list_item(row: &tokio_postgres::Row) -> PostListItem {
_client: &tokio_postgres::Client,
row: &tokio_postgres::Row,
) -> Post {
let id: i32 = row.get("id"); let id: i32 = row.get("id");
let role_str: String = row.get("status"); let status_str: String = row.get("status");
let status = PostStatus::from_str(&role_str).unwrap_or(PostStatus::Draft); let status = PostStatus::from_str(&status_str).unwrap_or(PostStatus::Draft);
// 聚合标签并过滤空字符串。 // 聚合标签并过滤空字符串。
let tags: Vec<String> = row let tags: Vec<String> = row
@ -35,17 +32,15 @@ pub(super) async fn row_to_post_list(
.filter(|t| !t.is_empty()) .filter(|t| !t.is_empty())
.collect(); .collect();
let content_md: String = row.get("content_md"); let word_count: i32 = row.get("word_count");
let word_count = count_words(&content_md); let reading_time: i32 = row.get("reading_time");
Post { PostListItem {
id, id,
author_id: row.get("author_id"), author_id: row.get("author_id"),
title: row.get("title"), title: row.get("title"),
slug: row.get("slug"), slug: row.get("slug"),
summary: row.get("summary"), summary: row.get("summary"),
content_md,
content_html: row.get("content_html"),
status, status,
published_at: row.get("published_at"), published_at: row.get("published_at"),
created_at: row.get("created_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(), deleted_at: row.try_get("deleted_at").ok(),
tags, tags,
cover_image: row.get("cover_image"), cover_image: row.get("cover_image"),
reading_time: (word_count / 200).max(1), reading_time: reading_time.max(1) as u32,
word_count, word_count: word_count.max(0) as u32,
toc_html: None,
prev_post: None,
next_post: None,
} }
} }
@ -110,6 +102,17 @@ pub(super) async fn row_to_post_full(
None 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<String> = row.get("content_html"); let content_html: Option<String> = row.get("content_html");
let toc_html_row: Option<String> = row.get("toc_html"); let toc_html_row: Option<String> = 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 { let (content_html, toc_html) = if let Some(html) = content_html {
(html, toc_html_row) (html, toc_html_row)
} else { } else {
let content_md: String = row.get("content_md");
let rendered = crate::api::markdown::render_markdown_enhanced(&content_md); let rendered = crate::api::markdown::render_markdown_enhanced(&content_md);
( (
rendered.html, 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 { Post {
id, id,
author_id: row.get("author_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(), deleted_at: row.try_get("deleted_at").ok(),
tags, tags,
cover_image: row.get("cover_image"), cover_image: row.get("cover_image"),
reading_time: (word_count / 200).max(1), reading_time,
word_count, word_count,
toc_html, toc_html,
prev_post, prev_post,

View File

@ -8,7 +8,7 @@
use dioxus::prelude::*; use dioxus::prelude::*;
#[cfg(feature = "server")] #[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; use super::types::PostListResponse;
#[cfg(feature = "server")] #[cfg(feature = "server")]
use crate::api::error::AppError; use crate::api::error::AppError;
@ -81,9 +81,10 @@ pub async fn list_published_posts(
let limit = per_page as i64; let limit = per_page as i64;
let rows = client let rows = client
.query( .query(
"SELECT "SELECT
p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.content_html, p.id, p.author_id, p.title, p.slug, p.summary, p.status,
p.status, p.published_at, p.created_at, p.updated_at, p.cover_image, 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 COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags
FROM posts p FROM posts p
LEFT JOIN post_tags pt ON p.id = pt.post_id 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(); let mut posts = Vec::new();
for row in &rows { 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; 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<PostListResponse, Se
let limit = per_page as i64; let limit = per_page as i64;
let rows = client let rows = client
.query( .query(
"SELECT "SELECT
p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.content_html, p.id, p.author_id, p.title, p.slug, p.summary, p.status,
p.status, p.published_at, p.created_at, p.updated_at, p.cover_image, 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 COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags
FROM posts p FROM posts p
LEFT JOIN post_tags pt ON p.id = pt.post_id LEFT JOIN post_tags pt ON p.id = pt.post_id
@ -156,7 +158,7 @@ pub async fn list_posts(page: i32, per_page: i32) -> Result<PostListResponse, Se
let mut posts = Vec::new(); let mut posts = Vec::new();
for row in &rows { for row in &rows {
posts.push(row_to_post_list(&client, row).await); posts.push(row_to_post_list_item(row));
} }
Ok(PostListResponse { posts, total }) Ok(PostListResponse { posts, total })
@ -197,9 +199,10 @@ pub async fn list_deleted_posts(
let limit = per_page as i64; let limit = per_page as i64;
let rows = client let rows = client
.query( .query(
"SELECT "SELECT
p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.content_html, p.id, p.author_id, p.title, p.slug, p.summary, p.status,
p.status, p.published_at, p.created_at, p.updated_at, p.cover_image, p.deleted_at, p.published_at, p.created_at, p.updated_at, p.cover_image, p.deleted_at,
p.word_count, p.reading_time,
COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags
FROM posts p FROM posts p
LEFT JOIN post_tags pt ON p.id = pt.post_id LEFT JOIN post_tags pt ON p.id = pt.post_id
@ -215,7 +218,7 @@ pub async fn list_deleted_posts(
let mut posts = Vec::new(); let mut posts = Vec::new();
for row in &rows { for row in &rows {
posts.push(row_to_post_list(&client, row).await); posts.push(row_to_post_list_item(row));
} }
Ok(PostListResponse { posts, total }) Ok(PostListResponse { posts, total })
@ -250,9 +253,10 @@ pub async fn get_posts_by_tag(tag_name: String) -> Result<PostListResponse, Serv
// 通过 JOIN 筛选含目标标签的已发布文章,并聚合该文章的所有标签。 // 通过 JOIN 筛选含目标标签的已发布文章,并聚合该文章的所有标签。
let rows = client let rows = client
.query( .query(
"SELECT "SELECT
p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.content_html, p.id, p.author_id, p.title, p.slug, p.summary, p.status,
p.status, p.published_at, p.created_at, p.updated_at, p.cover_image, p.published_at, p.created_at, p.updated_at, p.cover_image,
p.word_count, p.reading_time,
COALESCE(array_agg(t2.name) FILTER (WHERE t2.name IS NOT NULL), '{}') as tags COALESCE(array_agg(t2.name) FILTER (WHERE t2.name IS NOT NULL), '{}') as tags
FROM posts p FROM posts p
JOIN post_tags pt ON p.id = pt.post_id JOIN post_tags pt ON p.id = pt.post_id
@ -270,7 +274,7 @@ pub async fn get_posts_by_tag(tag_name: String) -> Result<PostListResponse, Serv
let mut posts = Vec::new(); let mut posts = Vec::new();
for row in &rows { for row in &rows {
posts.push(row_to_post_list(&client, row).await); posts.push(row_to_post_list_item(row));
} }
// 当前查询未分页,返回全部匹配文章,因此 total 等于结果长度。 // 当前查询未分页,返回全部匹配文章,因此 total 等于结果长度。

View File

@ -8,7 +8,7 @@
use dioxus::prelude::*; use dioxus::prelude::*;
#[cfg(feature = "server")] #[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; use super::types::SinglePostResponse;
#[cfg(feature = "server")] #[cfg(feature = "server")]
use crate::api::error::AppError; use crate::api::error::AppError;
@ -28,9 +28,10 @@ pub async fn get_post_by_id(post_id: i32) -> Result<SinglePostResponse, ServerFn
let row = client let row = client
.query_opt( .query_opt(
"SELECT "SELECT
p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.content_html, p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.content_html, p.toc_html,
p.status, p.published_at, p.created_at, p.updated_at, p.cover_image, 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 COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags
FROM posts p FROM posts p
LEFT JOIN post_tags pt ON p.id = pt.post_id LEFT JOIN post_tags pt ON p.id = pt.post_id
@ -43,7 +44,7 @@ pub async fn get_post_by_id(post_id: i32) -> Result<SinglePostResponse, ServerFn
.map_err(AppError::query)?; .map_err(AppError::query)?;
let post = match row { let post = match row {
Some(row) => Some(row_to_post_list(&client, &row).await), Some(row) => Some(row_to_post_full(&client, &row).await),
None => None, None => None,
}; };
@ -72,9 +73,10 @@ pub async fn get_post_by_slug(slug: String) -> Result<SinglePostResponse, Server
// 使用 LATERAL JOIN 查询按 published_at 排序的相邻文章。 // 使用 LATERAL JOIN 查询按 published_at 排序的相邻文章。
let row = client let row = client
.query_opt( .query_opt(
"SELECT "SELECT
p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.content_html, p.toc_html, p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.content_html, p.toc_html,
p.status, p.published_at, p.created_at, p.updated_at, p.cover_image, 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, COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags,
prev.title as prev_title, prev.slug as prev_slug, prev.title as prev_title, prev.slug as prev_slug,
next.title as next_title, next.slug as next_slug next.title as next_title, next.slug as next_slug

View File

@ -75,14 +75,25 @@ pub async fn rebuild_content_html(rebuild_all: bool) -> Result<RebuildResult, Se
Some(rendered.toc_html) Some(rendered.toc_html)
}; };
let word_count = crate::utils::text::count_words(&content_md);
let reading_time = crate::utils::text::reading_time(word_count);
match client match client
.execute( .execute(
"UPDATE posts SET content_html = $1, toc_html = $2 WHERE id = $3", "UPDATE posts SET content_html = $1, toc_html = $2, word_count = $3, reading_time = $4 WHERE id = $5",
&[&rendered.html, &toc_html, &id], &[
&rendered.html,
&toc_html,
&(word_count as i32),
&(reading_time as i32),
&id,
],
) )
.await .await
{ {
Ok(_) => rebuilt += 1, Ok(_) => {
rebuilt += 1;
}
Err(_) => { Err(_) => {
failed += 1; failed += 1;
if errors.len() < MAX_DISPLAY_ERRORS { if errors.len() < MAX_DISPLAY_ERRORS {
@ -92,9 +103,13 @@ pub async fn rebuild_content_html(rebuild_all: bool) -> Result<RebuildResult, Se
} }
} }
// 只要有文章被更新,就清空所有文章缓存。 // 重建会修改 word_count / reading_time 等列表项字段,批量影响列表、标签云、
if rebuilt > 0 || failed > 0 { // 标签文章及单篇缓存;这里使用全量失效作为务实的回退策略。
if rebuilt > 0 {
crate::cache::invalidate_all_post_caches(); crate::cache::invalidate_all_post_caches();
crate::cache::invalidate_search_results();
// 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。
crate::ssr_cache::bump_global_generation();
} }
Ok(RebuildResult { Ok(RebuildResult {

View File

@ -8,17 +8,19 @@
use dioxus::prelude::*; use dioxus::prelude::*;
#[cfg(feature = "server")] #[cfg(feature = "server")]
use super::helpers::row_to_post_list; use super::helpers::row_to_post_list_item;
use super::types::PostListResponse; use super::types::PostListResponse;
#[cfg(feature = "server")] #[cfg(feature = "server")]
use crate::api::error::AppError; use crate::api::error::AppError;
#[cfg(feature = "server")] #[cfg(feature = "server")]
use crate::cache;
#[cfg(feature = "server")]
use crate::db::pool::get_conn; use crate::db::pool::get_conn;
/// 搜索已发布文章。 /// 搜索已发布文章。
/// ///
/// 空查询直接返回空结果;非空查询使用 `word_similarity` 计算相关度, /// 空查询直接返回空结果;非空查询使用 `word_similarity` 计算相关度,
/// 并限制返回 50 条记录。当前未缓存,每次均查询数据库 /// 并限制返回 50 条记录。结果写入短 TTL 内存缓存以减轻 DB 压力
#[server(SearchPosts, "/api")] #[server(SearchPosts, "/api")]
pub async fn search_posts(query: String) -> Result<PostListResponse, ServerFnError> { pub async fn search_posts(query: String) -> Result<PostListResponse, ServerFnError> {
#[cfg(feature = "server")] #[cfg(feature = "server")]
@ -47,6 +49,12 @@ pub async fn search_posts(query: String) -> Result<PostListResponse, ServerFnErr
}); });
} }
// 先检查短 TTL 的搜索结果缓存。
let cache_key = cache::normalize_search_key(q);
if let Some((posts, total)) = cache::get_search_results(&cache_key).await {
return Ok(PostListResponse { posts, total });
}
// 转义 SQL LIKE 通配符,避免用户输入 % / _ 导致全表扫描。 // 转义 SQL LIKE 通配符,避免用户输入 % / _ 导致全表扫描。
let escaped = q let escaped = q
.replace('\\', "\\\\") .replace('\\', "\\\\")
@ -56,9 +64,10 @@ pub async fn search_posts(query: String) -> Result<PostListResponse, ServerFnErr
// 使用 ILIKE 做前缀模糊匹配,并按 word_similarity 降序、发布时间降序排序。 // 使用 ILIKE 做前缀模糊匹配,并按 word_similarity 降序、发布时间降序排序。
let rows = client let rows = client
.query( .query(
"SELECT "SELECT
p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.content_html, p.id, p.author_id, p.title, p.slug, p.summary, p.status,
p.status, p.published_at, p.created_at, p.updated_at, p.cover_image, 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, COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags,
word_similarity(p.search_text, $2) AS sml word_similarity(p.search_text, $2) AS sml
FROM posts p FROM posts p
@ -76,10 +85,11 @@ pub async fn search_posts(query: String) -> Result<PostListResponse, ServerFnErr
let mut posts = Vec::new(); let mut posts = Vec::new();
for row in &rows { for row in &rows {
posts.push(row_to_post_list(&client, row).await); posts.push(row_to_post_list_item(row));
} }
let total = posts.len() as i64; let total = posts.len() as i64;
cache::set_search_results(&cache_key, posts.clone(), total).await;
Ok(PostListResponse { posts, total }) Ok(PostListResponse { posts, total })
} }

View File

@ -18,7 +18,8 @@ use crate::models::post::PostStats;
/// 获取文章统计信息。 /// 获取文章统计信息。
/// ///
/// 需要 admin 权限;优先命中缓存,未命中时分别统计总数、草稿数与已发布数。 /// 需要 admin 权限;优先命中缓存,未命中时通过单次条件聚合查询同时统计
/// 未删除文章总数、草稿数与已发布数。
#[server(GetPostStats, "/api")] #[server(GetPostStats, "/api")]
pub async fn get_post_stats() -> Result<PostStatsResponse, ServerFnError> { pub async fn get_post_stats() -> Result<PostStatsResponse, ServerFnError> {
let _user = get_current_admin_user().await?; let _user = get_current_admin_user().await?;
@ -31,37 +32,23 @@ pub async fn get_post_stats() -> Result<PostStatsResponse, ServerFnError> {
let client = get_conn().await.map_err(AppError::db_conn)?; let client = get_conn().await.map_err(AppError::db_conn)?;
// 统计未删除文章总数。 // 通过单次条件聚合查询同时统计总数、草稿数与已发布数。
let total: i64 = client let row = client
.query_one("SELECT COUNT(*) FROM posts WHERE deleted_at IS NULL", &[])
.await
.map_err(AppError::query)?
.get(0);
// 统计草稿数量。
let drafts: i64 = client
.query_one( .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 .await
.map_err(AppError::query)? .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);
let stats = PostStats { let stats = PostStats {
total, total: row.get("total"),
drafts, drafts: row.get("drafts"),
published, published: row.get("published"),
}; };
crate::cache::set_post_stats(stats.clone()).await; crate::cache::set_post_stats(stats.clone()).await;
Ok(PostStatsResponse { stats }) Ok(PostStatsResponse { stats })

View File

@ -1,6 +1,7 @@
//! 回收站操作接口:恢复、彻底删除、批量操作与一键清空。 //! 回收站操作接口:恢复、彻底删除、批量操作与一键清空。
//! //!
//! 所有接口需要 admin 权限,操作后清空全部文章相关缓存。 //! 所有接口需要 admin 权限,操作后按影响范围精准失效缓存;
//! 仅在影响集很大(如批量清空)时才回退到全量缓存失效。
//! Dioxus server function注册在 `/api` 路径下。 //! Dioxus server function注册在 `/api` 路径下。
//! 仅在 `feature = "server"` 启用的服务端构建中执行数据库操作。 //! 仅在 `feature = "server"` 启用的服务端构建中执行数据库操作。
@ -16,6 +17,11 @@ use crate::api::slug::ensure_unique_slug;
#[cfg(feature = "server")] #[cfg(feature = "server")]
use crate::db::pool::get_conn; use crate::db::pool::get_conn;
/// 批量/清空操作使用精准失效的最大记录数阈值。
/// 超过该阈值时回退到 `invalidate_all_post_caches()`,避免大量串行缓存操作。
#[cfg(feature = "server")]
const PRECISE_INVALIDATION_LIMIT: usize = 50;
/// 恢复一篇已删除的文章(将 deleted_at 置空)。 /// 恢复一篇已删除的文章(将 deleted_at 置空)。
/// ///
/// 若该文章原始 slug 已被其他未删除文章占用,自动追加数字后缀。 /// 若该文章原始 slug 已被其他未删除文章占用,自动追加数字后缀。
@ -28,10 +34,10 @@ pub async fn restore_post(post_id: i32) -> Result<CreatePostResponse, ServerFnEr
let mut 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 tx = client.transaction().await.map_err(AppError::tx)?;
// 读取待恢复文章的当前 slug 与是否确已删除。 // 在事务内锁定行并读取当前 slug、标签与是否确已删除。
let row = tx let row = tx
.query_opt( .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",
&[&post_id], &[&post_id],
) )
.await .await
@ -51,6 +57,15 @@ pub async fn restore_post(post_id: i32) -> Result<CreatePostResponse, ServerFnEr
// 恢复时确保 slug 在未删除文章中唯一(自动加后缀);在事务内检查避免并发竞态。 // 恢复时确保 slug 在未删除文章中唯一(自动加后缀);在事务内检查避免并发竞态。
let new_slug = ensure_unique_slug(&tx, &current_slug, Some(post_id)).await?; let new_slug = ensure_unique_slug(&tx, &current_slug, Some(post_id)).await?;
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<String> = tag_rows.iter().map(|r| r.get(0)).collect();
// 置空 deleted_at并更新 slug可能已加后缀 // 置空 deleted_at并更新 slug可能已加后缀
let result = tx let result = tx
.execute( .execute(
@ -71,7 +86,17 @@ pub async fn restore_post(post_id: i32) -> Result<CreatePostResponse, ServerFnEr
tx.commit().await.map_err(AppError::tx)?; tx.commit().await.map_err(AppError::tx)?;
crate::cache::invalidate_all_post_caches(); // 精准失效:列表、标签云、统计、旧 slug 与新 slug、相关标签文章。
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(&current_slug).await;
crate::cache::invalidate_post_by_slug(&new_slug).await;
crate::cache::invalidate_tag_posts_for(&tags).await;
// 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。
crate::ssr_cache::bump_global_generation();
Ok(CreatePostResponse { Ok(CreatePostResponse {
success: true, success: true,
@ -102,15 +127,44 @@ pub async fn purge_post(post_id: i32) -> Result<CreatePostResponse, ServerFnErro
#[cfg(feature = "server")] #[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 // 在事务内锁定行并读取 slug 与标签,避免并发更新导致缓存失效目标过期。
let slug_row = tx
.query_opt(
"SELECT slug FROM posts WHERE id = $1 AND deleted_at IS NOT 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<String> = tag_rows.iter().map(|r| r.get(0)).collect();
let result = tx
.execute( .execute(
"DELETE FROM posts WHERE id = $1 AND deleted_at IS NOT NULL", "DELETE FROM posts WHERE id = $1 AND deleted_at IS NOT NULL",
&[&post_id], &[&post_id],
) )
.await .await
.map_err(AppError::query)?; .map_err(AppError::tx)?;
if result == 0 { if result == 0 {
return Ok(CreatePostResponse { return Ok(CreatePostResponse {
@ -121,13 +175,24 @@ pub async fn purge_post(post_id: i32) -> Result<CreatePostResponse, ServerFnErro
}); });
} }
crate::cache::invalidate_all_post_caches(); 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();
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();
Ok(CreatePostResponse { Ok(CreatePostResponse {
success: true, success: true,
message: "彻底删除成功".to_string(), message: "彻底删除成功".to_string(),
post_id: Some(post_id), post_id: Some(post_id),
slug: None, slug: Some(slug),
}) })
} }
@ -161,12 +226,19 @@ pub async fn batch_restore_posts(post_ids: Vec<i32>) -> Result<CreatePostRespons
let mut 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 tx = client.transaction().await.map_err(AppError::tx)?;
// 逐条恢复slug 冲突时自动加后缀。 // 记录数较少时使用精准失效;否则回退到全量失效。
let use_precise = post_ids.len() <= PRECISE_INVALIDATION_LIMIT;
// 逐条恢复slug 冲突时自动加后缀;同时收集受影响的 slug 与标签。
let mut restored = 0u64; let mut restored = 0u64;
let mut affected_slugs: Vec<String> = Vec::with_capacity(post_ids.len() * 2);
let mut affected_tags: std::collections::HashSet<String> =
std::collections::HashSet::new();
for id in &post_ids { for id in &post_ids {
let row = tx let row = tx
.query_opt( .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], &[&id],
) )
.await .await
@ -174,6 +246,20 @@ pub async fn batch_restore_posts(post_ids: Vec<i32>) -> Result<CreatePostRespons
if let Some(row) = row { if let Some(row) = row {
let current_slug: String = row.get("slug"); let current_slug: String = row.get("slug");
let new_slug = ensure_unique_slug(&tx, &current_slug, Some(*id)).await?; let new_slug = ensure_unique_slug(&tx, &current_slug, Some(*id)).await?;
if use_precise {
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 {
affected_tags.insert(tag_row.get(0));
}
}
let n = tx let n = tx
.execute( .execute(
"UPDATE posts SET deleted_at = NULL, slug = $1 WHERE id = $2 AND deleted_at IS NOT NULL", "UPDATE posts SET deleted_at = NULL, slug = $1 WHERE id = $2 AND deleted_at IS NOT NULL",
@ -182,12 +268,38 @@ pub async fn batch_restore_posts(post_ids: Vec<i32>) -> Result<CreatePostRespons
.await .await
.map_err(AppError::tx)?; .map_err(AppError::tx)?;
restored += n; restored += n;
if use_precise {
affected_slugs.push(current_slug);
affected_slugs.push(new_slug);
}
} }
} }
tx.commit().await.map_err(AppError::tx)?; tx.commit().await.map_err(AppError::tx)?;
crate::cache::invalidate_all_post_caches(); if use_precise {
// 精准失效:先去重 slug再统一失效列表/标签云/统计/标签文章。
let unique_slugs: std::collections::HashSet<String> =
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::<Vec<_>>()).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 { Ok(CreatePostResponse {
success: true, success: true,
@ -224,18 +336,76 @@ pub async fn batch_purge_posts(post_ids: Vec<i32>) -> Result<CreatePostResponse,
}); });
} }
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 total = post_ids.len() as i64; let total = post_ids.len() as i64;
let result = client
// 记录数较少时锁定行并读取 slug 与标签,使用精准失效;否则回退到全量失效。
let use_precise = post_ids.len() <= PRECISE_INVALIDATION_LIMIT;
let (slugs, tags) = if use_precise {
let mut slugs = Vec::with_capacity(post_ids.len());
let mut tags_set: std::collections::HashSet<String> =
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::<Vec<_>>())
} else {
(Vec::new(), Vec::new())
};
let result = tx
.execute( .execute(
"DELETE FROM posts WHERE id = ANY($1) AND deleted_at IS NOT NULL", "DELETE FROM posts WHERE id = ANY($1) AND deleted_at IS NOT NULL",
&[&post_ids], &[&post_ids],
) )
.await .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 { Ok(CreatePostResponse {
success: true, success: true,
@ -263,14 +433,65 @@ pub async fn empty_trash() -> Result<CreatePostResponse, ServerFnError> {
#[cfg(feature = "server")] #[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 // 在事务内锁定所有待删除行并读取 id/slug用于后续精准失效
.execute("DELETE FROM posts WHERE deleted_at IS NOT NULL", &[]) // 同时根据数量决定使用精准失效还是回退到全量失效。
let deleted_rows = tx
.query(
"SELECT id, slug FROM posts WHERE deleted_at IS NOT NULL FOR UPDATE",
&[],
)
.await .await
.map_err(AppError::query)?; .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<String> = deleted_rows.iter().map(|r| r.get("slug")).collect();
let ids: Vec<i32> = 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<String> = 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 { Ok(CreatePostResponse {
success: true, success: true,

View File

@ -1,6 +1,6 @@
//! 文章 API 的请求与响应数据结构。 //! 文章 API 的请求与响应数据结构。
use crate::models::post::{Post, PostStats, Tag}; use crate::models::post::{Post, PostListItem, PostStats, Tag};
/// 创建/更新/删除文章的统一响应结构。 /// 创建/更新/删除文章的统一响应结构。
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
@ -18,8 +18,8 @@ pub struct CreatePostResponse {
/// 文章列表响应。 /// 文章列表响应。
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PostListResponse { pub struct PostListResponse {
/// 文章列表 /// 文章列表(轻量 DTO不含正文
pub posts: Vec<Post>, pub posts: Vec<PostListItem>,
/// 符合查询条件的总数。 /// 符合查询条件的总数。
pub total: i64, pub total: i64,
} }

View File

@ -55,6 +55,10 @@ pub async fn update_post(
let post_status = PostStatus::from_str(&status).unwrap_or(PostStatus::Draft); let post_status = PostStatus::from_str(&status).unwrap_or(PostStatus::Draft);
let cover_image = cover_image.filter(|s| !s.trim().is_empty()); 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)?; let tx = client.transaction().await.map_err(AppError::tx)?;
// 查询旧 slug用于后续缓存失效。 // 查询旧 slug用于后续缓存失效。
@ -150,8 +154,8 @@ pub async fn update_post(
// 更新文章主表。 // 更新文章主表。
let updated = tx let updated = tx
.execute( .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() "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 = $10", WHERE id = $12",
&[ &[
&title.trim(), &title.trim(),
&final_slug, &final_slug,
@ -162,6 +166,8 @@ pub async fn update_post(
&post_status.as_str(), &post_status.as_str(),
&published_at, &published_at,
&cover_image, &cover_image,
&(word_count as i32),
&(reading_time as i32),
&post_id, &post_id,
], ],
) )
@ -192,17 +198,18 @@ pub async fn update_post(
// 失效文章列表、标签、当前 slug 与统计缓存。 // 失效文章列表、标签、当前 slug 与统计缓存。
crate::cache::invalidate_post_lists(); crate::cache::invalidate_post_lists();
crate::cache::invalidate_all_tags(); crate::cache::invalidate_all_tags();
crate::cache::invalidate_post_by_slug(&final_slug).await;
crate::cache::invalidate_post_stats(); 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<String> = old_tags let all_tags_to_invalidate: Vec<String> = old_tags
.into_iter() .into_iter()
.chain(tags_for_invalidation.into_iter()) .chain(tags_for_invalidation.into_iter())
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect(); .collect();
for tag_name in &all_tags_to_invalidate { crate::cache::invalidate_tag_posts_for(&all_tags_to_invalidate).await;
crate::cache::invalidate_posts_by_tag(tag_name).await;
}
// 若 slug 发生变更,额外失效旧 slug 缓存。 // 若 slug 发生变更,额外失效旧 slug 缓存。
if let Some(ref old) = old_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 { Ok(CreatePostResponse {
success: true, success: true,
message: "更新成功".to_string(), message: "更新成功".to_string(),

View File

@ -1,7 +1,7 @@
//! 基于 moka 的内存缓存层。 //! 基于 moka 的内存缓存层。
//! //!
//! 仅在启用 `server` feature 时编译,为文章列表、标签、单篇文章、统计信息 //! 仅在启用 `server` feature 时编译,为文章列表、标签、单篇文章、统计信息
//! 以及评论相关数据提供按键缓存与失效能力。 //! 评论、会话用户以及搜索结果提供按键缓存与失效能力。
//! 缓存使用 `std::sync::LazyLock` 全局实例,按不同业务数据设置独立的 TTL。 //! 缓存使用 `std::sync::LazyLock` 全局实例,按不同业务数据设置独立的 TTL。
#[cfg(feature = "server")] #[cfg(feature = "server")]
@ -14,7 +14,9 @@ use std::time::Duration;
#[cfg(feature = "server")] #[cfg(feature = "server")]
use crate::models::comment::PublicComment; use crate::models::comment::PublicComment;
#[cfg(feature = "server")] #[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 配置 // 缓存 TTL 配置
@ -48,6 +50,14 @@ const TTL_COMMENTS: Duration = Duration::from_secs(60);
#[cfg(feature = "server")] #[cfg(feature = "server")]
const TTL_PENDING_COUNT: Duration = Duration::from_secs(10); const TTL_PENDING_COUNT: Duration = Duration::from_secs(10);
/// 会话用户缓存 TTL300 秒5 分钟),短于 DB 会话过期时间。
#[cfg(feature = "server")]
const TTL_SESSION: Duration = Duration::from_secs(300);
/// 搜索结果缓存 TTL10 秒。
#[cfg(feature = "server")]
const TTL_SEARCH: Duration = Duration::from_secs(10);
// ============================================================================ // ============================================================================
// 缓存 Key 类型 // 缓存 Key 类型
// ============================================================================ // ============================================================================
@ -80,7 +90,7 @@ pub enum CacheKey {
/// 文章列表缓存类型,值为(文章列表,总数)。 /// 文章列表缓存类型,值为(文章列表,总数)。
#[cfg(feature = "server")] #[cfg(feature = "server")]
pub type PostListCache = Cache<CacheKey, (Vec<Post>, i64)>; pub type PostListCache = Cache<CacheKey, (Vec<PostListItem>, i64)>;
/// 标签列表缓存类型。 /// 标签列表缓存类型。
#[cfg(feature = "server")] #[cfg(feature = "server")]
@ -161,19 +171,45 @@ static PENDING_COUNT_CACHE: LazyLock<Cache<CacheKey, i64>> = LazyLock::new(|| {
.build() .build()
}); });
/// 会话用户缓存类型。
#[cfg(feature = "server")]
pub type SessionCache = Cache<String, SessionUser>;
/// 搜索结果缓存类型。
#[cfg(feature = "server")]
pub type SearchCache = Cache<String, (Vec<PostListItem>, i64)>;
/// 全局会话用户缓存实例,最大容量 1000TTL 5 分钟。
#[cfg(feature = "server")]
pub static SESSION_CACHE: LazyLock<SessionCache> = LazyLock::new(|| {
Cache::builder()
.max_capacity(1000)
.time_to_live(TTL_SESSION)
.build()
});
/// 全局搜索结果缓存实例,最大容量 200TTL 10 秒。
#[cfg(feature = "server")]
static SEARCH_CACHE: LazyLock<SearchCache> = LazyLock::new(|| {
Cache::builder()
.max_capacity(200)
.time_to_live(TTL_SEARCH)
.build()
});
// ============================================================================ // ============================================================================
// 公共缓存 API // 公共缓存 API
// ============================================================================ // ============================================================================
/// 读取文章分页列表缓存。 /// 读取文章分页列表缓存。
#[cfg(feature = "server")] #[cfg(feature = "server")]
pub async fn get_post_list(key: &CacheKey) -> Option<(Vec<Post>, i64)> { pub async fn get_post_list(key: &CacheKey) -> Option<(Vec<PostListItem>, i64)> {
POST_LIST_CACHE.get(key).await POST_LIST_CACHE.get(key).await
} }
/// 写入文章分页列表缓存。 /// 写入文章分页列表缓存。
#[cfg(feature = "server")] #[cfg(feature = "server")]
pub async fn set_post_list(key: &CacheKey, posts: Vec<Post>, total: i64) { pub async fn set_post_list(key: &CacheKey, posts: Vec<PostListItem>, total: i64) {
let _ = POST_LIST_CACHE.insert(key.clone(), (posts, total)).await; 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<Post>) {
/// 按标签读取文章列表缓存。 /// 按标签读取文章列表缓存。
#[cfg(feature = "server")] #[cfg(feature = "server")]
pub async fn get_posts_by_tag(tag: &str) -> Option<(Vec<Post>, i64)> { pub async fn get_posts_by_tag(tag: &str) -> Option<(Vec<PostListItem>, i64)> {
TAG_POSTS_CACHE TAG_POSTS_CACHE
.get(&CacheKey::PostsByTag(tag.to_string())) .get(&CacheKey::PostsByTag(tag.to_string()))
.await .await
@ -232,7 +268,7 @@ pub async fn get_posts_by_tag(tag: &str) -> Option<(Vec<Post>, i64)> {
/// 按标签写入文章列表缓存。 /// 按标签写入文章列表缓存。
#[cfg(feature = "server")] #[cfg(feature = "server")]
pub async fn set_posts_by_tag(tag: &str, posts: Vec<Post>, total: i64) { pub async fn set_posts_by_tag(tag: &str, posts: Vec<PostListItem>, total: i64) {
let _ = TAG_POSTS_CACHE let _ = TAG_POSTS_CACHE
.insert(CacheKey::PostsByTag(tag.to_string()), (posts, total)) .insert(CacheKey::PostsByTag(tag.to_string()), (posts, total))
.await; .await;
@ -288,7 +324,22 @@ pub fn invalidate_post_stats() {
POST_STATS_CACHE.invalidate_all(); 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")] #[cfg(feature = "server")]
pub fn invalidate_all_post_caches() { pub fn invalidate_all_post_caches() {
POST_LIST_CACHE.invalidate_all(); POST_LIST_CACHE.invalidate_all();
@ -330,6 +381,53 @@ pub async fn set_pending_count(count: i64) {
.await; .await;
} }
/// 规范化搜索查询键trim、转小写、截断至 200 字符。
#[cfg(feature = "server")]
pub fn normalize_search_key(query: &str) -> String {
query.trim().to_lowercase().chars().take(200).collect()
}
/// 读取会话用户缓存。
#[cfg(feature = "server")]
pub async fn get_session_user(token_hash: &str) -> Option<SessionUser> {
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<PostListItem>, i64)> {
SEARCH_CACHE.get(&normalize_search_key(query)).await
}
/// 写入搜索结果缓存。
#[cfg(feature = "server")]
pub async fn set_search_results(query: &str, posts: Vec<PostListItem>, total: i64) {
let _ = SEARCH_CACHE
.insert(normalize_search_key(query), (posts, total))
.await;
}
/// 清空所有搜索结果缓存。
///
/// 使用同步签名是因为 `moka::Cache::invalidate_all` 为同步操作;
/// 该函数通常由写路径直接调用,无需额外等待。
#[cfg(feature = "server")]
pub fn invalidate_search_results() {
SEARCH_CACHE.invalidate_all();
}
/// 按文章主键失效评论列表缓存。 /// 按文章主键失效评论列表缓存。
#[cfg(feature = "server")] #[cfg(feature = "server")]
pub async fn invalidate_comments_by_post(post_id: i32) { pub async fn invalidate_comments_by_post(post_id: i32) {
@ -351,6 +449,7 @@ mod tests {
use super::*; use super::*;
use crate::models::comment::PublicComment; use crate::models::comment::PublicComment;
use crate::models::post::PostStatus; use crate::models::post::PostStatus;
use crate::models::user::{SessionUser, UserRole};
use serial_test::serial; use serial_test::serial;
#[test] #[test]
@ -379,15 +478,31 @@ mod tests {
page: 999, page: 999,
per_page: 99, 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; let cached = get_post_list(&key).await;
assert!(cached.is_some()); assert!(cached.is_some());
let (cached_posts, cached_total) = cached.unwrap(); let (cached_posts, cached_total) = cached.unwrap();
assert_eq!(cached_posts.len(), 0); assert_eq!(cached_posts.len(), 1);
assert_eq!(cached_total, 0); assert_eq!(cached_posts[0].title, "List Item");
assert_eq!(cached_total, 1);
} }
#[tokio::test] #[tokio::test]
@ -541,4 +656,94 @@ mod tests {
assert!(get_pending_count().await.is_none()); 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());
}
} }

View File

@ -6,7 +6,7 @@ use dioxus::prelude::*;
use dioxus::router::components::Link; use dioxus::router::components::Link;
use crate::components::image_viewer::ImageViewer; use crate::components::image_viewer::ImageViewer;
use crate::models::post::Post; use crate::models::post::PostListItem;
use crate::router::Route; use crate::router::Route;
/// 文章卡片组件。 /// 文章卡片组件。
@ -23,7 +23,7 @@ use crate::router::Route;
/// 关键事件: /// 关键事件:
/// - 点击标签时阻止事件冒泡,避免触发整卡跳转 /// - 点击标签时阻止事件冒泡,避免触发整卡跳转
#[component] #[component]
pub fn PostCard(post: Post) -> Element { pub fn PostCard(post: PostListItem) -> Element {
let post_slug = post.slug.clone(); let post_slug = post.slug.clone();
let date_str = post.formatted_date(); let date_str = post.formatted_date();
let has_cover = post.cover_image.is_some(); let has_cover = post.cover_image.is_some();

View File

@ -23,6 +23,9 @@ mod hooks;
mod models; mod models;
mod pages; mod pages;
mod router; mod router;
// ssr_cache 仅在 server feature 启用时编译;保存 SSR 世代号失效状态。
#[cfg(feature = "server")]
mod ssr_cache;
mod tasks; mod tasks;
mod theme; mod theme;
mod utils; mod utils;
@ -225,7 +228,15 @@ fn main() {
tasks::post_purge::run_purge().await; 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( let config = ServeConfig::builder().incremental(
dioxus::server::IncrementalRendererConfig::default().invalidate_after( dioxus::server::IncrementalRendererConfig::default().invalidate_after(
std::time::Duration::from_secs( 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<axum::body::Body>,
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 路由:图片上传(大文件,需要更长超时) // 自定义 API 路由:图片上传(大文件,需要更长超时)
let upload_route = axum::Router::new() let upload_route = axum::Router::new()
.route( .route(
@ -253,8 +286,10 @@ fn main() {
let dioxus_app = let dioxus_app =
axum::Router::new().serve_dioxus_application(config, router::AppRouter); axum::Router::new().serve_dioxus_application(config, router::AppRouter);
// 合并 Dioxus + 缓存头/可选压缩/30s 超时中间件 // 合并 Dioxus + 世代号/缓存头/可选压缩/30s 超时中间件
let mut app_routes = dioxus_app.layer(axum::middleware::from_fn(add_cache_control)); 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() { if let Some(layer) = compression_layer_from_env() {
app_routes = app_routes.layer(layer); app_routes = app_routes.layer(layer);
} }

View File

@ -85,6 +85,51 @@ impl Post {
.map(|d| d.format("%Y-%m-%d").to_string()) .map(|d| d.format("%Y-%m-%d").to_string())
.unwrap_or_else(|| self.created_at.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<String>,
/// 文章发布状态。
pub status: PostStatus,
/// 正式发布时间None 表示尚未发布。
pub published_at: Option<DateTime<Utc>>,
/// 创建时间。
pub created_at: DateTime<Utc>,
/// 最后更新时间。
pub updated_at: DateTime<Utc>,
/// 软删除时间None 表示未删除。仅回收站查询填充。
pub deleted_at: Option<DateTime<Utc>>,
/// 关联标签列表。
pub tags: Vec<String>,
/// 封面图片 URL。
pub cover_image: Option<String>,
/// 预计阅读时间(分钟)。
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 { 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] #[test]
#[cfg(feature = "server")] #[cfg(feature = "server")]
fn post_status_from_str() { fn post_status_from_str() {
@ -213,8 +277,21 @@ mod tests {
} }
#[test] #[test]
fn status_label() { fn post_list_item_formatted_date_uses_published_at_when_available() {
let mut post = sample_post(); 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; post.status = PostStatus::Published;
assert_eq!(post.status_label(), "已发布"); assert_eq!(post.status_label(), "已发布");
post.status = PostStatus::Draft; post.status = PostStatus::Draft;
@ -222,8 +299,8 @@ mod tests {
} }
#[test] #[test]
fn status_class_returns_non_empty() { fn post_list_item_status_class_returns_non_empty() {
let mut post = sample_post(); let mut post = sample_post_list_item();
post.status = PostStatus::Published; post.status = PostStatus::Published;
assert_eq!(post.status_class(), "text-green-600 dark:text-green-400"); assert_eq!(post.status_class(), "text-green-600 dark:text-green-400");
post.status = PostStatus::Draft; post.status = PostStatus::Draft;
@ -231,8 +308,8 @@ mod tests {
} }
#[test] #[test]
fn status_badge_class_returns_non_empty() { fn post_list_item_status_badge_class_returns_non_empty() {
let mut post = sample_post(); let mut post = sample_post_list_item();
post.status = PostStatus::Published; post.status = PostStatus::Published;
assert_eq!( assert_eq!(
post.status_badge_class(), post.status_badge_class(),

View File

@ -44,6 +44,21 @@ pub struct User {
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
} }
/// 会话缓存使用的轻量用户结构体,不含密码哈希。
#[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<Utc>,
}
/// 可公开的用户信息,从 User 转换而来,不含密码哈希。 /// 可公开的用户信息,从 User 转换而来,不含密码哈希。
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PublicUser { pub struct PublicUser {
@ -59,6 +74,32 @@ pub struct PublicUser {
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
} }
impl From<User> 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<SessionUser> 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<User> for PublicUser { impl From<User> for PublicUser {
/// 将 User 转换为 PublicUser丢弃 password_hash 字段。 /// 将 User 转换为 PublicUser丢弃 password_hash 字段。
fn from(u: User) -> Self { fn from(u: User) -> Self {
@ -124,4 +165,24 @@ mod tests {
UserRole::Admin 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"));
}
} }

View File

@ -13,7 +13,7 @@ use crate::api::posts::{get_post_stats, list_posts};
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
use crate::api::posts::{PostListResponse, PostStatsResponse}; use crate::api::posts::{PostListResponse, PostStatsResponse};
use crate::components::ui::ADMIN_CARD_CLASS; use crate::components::ui::ADMIN_CARD_CLASS;
use crate::models::post::{Post, PostStats}; use crate::models::post::{PostListItem, PostStats};
use crate::router::Route; use crate::router::Route;
/// 后台仪表盘页面组件。 /// 后台仪表盘页面组件。
@ -25,7 +25,7 @@ use crate::router::Route;
pub fn Admin() -> Element { pub fn Admin() -> Element {
// 仪表盘状态:统计数据、最近文章、待审核评论数与首次加载标志。 // 仪表盘状态:统计数据、最近文章、待审核评论数与首次加载标志。
let mut stats = use_signal(|| None::<PostStats>); let mut stats = use_signal(|| None::<PostStats>);
let mut recent_posts = use_signal(|| None::<Vec<Post>>); let mut recent_posts = use_signal(|| None::<Vec<PostListItem>>);
let mut pending_count = use_signal(|| None::<i64>); let mut pending_count = use_signal(|| None::<i64>);
let mut loaded = use_signal(|| false); let mut loaded = use_signal(|| false);
@ -165,7 +165,7 @@ fn StatCard(value: String, label: String) -> Element {
/// 最近文章列表项,显示标题、状态标签与发布日期。 /// 最近文章列表项,显示标题、状态标签与发布日期。
#[component] #[component]
fn RecentPostItem(post: Post) -> Element { fn RecentPostItem(post: PostListItem) -> Element {
let date_str = post.formatted_date(); let date_str = post.formatted_date();
let status_label = post.status_label(); let status_label = post.status_label();
let status_class = post.status_class(); let status_class = post.status_class();

View File

@ -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::delayed_skeleton::DelayedSkeleton;
use crate::components::skeletons::posts_skeleton::PostsSkeleton; 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::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; use crate::router::Route;
/// 每页展示的文章数量。 /// 每页展示的文章数量。
@ -34,7 +34,7 @@ pub fn Posts() -> Element {
pub fn PostsPage(page: i32) -> Element { pub fn PostsPage(page: i32) -> Element {
let current_page = page.max(1); let current_page = page.max(1);
// 文章列表、总数、加载状态、删除中 ID、重建缓存状态与结果。 // 文章列表、总数、加载状态、删除中 ID、重建缓存状态与结果。
let mut posts = use_signal(Vec::new); let mut posts = use_signal(Vec::<PostListItem>::new);
let mut total = use_signal(|| 0_i64); let mut total = use_signal(|| 0_i64);
let mut loading = use_signal(|| true); let mut loading = use_signal(|| true);
let mut deleting = use_signal(|| None::<i32>); let mut deleting = use_signal(|| None::<i32>);
@ -96,7 +96,7 @@ pub fn PostsPage(page: i32) -> Element {
} }
}); });
let get_posts = move || -> Vec<Post> { posts() }; let get_posts = move || -> Vec<PostListItem> { posts() };
rsx! { rsx! {
div { class: "space-y-6", div { class: "space-y-6",
@ -215,7 +215,7 @@ pub fn PostsPage(page: i32) -> Element {
/// 文章表格行组件,展示单篇文章的标题、状态、日期与操作按钮。 /// 文章表格行组件,展示单篇文章的标题、状态、日期与操作按钮。
#[component] #[component]
fn PostRow(post: Post, deleting: bool, on_delete: EventHandler<i32>) -> Element { fn PostRow(post: PostListItem, deleting: bool, on_delete: EventHandler<i32>) -> Element {
let date_str = post.formatted_date(); let date_str = post.formatted_date();
rsx! { rsx! {

View File

@ -23,7 +23,7 @@ use crate::components::ui::{
EmptyState, Pagination, StatusBadge, ADMIN_ROW_HOVER, ADMIN_TABLE_CLASS, BTN_SOLID_GREEN, EmptyState, Pagination, StatusBadge, ADMIN_ROW_HOVER, ADMIN_TABLE_CLASS, BTN_SOLID_GREEN,
BTN_SOLID_RED, BTN_TEXT_ACCENT, BTN_TEXT_RED, CHECKBOX_CLASS, 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::models::settings::TrashSettings;
use crate::router::Route; use crate::router::Route;
@ -44,7 +44,7 @@ pub fn Trash() -> Element {
pub fn TrashPage(page: i32) -> Element { pub fn TrashPage(page: i32) -> Element {
let current_page = page.max(1); let current_page = page.max(1);
let mut selected_ids: Signal<HashSet<i32>> = use_signal(HashSet::new); let mut selected_ids: Signal<HashSet<i32>> = use_signal(HashSet::new);
let mut posts: Signal<Vec<Post>> = use_signal(Vec::new); let mut posts: Signal<Vec<PostListItem>> = use_signal(Vec::new);
let mut total: Signal<i64> = use_signal(|| 0); let mut total: Signal<i64> = use_signal(|| 0);
#[allow(unused_mut)] #[allow(unused_mut)]
let mut loading: Signal<bool> = use_signal(|| false); let mut loading: Signal<bool> = 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")] #[cfg(target_arch = "wasm32")]
{ {
if let Some(deleted_at) = post.deleted_at { if let Some(deleted_at) = post.deleted_at {
@ -523,7 +523,7 @@ fn remaining_days(post: &Post, retention_days: i32) -> (i64, bool) {
/// 回收站表格行组件。 /// 回收站表格行组件。
#[component] #[component]
fn TrashRow( fn TrashRow(
post: Post, post: PostListItem,
retention_days: i32, retention_days: i32,
selected: bool, selected: bool,
on_select: EventHandler<bool>, on_select: EventHandler<bool>,

View File

@ -13,7 +13,7 @@ use dioxus::router::components::Link;
use crate::api::posts::{list_published_posts, PostListResponse}; use crate::api::posts::{list_published_posts, PostListResponse};
use crate::components::skeletons::archive_skeleton::ArchiveSkeleton; use crate::components::skeletons::archive_skeleton::ArchiveSkeleton;
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton; use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
use crate::models::post::Post; use crate::models::post::PostListItem;
use crate::router::Route; use crate::router::Route;
/// 按年份分组的文章归档结构。 /// 按年份分组的文章归档结构。
@ -28,13 +28,13 @@ struct YearGroup {
struct MonthGroup { struct MonthGroup {
month: String, month: String,
month_en: String, month_en: String,
posts: Vec<Post>, posts: Vec<PostListItem>,
} }
/// 将文章列表按 `formatted_date()` 返回的 `YYYY-MM-DD` 格式进行年、月分组。 /// 将文章列表按 `formatted_date()` 返回的 `YYYY-MM-DD` 格式进行年、月分组。
/// ///
/// 返回的结果按原始文章顺序组织,调用前已按发布时间降序排列。 /// 返回的结果按原始文章顺序组织,调用前已按发布时间降序排列。
fn group_posts(posts: &[Post]) -> Vec<YearGroup> { fn group_posts(posts: &[PostListItem]) -> Vec<YearGroup> {
let mut years: Vec<YearGroup> = vec![]; let mut years: Vec<YearGroup> = vec![];
for post in posts { for post in posts {
@ -204,7 +204,7 @@ fn MonthSection(month_group: MonthGroup, year: String) -> Element {
/// 单条归档文章组件,展示标题与发布日期,并通过覆盖层链接到文章详情。 /// 单条归档文章组件,展示标题与发布日期,并通过覆盖层链接到文章详情。
#[component] #[component]
fn ArchiveEntry(post: Post) -> Element { fn ArchiveEntry(post: PostListItem) -> Element {
let date_str = post.formatted_date(); let date_str = post.formatted_date();
rsx! { rsx! {

70
src/ssr_cache.rs Normal file
View File

@ -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<AtomicU64> = 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);
}
}

View File

@ -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<PathBuf>, 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();
}
}

View File

@ -11,3 +11,6 @@ pub mod session_cleanup;
/// 定时清理回收站中超过保留期的已删除文章。 /// 定时清理回收站中超过保留期的已删除文章。
#[cfg(feature = "server")] #[cfg(feature = "server")]
pub mod post_purge; pub mod post_purge;
/// 定时清理图片磁盘缓存,避免缓存目录无限增长。
#[cfg(feature = "server")]
pub mod image_cache_cleanup;

View File

@ -16,11 +16,17 @@ pub async fn run_cleanup() {
match get_conn().await { match get_conn().await {
Ok(client) => { Ok(client) => {
// 删除已过期会话 // 删除已过期会话
if let Err(e) = client match client
.execute("DELETE FROM sessions WHERE expires_at < NOW()", &[]) .execute("DELETE FROM sessions WHERE expires_at < NOW()", &[])
.await .await
{ {
tracing::error!("Session cleanup error: {:?}", e); Ok(_) => {
// 同时清空内存中的会话缓存,避免已失效会话继续命中。
crate::cache::SESSION_CACHE.invalidate_all();
}
Err(e) => {
tracing::error!("Session cleanup error: {:?}", e);
}
} }
} }
Err(e) => { Err(e) => {

View File

@ -72,6 +72,13 @@ pub fn count_words(md: &str) -> u32 {
count.max(1) count.max(1)
} }
/// 由字数估算阅读时长(分钟)。
///
/// 按每分钟 200 字计算,至少返回 1 分钟。
pub fn reading_time(word_count: u32) -> u32 {
(word_count / 200).max(1)
}
/// 自动生成文本摘要,取去除 Markdown 后的前 200 个字符。 /// 自动生成文本摘要,取去除 Markdown 后的前 200 个字符。
pub fn auto_summary(md: &str) -> String { pub fn auto_summary(md: &str) -> String {
let plain = strip_markdown(md); let plain = strip_markdown(md);
@ -158,6 +165,21 @@ mod tests {
assert_eq!(count_words(""), 1); 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] #[test]
fn auto_summary_truncates_at_200_chars() { fn auto_summary_truncates_at_200_chars() {
let long_md: String = "a ".repeat(200); let long_md: String = "a ".repeat(200);