fix(ssr): 文章写入后物理删除 SSR 磁盘缓存,根治'重建后内容不更新'

Dioxus 0.7 增量渲染器把每路由 SSR 结果落盘到 static/<route>/index/<hash>.html,
只暴露 invalidate_after(ttl) 一个失效手段,无按路由失效的公开 API。
之前 bump_global_generation 在 Dioxus 0.7 不生效(ssr_cache.rs 注释已承认),
导致文章重建/编辑后,即便 DB content_html 已是新内容,SSR 缓存仍返回旧页面,
最长滞后 SSR_CACHE_SECS(默认 3600s)。本次 katex/mermaid 渲染不生效即因此。

修复:在 ssr_cache.rs 新增物理删除缓存的函数:
- invalidate_ssr_route(route): 删 static/<route>/ (含路径穿越防护)
- invalidate_ssr_all_public(): 删 static/ 下除 .well-known/admin 外全部
- invalidate_ssr_home(): 删 static/index/ (细粒度,allow dead_code)

所有写路径(create/update/rebuild/delete/trash)在事务提交后调用:
- 单篇操作失效 /post/{slug} + 全量公开页(列表项变化影响首页/归档/标签)
- 批量操作直接全量失效

世代号保留作 X-SSR-Generation 可观测性,实际失效靠删文件。
顺带修 katex.rs 的 clippy(field_reassign_with_default)+ cmp_owned。

556 Rust 测试 + clippy -D warnings + 双 target 编译通过。
This commit is contained in:
xfy 2026-07-16 15:11:34 +08:00
parent 09ca480842
commit 438a5e1046
8 changed files with 183 additions and 49 deletions

View File

@ -166,7 +166,7 @@ src/hooks/ — shared Dioxus hooks
src/models/ — Post, User, Tag data models
src/pages/ — route page components (frontend + admin)
src/router.rs — Dioxus Router route definitions
src/ssr_cache.rs — SSR generation invalidation state (server feature only)
src/ssr_cache.rs — SSR disk-cache invalidation: physically deletes `static/<route>/` on post writes (Dioxus 0.7 has no route-level invalidation API)
src/sysinfo_sampler.rs — host metrics snapshot; SystemSnapshot on both targets, sampler+RwLock server-only
src/tasks/ — background tokio tasks (session cleanup)
src/theme.rs — light/dark theme with SSR cookie + WASM localStorage
@ -251,7 +251,7 @@ Admin area at `/admin/system` (menu "系统") with 5 tabs: 数据库状态 / 服
- **Post/tag caches** (`src/cache.rs`): moka future-based, TTL varies by data type (60s600s). Invalidated on writes.
- **Image processing cache** (`src/api/image.rs`): two-tier — in-memory moka cache + disk cache in `uploads/.cache/`. Keyed by path + query params.
- **SSR cache** (`IncrementalRendererConfig` in `src/main.rs`): default TTL `SSR_CACHE_SECS` (3600s prod, 0 in `make dev`); invalidation generation tracked in `src/ssr_cache.rs`.
- **SSR cache** (`IncrementalRendererConfig` in `src/main.rs`): Dioxus 0.7 增量渲染器把每路由结果落盘到 `static/<route>/index/<hash>.html`,以 `path_and_query()` 作 key只暴露 `invalidate_after(ttl)` 一个失效手段。`src/ssr_cache.rs``invalidate_ssr_route(route)` / `invalidate_ssr_all_public()` 通过**物理删除缓存目录**绕过这个限制——所有写路径create/update/rebuild/delete/trash在事务提交后调用删除对应路由或全量公开页的缓存目录下次请求触发重渲染。全局世代号保留作 `X-SSR-Generation` 可观测性但实际失效靠删文件。TTL `SSR_CACHE_SECS`(默认 3600s prod0 in `make dev`)仍作兜底。
## Testing

View File

