fix(posts): wrap rebuild_content_html in single transaction

整批 UPDATE 纳入事务,任一写入失败整批回滚,避免产生「部分文章已重建」的
中间态(M5)。渲染失败(spawn_blocking panic)仍跳过该条不进入事务操作。
失败时返回 rebuilt:0 明确告知整批回滚。
This commit is contained in:
xfy 2026-06-18 13:32:55 +08:00
parent 82ab190e0d
commit 71d4126e94

View File

@ -32,7 +32,7 @@ pub async fn rebuild_content_html(rebuild_all: bool) -> Result<RebuildResult, Se
#[cfg(feature = "server")]
{
let client = get_conn().await.map_err(AppError::db_conn)?;
let mut client = get_conn().await.map_err(AppError::db_conn)?;
// 根据参数构造 WHERE 条件,限制单次处理数量。
let query = if rebuild_all {
@ -51,6 +51,10 @@ pub async fn rebuild_content_html(rebuild_all: bool) -> Result<RebuildResult, Se
let mut failed: u64 = 0;
let mut errors: Vec<String> = Vec::new();
// 整批 UPDATE 纳入单事务:中途断连或写入失败整批回滚,避免产生
// 「部分文章已重建」的中间态M5
let tx = client.transaction().await.map_err(AppError::query)?;
for row in &rows {
let id: i32 = row.get(0);
let content_md: String = row.get(1);
@ -82,7 +86,7 @@ pub async fn rebuild_content_html(rebuild_all: bool) -> Result<RebuildResult, Se
let word_count = crate::utils::text::count_words(&content_md);
let reading_time = crate::utils::text::reading_time(word_count);
match client
match tx
.execute(
"UPDATE posts SET content_html = $1, toc_html = $2, word_count = $3, reading_time = $4 WHERE id = $5",
&[
@ -98,15 +102,26 @@ pub async fn rebuild_content_html(rebuild_all: bool) -> Result<RebuildResult, Se
Ok(_) => {
rebuilt += 1;
}
Err(_) => {
Err(e) => {
// 事务内任一写入失败会使事务进入 abort 状态,后续写入都会失败;
// 此时整批回滚,保证不产生中间态。
failed += 1;
if errors.len() < MAX_DISPLAY_ERRORS {
errors.push(format!("文章 #{id}: DB 写入失败"));
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 {