yggdrasil/api/posts/
rebuild.rs1use 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#[cfg(feature = "server")]
21const REBUILD_BATCH_LIMIT: i64 = 100;
22#[cfg(feature = "server")]
24const MAX_DISPLAY_ERRORS: usize = 5;
25
26#[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 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 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 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 if rebuilt > 0 {
121 crate::cache::invalidate_all_post_caches();
122 crate::cache::invalidate_search_results();
123 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#[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 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 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 crate::cache::invalidate_post_lists();
201 crate::cache::invalidate_search_results();
202 crate::cache::invalidate_post_by_slug(&slug).await;
203 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}