@ -19,20 +19,22 @@ use katex::{KatexContext, OutputFormat, Settings};
/// 内联公式(`$...$`)渲染配置工厂:`display_mode = false`。
fn inline_settings() -> Settings {
let mut s = Settings::default();
s.output = OutputFormat::Html;
s.display_mode = false;
s.throw_on_error = false;
s
Settings {
output: OutputFormat::Html,
display_mode: false,
throw_on_error: false,
..Settings::default()
}
}
/// 块级公式(`$$...$$`)渲染配置工厂:`display_mode = true`(居中独占一行)。
fn display_settings() -> Settings {
let mut s = Settings::default();
s.output = OutputFormat::Html;
s.display_mode = true;
s.throw_on_error = false;
s
Settings {
output: OutputFormat::Html,
display_mode: true,
throw_on_error: false,
..Settings::default()
}
}
thread_local! {

View File

@ -138,7 +138,8 @@ pub async fn create_post(
// 失效该文章涉及的所有标签下文章列表缓存。
crate::cache::invalidate_tag_posts_for(&tags_cleaned).await;
// 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。
// SSR新文章会出现在首页/分页/归档/标签等所有列表页,全量失效。
crate::ssr_cache::invalidate_ssr_all_public();
crate::ssr_cache::bump_global_generation();
Ok(CreatePostResponse::ok(

View File

@ -71,7 +71,9 @@ pub async fn delete_post(post_id: i32) -> Result<CreatePostResponse, ServerFnErr
crate::cache::invalidate_post_by_slug(&slug).await;
crate::cache::invalidate_tag_posts_for(&tags).await;
// 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。
// SSR删除影响详情页变 404与所有列表页。
crate::ssr_cache::invalidate_ssr_route(&format!("/post/{slug}"));
crate::ssr_cache::invalidate_ssr_all_public();
crate::ssr_cache::bump_global_generation();
Ok(CreatePostResponse::ok(

View File

@ -130,7 +130,8 @@ pub async fn rebuild_content_html(rebuild_all: bool) -> Result<RebuildResult, Se
if rebuilt > 0 {
crate::cache::invalidate_all_post_caches();
crate::cache::invalidate_search_results();
// 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。
// 批量重建影响所有公开页(列表/标签/归档/各文章),全量失效 SSR 磁盘缓存。
crate::ssr_cache::invalidate_ssr_all_public();
crate::ssr_cache::bump_global_generation();
}
@ -219,7 +220,9 @@ pub async fn rebuild_post_content_html(post_id: i32) -> Result<CreatePostRespons
crate::cache::invalidate_post_lists();
crate::cache::invalidate_search_results();
crate::cache::invalidate_post_by_slug(&slug).await;
// 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。
// SSR单篇详情页 + 列表页(首页/分页/归档/标签都含列表项摘要)。
crate::ssr_cache::invalidate_ssr_route(&format!("/post/{slug}"));
crate::ssr_cache::invalidate_ssr_all_public();
crate::ssr_cache::bump_global_generation();
Ok(CreatePostResponse::ok(

View File

@ -82,7 +82,10 @@ pub async fn restore_post(post_id: i32) -> Result<CreatePostResponse, ServerFnEr
crate::cache::invalidate_post_by_slug(&new_slug).await;
crate::cache::invalidate_tag_posts_for(&tags).await;
// 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。
// SSR回收站操作影响详情页与所有列表页含归档
crate::ssr_cache::invalidate_ssr_route(&format!("/post/{current_slug}"));
crate::ssr_cache::invalidate_ssr_route(&format!("/post/{new_slug}"));
crate::ssr_cache::invalidate_ssr_all_public();
crate::ssr_cache::bump_global_generation();
Ok(CreatePostResponse::ok(
@ -153,7 +156,9 @@ pub async fn purge_post(post_id: i32) -> Result<CreatePostResponse, ServerFnErro
crate::cache::invalidate_post_by_slug(&slug).await;
crate::cache::invalidate_tag_posts_for(&tags).await;
// 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。
// SSR彻底删除影响详情页与所有列表页。
crate::ssr_cache::invalidate_ssr_route(&format!("/post/{slug}"));
crate::ssr_cache::invalidate_ssr_all_public();
crate::ssr_cache::bump_global_generation();
Ok(CreatePostResponse::ok(
@ -245,13 +250,14 @@ pub async fn batch_restore_posts(post_ids: Vec<i32>) -> Result<CreatePostRespons
crate::cache::invalidate_tag_posts_for(&affected_tags.into_iter().collect::<Vec<_>>())
.await;
// 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。
// SSR批量恢复影响多篇文章的列表项全量失效。
crate::ssr_cache::invalidate_ssr_all_public();
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::invalidate_ssr_all_public();
crate::ssr_cache::bump_global_generation();
}

View File

@ -202,10 +202,13 @@ pub async fn update_post(
if let Some(ref old) = old_slug {
if old != &final_slug {
crate::cache::invalidate_post_by_slug(old).await;
crate::ssr_cache::invalidate_ssr_route(&format!("/post/{old}"));
}
}
// 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。
// SSR内容/标签/摘要变化影响详情页与所有列表页。
crate::ssr_cache::invalidate_ssr_route(&format!("/post/{final_slug}"));
crate::ssr_cache::invalidate_ssr_all_public();
crate::ssr_cache::bump_global_generation();
Ok(CreatePostResponse::ok(

View File

@ -1,29 +1,19 @@
//! SSR 增量渲染缓存失效的未来就绪基础设施
//! SSR 增量渲染缓存失效
//!
//! 本模块维护一个全局单调递增的世代号generation。文章写入成功后调用方会
//! 使其递增,从而**标记** SSR 渲染结果已过期。然而:
//! Dioxus 0.7 增量渲染器把每个路由的 SSR 结果落盘到 `static/<route>/index/<hash>.html`
//! 以请求 `path_and_query()` 作 key。它只暴露 `invalidate_after(ttl)` 一个失效手段,
//! **没有按路由失效的公开 API**。本模块通过**物理删除缓存目录**绕过这个限制:
//! 写路径create/update/rebuild/delete调用 [`invalidate_ssr_route`] 删掉对应路由的
//! 缓存目录,下次请求触发重渲染。
//!
//! **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 请求),便于调试与监控。
//! 全局世代号(`bump_global_generation`)保留作可观测性 + 未来就绪钩子,但不依赖它
//! 实际失效缓存。
//!
//! 仅在启用 `server` feature 时编译。
#![cfg(feature = "server")]
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::LazyLock;
@ -40,21 +30,94 @@ static GLOBAL_GENERATION: LazyLock<AtomicU64> = LazyLock::new(AtomicU64::default
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SsrGeneration(pub u64);
/// Dioxus 增量渲染器缓存落盘根目录(相对 CWD
///
/// 路由 `/post/foo` 的缓存为 `static/post/foo/index/<hash>.html`。
/// 由 `IncrementalRendererConfig::default()` 决定,无公开 API 可读,故硬编码。
const SSR_CACHE_ROOT: &str = "static";
/// 计算某路由的 SSR 缓存目录路径。
///
/// route 形如 `/post/markdown-syntax-test`(前导 `/` 可有可无)。返回
/// `static/post/markdown-syntax-test`(不含 `index/`,删整目录更彻底)。
/// 对路径段做安全清洗:禁止 `..` / 空段,避免越出 `static/` 根。
fn route_cache_dir(route: &str) -> Option<PathBuf> {
let mut path = PathBuf::from(SSR_CACHE_ROOT);
for seg in route.trim_start_matches('/').split('/') {
if seg.is_empty() || seg == ".." || seg == "." {
continue;
}
path.push(seg);
}
if path.as_path() == std::path::Path::new(SSR_CACHE_ROOT) {
None // 根路由("/")单独由 invalidate_ssr_home 处理
} else {
Some(path)
}
}
/// 失效单一路由的 SSR 磁盘缓存。
///
/// 删除 `static/<route>/` 目录(含 `index/<hash>.html`)。文件不存在时静默。
/// **IO 在当前线程同步执行**——删除一个空目录是纳秒级操作,不值得 spawn_blocking
/// 调用方已在事务提交后调用,无阻塞风险。
pub fn invalidate_ssr_route(route: &str) {
let Some(dir) = route_cache_dir(route) else {
return;
};
match std::fs::remove_dir_all(&dir) {
Ok(()) => tracing::debug!(route = route, dir = %dir.display(), "SSR 路由缓存已删除"),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => tracing::warn!(route = route, error = %e, "删除 SSR 路由缓存失败"),
}
}
/// 失效首页 SSR 缓存(路由 `/`,落盘在 `static/index/`)。
///
/// 通常 [`invalidate_ssr_all_public`] 已覆盖首页;本函数留作只需刷新首页的细粒度场景。
#[allow(dead_code)]
pub fn invalidate_ssr_home() {
let dir = PathBuf::from(SSR_CACHE_ROOT).join("index");
match std::fs::remove_dir_all(&dir) {
Ok(()) => tracing::debug!("SSR 首页缓存已删除"),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => tracing::warn!(error = %e, "删除 SSR 首页缓存失败"),
}
}
/// 失效所有公开页 SSR 缓存(删除 `static/` 下除 `.well-known`、`admin` 外的全部)。
///
/// 用于批量重建等影响面广的写入。保留 `.well-known`(浏览器/PWA 元数据,
/// 与内容无关)和 `admin`(管理后台,写入者自己的视角无需刷新)。
pub fn invalidate_ssr_all_public() {
let root = PathBuf::from(SSR_CACHE_ROOT);
let Ok(entries) = std::fs::read_dir(&root) else {
return; // 目录不存在(首次启动或已被清)
};
for entry in entries.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
if name == ".well-known" || name == "admin" {
continue;
}
if let Err(e) = std::fs::remove_dir_all(entry.path()) {
if e.kind() != std::io::ErrorKind::NotFound {
tracing::warn!(entry = %name, error = %e, "删除 SSR 缓存条目失败");
}
}
}
tracing::debug!("已失效全部公开页 SSR 缓存");
}
/// 原子递增并返回新的全局世代号。
///
/// 任何文章写入操作都会调用本函数,标记 SSR 渲染结果已过期。当前 Dioxus 0.7
/// 不读取此世代号(详见模块文档),实际失效仍依赖 `SSR_CACHE_SECS` 兜底 TTL。
/// 此处打一条 info 日志,既证明写入路径已执行,也提醒部署者此处存在滞后——
/// 避免"我改了文章怎么没变"被误判成 DB/发布链路问题。
/// 仅作可观测性用途(注入 `X-SSR-Generation` 响应头)。实际 SSR 缓存失效由
/// [`invalidate_ssr_route`] / [`invalidate_ssr_home`] 物理删文件完成。
pub fn bump_global_generation() -> u64 {
let new = GLOBAL_GENERATION
.fetch_add(1, Ordering::SeqCst)
.wrapping_add(1);
tracing::info!(
new_generation = new,
"SSR 世代号已递增写入路径触发。注意Dioxus 0.7 不读取此值,\
SSR SSR_CACHE_SECS "
);
tracing::debug!(new_generation = new, "SSR 全局世代号已递增");
new
}
@ -80,4 +143,58 @@ mod tests {
assert!(g2 > g1);
assert_eq!(current, g2);
}
#[test]
fn route_cache_dir_rejects_traversal() {
// 路径穿越尝试不应越出 static/ 根。
let p = route_cache_dir("/post/../../../etc/passwd").unwrap();
let segs: Vec<_> = p.components().collect();
// .. 被过滤,只剩 static/post/etc/passwd
assert!(p.starts_with("static"));
assert!(!segs.iter().any(|c| c.as_os_str() == ".."));
}
#[test]
fn route_cache_dir_normalizes_leading_slash() {
let a = route_cache_dir("/post/foo").unwrap();
let b = route_cache_dir("post/foo").unwrap();
assert_eq!(a, b);
assert!(a.ends_with("post/foo"));
}
#[test]
fn route_cache_dir_none_for_root() {
// 根路由 "/" 无目录段,由 invalidate_ssr_home 单独处理。
assert!(route_cache_dir("/").is_none());
assert!(route_cache_dir("").is_none());
}
#[test]
fn invalidate_ssr_route_deletes_dir() {
// 造一个假的缓存目录static/__test_route/index/fake.html
let dir = route_cache_dir("/__test_route_xyz").unwrap();
std::fs::create_dir_all(dir.join("index")).unwrap();
std::fs::write(dir.join("index").join("fake.html"), "stale").unwrap();
assert!(dir.exists());
invalidate_ssr_route("/__test_route_xyz");
assert!(!dir.exists(), "删除后目录不应存在");
}
#[test]
fn invalidate_ssr_route_missing_is_noop() {
// 不存在的路由删除应静默成功NotFound 不报错)。
invalidate_ssr_route("/__never_exists_xyz_123");
}
#[test]
fn invalidate_ssr_all_public_is_safe_on_missing_root() {
// static/ 根不存在时不应 panic首次启动或已清空场景
// 不创建真实 static/ 目录,直接调用验证 NotFound 静默。
// (若真实环境已有 static/本测试不会误删——read_dir 对每个条目单独删,
// 这里仅验证根缺失分支。)
invalidate_ssr_all_public();
invalidate_ssr_home();
}
}