1#[cfg(feature = "server")]
7use crate::api::error::AppError;
8#[cfg(feature = "server")]
9use crate::models::post::{Post, PostListItem, PostStatus};
10#[cfg(feature = "server")]
11use crate::utils::text::{auto_summary, count_words, reading_time};
12
13#[cfg(feature = "server")]
15pub(super) use crate::api::auth::get_current_admin_user;
16
17#[cfg(feature = "server")]
22pub(super) fn row_to_post_list_item(row: &tokio_postgres::Row) -> PostListItem {
23 let id: i32 = row.get("id");
24 let status_str: String = row.get("status");
25 let status = PostStatus::from_str(&status_str).unwrap_or(PostStatus::Draft);
26
27 let mut tags: Vec<String> = row.try_get::<_, Vec<String>>("tags").unwrap_or_default();
29 tags.retain(|t| !t.is_empty());
30
31 let word_count: i32 = row.get("word_count");
32 let reading_time: i32 = row.get("reading_time");
33
34 PostListItem {
35 id,
36 author_id: row.get("author_id"),
37 title: row.get("title"),
38 slug: row.get("slug"),
39 summary: row.get("summary"),
40 status,
41 published_at: row.get("published_at"),
42 created_at: row.get("created_at"),
43 updated_at: row.get("updated_at"),
44 deleted_at: row.try_get("deleted_at").ok(),
45 tags,
46 cover_image: row.get("cover_image"),
47 reading_time: reading_time.max(1) as u32,
48 word_count: word_count.max(0) as u32,
49 }
50}
51
52#[cfg(feature = "server")]
57pub(super) async fn row_to_post_full(row: &tokio_postgres::Row) -> Result<Post, AppError> {
58 let id: i32 = row.get("id");
59 let role_str: String = row.get("status");
60 let status = PostStatus::from_str(&role_str).unwrap_or(PostStatus::Draft);
61
62 let mut tags: Vec<String> = row.try_get::<_, Vec<String>>("tags").unwrap_or_default();
64 tags.retain(|t| !t.is_empty());
65
66 let prev_post = if let Ok(prev_title) = row.try_get::<_, String>("prev_title") {
68 if let Ok(prev_slug) = row.try_get::<_, String>("prev_slug") {
69 Some(crate::models::post::PostNav {
70 title: prev_title,
71 slug: prev_slug,
72 })
73 } else {
74 None
75 }
76 } else {
77 None
78 };
79
80 let next_post = if let Ok(next_title) = row.try_get::<_, String>("next_title") {
82 if let Ok(next_slug) = row.try_get::<_, String>("next_slug") {
83 Some(crate::models::post::PostNav {
84 title: next_title,
85 slug: next_slug,
86 })
87 } else {
88 None
89 }
90 } else {
91 None
92 };
93
94 let content_md: String = row.get("content_md");
96 let stored_word_count: i32 = row.get("word_count");
97 let stored_reading_time: i32 = row.get("reading_time");
98 let (word_count, reading_time) = if stored_word_count > 0 && stored_reading_time > 0 {
99 (stored_word_count as u32, stored_reading_time as u32)
100 } else {
101 let wc = count_words(&content_md);
102 (wc, reading_time(wc))
103 };
104
105 let content_html: Option<String> = row.get("content_html");
106 let toc_html_row: Option<String> = row.get("toc_html");
107
108 let (content_html, toc_html) = if let Some(html) = content_html {
110 (html, toc_html_row)
111 } else {
112 let md_for_render = content_md.clone();
115 let rendered = tokio::task::spawn_blocking(move || {
116 crate::api::markdown::render_markdown_enhanced(&md_for_render)
117 })
118 .await
119 .map_err(|_| AppError::Internal("旧文章 Markdown 渲染任务失败"))?;
120 (
121 rendered.html,
122 if rendered.toc_html.is_empty() {
123 None
124 } else {
125 Some(rendered.toc_html)
126 },
127 )
128 };
129
130 Ok(Post {
131 id,
132 author_id: row.get("author_id"),
133 title: row.get("title"),
134 slug: row.get("slug"),
135 summary: row.get("summary"),
136 content_md,
137 content_html: Some(content_html),
138 status,
139 published_at: row.get("published_at"),
140 created_at: row.get("created_at"),
141 updated_at: row.get("updated_at"),
142 deleted_at: row.try_get("deleted_at").ok(),
143 tags,
144 cover_image: row.get("cover_image"),
145 reading_time,
146 word_count,
147 toc_html,
148 prev_post,
149 next_post,
150 })
151}
152
153#[cfg(feature = "server")]
158pub(crate) async fn sync_tags(
159 tx: &deadpool_postgres::Transaction<'_>,
160 post_id: i32,
161 tags: &[String],
162) -> Result<(), AppError> {
163 for tag_name in tags {
164 let tag_id: i32 = {
165 let row = tx
167 .query_opt(
168 "INSERT INTO tags (name) VALUES ($1) ON CONFLICT (name) DO NOTHING RETURNING id",
169 &[&tag_name.as_str()],
170 )
171 .await
172 .map_err(AppError::tx)?;
173
174 match row {
175 Some(r) => r.get(0),
176 None => {
177 let row = tx
179 .query_opt("SELECT id FROM tags WHERE name = $1", &[&tag_name.as_str()])
180 .await
181 .map_err(AppError::query)?;
182 row.map(|r| r.get(0))
183 .ok_or(AppError::NotFound("标签不存在"))?
184 }
185 }
186 };
187
188 tx.execute(
189 "INSERT INTO post_tags (post_id, tag_id) VALUES ($1, $2)",
190 &[&post_id, &tag_id],
191 )
192 .await
193 .map_err(AppError::tx)?;
194 }
195
196 Ok(())
197}
198
199#[cfg(feature = "server")]
201pub(crate) fn clean_tags(tags: &[String]) -> Vec<String> {
202 let mut seen = std::collections::HashSet::new();
203 tags.iter()
204 .map(|t| t.trim().to_string())
205 .filter(|t| !t.is_empty())
206 .filter(|t| seen.insert(t.to_lowercase()))
207 .collect()
208}
209#[cfg(feature = "server")]
210pub(crate) async fn fetch_post_tags(
212 client: &impl deadpool_postgres::GenericClient,
213 post_id: i32,
214) -> Result<Vec<String>, AppError> {
215 let rows = client
216 .query(
217 "SELECT t.name FROM tags t JOIN post_tags pt ON t.id = pt.tag_id WHERE pt.post_id = $1",
218 &[&post_id],
219 )
220 .await
221 .map_err(AppError::query)?;
222 Ok(rows.iter().map(|r| r.get(0)).collect())
223}
224#[cfg(feature = "server")]
225pub(crate) async fn fetch_post_tags_batch(
227 client: &impl deadpool_postgres::GenericClient,
228 post_ids: &[i32],
229) -> Result<Vec<String>, AppError> {
230 let rows = client
231 .query(
232 "SELECT DISTINCT t.name FROM tags t JOIN post_tags pt ON t.id = pt.tag_id WHERE pt.post_id = ANY($1)",
233 &[&post_ids],
234 )
235 .await
236 .map_err(AppError::query)?;
237 Ok(rows.iter().map(|r| r.get(0)).collect())
238}
239
240#[cfg(feature = "server")]
244static ASSET_PATH_RE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
245 regex::Regex::new(r#"/uploads/(\d{4}/\d{2}/\d{2}/[^\"'\s?#\)]+)"#)
246 .expect("ASSET_PATH_RE 正则模式应在编译期通过校验")
247});
248
249#[cfg(feature = "server")]
254pub(crate) fn extract_asset_paths(content_html: &str, cover_image: Option<&str>) -> Vec<String> {
255 let mut seen = std::collections::HashSet::new();
256 let mut paths: Vec<String> = ASSET_PATH_RE
257 .captures_iter(content_html)
258 .filter_map(|c| c.get(1).map(|m| m.as_str().to_string()))
259 .filter(|p| seen.insert(p.clone()))
260 .collect();
261 if let Some(cover) = cover_image {
262 if let Some(rel) = cover
263 .strip_prefix("/uploads/")
264 .map(|p| p.split('?').next().unwrap_or(p))
265 {
266 if seen.insert(rel.to_string()) {
267 paths.push(rel.to_string());
268 }
269 }
270 }
271 paths
272}
273
274#[cfg(feature = "server")]
280pub(crate) async fn sync_asset_refs(
281 tx: &deadpool_postgres::Transaction<'_>,
282 post_id: i32,
283 content_html: &str,
284 cover_image: Option<&str>,
285) -> Result<(), AppError> {
286 tx.execute("DELETE FROM asset_refs WHERE post_id = $1", &[&post_id])
287 .await
288 .map_err(AppError::tx)?;
289
290 let paths = extract_asset_paths(content_html, cover_image);
291 if !paths.is_empty() {
292 tx.execute(
293 "INSERT INTO asset_refs (asset_id, post_id) \
294 SELECT id, $1 FROM assets WHERE path = ANY($2) \
295 ON CONFLICT DO NOTHING",
296 &[&post_id, &paths],
297 )
298 .await
299 .map_err(AppError::tx)?;
300 }
301 Ok(())
302}
303
304#[cfg(feature = "server")]
309pub(crate) struct RenderedFields {
310 pub content_html: String,
311 pub toc_html: Option<String>,
312 pub auto_summary: String,
313 pub status: PostStatus,
314 pub cover_image: Option<String>,
315 pub word_count: i32,
316 pub reading_time: i32,
317}
318
319#[cfg(feature = "server")]
325pub(crate) async fn render_post_fields(
326 content_md: &str,
327 status_str: &str,
328 cover_image: Option<&str>,
329) -> Result<RenderedFields, AppError> {
330 let md_for_render = content_md.to_string();
331 let rendered = tokio::task::spawn_blocking(move || {
332 crate::api::markdown::render_markdown_enhanced(&md_for_render)
333 })
334 .await
335 .map_err(|_| AppError::Internal("Markdown 渲染任务失败"))?;
336
337 let toc_html = if rendered.toc_html.is_empty() {
338 None
339 } else {
340 Some(rendered.toc_html)
341 };
342
343 let auto_summary = auto_summary(content_md);
344 let status = PostStatus::from_str(status_str).unwrap_or(PostStatus::Draft);
345 let cover_image = cover_image
346 .filter(|s| !s.trim().is_empty())
347 .map(str::to_string);
348
349 let word_count = count_words(content_md);
350 let reading_time = reading_time(word_count);
351
352 Ok(RenderedFields {
353 content_html: rendered.html,
354 toc_html,
355 auto_summary,
356 status,
357 cover_image,
358 word_count: word_count as i32,
359 reading_time: reading_time as i32,
360 })
361}
362
363#[cfg(feature = "server")]
368pub(crate) async fn render_post_fields_minimal(
369 content_md: &str,
370) -> Result<(String, Option<String>, i32, i32), AppError> {
371 let md_for_render = content_md.to_string();
372 let rendered = tokio::task::spawn_blocking(move || {
373 crate::api::markdown::render_markdown_enhanced(&md_for_render)
374 })
375 .await
376 .map_err(|_| AppError::Internal("Markdown 渲染任务失败"))?;
377
378 let toc_html = if rendered.toc_html.is_empty() {
379 None
380 } else {
381 Some(rendered.toc_html)
382 };
383
384 let word_count = count_words(content_md);
385 let reading_time = reading_time(word_count);
386
387 Ok((
388 rendered.html,
389 toc_html,
390 word_count as i32,
391 reading_time as i32,
392 ))
393}
394
395#[cfg(all(test, feature = "server"))]
396mod tests {
397 use super::{clean_tags, extract_asset_paths};
398
399 #[test]
400 fn clean_tags_trims_whitespace() {
401 let input = vec![" rust ".to_string(), "\t\nwasm\t".to_string()];
402 assert_eq!(
403 clean_tags(&input),
404 vec!["rust".to_string(), "wasm".to_string()]
405 );
406 }
407
408 #[test]
409 fn clean_tags_filters_empty_strings() {
410 let input = vec![
411 "".to_string(),
412 " ".to_string(),
413 "\t".to_string(),
414 "valid".to_string(),
415 ];
416 assert_eq!(clean_tags(&input), vec!["valid".to_string()]);
417 }
418
419 #[test]
420 fn clean_tags_removes_duplicates_case_insensitive() {
421 let input = vec![
422 "rust".to_string(),
423 " rust ".to_string(),
424 "Rust".to_string(),
425 "wasm".to_string(),
426 ];
427 assert_eq!(
428 clean_tags(&input),
429 vec!["rust".to_string(), "wasm".to_string()]
430 );
431 }
432
433 #[test]
434 fn clean_tags_keeps_already_clean_input() {
435 let input = vec!["rust".to_string(), "wasm".to_string(), "dioxus".to_string()];
436 assert_eq!(
437 clean_tags(&input),
438 vec!["rust".to_string(), "wasm".to_string(), "dioxus".to_string()]
439 );
440 }
441
442 #[test]
445 fn extract_asset_paths_from_blur_img_html() {
446 let html = r#"<span class="blur-img"><img class="blur-img-placeholder" src="/uploads/2026/07/24/a.webp?w=20"><img class="blur-img-full" data-src="/uploads/2026/07/24/a.webp?w=800"></span>"#;
448 assert_eq!(
449 extract_asset_paths(html, None),
450 vec!["2026/07/24/a.webp".to_string()]
451 );
452 }
453
454 #[test]
455 fn extract_asset_paths_multiple_and_cover() {
456 let html = r#"<p><img src="/uploads/2026/07/24/a.webp"></p><p><img src="/uploads/2026/06/01/b.png?w=800"></p><p><img src="https://cdn.example.com/x.webp"></p>"#;
457 let paths = extract_asset_paths(html, Some("/uploads/2026/07/24/cover.jpg?w=600"));
458 assert_eq!(paths.len(), 3);
459 assert!(paths.contains(&"2026/07/24/a.webp".to_string()));
460 assert!(paths.contains(&"2026/06/01/b.png".to_string()));
461 assert!(paths.contains(&"2026/07/24/cover.jpg".to_string()));
462 }
463
464 #[test]
465 fn extract_asset_paths_cover_dedup_and_external() {
466 let html = r#"<img src="/uploads/2026/07/24/a.webp">"#;
468 assert_eq!(
469 extract_asset_paths(html, Some("/uploads/2026/07/24/a.webp")),
470 vec!["2026/07/24/a.webp".to_string()]
471 );
472 assert!(extract_asset_paths(html, Some("https://example.com/c.webp")).len() == 1);
473 assert!(extract_asset_paths(html, None).len() == 1);
474 }
475
476 #[test]
477 fn extract_asset_paths_empty() {
478 assert!(extract_asset_paths("<p>no image</p>", None).is_empty());
479 }
480}