yggdrasil/src/ssr_cache.rs
xfy 438a5e1046 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 编译通过。
2026-07-16 15:11:34 +08:00

201 lines
7.7 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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`] 删掉对应路由的
//! 缓存目录,下次请求触发重渲染。
//!
//! 全局世代号(`bump_global_generation`)保留作可观测性 + 未来就绪钩子,但不依赖它
//! 实际失效缓存。
//!
//! 仅在启用 `server` feature 时编译。
#![cfg(feature = "server")]
use std::path::PathBuf;
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);
/// 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 缓存");
}
/// 原子递增并返回新的全局世代号。
///
/// 仅作可观测性用途(注入 `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::debug!(new_generation = new, "SSR 全局世代号已递增");
new
}
/// 返回当前全局世代号。
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);
}
#[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();
}
}