Skip to main content

yggdrasil/api/posts/
rebuild.rs

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