Skip to main content

yggdrasil/api/posts/
rebuild.rs

1//! 批量重建文章 HTML 与目录。
2//!
3//! 用于数据迁移或修复:遍历符合条件的文章,将 Markdown 重新渲染为 HTML,
4//! 并更新 content_html 与 toc_html 字段。
5//! Dioxus server function,注册在 `/api` 路径下。
6//! 仅在 `feature = "server"` 启用的服务端构建中执行数据库更新。
7
8use dioxus::prelude::*;
9
10#[cfg(feature = "server")]
11use super::helpers::{get_current_admin_user, render_post_fields_minimal};
12#[cfg(feature = "server")]
13use crate::api::error::AppError;
14use crate::api::posts::{CreatePostResponse, RebuildResult};
15#[cfg(feature = "server")]
16use crate::db::pool::get_conn;
17
18/// 单次重建批处理数量上限。
19#[cfg(feature = "server")]
20const REBUILD_BATCH_LIMIT: i64 = 500;
21/// 返回给前端展示的最大错误条数。
22#[cfg(feature = "server")]
23const MAX_DISPLAY_ERRORS: usize = 5;
24
25/// 批量重建文章 content_html 与 toc_html。
26///
27/// 当 `rebuild_all` 为 true 时重建所有未删除文章;否则仅重建 content_html 为空的文章。
28/// 单批最多处理 500 条,渲染异常或写入失败会被捕获并汇总。
29#[server(RebuildContentHtml, "/api")]
30pub async fn rebuild_content_html(rebuild_all: bool) -> Result<RebuildResult, ServerFnError> {
31    let _user = get_current_admin_user().await?;
32
33    #[cfg(feature = "server")]
34    {
35        let mut client = get_conn().await.map_err(AppError::db_conn)?;
36
37        // 根据参数构造 WHERE 条件,限制单次处理数量。
38        // SELECT 在事务内并加 FOR UPDATE,锁住待处理行直到 UPDATE 完成,避免
39        // 并发编辑造成非可重复读(review 发现):事务外读到的 content_md 可能在
40        // UPDATE 前被并发请求修改,导致用旧内容覆盖新内容。
41        let query = if rebuild_all {
42            format!(
43                "SELECT id, content_md FROM posts WHERE deleted_at IS NULL ORDER BY id LIMIT {REBUILD_BATCH_LIMIT} FOR UPDATE"
44            )
45        } else {
46            format!(
47                "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"
48            )
49        };
50
51        let mut rebuilt: u64 = 0;
52        let mut failed: u64 = 0;
53        let mut errors: Vec<String> = Vec::new();
54
55        // 整批 SELECT + UPDATE 纳入单事务:中途断连或写入失败整批回滚,避免产生
56        // 「部分文章已重建」的中间态(M5);FOR UPDATE 锁住的行随事务结束释放。
57        let tx = client.transaction().await.map_err(AppError::query)?;
58
59        let rows = tx.query(&query, &[]).await.map_err(AppError::query)?;
60
61        for row in &rows {
62            let id: i32 = row.get(0);
63            let content_md: String = row.get(1);
64
65            // Markdown 渲染 + 度量派生收敛到 helper(R4)。
66            let (content_html, toc_html, word_count, reading_time) =
67                match render_post_fields_minimal(&content_md).await {
68                    Ok(f) => f,
69                    Err(_) => {
70                        failed += 1;
71                        if errors.len() < MAX_DISPLAY_ERRORS {
72                            errors.push(format!("文章 #{id}: 渲染异常"));
73                        }
74                        continue;
75                    }
76                };
77
78            match tx
79                .execute(
80                    "UPDATE posts SET content_html = $1, toc_html = $2, word_count = $3, reading_time = $4 WHERE id = $5",
81                    &[
82                        &content_html,
83                        &toc_html,
84                        &word_count,
85                        &reading_time,
86                        &id,
87                    ],
88                )
89                .await
90            {
91                Ok(_) => {
92                    rebuilt += 1;
93                }
94                Err(e) => {
95                    // 事务内任一写入失败会使事务进入 abort 状态,后续写入都会失败;
96                    // 此时整批回滚,保证不产生中间态。
97                    failed += 1;
98                    if errors.len() < MAX_DISPLAY_ERRORS {
99                        errors.push(format!("文章 #{id}: DB 写入失败(整批将回滚)"));
100                    }
101                    tracing::error!("rebuild UPDATE 失败,整批回滚: {:?}", e);
102                    tx.rollback().await.ok();
103                    return Ok(RebuildResult {
104                        rebuilt: 0,
105                        failed,
106                        errors,
107                    });
108                }
109            }
110        }
111
112        tx.commit().await.map_err(AppError::query)?;
113
114        // 重建会修改 word_count / reading_time 等列表项字段,批量影响列表、标签云、
115        // 标签文章及单篇缓存;这里使用全量失效作为务实的回退策略。
116        if rebuilt > 0 {
117            crate::cache::invalidate_all_post_caches();
118            crate::cache::invalidate_search_results();
119            // 批量重建影响所有公开页(列表/标签/归档/各文章),全量失效 SSR 磁盘缓存。
120            crate::ssr_cache::invalidate_ssr_all_public();
121            crate::ssr_cache::bump_global_generation();
122        }
123
124        Ok(RebuildResult {
125            rebuilt,
126            failed,
127            errors,
128        })
129    }
130
131    #[cfg(not(feature = "server"))]
132    {
133        Ok(RebuildResult {
134            rebuilt: 0,
135            failed: 0,
136            errors: vec![],
137        })
138    }
139}
140
141/// 重新渲染指定文章的 content_html 与 toc_html。
142///
143/// 从数据库读取 content_md(`FOR UPDATE` 锁行,避免并发编辑产生非可重复读),
144/// 在阻塞线程池渲染 HTML,并更新 content_html / toc_html / word_count / reading_time。
145/// 仅 admin 可调用;成功后按影响范围失效文章列表、slug 单篇与搜索缓存。
146#[server(RebuildPostContentHtml, "/api")]
147pub async fn rebuild_post_content_html(post_id: i32) -> Result<CreatePostResponse, ServerFnError> {
148    let _user = get_current_admin_user().await?;
149
150    #[cfg(feature = "server")]
151    {
152        let mut client = get_conn().await.map_err(AppError::db_conn)?;
153        let tx = client.transaction().await.map_err(AppError::tx)?;
154
155        // 在事务内锁行并读取 content_md / slug,避免并发更新导致用旧内容覆盖新内容
156        // 或缓存失效目标过期(与 delete_post 一致的事务策略)。
157        let row = tx
158            .query_opt(
159                "SELECT content_md, slug FROM posts WHERE id = $1 AND deleted_at IS NULL FOR UPDATE",
160                &[&post_id],
161            )
162            .await
163            .map_err(AppError::query)?;
164
165        let Some(row) = row else {
166            return Ok(CreatePostResponse::err("文章不存在".to_string()));
167        };
168
169        let content_md: String = row.get(0);
170        let slug: String = row.get(1);
171
172        // Markdown 渲染 + 度量派生收敛到 helper(R4)。
173        let (content_html, toc_html, word_count, reading_time) =
174            render_post_fields_minimal(&content_md).await?;
175
176        tx.execute(
177            "UPDATE posts SET content_html = $1, toc_html = $2, word_count = $3, reading_time = $4 WHERE id = $5",
178            &[
179                &content_html,
180                &toc_html,
181                &word_count,
182                &reading_time,
183                &post_id,
184            ],
185        )
186        .await
187        .map_err(AppError::query)?;
188
189        tx.commit().await.map_err(AppError::tx)?;
190
191        // 重建修改了 word_count / reading_time 等列表项字段以及单篇 content_html,
192        // 按影响范围精准失效(标签与统计不受影响)。
193        crate::cache::invalidate_post_lists();
194        crate::cache::invalidate_search_results();
195        crate::cache::invalidate_post_by_slug(&slug).await;
196        // SSR:单篇详情页 + 列表页(首页/分页/归档/标签都含列表项摘要)。
197        crate::ssr_cache::invalidate_ssr_route(&format!("/post/{slug}"));
198        crate::ssr_cache::invalidate_ssr_all_public();
199        crate::ssr_cache::bump_global_generation();
200
201        Ok(CreatePostResponse::ok(
202            "重建成功".to_string(),
203            post_id,
204            slug,
205        ))
206    }
207
208    #[cfg(not(feature = "server"))]
209    {
210        Ok(CreatePostResponse::err("server only".to_string()))
211    }
212}