Compare commits
36 Commits
05b9a3a595
...
306da3cf83
| Author | SHA1 | Date | |
|---|---|---|---|
| 306da3cf83 | |||
| ed589f9c79 | |||
| 668920e9fb | |||
| 7f372446da | |||
| 3ee39d910c | |||
| 4c695b4fc3 | |||
| 12355b7859 | |||
| a71da7473d | |||
| c0b14ed498 | |||
| f1e5b657a3 | |||
| 0b107c3f2e | |||
| 36554af5f5 | |||
| 411e565465 | |||
| 24bc6f44a0 | |||
| 518b4e5d64 | |||
| c780247d17 | |||
| c40a771989 | |||
| 1d216faa2f | |||
| 75b8f80631 | |||
| 7bd02d0ea9 | |||
| 2d8f1e0d98 | |||
| c77f751377 | |||
| ca212a2aab | |||
| 1092fbb3ce | |||
| c03093fc8b | |||
| bb34e2c36e | |||
| 82b070e7ac | |||
| c528936abb | |||
| 15e7e2578d | |||
| e683efe0ea | |||
| 468a819951 | |||
| 9f41ea7354 | |||
| 1eedab8f21 | |||
| a6f08d5d3f | |||
| 51e20980db | |||
| 170c021b37 |
11
.env.example
11
.env.example
@ -23,7 +23,10 @@ MAX_SESSIONS_PER_USER=5
|
||||
# Database connection pool size (default: 20)
|
||||
DB_POOL_SIZE=20
|
||||
|
||||
# SSR page cache duration in seconds (default: 3600)
|
||||
# SSR page cache duration in seconds (default: 3600).
|
||||
# src/ssr_cache.rs maintains a global generation counter bumped on every post write, but
|
||||
# Dioxus 0.7 does not expose an API to wire it into the incremental SSR cache key. Until such
|
||||
# an API is available, this TTL is the only effective SSR cache invalidation mechanism.
|
||||
SSR_CACHE_SECS=3600
|
||||
|
||||
# Compression algorithms for HTTP responses.
|
||||
@ -36,3 +39,9 @@ COMPRESSION_ALGORITHMS=gzip,brotli,deflate,zstd
|
||||
# Processed variants (?w=, ?format=, etc.) are cached for 24 hours.
|
||||
# To invalidate a cached raw upload, change its file path.
|
||||
# To refresh a processed variant, change its processing parameters.
|
||||
|
||||
# Image disk cache limits
|
||||
# Max total size in MB (default: 1024)
|
||||
IMAGE_DISK_CACHE_MAX_MB=1024
|
||||
# Max file age in hours before forced deletion (default: 168)
|
||||
IMAGE_DISK_CACHE_MAX_AGE_HOURS=168
|
||||
|
||||
23
CHANGELOG.md
23
CHANGELOG.md
@ -10,6 +10,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Added
|
||||
|
||||
- 图片响应新增 `Cache-Control` 与 `ETag` 头:原始上传文件使用 `immutable, max-age=31536000`,处理变体使用 `max-age=86400`。
|
||||
- 新增 `PostListItem` 轻量 DTO,列表/标签/搜索接口不再返回完整正文,显著降低缓存与序列化体积。
|
||||
- 新增数据库迁移 `010_post_word_counts.sql`,在 `posts` 表存储 `word_count` 与 `reading_time`,并在写入时维护。
|
||||
- 新增基于 moka 的会话内存缓存,减少每次认证请求的 `sessions JOIN users` 数据库查询;缓存对象 `SessionUser` 不包含密码哈希。
|
||||
- 新增搜索结果短 TTL 缓存(10 秒),并对查询 key 做规范化处理。
|
||||
- 新增图片内存缓存使用 `bytes::Bytes` 存储,命中时仅做引用计数克隆。
|
||||
- 新增图片磁盘缓存定时清理后台任务,按文件年龄与总大小上限淘汰。
|
||||
- 新增 `src/ssr_cache.rs` SSR 生成号基础设施,为后续 Dioxus 暴露缓存失效 API 做准备。
|
||||
|
||||
### Changed
|
||||
|
||||
- 文章写路径缓存失效从「全量清空」改为「精确到 slug / tag / 列表页」,并在读取 slug/tag 元数据时使用事务 + `FOR UPDATE` 避免并发竞态。
|
||||
- `get_post_stats` 将 3 次独立 `COUNT(*)` 合并为单次条件聚合查询。
|
||||
- `row_to_post_list` 已移除,`get_post_by_id` 复用 `row_to_post_full`。
|
||||
|
||||
### Fixed
|
||||
|
||||
- 过期 session 清理任务现在同时失效会话内存缓存,避免已过期会话在缓存 TTL 窗口内继续被使用。
|
||||
- 图片磁盘清理跳过符号链接,防止遍历到缓存目录外部。
|
||||
|
||||
### Internal
|
||||
|
||||
- 新增 `utils::text::reading_time` 辅助函数,统一阅读时间计算逻辑。
|
||||
- 新增缓存、SSR 生成号相关单元测试。
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@ -5393,10 +5393,12 @@ version = "0.2.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"axum",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"deadpool-postgres",
|
||||
"dioxus",
|
||||
"dotenvy",
|
||||
"futures",
|
||||
"governor",
|
||||
"hex",
|
||||
"http",
|
||||
|
||||
@ -36,6 +36,8 @@ zenwebp = { version = "0.3", optional = true }
|
||||
moka = { version = "0.12", features = ["future"], optional = true }
|
||||
governor = { version = "0.8", optional = true }
|
||||
md-5 = { version = "0.10", optional = true }
|
||||
futures = { version = "0.3", optional = true }
|
||||
bytes = { version = "1", optional = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
web-sys = { version = "0.3", features = ["Document", "Window", "Storage", "Element", "HtmlElement", "DomTokenList", "MediaQueryList", "HtmlImageElement", "MouseEvent", "KeyboardEvent", "Node", "EventTarget", "Navigator"] }
|
||||
@ -82,4 +84,6 @@ server = [
|
||||
"dep:moka",
|
||||
"dep:governor",
|
||||
"dep:md-5",
|
||||
"dep:futures",
|
||||
"dep:bytes",
|
||||
]
|
||||
|
||||
29
migrations/010_post_word_counts.sql
Normal file
29
migrations/010_post_word_counts.sql
Normal 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;
|
||||
@ -20,7 +20,7 @@ use crate::auth::{password, session};
|
||||
#[cfg(feature = "server")]
|
||||
use crate::db::pool::get_conn;
|
||||
#[cfg(feature = "server")]
|
||||
use crate::models::user::{User, UserRole};
|
||||
use crate::models::user::{SessionUser, UserRole};
|
||||
use crate::models::user::PublicUser;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
@ -293,6 +293,7 @@ pub async fn logout() -> Result<AuthResponse, ServerFnError> {
|
||||
|
||||
if let Some(t) = token {
|
||||
let token_hash = session::hash_token(&t);
|
||||
crate::cache::invalidate_session_user(&token_hash).await;
|
||||
client
|
||||
.execute("DELETE FROM sessions WHERE token_hash = $1", &[&token_hash])
|
||||
.await
|
||||
@ -314,16 +315,22 @@ pub struct CurrentUserResponse {
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
/// 根据会话 token 查询对应用户(含密码哈希等完整信息)。
|
||||
/// 根据会话 token 查询对应用户(不含密码哈希,供会话缓存使用)。
|
||||
///
|
||||
/// 优先命中内存缓存,避免每次请求都执行 DB JOIN;未命中时回查数据库并回填缓存。
|
||||
/// 仅服务端内部使用,不会暴露给前端。
|
||||
pub async fn get_user_by_token(token: &str) -> Result<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 token_hash = session::hash_token(token);
|
||||
let row = client
|
||||
.query_opt(
|
||||
"SELECT u.id, u.username, u.email, u.password_hash, u.role, u.created_at
|
||||
"SELECT u.id, u.username, u.email, u.role, u.created_at
|
||||
FROM sessions s
|
||||
JOIN users u ON s.user_id = u.id
|
||||
WHERE s.token_hash = $1 AND s.expires_at > NOW()",
|
||||
@ -336,11 +343,10 @@ pub async fn get_user_by_token(token: &str) -> Result<Option<User>, ServerFnErro
|
||||
Some(row) => {
|
||||
let role_str: String = row.get("role");
|
||||
let role = UserRole::from_str(&role_str).unwrap_or(UserRole::Blocked);
|
||||
Some(User {
|
||||
Some(SessionUser {
|
||||
id: row.get("id"),
|
||||
username: row.get("username"),
|
||||
email: row.get("email"),
|
||||
password_hash: row.get("password_hash"),
|
||||
role,
|
||||
created_at: row.get("created_at"),
|
||||
})
|
||||
@ -348,6 +354,10 @@ pub async fn get_user_by_token(token: &str) -> Result<Option<User>, ServerFnErro
|
||||
None => None,
|
||||
};
|
||||
|
||||
if let Some(ref u) = user {
|
||||
crate::cache::set_session_user(&token_hash, u.clone()).await;
|
||||
}
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
@ -370,19 +380,19 @@ pub async fn get_current_user() -> Result<CurrentUserResponse, ServerFnError> {
|
||||
/// 获取当前登录用户并要求其为 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 user = get_user_by_token(&token)
|
||||
let session_user = get_user_by_token(&token)
|
||||
.await
|
||||
.map_err(AppError::query)?
|
||||
.ok_or(AppError::Unauthorized("会话已过期"))?;
|
||||
|
||||
if user.role != UserRole::Admin {
|
||||
if session_user.role != UserRole::Admin {
|
||||
return Err(AppError::Forbidden("权限不足"));
|
||||
}
|
||||
|
||||
Ok(user)
|
||||
Ok(session_user)
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "server"))]
|
||||
|
||||
@ -19,6 +19,8 @@ use moka::future::Cache;
|
||||
use serde::Deserialize;
|
||||
#[cfg(feature = "server")]
|
||||
use std::sync::LazyLock;
|
||||
#[cfg(feature = "server")]
|
||||
use bytes::Bytes;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
fn etag_for(data: &[u8]) -> String {
|
||||
@ -51,7 +53,7 @@ pub const MAX_IMAGE_PIXELS: u32 = 25_000_000; // ~5k x 5k
|
||||
#[derive(Debug, Clone)]
|
||||
/// 缓存条目,保存处理后的图片字节与 Content-Type。
|
||||
struct CachedImage {
|
||||
data: Vec<u8>,
|
||||
data: Bytes,
|
||||
content_type: HeaderValue,
|
||||
}
|
||||
|
||||
@ -186,7 +188,7 @@ fn content_type(format: image::ImageFormat) -> HeaderValue {
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
fn image_response(
|
||||
data: Vec<u8>,
|
||||
data: Bytes,
|
||||
content_type: HeaderValue,
|
||||
cache_control: &'static str,
|
||||
headers: &HeaderMap,
|
||||
@ -399,7 +401,10 @@ async fn read_disk_cache(cache_key: &str) -> Option<CachedImage> {
|
||||
.ok()
|
||||
.unwrap_or_else(|| "application/octet-stream".to_string());
|
||||
let content_type = HeaderValue::from_str(&ct_str).ok()?;
|
||||
Some(CachedImage { data, content_type })
|
||||
Some(CachedImage {
|
||||
data: Bytes::from(data),
|
||||
content_type,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
@ -454,7 +459,7 @@ pub async fn serve_image(
|
||||
return match tokio::fs::read(&file_path).await {
|
||||
Ok(data) => {
|
||||
let ct = content_type(detect_format(&path));
|
||||
image_response(data, ct, "public, max-age=31536000, immutable", &headers)
|
||||
image_response(Bytes::from(data), ct, "public, max-age=31536000, immutable", &headers)
|
||||
}
|
||||
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
||||
};
|
||||
@ -462,20 +467,19 @@ pub async fn serve_image(
|
||||
|
||||
let cache_key = params.cache_key(&path);
|
||||
if let Some(cached) = IMAGE_CACHE.get(&cache_key).await {
|
||||
return image_response(cached.data, cached.content_type, "public, max-age=86400", &headers);
|
||||
return image_response(
|
||||
cached.data.clone(),
|
||||
cached.content_type,
|
||||
"public, max-age=86400",
|
||||
&headers,
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(cached) = read_disk_cache(&cache_key).await {
|
||||
let _ = IMAGE_CACHE
|
||||
.insert(
|
||||
cache_key.clone(),
|
||||
CachedImage {
|
||||
data: cached.data.clone(),
|
||||
content_type: cached.content_type.clone(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
return image_response(cached.data, cached.content_type, "public, max-age=86400", &headers);
|
||||
let data = cached.data.clone();
|
||||
let content_type = cached.content_type.clone();
|
||||
let _ = IMAGE_CACHE.insert(cache_key.clone(), cached).await;
|
||||
return image_response(data, content_type, "public, max-age=86400", &headers);
|
||||
}
|
||||
|
||||
let data = match tokio::fs::read(&file_path).await {
|
||||
@ -485,12 +489,11 @@ pub async fn serve_image(
|
||||
|
||||
// Offload decode + resize + encode to the blocking pool so the async
|
||||
// runtime stays responsive to other requests.
|
||||
let data_for_blocking = data.clone();
|
||||
let path_for_blocking = path.clone();
|
||||
let params_for_blocking = params.clone();
|
||||
let (processed, content_type) =
|
||||
match tokio::task::spawn_blocking(move || {
|
||||
process_image_blocking(data_for_blocking, params_for_blocking, path_for_blocking)
|
||||
process_image_blocking(data, params_for_blocking, path_for_blocking)
|
||||
})
|
||||
.await
|
||||
{
|
||||
@ -502,6 +505,7 @@ pub async fn serve_image(
|
||||
}
|
||||
};
|
||||
|
||||
let processed = Bytes::from(processed);
|
||||
let cached = CachedImage {
|
||||
data: processed.clone(),
|
||||
content_type: content_type.clone(),
|
||||
@ -758,7 +762,7 @@ mod tests {
|
||||
#[test]
|
||||
fn image_response_includes_cache_headers() {
|
||||
let resp = image_response(
|
||||
vec![1, 2, 3],
|
||||
Bytes::from(vec![1, 2, 3]),
|
||||
HeaderValue::from_static("image/webp"),
|
||||
"public, max-age=86400",
|
||||
&HeaderMap::new(),
|
||||
@ -778,7 +782,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn image_response_returns_304_when_etag_matches() {
|
||||
let data = vec![1, 2, 3];
|
||||
let data = Bytes::from(vec![1, 2, 3]);
|
||||
let etag = etag_for(&data);
|
||||
let mut req_headers = HeaderMap::new();
|
||||
req_headers.insert(
|
||||
@ -826,7 +830,7 @@ mod tests {
|
||||
#[test]
|
||||
fn image_response_raw_file_is_immutable() {
|
||||
let resp = image_response(
|
||||
vec![1, 2, 3],
|
||||
Bytes::from(vec![1, 2, 3]),
|
||||
HeaderValue::from_static("image/jpeg"),
|
||||
"public, max-age=31536000, immutable",
|
||||
&HeaderMap::new(),
|
||||
|
||||
@ -91,6 +91,10 @@ pub async fn create_post(
|
||||
let post_status = PostStatus::from_str(&status).unwrap_or(PostStatus::Draft);
|
||||
let cover_image = cover_image.filter(|s| !s.trim().is_empty());
|
||||
|
||||
// 计算字数与阅读时长,随文章一并持久化,供列表查询直接使用。
|
||||
let word_count = crate::utils::text::count_words(&content_md);
|
||||
let reading_time = crate::utils::text::reading_time(word_count);
|
||||
|
||||
// 发布状态的文章设置当前发布时间;草稿则为 None。
|
||||
let published_at = if post_status == PostStatus::Published {
|
||||
Some(chrono::Utc::now())
|
||||
@ -106,8 +110,8 @@ pub async fn create_post(
|
||||
// 插入文章记录。
|
||||
let row = tx
|
||||
.query_one(
|
||||
"INSERT INTO posts (author_id, title, slug, summary, content_md, content_html, toc_html, status, published_at, cover_image)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
"INSERT INTO posts (author_id, title, slug, summary, content_md, content_html, toc_html, status, published_at, cover_image, word_count, reading_time)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
RETURNING id",
|
||||
&[
|
||||
&user.id,
|
||||
@ -120,6 +124,8 @@ pub async fn create_post(
|
||||
&post_status.as_str(),
|
||||
&published_at,
|
||||
&cover_image,
|
||||
&(word_count as i32),
|
||||
&(reading_time as i32),
|
||||
],
|
||||
)
|
||||
.await
|
||||
@ -133,17 +139,18 @@ pub async fn create_post(
|
||||
|
||||
tx.commit().await.map_err(AppError::tx)?;
|
||||
|
||||
// 写入成功后失效文章列表、标签与统计缓存。
|
||||
// 写入成功后按粒度失效相关缓存。
|
||||
crate::cache::invalidate_post_lists();
|
||||
crate::cache::invalidate_all_tags();
|
||||
crate::cache::invalidate_post_stats();
|
||||
crate::cache::invalidate_search_results();
|
||||
// 失效按 slug 缓存,避免之前缓存的 404 继续命中。
|
||||
crate::cache::invalidate_post_by_slug(&final_slug).await;
|
||||
// 失效该文章涉及的所有标签下文章列表缓存。
|
||||
crate::cache::invalidate_tag_posts_for(&tags_cleaned).await;
|
||||
|
||||
// 失效该文章涉及的所有标签缓存。
|
||||
for tag_name in &tags_cleaned {
|
||||
crate::cache::invalidate_posts_by_tag(tag_name).await;
|
||||
}
|
||||
// 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。
|
||||
crate::ssr_cache::bump_global_generation();
|
||||
|
||||
Ok(CreatePostResponse {
|
||||
success: true,
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
//! 删除文章接口。
|
||||
//!
|
||||
//! 采用软删除方式,将 posts.deleted_at 设置为当前时间,
|
||||
//! 同时清空所有文章相关缓存。
|
||||
//! 并按影响范围失效相关缓存。
|
||||
//! Dioxus server function,注册在 `/api` 路径下。
|
||||
//! 仅在 `feature = "server"` 启用的服务端构建中执行删除与缓存失效。
|
||||
|
||||
@ -18,23 +18,52 @@ use crate::db::pool::get_conn;
|
||||
/// 删除指定文章。
|
||||
///
|
||||
/// 仅 admin 可调用;通过设置 deleted_at 实现软删除,
|
||||
/// 成功后清空全部文章缓存。
|
||||
/// 成功后按影响范围失效文章列表、标签云、统计、slug 及相关标签文章缓存。
|
||||
#[server(DeletePost, "/api")]
|
||||
pub async fn delete_post(post_id: i32) -> Result<CreatePostResponse, ServerFnError> {
|
||||
let _user = get_current_admin_user().await?;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||
let mut client = get_conn().await.map_err(AppError::db_conn)?;
|
||||
let tx = client.transaction().await.map_err(AppError::tx)?;
|
||||
|
||||
// 在事务内锁定行并读取 slug,避免并发更新导致缓存失效目标过期。
|
||||
let slug_row = tx
|
||||
.query_opt(
|
||||
"SELECT slug FROM posts WHERE id = $1 AND deleted_at IS NULL FOR UPDATE",
|
||||
&[&post_id],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?;
|
||||
|
||||
let Some(slug_row) = slug_row else {
|
||||
return Ok(CreatePostResponse {
|
||||
success: false,
|
||||
message: "文章不存在".to_string(),
|
||||
post_id: None,
|
||||
slug: None,
|
||||
});
|
||||
};
|
||||
let slug: String = slug_row.get(0);
|
||||
|
||||
let tag_rows = tx
|
||||
.query(
|
||||
"SELECT t.name FROM tags t JOIN post_tags pt ON t.id = pt.tag_id WHERE pt.post_id = $1",
|
||||
&[&post_id],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?;
|
||||
let tags: Vec<String> = tag_rows.iter().map(|r| r.get(0)).collect();
|
||||
|
||||
// 软删除:仅影响未被删除的文章。
|
||||
let result = client
|
||||
let result = tx
|
||||
.execute(
|
||||
"UPDATE posts SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL",
|
||||
&[&post_id],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?;
|
||||
.map_err(AppError::tx)?;
|
||||
|
||||
if result == 0 {
|
||||
return Ok(CreatePostResponse {
|
||||
@ -45,14 +74,24 @@ pub async fn delete_post(post_id: i32) -> Result<CreatePostResponse, ServerFnErr
|
||||
});
|
||||
}
|
||||
|
||||
// 删除后所有文章相关缓存均失效。
|
||||
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 {
|
||||
success: true,
|
||||
message: "删除成功".to_string(),
|
||||
post_id: Some(post_id),
|
||||
slug: None,
|
||||
slug: Some(slug),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -6,26 +6,23 @@
|
||||
#[cfg(feature = "server")]
|
||||
use crate::api::error::AppError;
|
||||
#[cfg(feature = "server")]
|
||||
use crate::models::post::{Post, PostStatus};
|
||||
use crate::models::post::{Post, PostListItem, PostStatus};
|
||||
#[cfg(feature = "server")]
|
||||
use crate::utils::text::count_words;
|
||||
use crate::utils::text::{count_words, reading_time};
|
||||
|
||||
/// 复用认证模块的当前 admin 用户获取逻辑。
|
||||
#[cfg(feature = "server")]
|
||||
pub(super) use crate::api::auth::get_current_admin_user;
|
||||
|
||||
/// 将数据库行转换为文章列表项。
|
||||
/// 将数据库行转换为轻量列表项 DTO。
|
||||
///
|
||||
/// 用于列表接口,包含标签聚合、字数与阅读时长估算,
|
||||
/// 不包含上下篇导航与目录。
|
||||
/// 不包含 `content_md`/`content_html`;字数与阅读时长直接读取已持久化的列。
|
||||
/// 同步函数,不依赖数据库连接。
|
||||
#[cfg(feature = "server")]
|
||||
pub(super) async fn row_to_post_list(
|
||||
_client: &tokio_postgres::Client,
|
||||
row: &tokio_postgres::Row,
|
||||
) -> Post {
|
||||
pub(super) fn row_to_post_list_item(row: &tokio_postgres::Row) -> PostListItem {
|
||||
let id: i32 = row.get("id");
|
||||
let role_str: String = row.get("status");
|
||||
let status = PostStatus::from_str(&role_str).unwrap_or(PostStatus::Draft);
|
||||
let status_str: String = row.get("status");
|
||||
let status = PostStatus::from_str(&status_str).unwrap_or(PostStatus::Draft);
|
||||
|
||||
// 聚合标签并过滤空字符串。
|
||||
let tags: Vec<String> = row
|
||||
@ -35,17 +32,15 @@ pub(super) async fn row_to_post_list(
|
||||
.filter(|t| !t.is_empty())
|
||||
.collect();
|
||||
|
||||
let content_md: String = row.get("content_md");
|
||||
let word_count = count_words(&content_md);
|
||||
let word_count: i32 = row.get("word_count");
|
||||
let reading_time: i32 = row.get("reading_time");
|
||||
|
||||
Post {
|
||||
PostListItem {
|
||||
id,
|
||||
author_id: row.get("author_id"),
|
||||
title: row.get("title"),
|
||||
slug: row.get("slug"),
|
||||
summary: row.get("summary"),
|
||||
content_md,
|
||||
content_html: row.get("content_html"),
|
||||
status,
|
||||
published_at: row.get("published_at"),
|
||||
created_at: row.get("created_at"),
|
||||
@ -53,11 +48,8 @@ pub(super) async fn row_to_post_list(
|
||||
deleted_at: row.try_get("deleted_at").ok(),
|
||||
tags,
|
||||
cover_image: row.get("cover_image"),
|
||||
reading_time: (word_count / 200).max(1),
|
||||
word_count,
|
||||
toc_html: None,
|
||||
prev_post: None,
|
||||
next_post: None,
|
||||
reading_time: reading_time.max(1) as u32,
|
||||
word_count: word_count.max(0) as u32,
|
||||
}
|
||||
}
|
||||
|
||||
@ -110,6 +102,17 @@ pub(super) async fn row_to_post_full(
|
||||
None
|
||||
};
|
||||
|
||||
// 读取正文与已持久化的字数/阅读时长;若列尚未回填(旧数据为 0),则现场计算。
|
||||
let content_md: String = row.get("content_md");
|
||||
let stored_word_count: i32 = row.get("word_count");
|
||||
let stored_reading_time: i32 = row.get("reading_time");
|
||||
let (word_count, reading_time) = if stored_word_count > 0 && stored_reading_time > 0 {
|
||||
(stored_word_count as u32, stored_reading_time as u32)
|
||||
} else {
|
||||
let wc = count_words(&content_md);
|
||||
(wc, reading_time(wc))
|
||||
};
|
||||
|
||||
let content_html: Option<String> = row.get("content_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 {
|
||||
(html, toc_html_row)
|
||||
} else {
|
||||
let content_md: String = row.get("content_md");
|
||||
let rendered = crate::api::markdown::render_markdown_enhanced(&content_md);
|
||||
(
|
||||
rendered.html,
|
||||
@ -129,9 +131,6 @@ pub(super) async fn row_to_post_full(
|
||||
)
|
||||
};
|
||||
|
||||
let content_md: String = row.get("content_md");
|
||||
let word_count = count_words(&content_md);
|
||||
|
||||
Post {
|
||||
id,
|
||||
author_id: row.get("author_id"),
|
||||
@ -147,7 +146,7 @@ pub(super) async fn row_to_post_full(
|
||||
deleted_at: row.try_get("deleted_at").ok(),
|
||||
tags,
|
||||
cover_image: row.get("cover_image"),
|
||||
reading_time: (word_count / 200).max(1),
|
||||
reading_time,
|
||||
word_count,
|
||||
toc_html,
|
||||
prev_post,
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
use super::helpers::{get_current_admin_user, row_to_post_list};
|
||||
use super::helpers::{get_current_admin_user, row_to_post_list_item};
|
||||
use super::types::PostListResponse;
|
||||
#[cfg(feature = "server")]
|
||||
use crate::api::error::AppError;
|
||||
@ -82,8 +82,9 @@ pub async fn list_published_posts(
|
||||
let rows = client
|
||||
.query(
|
||||
"SELECT
|
||||
p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.content_html,
|
||||
p.status, p.published_at, p.created_at, p.updated_at, p.cover_image,
|
||||
p.id, p.author_id, p.title, p.slug, p.summary, p.status,
|
||||
p.published_at, p.created_at, p.updated_at, p.cover_image,
|
||||
p.word_count, p.reading_time,
|
||||
COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags
|
||||
FROM posts p
|
||||
LEFT JOIN post_tags pt ON p.id = pt.post_id
|
||||
@ -99,7 +100,7 @@ pub async fn list_published_posts(
|
||||
|
||||
let mut posts = Vec::new();
|
||||
for row in &rows {
|
||||
posts.push(row_to_post_list(&client, row).await);
|
||||
posts.push(row_to_post_list_item(row));
|
||||
}
|
||||
|
||||
crate::cache::set_post_list(&cache_key, posts.clone(), total).await;
|
||||
@ -139,8 +140,9 @@ pub async fn list_posts(page: i32, per_page: i32) -> Result<PostListResponse, Se
|
||||
let rows = client
|
||||
.query(
|
||||
"SELECT
|
||||
p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.content_html,
|
||||
p.status, p.published_at, p.created_at, p.updated_at, p.cover_image,
|
||||
p.id, p.author_id, p.title, p.slug, p.summary, p.status,
|
||||
p.published_at, p.created_at, p.updated_at, p.cover_image,
|
||||
p.word_count, p.reading_time,
|
||||
COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags
|
||||
FROM posts p
|
||||
LEFT JOIN post_tags pt ON p.id = pt.post_id
|
||||
@ -156,7 +158,7 @@ pub async fn list_posts(page: i32, per_page: i32) -> Result<PostListResponse, Se
|
||||
|
||||
let mut posts = Vec::new();
|
||||
for row in &rows {
|
||||
posts.push(row_to_post_list(&client, row).await);
|
||||
posts.push(row_to_post_list_item(row));
|
||||
}
|
||||
|
||||
Ok(PostListResponse { posts, total })
|
||||
@ -198,8 +200,9 @@ pub async fn list_deleted_posts(
|
||||
let rows = client
|
||||
.query(
|
||||
"SELECT
|
||||
p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.content_html,
|
||||
p.status, p.published_at, p.created_at, p.updated_at, p.cover_image, p.deleted_at,
|
||||
p.id, p.author_id, p.title, p.slug, p.summary, p.status,
|
||||
p.published_at, p.created_at, p.updated_at, p.cover_image, p.deleted_at,
|
||||
p.word_count, p.reading_time,
|
||||
COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags
|
||||
FROM posts p
|
||||
LEFT JOIN post_tags pt ON p.id = pt.post_id
|
||||
@ -215,7 +218,7 @@ pub async fn list_deleted_posts(
|
||||
|
||||
let mut posts = Vec::new();
|
||||
for row in &rows {
|
||||
posts.push(row_to_post_list(&client, row).await);
|
||||
posts.push(row_to_post_list_item(row));
|
||||
}
|
||||
|
||||
Ok(PostListResponse { posts, total })
|
||||
@ -251,8 +254,9 @@ pub async fn get_posts_by_tag(tag_name: String) -> Result<PostListResponse, Serv
|
||||
let rows = client
|
||||
.query(
|
||||
"SELECT
|
||||
p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.content_html,
|
||||
p.status, p.published_at, p.created_at, p.updated_at, p.cover_image,
|
||||
p.id, p.author_id, p.title, p.slug, p.summary, p.status,
|
||||
p.published_at, p.created_at, p.updated_at, p.cover_image,
|
||||
p.word_count, p.reading_time,
|
||||
COALESCE(array_agg(t2.name) FILTER (WHERE t2.name IS NOT NULL), '{}') as tags
|
||||
FROM posts p
|
||||
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();
|
||||
for row in &rows {
|
||||
posts.push(row_to_post_list(&client, row).await);
|
||||
posts.push(row_to_post_list_item(row));
|
||||
}
|
||||
|
||||
// 当前查询未分页,返回全部匹配文章,因此 total 等于结果长度。
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
use super::helpers::{get_current_admin_user, row_to_post_full, row_to_post_list};
|
||||
use super::helpers::{get_current_admin_user, row_to_post_full};
|
||||
use super::types::SinglePostResponse;
|
||||
#[cfg(feature = "server")]
|
||||
use crate::api::error::AppError;
|
||||
@ -29,8 +29,9 @@ pub async fn get_post_by_id(post_id: i32) -> Result<SinglePostResponse, ServerFn
|
||||
let row = client
|
||||
.query_opt(
|
||||
"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.word_count, p.reading_time,
|
||||
COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags
|
||||
FROM posts p
|
||||
LEFT JOIN post_tags pt ON p.id = pt.post_id
|
||||
@ -43,7 +44,7 @@ pub async fn get_post_by_id(post_id: i32) -> Result<SinglePostResponse, ServerFn
|
||||
.map_err(AppError::query)?;
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
@ -75,6 +76,7 @@ pub async fn get_post_by_slug(slug: String) -> Result<SinglePostResponse, Server
|
||||
"SELECT
|
||||
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.word_count, p.reading_time,
|
||||
COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags,
|
||||
prev.title as prev_title, prev.slug as prev_slug,
|
||||
next.title as next_title, next.slug as next_slug
|
||||
|
||||
@ -75,14 +75,25 @@ pub async fn rebuild_content_html(rebuild_all: bool) -> Result<RebuildResult, Se
|
||||
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
|
||||
.execute(
|
||||
"UPDATE posts SET content_html = $1, toc_html = $2 WHERE id = $3",
|
||||
&[&rendered.html, &toc_html, &id],
|
||||
"UPDATE posts SET content_html = $1, toc_html = $2, word_count = $3, reading_time = $4 WHERE id = $5",
|
||||
&[
|
||||
&rendered.html,
|
||||
&toc_html,
|
||||
&(word_count as i32),
|
||||
&(reading_time as i32),
|
||||
&id,
|
||||
],
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => rebuilt += 1,
|
||||
Ok(_) => {
|
||||
rebuilt += 1;
|
||||
}
|
||||
Err(_) => {
|
||||
failed += 1;
|
||||
if errors.len() < MAX_DISPLAY_ERRORS {
|
||||
@ -92,9 +103,13 @@ pub async fn rebuild_content_html(rebuild_all: bool) -> Result<RebuildResult, Se
|
||||
}
|
||||
}
|
||||
|
||||
// 只要有文章被更新,就清空所有文章缓存。
|
||||
if rebuilt > 0 || failed > 0 {
|
||||
// 重建会修改 word_count / reading_time 等列表项字段,批量影响列表、标签云、
|
||||
// 标签文章及单篇缓存;这里使用全量失效作为务实的回退策略。
|
||||
if rebuilt > 0 {
|
||||
crate::cache::invalidate_all_post_caches();
|
||||
crate::cache::invalidate_search_results();
|
||||
// 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。
|
||||
crate::ssr_cache::bump_global_generation();
|
||||
}
|
||||
|
||||
Ok(RebuildResult {
|
||||
|
||||
@ -8,17 +8,19 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
use super::helpers::row_to_post_list;
|
||||
use super::helpers::row_to_post_list_item;
|
||||
use super::types::PostListResponse;
|
||||
#[cfg(feature = "server")]
|
||||
use crate::api::error::AppError;
|
||||
#[cfg(feature = "server")]
|
||||
use crate::cache;
|
||||
#[cfg(feature = "server")]
|
||||
use crate::db::pool::get_conn;
|
||||
|
||||
/// 搜索已发布文章。
|
||||
///
|
||||
/// 空查询直接返回空结果;非空查询使用 `word_similarity` 计算相关度,
|
||||
/// 并限制返回 50 条记录。当前未缓存,每次均查询数据库。
|
||||
/// 并限制返回 50 条记录。结果写入短 TTL 内存缓存以减轻 DB 压力。
|
||||
#[server(SearchPosts, "/api")]
|
||||
pub async fn search_posts(query: String) -> Result<PostListResponse, ServerFnError> {
|
||||
#[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 通配符,避免用户输入 % / _ 导致全表扫描。
|
||||
let escaped = q
|
||||
.replace('\\', "\\\\")
|
||||
@ -57,8 +65,9 @@ pub async fn search_posts(query: String) -> Result<PostListResponse, ServerFnErr
|
||||
let rows = client
|
||||
.query(
|
||||
"SELECT
|
||||
p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.content_html,
|
||||
p.status, p.published_at, p.created_at, p.updated_at, p.cover_image,
|
||||
p.id, p.author_id, p.title, p.slug, p.summary, p.status,
|
||||
p.published_at, p.created_at, p.updated_at, p.cover_image,
|
||||
p.word_count, p.reading_time,
|
||||
COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags,
|
||||
word_similarity(p.search_text, $2) AS sml
|
||||
FROM posts p
|
||||
@ -76,10 +85,11 @@ pub async fn search_posts(query: String) -> Result<PostListResponse, ServerFnErr
|
||||
|
||||
let mut posts = Vec::new();
|
||||
for row in &rows {
|
||||
posts.push(row_to_post_list(&client, row).await);
|
||||
posts.push(row_to_post_list_item(row));
|
||||
}
|
||||
|
||||
let total = posts.len() as i64;
|
||||
cache::set_search_results(&cache_key, posts.clone(), total).await;
|
||||
Ok(PostListResponse { posts, total })
|
||||
}
|
||||
|
||||
|
||||
@ -18,7 +18,8 @@ use crate::models::post::PostStats;
|
||||
|
||||
/// 获取文章统计信息。
|
||||
///
|
||||
/// 需要 admin 权限;优先命中缓存,未命中时分别统计总数、草稿数与已发布数。
|
||||
/// 需要 admin 权限;优先命中缓存,未命中时通过单次条件聚合查询同时统计
|
||||
/// 未删除文章总数、草稿数与已发布数。
|
||||
#[server(GetPostStats, "/api")]
|
||||
pub async fn get_post_stats() -> Result<PostStatsResponse, ServerFnError> {
|
||||
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 total: i64 = client
|
||||
.query_one("SELECT COUNT(*) FROM posts WHERE deleted_at IS NULL", &[])
|
||||
.await
|
||||
.map_err(AppError::query)?
|
||||
.get(0);
|
||||
|
||||
// 统计草稿数量。
|
||||
let drafts: i64 = client
|
||||
// 通过单次条件聚合查询同时统计总数、草稿数与已发布数。
|
||||
let row = client
|
||||
.query_one(
|
||||
"SELECT COUNT(*) FROM posts WHERE deleted_at IS NULL AND status = 'draft'",
|
||||
"SELECT
|
||||
COUNT(*) FILTER (WHERE deleted_at IS NULL) AS total,
|
||||
COUNT(*) FILTER (WHERE deleted_at IS NULL AND status = 'draft') AS drafts,
|
||||
COUNT(*) FILTER (WHERE deleted_at IS NULL AND status = 'published') AS published
|
||||
FROM posts",
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?
|
||||
.get(0);
|
||||
|
||||
// 统计已发布数量。
|
||||
let published: i64 = client
|
||||
.query_one(
|
||||
"SELECT COUNT(*) FROM posts WHERE deleted_at IS NULL AND status = 'published'",
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?
|
||||
.get(0);
|
||||
.map_err(AppError::query)?;
|
||||
|
||||
let stats = PostStats {
|
||||
total,
|
||||
drafts,
|
||||
published,
|
||||
total: row.get("total"),
|
||||
drafts: row.get("drafts"),
|
||||
published: row.get("published"),
|
||||
};
|
||||
crate::cache::set_post_stats(stats.clone()).await;
|
||||
Ok(PostStatsResponse { stats })
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
//! 回收站操作接口:恢复、彻底删除、批量操作与一键清空。
|
||||
//!
|
||||
//! 所有接口需要 admin 权限,操作后清空全部文章相关缓存。
|
||||
//! 所有接口需要 admin 权限,操作后按影响范围精准失效缓存;
|
||||
//! 仅在影响集很大(如批量清空)时才回退到全量缓存失效。
|
||||
//! Dioxus server function,注册在 `/api` 路径下。
|
||||
//! 仅在 `feature = "server"` 启用的服务端构建中执行数据库操作。
|
||||
|
||||
@ -16,6 +17,11 @@ use crate::api::slug::ensure_unique_slug;
|
||||
#[cfg(feature = "server")]
|
||||
use crate::db::pool::get_conn;
|
||||
|
||||
/// 批量/清空操作使用精准失效的最大记录数阈值。
|
||||
/// 超过该阈值时回退到 `invalidate_all_post_caches()`,避免大量串行缓存操作。
|
||||
#[cfg(feature = "server")]
|
||||
const PRECISE_INVALIDATION_LIMIT: usize = 50;
|
||||
|
||||
/// 恢复一篇已删除的文章(将 deleted_at 置空)。
|
||||
///
|
||||
/// 若该文章原始 slug 已被其他未删除文章占用,自动追加数字后缀。
|
||||
@ -28,10 +34,10 @@ pub async fn restore_post(post_id: i32) -> Result<CreatePostResponse, ServerFnEr
|
||||
let mut client = get_conn().await.map_err(AppError::db_conn)?;
|
||||
let tx = client.transaction().await.map_err(AppError::tx)?;
|
||||
|
||||
// 读取待恢复文章的当前 slug 与是否确已删除。
|
||||
// 在事务内锁定行并读取当前 slug、标签与是否确已删除。
|
||||
let row = tx
|
||||
.query_opt(
|
||||
"SELECT slug FROM posts WHERE id = $1 AND deleted_at IS NOT NULL",
|
||||
"SELECT slug FROM posts WHERE id = $1 AND deleted_at IS NOT NULL FOR UPDATE",
|
||||
&[&post_id],
|
||||
)
|
||||
.await
|
||||
@ -51,6 +57,15 @@ pub async fn restore_post(post_id: i32) -> Result<CreatePostResponse, ServerFnEr
|
||||
// 恢复时确保 slug 在未删除文章中唯一(自动加后缀);在事务内检查避免并发竞态。
|
||||
let new_slug = ensure_unique_slug(&tx, ¤t_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(可能已加后缀)。
|
||||
let result = tx
|
||||
.execute(
|
||||
@ -71,7 +86,17 @@ pub async fn restore_post(post_id: i32) -> Result<CreatePostResponse, ServerFnEr
|
||||
|
||||
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(¤t_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 {
|
||||
success: true,
|
||||
@ -102,15 +127,44 @@ pub async fn purge_post(post_id: i32) -> Result<CreatePostResponse, ServerFnErro
|
||||
|
||||
#[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(
|
||||
"DELETE FROM posts WHERE id = $1 AND deleted_at IS NOT NULL",
|
||||
&[&post_id],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?;
|
||||
.map_err(AppError::tx)?;
|
||||
|
||||
if result == 0 {
|
||||
return Ok(CreatePostResponse {
|
||||
@ -121,13 +175,24 @@ pub async fn purge_post(post_id: i32) -> Result<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 {
|
||||
success: true,
|
||||
message: "彻底删除成功".to_string(),
|
||||
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 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 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 {
|
||||
let row = tx
|
||||
.query_opt(
|
||||
"SELECT slug FROM posts WHERE id = $1 AND deleted_at IS NOT NULL",
|
||||
"SELECT slug FROM posts WHERE id = $1 AND deleted_at IS NOT NULL FOR UPDATE",
|
||||
&[&id],
|
||||
)
|
||||
.await
|
||||
@ -174,6 +246,20 @@ pub async fn batch_restore_posts(post_ids: Vec<i32>) -> Result<CreatePostRespons
|
||||
if let Some(row) = row {
|
||||
let current_slug: String = row.get("slug");
|
||||
let new_slug = ensure_unique_slug(&tx, ¤t_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
|
||||
.execute(
|
||||
"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
|
||||
.map_err(AppError::tx)?;
|
||||
restored += n;
|
||||
|
||||
if use_precise {
|
||||
affected_slugs.push(current_slug);
|
||||
affected_slugs.push(new_slug);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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 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(
|
||||
"DELETE FROM posts WHERE id = ANY($1) AND deleted_at IS NOT NULL",
|
||||
&[&post_ids],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?;
|
||||
.map_err(AppError::tx)?;
|
||||
|
||||
crate::cache::invalidate_all_post_caches();
|
||||
tx.commit().await.map_err(AppError::tx)?;
|
||||
|
||||
if use_precise {
|
||||
crate::cache::invalidate_post_lists();
|
||||
crate::cache::invalidate_all_tags();
|
||||
crate::cache::invalidate_post_stats();
|
||||
crate::cache::invalidate_search_results();
|
||||
for slug in &slugs {
|
||||
crate::cache::invalidate_post_by_slug(slug).await;
|
||||
}
|
||||
crate::cache::invalidate_tag_posts_for(&tags).await;
|
||||
|
||||
// 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。
|
||||
crate::ssr_cache::bump_global_generation();
|
||||
} else {
|
||||
// 影响集过大时回退到全量失效,避免大量串行缓存操作。
|
||||
crate::cache::invalidate_all_post_caches();
|
||||
crate::cache::invalidate_search_results();
|
||||
// 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。
|
||||
crate::ssr_cache::bump_global_generation();
|
||||
}
|
||||
|
||||
Ok(CreatePostResponse {
|
||||
success: true,
|
||||
@ -263,14 +433,65 @@ pub async fn empty_trash() -> Result<CreatePostResponse, ServerFnError> {
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||
let mut client = get_conn().await.map_err(AppError::db_conn)?;
|
||||
let tx = client.transaction().await.map_err(AppError::tx)?;
|
||||
|
||||
let result = client
|
||||
.execute("DELETE FROM posts WHERE deleted_at IS NOT NULL", &[])
|
||||
// 在事务内锁定所有待删除行并读取 id/slug,用于后续精准失效;
|
||||
// 同时根据数量决定使用精准失效还是回退到全量失效。
|
||||
let deleted_rows = tx
|
||||
.query(
|
||||
"SELECT id, slug FROM posts WHERE deleted_at IS NOT NULL FOR UPDATE",
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?;
|
||||
let use_precise = !deleted_rows.is_empty()
|
||||
&& deleted_rows.len() <= PRECISE_INVALIDATION_LIMIT;
|
||||
|
||||
crate::cache::invalidate_all_post_caches();
|
||||
let (slugs, tags) = if use_precise {
|
||||
let slugs: Vec<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 {
|
||||
success: true,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
//! 文章 API 的请求与响应数据结构。
|
||||
|
||||
use crate::models::post::{Post, PostStats, Tag};
|
||||
use crate::models::post::{Post, PostListItem, PostStats, Tag};
|
||||
|
||||
/// 创建/更新/删除文章的统一响应结构。
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
@ -18,8 +18,8 @@ pub struct CreatePostResponse {
|
||||
/// 文章列表响应。
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct PostListResponse {
|
||||
/// 文章列表。
|
||||
pub posts: Vec<Post>,
|
||||
/// 文章列表(轻量 DTO,不含正文)。
|
||||
pub posts: Vec<PostListItem>,
|
||||
/// 符合查询条件的总数。
|
||||
pub total: i64,
|
||||
}
|
||||
|
||||
@ -55,6 +55,10 @@ pub async fn update_post(
|
||||
let post_status = PostStatus::from_str(&status).unwrap_or(PostStatus::Draft);
|
||||
let cover_image = cover_image.filter(|s| !s.trim().is_empty());
|
||||
|
||||
// 重新计算字数与阅读时长,保持与正文同步。
|
||||
let word_count = crate::utils::text::count_words(&content_md);
|
||||
let reading_time = crate::utils::text::reading_time(word_count);
|
||||
|
||||
let tx = client.transaction().await.map_err(AppError::tx)?;
|
||||
|
||||
// 查询旧 slug,用于后续缓存失效。
|
||||
@ -150,8 +154,8 @@ pub async fn update_post(
|
||||
// 更新文章主表。
|
||||
let updated = tx
|
||||
.execute(
|
||||
"UPDATE posts SET title = $1, slug = $2, summary = $3, content_md = $4, content_html = $5, toc_html = $6, status = $7, published_at = $8, cover_image = $9, updated_at = NOW()
|
||||
WHERE id = $10",
|
||||
"UPDATE posts SET title = $1, slug = $2, summary = $3, content_md = $4, content_html = $5, toc_html = $6, status = $7, published_at = $8, cover_image = $9, word_count = $10, reading_time = $11, updated_at = NOW()
|
||||
WHERE id = $12",
|
||||
&[
|
||||
&title.trim(),
|
||||
&final_slug,
|
||||
@ -162,6 +166,8 @@ pub async fn update_post(
|
||||
&post_status.as_str(),
|
||||
&published_at,
|
||||
&cover_image,
|
||||
&(word_count as i32),
|
||||
&(reading_time as i32),
|
||||
&post_id,
|
||||
],
|
||||
)
|
||||
@ -192,17 +198,18 @@ pub async fn update_post(
|
||||
// 失效文章列表、标签、当前 slug 与统计缓存。
|
||||
crate::cache::invalidate_post_lists();
|
||||
crate::cache::invalidate_all_tags();
|
||||
crate::cache::invalidate_post_by_slug(&final_slug).await;
|
||||
crate::cache::invalidate_post_stats();
|
||||
crate::cache::invalidate_search_results();
|
||||
crate::cache::invalidate_post_by_slug(&final_slug).await;
|
||||
|
||||
// 合并旧标签与新标签,统一失效标签下的文章列表缓存。
|
||||
let all_tags_to_invalidate: std::collections::HashSet<String> = old_tags
|
||||
let all_tags_to_invalidate: Vec<String> = old_tags
|
||||
.into_iter()
|
||||
.chain(tags_for_invalidation.into_iter())
|
||||
.collect::<std::collections::HashSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
for tag_name in &all_tags_to_invalidate {
|
||||
crate::cache::invalidate_posts_by_tag(tag_name).await;
|
||||
}
|
||||
crate::cache::invalidate_tag_posts_for(&all_tags_to_invalidate).await;
|
||||
|
||||
// 若 slug 发生变更,额外失效旧 slug 缓存。
|
||||
if let Some(ref old) = old_slug {
|
||||
@ -211,6 +218,9 @@ pub async fn update_post(
|
||||
}
|
||||
}
|
||||
|
||||
// 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。
|
||||
crate::ssr_cache::bump_global_generation();
|
||||
|
||||
Ok(CreatePostResponse {
|
||||
success: true,
|
||||
message: "更新成功".to_string(),
|
||||
|
||||
229
src/cache.rs
229
src/cache.rs
@ -1,7 +1,7 @@
|
||||
//! 基于 moka 的内存缓存层。
|
||||
//!
|
||||
//! 仅在启用 `server` feature 时编译,为文章列表、标签、单篇文章、统计信息
|
||||
//! 以及评论相关数据提供按键缓存与失效能力。
|
||||
//! 仅在启用 `server` feature 时编译,为文章列表、标签、单篇文章、统计信息、
|
||||
//! 评论、会话用户以及搜索结果提供按键缓存与失效能力。
|
||||
//! 缓存使用 `std::sync::LazyLock` 全局实例,按不同业务数据设置独立的 TTL。
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
@ -14,7 +14,9 @@ use std::time::Duration;
|
||||
#[cfg(feature = "server")]
|
||||
use crate::models::comment::PublicComment;
|
||||
#[cfg(feature = "server")]
|
||||
use crate::models::post::{Post, PostStats, Tag};
|
||||
use crate::models::post::{Post, PostListItem, PostStats, Tag};
|
||||
#[cfg(feature = "server")]
|
||||
use crate::models::user::SessionUser;
|
||||
|
||||
// ============================================================================
|
||||
// 缓存 TTL 配置
|
||||
@ -48,6 +50,14 @@ const TTL_COMMENTS: Duration = Duration::from_secs(60);
|
||||
#[cfg(feature = "server")]
|
||||
const TTL_PENDING_COUNT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// 会话用户缓存 TTL:300 秒(5 分钟),短于 DB 会话过期时间。
|
||||
#[cfg(feature = "server")]
|
||||
const TTL_SESSION: Duration = Duration::from_secs(300);
|
||||
|
||||
/// 搜索结果缓存 TTL:10 秒。
|
||||
#[cfg(feature = "server")]
|
||||
const TTL_SEARCH: Duration = Duration::from_secs(10);
|
||||
|
||||
// ============================================================================
|
||||
// 缓存 Key 类型
|
||||
// ============================================================================
|
||||
@ -80,7 +90,7 @@ pub enum CacheKey {
|
||||
|
||||
/// 文章列表缓存类型,值为(文章列表,总数)。
|
||||
#[cfg(feature = "server")]
|
||||
pub type PostListCache = Cache<CacheKey, (Vec<Post>, i64)>;
|
||||
pub type PostListCache = Cache<CacheKey, (Vec<PostListItem>, i64)>;
|
||||
|
||||
/// 标签列表缓存类型。
|
||||
#[cfg(feature = "server")]
|
||||
@ -161,19 +171,45 @@ static PENDING_COUNT_CACHE: LazyLock<Cache<CacheKey, i64>> = LazyLock::new(|| {
|
||||
.build()
|
||||
});
|
||||
|
||||
/// 会话用户缓存类型。
|
||||
#[cfg(feature = "server")]
|
||||
pub type SessionCache = Cache<String, SessionUser>;
|
||||
|
||||
/// 搜索结果缓存类型。
|
||||
#[cfg(feature = "server")]
|
||||
pub type SearchCache = Cache<String, (Vec<PostListItem>, i64)>;
|
||||
|
||||
/// 全局会话用户缓存实例,最大容量 1000,TTL 5 分钟。
|
||||
#[cfg(feature = "server")]
|
||||
pub static SESSION_CACHE: LazyLock<SessionCache> = LazyLock::new(|| {
|
||||
Cache::builder()
|
||||
.max_capacity(1000)
|
||||
.time_to_live(TTL_SESSION)
|
||||
.build()
|
||||
});
|
||||
|
||||
/// 全局搜索结果缓存实例,最大容量 200,TTL 10 秒。
|
||||
#[cfg(feature = "server")]
|
||||
static SEARCH_CACHE: LazyLock<SearchCache> = LazyLock::new(|| {
|
||||
Cache::builder()
|
||||
.max_capacity(200)
|
||||
.time_to_live(TTL_SEARCH)
|
||||
.build()
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// 公共缓存 API
|
||||
// ============================================================================
|
||||
|
||||
/// 读取文章分页列表缓存。
|
||||
#[cfg(feature = "server")]
|
||||
pub async fn get_post_list(key: &CacheKey) -> Option<(Vec<Post>, i64)> {
|
||||
pub async fn get_post_list(key: &CacheKey) -> Option<(Vec<PostListItem>, i64)> {
|
||||
POST_LIST_CACHE.get(key).await
|
||||
}
|
||||
|
||||
/// 写入文章分页列表缓存。
|
||||
#[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;
|
||||
}
|
||||
|
||||
@ -224,7 +260,7 @@ pub async fn set_post_by_slug(slug: &str, post: Option<Post>) {
|
||||
|
||||
/// 按标签读取文章列表缓存。
|
||||
#[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
|
||||
.get(&CacheKey::PostsByTag(tag.to_string()))
|
||||
.await
|
||||
@ -232,7 +268,7 @@ pub async fn get_posts_by_tag(tag: &str) -> Option<(Vec<Post>, i64)> {
|
||||
|
||||
/// 按标签写入文章列表缓存。
|
||||
#[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
|
||||
.insert(CacheKey::PostsByTag(tag.to_string()), (posts, total))
|
||||
.await;
|
||||
@ -288,7 +324,22 @@ pub fn invalidate_post_stats() {
|
||||
POST_STATS_CACHE.invalidate_all();
|
||||
}
|
||||
|
||||
/// 按标签批量失效文章列表缓存。
|
||||
#[cfg(feature = "server")]
|
||||
pub async fn invalidate_tag_posts_for(tags: &[String]) {
|
||||
let futures: Vec<_> = tags
|
||||
.iter()
|
||||
.map(|tag| invalidate_posts_by_tag(tag))
|
||||
.collect();
|
||||
let _ = futures::future::join_all(futures).await;
|
||||
}
|
||||
|
||||
/// 清空所有文章相关缓存(列表、标签、单篇、统计、标签文章)。
|
||||
///
|
||||
/// 这是一个“紧急”使用的全量失效开关,会一次性冲刷所有文章缓存;
|
||||
/// 正常写路径应当使用更细粒度的 `invalidate_post_lists` / `invalidate_all_tags` /
|
||||
/// `invalidate_post_by_slug` / `invalidate_posts_by_tag` / `invalidate_post_stats` /
|
||||
/// `invalidate_tag_posts_for` 等函数,避免不必要的缓存击穿。
|
||||
#[cfg(feature = "server")]
|
||||
pub fn invalidate_all_post_caches() {
|
||||
POST_LIST_CACHE.invalidate_all();
|
||||
@ -330,6 +381,53 @@ pub async fn set_pending_count(count: i64) {
|
||||
.await;
|
||||
}
|
||||
|
||||
/// 规范化搜索查询键:trim、转小写、截断至 200 字符。
|
||||
#[cfg(feature = "server")]
|
||||
pub fn normalize_search_key(query: &str) -> String {
|
||||
query.trim().to_lowercase().chars().take(200).collect()
|
||||
}
|
||||
|
||||
/// 读取会话用户缓存。
|
||||
#[cfg(feature = "server")]
|
||||
pub async fn get_session_user(token_hash: &str) -> Option<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")]
|
||||
pub async fn invalidate_comments_by_post(post_id: i32) {
|
||||
@ -351,6 +449,7 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::models::comment::PublicComment;
|
||||
use crate::models::post::PostStatus;
|
||||
use crate::models::user::{SessionUser, UserRole};
|
||||
use serial_test::serial;
|
||||
|
||||
#[test]
|
||||
@ -379,15 +478,31 @@ mod tests {
|
||||
page: 999,
|
||||
per_page: 99,
|
||||
};
|
||||
let posts = vec![];
|
||||
let posts = vec![PostListItem {
|
||||
id: 1,
|
||||
author_id: 1,
|
||||
title: "List Item".to_string(),
|
||||
slug: "list-item".to_string(),
|
||||
summary: None,
|
||||
status: PostStatus::Published,
|
||||
published_at: None,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
deleted_at: None,
|
||||
tags: vec!["rust".to_string()],
|
||||
cover_image: None,
|
||||
reading_time: 1,
|
||||
word_count: 10,
|
||||
}];
|
||||
|
||||
set_post_list(&key, posts.clone(), 0).await;
|
||||
set_post_list(&key, posts.clone(), 1).await;
|
||||
let cached = get_post_list(&key).await;
|
||||
|
||||
assert!(cached.is_some());
|
||||
let (cached_posts, cached_total) = cached.unwrap();
|
||||
assert_eq!(cached_posts.len(), 0);
|
||||
assert_eq!(cached_total, 0);
|
||||
assert_eq!(cached_posts.len(), 1);
|
||||
assert_eq!(cached_posts[0].title, "List Item");
|
||||
assert_eq!(cached_total, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@ -541,4 +656,94 @@ mod tests {
|
||||
assert!(get_pending_count().await.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn session_cache_roundtrip() {
|
||||
let user = SessionUser {
|
||||
id: 42,
|
||||
username: "cached_user".to_string(),
|
||||
email: "cached@example.com".to_string(),
|
||||
role: UserRole::Admin,
|
||||
created_at: chrono::Utc::now(),
|
||||
};
|
||||
let token_hash = "sha256_token_hash";
|
||||
|
||||
set_session_user(token_hash, user.clone()).await;
|
||||
let cached = get_session_user(token_hash).await;
|
||||
|
||||
assert!(cached.is_some());
|
||||
let cached_user = cached.unwrap();
|
||||
assert_eq!(cached_user.id, user.id);
|
||||
assert_eq!(cached_user.username, user.username);
|
||||
assert_eq!(cached_user.email, user.email);
|
||||
assert_eq!(cached_user.role, user.role);
|
||||
|
||||
invalidate_session_user(token_hash).await;
|
||||
assert!(get_session_user(token_hash).await.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_key_normalization() {
|
||||
assert_eq!(normalize_search_key(" Rust "), "rust");
|
||||
assert_eq!(normalize_search_key("Rust"), "rust");
|
||||
assert_eq!(normalize_search_key(" rust "), "rust");
|
||||
assert_eq!(normalize_search_key(""), "");
|
||||
|
||||
let long = "a".repeat(250);
|
||||
let normalized = normalize_search_key(&long);
|
||||
assert_eq!(normalized.len(), 200);
|
||||
assert!(normalized.chars().all(|c| c == 'a'));
|
||||
|
||||
// 大小写与空格差异应映射到同一键。
|
||||
assert_eq!(
|
||||
normalize_search_key(" Dioxus Fullstack "),
|
||||
normalize_search_key("dioxus fullstack")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn search_cache_roundtrip() {
|
||||
let query = "Rust";
|
||||
let posts = vec![PostListItem {
|
||||
id: 1,
|
||||
author_id: 1,
|
||||
title: "Search Result".to_string(),
|
||||
slug: "search-result".to_string(),
|
||||
summary: None,
|
||||
status: PostStatus::Published,
|
||||
published_at: None,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
deleted_at: None,
|
||||
tags: vec!["rust".to_string()],
|
||||
cover_image: None,
|
||||
reading_time: 1,
|
||||
word_count: 10,
|
||||
}];
|
||||
|
||||
set_search_results(query, posts.clone(), 1).await;
|
||||
|
||||
// 大小写与空格差异应命中同一缓存条目。
|
||||
let cached = get_search_results(" rust ").await;
|
||||
assert!(cached.is_some());
|
||||
let (cached_posts, cached_total) = cached.unwrap();
|
||||
assert_eq!(cached_posts.len(), 1);
|
||||
assert_eq!(cached_posts[0].title, "Search Result");
|
||||
assert_eq!(cached_total, 1);
|
||||
|
||||
invalidate_search_results();
|
||||
assert!(get_search_results(query).await.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn search_cache_invalidation() {
|
||||
set_search_results("tokio", vec![], 0).await;
|
||||
assert!(get_search_results("tokio").await.is_some());
|
||||
|
||||
invalidate_search_results();
|
||||
assert!(get_search_results("tokio").await.is_none());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@ use dioxus::prelude::*;
|
||||
use dioxus::router::components::Link;
|
||||
|
||||
use crate::components::image_viewer::ImageViewer;
|
||||
use crate::models::post::Post;
|
||||
use crate::models::post::PostListItem;
|
||||
use crate::router::Route;
|
||||
|
||||
/// 文章卡片组件。
|
||||
@ -23,7 +23,7 @@ use crate::router::Route;
|
||||
/// 关键事件:
|
||||
/// - 点击标签时阻止事件冒泡,避免触发整卡跳转
|
||||
#[component]
|
||||
pub fn PostCard(post: Post) -> Element {
|
||||
pub fn PostCard(post: PostListItem) -> Element {
|
||||
let post_slug = post.slug.clone();
|
||||
let date_str = post.formatted_date();
|
||||
let has_cover = post.cover_image.is_some();
|
||||
|
||||
41
src/main.rs
41
src/main.rs
@ -23,6 +23,9 @@ mod hooks;
|
||||
mod models;
|
||||
mod pages;
|
||||
mod router;
|
||||
// ssr_cache 仅在 server feature 启用时编译;保存 SSR 世代号失效状态。
|
||||
#[cfg(feature = "server")]
|
||||
mod ssr_cache;
|
||||
mod tasks;
|
||||
mod theme;
|
||||
mod utils;
|
||||
@ -225,7 +228,15 @@ fn main() {
|
||||
tasks::post_purge::run_purge().await;
|
||||
});
|
||||
|
||||
// 配置增量渲染缓存,默认缓存 3600 秒,可通过 SSR_CACHE_SECS 覆盖
|
||||
// 启动后台定时任务:图片磁盘缓存清理
|
||||
tokio::spawn(async {
|
||||
tasks::image_cache_cleanup::run_cleanup().await;
|
||||
});
|
||||
|
||||
// 配置增量渲染缓存,默认缓存 3600 秒,可通过 SSR_CACHE_SECS 覆盖。
|
||||
// 注意:src/ssr_cache.rs 中的世代号是未来就绪基础设施,当前并不会使
|
||||
// Dioxus 0.7 的 SSR 缓存实际失效(Dioxus 未暴露相应 API)。在 API 可用
|
||||
// 之前,SSR_CACHE_SECS 仍是唯一有效的兜底 TTL。
|
||||
let config = ServeConfig::builder().incremental(
|
||||
dioxus::server::IncrementalRendererConfig::default().invalidate_after(
|
||||
std::time::Duration::from_secs(
|
||||
@ -237,6 +248,28 @@ fn main() {
|
||||
),
|
||||
);
|
||||
|
||||
// SSR 世代号中间件:把当前全局世代号注入请求扩展,并对 GET 请求的
|
||||
// 响应附加 `X-SSR-Generation` 头。这是为未来 Dioxus 支持自定义 SSR 缓存键
|
||||
// 预留的钩子;目前主要提供可观测性,不会实际失效 SSR 缓存。
|
||||
async fn ssr_generation_middleware(
|
||||
req: axum::http::Request<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 路由:图片上传(大文件,需要更长超时)
|
||||
let upload_route = axum::Router::new()
|
||||
.route(
|
||||
@ -253,8 +286,10 @@ fn main() {
|
||||
let dioxus_app =
|
||||
axum::Router::new().serve_dioxus_application(config, router::AppRouter);
|
||||
|
||||
// 合并 Dioxus + 缓存头/可选压缩/30s 超时中间件
|
||||
let mut app_routes = dioxus_app.layer(axum::middleware::from_fn(add_cache_control));
|
||||
// 合并 Dioxus + 世代号/缓存头/可选压缩/30s 超时中间件
|
||||
let mut app_routes = dioxus_app
|
||||
.layer(axum::middleware::from_fn(ssr_generation_middleware))
|
||||
.layer(axum::middleware::from_fn(add_cache_control));
|
||||
if let Some(layer) = compression_layer_from_env() {
|
||||
app_routes = app_routes.layer(layer);
|
||||
}
|
||||
|
||||
@ -85,6 +85,51 @@ impl Post {
|
||||
.map(|d| d.format("%Y-%m-%d").to_string())
|
||||
.unwrap_or_else(|| self.created_at.format("%Y-%m-%d").to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// 文章列表项 DTO。
|
||||
///
|
||||
/// 仅包含列表/标签/搜索/归档等场景需要的字段,不含 `content_md` 与 `content_html`,
|
||||
/// 以降低缓存内存占用与序列化体积。`deleted_at` 保留,供回收站列表使用。
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PostListItem {
|
||||
/// 文章主键。
|
||||
pub id: i32,
|
||||
/// 作者用户主键。
|
||||
pub author_id: i32,
|
||||
/// 文章标题。
|
||||
pub title: String,
|
||||
/// URL slug,用于生成文章链接。
|
||||
pub slug: String,
|
||||
/// 摘要,可选。
|
||||
pub summary: Option<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 {
|
||||
@ -173,6 +218,25 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_post_list_item() -> PostListItem {
|
||||
PostListItem {
|
||||
id: 1,
|
||||
author_id: 1,
|
||||
title: "Test".to_string(),
|
||||
slug: "test".to_string(),
|
||||
summary: None,
|
||||
status: PostStatus::Draft,
|
||||
published_at: None,
|
||||
created_at: Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap(),
|
||||
updated_at: Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap(),
|
||||
deleted_at: None,
|
||||
tags: vec![],
|
||||
cover_image: None,
|
||||
reading_time: 1,
|
||||
word_count: 10,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "server")]
|
||||
fn post_status_from_str() {
|
||||
@ -213,8 +277,21 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_label() {
|
||||
let mut post = sample_post();
|
||||
fn post_list_item_formatted_date_uses_published_at_when_available() {
|
||||
let mut post = sample_post_list_item();
|
||||
post.published_at = Some(Utc.with_ymd_and_hms(2024, 6, 1, 12, 0, 0).unwrap());
|
||||
assert_eq!(post.formatted_date(), "2024-06-01");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_list_item_formatted_date_falls_back_to_created_at() {
|
||||
let post = sample_post_list_item();
|
||||
assert_eq!(post.formatted_date(), "2024-01-15");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_list_item_status_label() {
|
||||
let mut post = sample_post_list_item();
|
||||
post.status = PostStatus::Published;
|
||||
assert_eq!(post.status_label(), "已发布");
|
||||
post.status = PostStatus::Draft;
|
||||
@ -222,8 +299,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_class_returns_non_empty() {
|
||||
let mut post = sample_post();
|
||||
fn post_list_item_status_class_returns_non_empty() {
|
||||
let mut post = sample_post_list_item();
|
||||
post.status = PostStatus::Published;
|
||||
assert_eq!(post.status_class(), "text-green-600 dark:text-green-400");
|
||||
post.status = PostStatus::Draft;
|
||||
@ -231,8 +308,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_badge_class_returns_non_empty() {
|
||||
let mut post = sample_post();
|
||||
fn post_list_item_status_badge_class_returns_non_empty() {
|
||||
let mut post = sample_post_list_item();
|
||||
post.status = PostStatus::Published;
|
||||
assert_eq!(
|
||||
post.status_badge_class(),
|
||||
|
||||
@ -44,6 +44,21 @@ pub struct User {
|
||||
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 转换而来,不含密码哈希。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PublicUser {
|
||||
@ -59,6 +74,32 @@ pub struct PublicUser {
|
||||
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 {
|
||||
/// 将 User 转换为 PublicUser,丢弃 password_hash 字段。
|
||||
fn from(u: User) -> Self {
|
||||
@ -124,4 +165,24 @@ mod tests {
|
||||
UserRole::Admin
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_to_session_user_excludes_password_hash() {
|
||||
let user = sample_user();
|
||||
let session: SessionUser = user.clone().into();
|
||||
assert_eq!(session.id, user.id);
|
||||
assert_eq!(session.username, user.username);
|
||||
assert_eq!(session.email, user.email);
|
||||
assert_eq!(session.role, user.role);
|
||||
assert_eq!(session.created_at, user.created_at);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_user_to_public_user_excludes_password_hash() {
|
||||
let user = sample_user();
|
||||
let session: SessionUser = user.into();
|
||||
let public: PublicUser = session.into();
|
||||
let json = serde_json::to_string(&public).unwrap();
|
||||
assert!(!json.contains("password_hash"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,7 +13,7 @@ use crate::api::posts::{get_post_stats, list_posts};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use crate::api::posts::{PostListResponse, PostStatsResponse};
|
||||
use crate::components::ui::ADMIN_CARD_CLASS;
|
||||
use crate::models::post::{Post, PostStats};
|
||||
use crate::models::post::{PostListItem, PostStats};
|
||||
use crate::router::Route;
|
||||
|
||||
/// 后台仪表盘页面组件。
|
||||
@ -25,7 +25,7 @@ use crate::router::Route;
|
||||
pub fn Admin() -> Element {
|
||||
// 仪表盘状态:统计数据、最近文章、待审核评论数与首次加载标志。
|
||||
let mut stats = use_signal(|| None::<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 loaded = use_signal(|| false);
|
||||
|
||||
@ -165,7 +165,7 @@ fn StatCard(value: String, label: String) -> Element {
|
||||
|
||||
/// 最近文章列表项,显示标题、状态标签与发布日期。
|
||||
#[component]
|
||||
fn RecentPostItem(post: Post) -> Element {
|
||||
fn RecentPostItem(post: PostListItem) -> Element {
|
||||
let date_str = post.formatted_date();
|
||||
let status_label = post.status_label();
|
||||
let status_class = post.status_class();
|
||||
|
||||
@ -15,7 +15,7 @@ use crate::api::posts::{delete_post, rebuild_content_html, CreatePostResponse, R
|
||||
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
|
||||
use crate::components::skeletons::posts_skeleton::PostsSkeleton;
|
||||
use crate::components::ui::{EmptyState, Pagination, StatusBadge, ADMIN_ROW_HOVER, ADMIN_TABLE_CLASS, BTN_TEXT_RED};
|
||||
use crate::models::post::Post;
|
||||
use crate::models::post::PostListItem;
|
||||
use crate::router::Route;
|
||||
|
||||
/// 每页展示的文章数量。
|
||||
@ -34,7 +34,7 @@ pub fn Posts() -> Element {
|
||||
pub fn PostsPage(page: i32) -> Element {
|
||||
let current_page = page.max(1);
|
||||
// 文章列表、总数、加载状态、删除中 ID、重建缓存状态与结果。
|
||||
let mut posts = use_signal(Vec::new);
|
||||
let mut posts = use_signal(Vec::<PostListItem>::new);
|
||||
let mut total = use_signal(|| 0_i64);
|
||||
let mut loading = use_signal(|| true);
|
||||
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! {
|
||||
div { class: "space-y-6",
|
||||
@ -215,7 +215,7 @@ pub fn PostsPage(page: i32) -> Element {
|
||||
|
||||
/// 文章表格行组件,展示单篇文章的标题、状态、日期与操作按钮。
|
||||
#[component]
|
||||
fn PostRow(post: Post, deleting: bool, on_delete: EventHandler<i32>) -> Element {
|
||||
fn PostRow(post: PostListItem, deleting: bool, on_delete: EventHandler<i32>) -> Element {
|
||||
let date_str = post.formatted_date();
|
||||
|
||||
rsx! {
|
||||
|
||||
@ -23,7 +23,7 @@ use crate::components::ui::{
|
||||
EmptyState, Pagination, StatusBadge, ADMIN_ROW_HOVER, ADMIN_TABLE_CLASS, BTN_SOLID_GREEN,
|
||||
BTN_SOLID_RED, BTN_TEXT_ACCENT, BTN_TEXT_RED, CHECKBOX_CLASS,
|
||||
};
|
||||
use crate::models::post::Post;
|
||||
use crate::models::post::PostListItem;
|
||||
use crate::models::settings::TrashSettings;
|
||||
use crate::router::Route;
|
||||
|
||||
@ -44,7 +44,7 @@ pub fn Trash() -> Element {
|
||||
pub fn TrashPage(page: i32) -> Element {
|
||||
let current_page = page.max(1);
|
||||
let mut selected_ids: Signal<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);
|
||||
#[allow(unused_mut)]
|
||||
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")]
|
||||
{
|
||||
if let Some(deleted_at) = post.deleted_at {
|
||||
@ -523,7 +523,7 @@ fn remaining_days(post: &Post, retention_days: i32) -> (i64, bool) {
|
||||
/// 回收站表格行组件。
|
||||
#[component]
|
||||
fn TrashRow(
|
||||
post: Post,
|
||||
post: PostListItem,
|
||||
retention_days: i32,
|
||||
selected: bool,
|
||||
on_select: EventHandler<bool>,
|
||||
|
||||
@ -13,7 +13,7 @@ use dioxus::router::components::Link;
|
||||
use crate::api::posts::{list_published_posts, PostListResponse};
|
||||
use crate::components::skeletons::archive_skeleton::ArchiveSkeleton;
|
||||
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
|
||||
use crate::models::post::Post;
|
||||
use crate::models::post::PostListItem;
|
||||
use crate::router::Route;
|
||||
|
||||
/// 按年份分组的文章归档结构。
|
||||
@ -28,13 +28,13 @@ struct YearGroup {
|
||||
struct MonthGroup {
|
||||
month: String,
|
||||
month_en: String,
|
||||
posts: Vec<Post>,
|
||||
posts: Vec<PostListItem>,
|
||||
}
|
||||
|
||||
/// 将文章列表按 `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![];
|
||||
|
||||
for post in posts {
|
||||
@ -204,7 +204,7 @@ fn MonthSection(month_group: MonthGroup, year: String) -> Element {
|
||||
|
||||
/// 单条归档文章组件,展示标题与发布日期,并通过覆盖层链接到文章详情。
|
||||
#[component]
|
||||
fn ArchiveEntry(post: Post) -> Element {
|
||||
fn ArchiveEntry(post: PostListItem) -> Element {
|
||||
let date_str = post.formatted_date();
|
||||
|
||||
rsx! {
|
||||
|
||||
70
src/ssr_cache.rs
Normal file
70
src/ssr_cache.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
242
src/tasks/image_cache_cleanup.rs
Normal file
242
src/tasks/image_cache_cleanup.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -11,3 +11,6 @@ pub mod session_cleanup;
|
||||
/// 定时清理回收站中超过保留期的已删除文章。
|
||||
#[cfg(feature = "server")]
|
||||
pub mod post_purge;
|
||||
/// 定时清理图片磁盘缓存,避免缓存目录无限增长。
|
||||
#[cfg(feature = "server")]
|
||||
pub mod image_cache_cleanup;
|
||||
|
||||
@ -16,11 +16,17 @@ pub async fn run_cleanup() {
|
||||
match get_conn().await {
|
||||
Ok(client) => {
|
||||
// 删除已过期会话
|
||||
if let Err(e) = client
|
||||
match client
|
||||
.execute("DELETE FROM sessions WHERE expires_at < NOW()", &[])
|
||||
.await
|
||||
{
|
||||
tracing::error!("Session cleanup error: {:?}", e);
|
||||
Ok(_) => {
|
||||
// 同时清空内存中的会话缓存,避免已失效会话继续命中。
|
||||
crate::cache::SESSION_CACHE.invalidate_all();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Session cleanup error: {:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
@ -72,6 +72,13 @@ pub fn count_words(md: &str) -> u32 {
|
||||
count.max(1)
|
||||
}
|
||||
|
||||
/// 由字数估算阅读时长(分钟)。
|
||||
///
|
||||
/// 按每分钟 200 字计算,至少返回 1 分钟。
|
||||
pub fn reading_time(word_count: u32) -> u32 {
|
||||
(word_count / 200).max(1)
|
||||
}
|
||||
|
||||
/// 自动生成文本摘要,取去除 Markdown 后的前 200 个字符。
|
||||
pub fn auto_summary(md: &str) -> String {
|
||||
let plain = strip_markdown(md);
|
||||
@ -158,6 +165,21 @@ mod tests {
|
||||
assert_eq!(count_words(""), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_time_defaults_to_one() {
|
||||
assert_eq!(reading_time(0), 1);
|
||||
assert_eq!(reading_time(1), 1);
|
||||
assert_eq!(reading_time(199), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_time_scales_by_two_hundred() {
|
||||
assert_eq!(reading_time(200), 1);
|
||||
assert_eq!(reading_time(201), 1);
|
||||
assert_eq!(reading_time(400), 2);
|
||||
assert_eq!(reading_time(1000), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_summary_truncates_at_200_chars() {
|
||||
let long_md: String = "a ".repeat(200);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user