- 全项目 .rs 统一 cargo fmt 排版(换行/缩进/参数分行/导入排序) - infra/docker: DOCKER_CLIENT 连接失败不 panic,降级返回 None (博客不依赖 Docker,panic=abort 下会导致整个进程崩溃) - infra/mod: 去除多余空行 - database/mod: 模块声明按字母序重新排序
237 lines
9.0 KiB
Rust
237 lines
9.0 KiB
Rust
//! 批量重建文章 HTML 与目录。
|
||
//!
|
||
//! 用于数据迁移或修复:遍历符合条件的文章,将 Markdown 重新渲染为 HTML,
|
||
//! 并更新 content_html 与 toc_html 字段。
|
||
//! Dioxus server function,注册在 `/api` 路径下。
|
||
//! 仅在 `feature = "server"` 启用的服务端构建中执行数据库更新。
|
||
|
||
use dioxus::prelude::*;
|
||
|
||
#[cfg(feature = "server")]
|
||
use super::helpers::get_current_admin_user;
|
||
#[cfg(feature = "server")]
|
||
use crate::api::error::AppError;
|
||
use crate::api::posts::{CreatePostResponse, RebuildResult};
|
||
#[cfg(feature = "server")]
|
||
use crate::db::pool::get_conn;
|
||
|
||
/// 单次重建批处理数量上限。
|
||
#[cfg(feature = "server")]
|
||
const REBUILD_BATCH_LIMIT: i64 = 500;
|
||
/// 返回给前端展示的最大错误条数。
|
||
#[cfg(feature = "server")]
|
||
const MAX_DISPLAY_ERRORS: usize = 5;
|
||
|
||
/// 批量重建文章 content_html 与 toc_html。
|
||
///
|
||
/// 当 `rebuild_all` 为 true 时重建所有未删除文章;否则仅重建 content_html 为空的文章。
|
||
/// 单批最多处理 500 条,渲染异常或写入失败会被捕获并汇总。
|
||
#[server(RebuildContentHtml, "/api")]
|
||
pub async fn rebuild_content_html(rebuild_all: bool) -> Result<RebuildResult, ServerFnError> {
|
||
let _user = get_current_admin_user().await?;
|
||
|
||
#[cfg(feature = "server")]
|
||
{
|
||
let mut client = get_conn().await.map_err(AppError::db_conn)?;
|
||
|
||
// 根据参数构造 WHERE 条件,限制单次处理数量。
|
||
// SELECT 在事务内并加 FOR UPDATE,锁住待处理行直到 UPDATE 完成,避免
|
||
// 并发编辑造成非可重复读(review 发现):事务外读到的 content_md 可能在
|
||
// UPDATE 前被并发请求修改,导致用旧内容覆盖新内容。
|
||
let query = if rebuild_all {
|
||
format!(
|
||
"SELECT id, content_md FROM posts WHERE deleted_at IS NULL ORDER BY id LIMIT {REBUILD_BATCH_LIMIT} FOR UPDATE"
|
||
)
|
||
} else {
|
||
format!(
|
||
"SELECT id, content_md FROM posts WHERE deleted_at IS NULL AND content_html IS NULL ORDER BY id LIMIT {REBUILD_BATCH_LIMIT} FOR UPDATE"
|
||
)
|
||
};
|
||
|
||
let mut rebuilt: u64 = 0;
|
||
let mut failed: u64 = 0;
|
||
let mut errors: Vec<String> = Vec::new();
|
||
|
||
// 整批 SELECT + UPDATE 纳入单事务:中途断连或写入失败整批回滚,避免产生
|
||
// 「部分文章已重建」的中间态(M5);FOR UPDATE 锁住的行随事务结束释放。
|
||
let tx = client.transaction().await.map_err(AppError::query)?;
|
||
|
||
let rows = tx.query(&query, &[]).await.map_err(AppError::query)?;
|
||
|
||
for row in &rows {
|
||
let id: i32 = row.get(0);
|
||
let content_md: String = row.get(1);
|
||
|
||
// Markdown 渲染在阻塞线程池执行;spawn_blocking 的 JoinError 自动捕获 panic,
|
||
// 替代原先的 catch_unwind。
|
||
let md_for_render = content_md.clone();
|
||
let rendered = match tokio::task::spawn_blocking(move || {
|
||
crate::api::markdown::render_markdown_enhanced(&md_for_render)
|
||
})
|
||
.await
|
||
{
|
||
Ok(r) => r,
|
||
Err(_) => {
|
||
failed += 1;
|
||
if errors.len() < MAX_DISPLAY_ERRORS {
|
||
errors.push(format!("文章 #{id}: 渲染异常"));
|
||
}
|
||
continue;
|
||
}
|
||
};
|
||
|
||
let toc_html = if rendered.toc_html.is_empty() {
|
||
None::<String>
|
||
} else {
|
||
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 tx
|
||
.execute(
|
||
"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;
|
||
}
|
||
Err(e) => {
|
||
// 事务内任一写入失败会使事务进入 abort 状态,后续写入都会失败;
|
||
// 此时整批回滚,保证不产生中间态。
|
||
failed += 1;
|
||
if errors.len() < MAX_DISPLAY_ERRORS {
|
||
errors.push(format!("文章 #{id}: DB 写入失败(整批将回滚)"));
|
||
}
|
||
tracing::error!("rebuild UPDATE 失败,整批回滚: {:?}", e);
|
||
tx.rollback().await.ok();
|
||
return Ok(RebuildResult {
|
||
rebuilt: 0,
|
||
failed,
|
||
errors,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
tx.commit().await.map_err(AppError::query)?;
|
||
|
||
// 重建会修改 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 {
|
||
rebuilt,
|
||
failed,
|
||
errors,
|
||
})
|
||
}
|
||
|
||
#[cfg(not(feature = "server"))]
|
||
{
|
||
Ok(RebuildResult {
|
||
rebuilt: 0,
|
||
failed: 0,
|
||
errors: vec![],
|
||
})
|
||
}
|
||
}
|
||
|
||
/// 重新渲染指定文章的 content_html 与 toc_html。
|
||
///
|
||
/// 从数据库读取 content_md(`FOR UPDATE` 锁行,避免并发编辑产生非可重复读),
|
||
/// 在阻塞线程池渲染 HTML,并更新 content_html / toc_html / word_count / reading_time。
|
||
/// 仅 admin 可调用;成功后按影响范围失效文章列表、slug 单篇与搜索缓存。
|
||
#[server(RebuildPostContentHtml, "/api")]
|
||
pub async fn rebuild_post_content_html(post_id: i32) -> Result<CreatePostResponse, ServerFnError> {
|
||
let _user = get_current_admin_user().await?;
|
||
|
||
#[cfg(feature = "server")]
|
||
{
|
||
let mut client = get_conn().await.map_err(AppError::db_conn)?;
|
||
let tx = client.transaction().await.map_err(AppError::tx)?;
|
||
|
||
// 在事务内锁行并读取 content_md / slug,避免并发更新导致用旧内容覆盖新内容
|
||
// 或缓存失效目标过期(与 delete_post 一致的事务策略)。
|
||
let row = tx
|
||
.query_opt(
|
||
"SELECT content_md, slug FROM posts WHERE id = $1 AND deleted_at IS NULL FOR UPDATE",
|
||
&[&post_id],
|
||
)
|
||
.await
|
||
.map_err(AppError::query)?;
|
||
|
||
let Some(row) = row else {
|
||
return Ok(CreatePostResponse::err("文章不存在".to_string()));
|
||
};
|
||
|
||
let content_md: String = row.get(0);
|
||
let slug: String = row.get(1);
|
||
|
||
// Markdown 渲染在阻塞线程池执行(CPU 密集)。
|
||
let md_for_render = content_md.clone();
|
||
let rendered = tokio::task::spawn_blocking(move || {
|
||
crate::api::markdown::render_markdown_enhanced(&md_for_render)
|
||
})
|
||
.await
|
||
.map_err(|_| AppError::Internal("Markdown 渲染任务失败"))?;
|
||
|
||
let toc_html = if rendered.toc_html.is_empty() {
|
||
None::<String>
|
||
} else {
|
||
Some(rendered.toc_html)
|
||
};
|
||
|
||
let word_count = crate::utils::text::count_words(&content_md);
|
||
let reading_time = crate::utils::text::reading_time(word_count);
|
||
|
||
tx.execute(
|
||
"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),
|
||
&post_id,
|
||
],
|
||
)
|
||
.await
|
||
.map_err(AppError::query)?;
|
||
|
||
tx.commit().await.map_err(AppError::tx)?;
|
||
|
||
// 重建修改了 word_count / reading_time 等列表项字段以及单篇 content_html,
|
||
// 按影响范围精准失效(标签与统计不受影响)。
|
||
crate::cache::invalidate_post_lists();
|
||
crate::cache::invalidate_search_results();
|
||
crate::cache::invalidate_post_by_slug(&slug).await;
|
||
// 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。
|
||
crate::ssr_cache::bump_global_generation();
|
||
|
||
Ok(CreatePostResponse::ok(
|
||
"重建成功".to_string(),
|
||
post_id,
|
||
slug,
|
||
))
|
||
}
|
||
|
||
#[cfg(not(feature = "server"))]
|
||
{
|
||
Ok(CreatePostResponse::err("server only".to_string()))
|
||
}
|
||
}
|