Skip to main content

yggdrasil/api/posts/
helpers.rs

1//! 文章模块内部辅助函数。
2//!
3//! 提供数据库行到 `Post` 模型的转换、标签同步与标签清洗等工具函数。
4//! 仅在 `feature = "server"` 启用的服务端构建中可用。
5
6#[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/// 复用认证模块的当前 admin 用户获取逻辑。
14#[cfg(feature = "server")]
15pub(super) use crate::api::auth::get_current_admin_user;
16
17/// 将数据库行转换为轻量列表项 DTO。
18///
19/// 不包含 `content_md`/`content_html`;字数与阅读时长直接读取已持久化的列。
20/// 同步函数,不依赖数据库连接。
21#[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    // 聚合标签并原地过滤空字符串(retain 避免 into_iter+filter+collect 的二次 Vec 分配)。
28    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/// 将数据库行转换为完整文章详情。
53///
54/// 相比列表项额外包含上一篇/下一篇导航,
55/// 并在 content_html 为空时重新渲染 Markdown 以兼容旧数据。
56#[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    // 聚合标签并原地过滤空字符串(retain 避免 into_iter+filter+collect 的二次 Vec 分配)。
63    let mut tags: Vec<String> = row.try_get::<_, Vec<String>>("tags").unwrap_or_default();
64    tags.retain(|t| !t.is_empty());
65
66    // 解析上一篇文章导航。
67    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    // 解析下一篇文章导航。
81    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    // 读取正文与已持久化的字数/阅读时长;若列尚未回填(旧数据为 0),则现场计算。
95    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    // 若数据库中未渲染 HTML(旧数据兼容),则现场渲染 Markdown。
109    let (content_html, toc_html) = if let Some(html) = content_html {
110        (html, toc_html_row)
111    } else {
112        // 旧数据 fallback 仍可能触发完整 Markdown/KaTeX/高亮渲染,
113        // 必须移出 Tokio worker,避免单条旧文章阻塞其它请求。
114        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/// 在事务中同步文章的标签关联。
154///
155/// 对传入的每个标签:若不存在则插入 tags 表,否则查询已有 id,
156/// 然后在 post_tags 表中建立关联。不会删除旧关联,调用方需先清理。
157#[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            // 先尝试插入,若已存在则返回空。
166            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                    // 插入冲突时回查标签 id。
178                    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/// 清洗标签列表:去头尾空白、过滤空字符串并去重(保留原始顺序)。
200#[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")]
210/// 取指定文章的全部标签名(按 post_tags 关联)。R5:此前 9+ 处重复同一条 SQL。
211pub(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")]
225/// 批量取多篇文章的标签名并集(去重)。用于批量删除/清空回收站的缓存失效。
226pub(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/// 匹配 HTML/Markdown 中出现的本地上传图片路径,捕获组为相对路径
241/// (如 `2026/07/24/153000.<uuid>.webp`,不含 /uploads/ 前缀与 query)。
242/// 覆盖 blur-img 双层结构的 src 与 data-src。
243#[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/// 从文章 HTML 与封面 URL 中提取全部引用的本地上传图片相对路径(去重)。
250///
251/// 外链图(非 /uploads/ 路径)与无法识别的路径自然被忽略。
252/// pub(crate):重建素材索引(api::assets::rebuild)复用同一提取逻辑。
253#[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/// 在事务中同步文章的素材引用关联(asset_refs)。
275///
276/// 语义镜像 [`sync_tags`]:调用方需在事务内先删除旧关联(本函数自带 DELETE),
277/// 再按 content_html + cover_image 中出现的 /uploads/ 路径重建。
278/// 未登记到 assets 表的路径(如回填前的旧图)静默跳过,由重建索引兜底。
279#[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/// Markdown 渲染 + 度量派生的完整结果。
305///
306/// `auto_summary` 为正文自动摘要(非用户填写值),调用方负责与用户 summary 合并。
307/// `word_count` / `reading_time` 已转为 `i32` 以直接绑定 SQL 参数。
308#[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/// 渲染 Markdown 并派生全部度量字段(R4)。
320///
321/// 收敛 create / update / MCP 写操作中重复的「spawn_blocking 渲染 → 7 步派生」:
322/// content_html、toc_html(空则 None)、auto_summary、status(from_str 回退 Draft)、
323/// cover_image(trim 后非空则保留)、word_count、reading_time。
324#[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/// 渲染 Markdown 并仅派生重建所需的最小字段(R4)。
364///
365/// rebuild 仅需 content_html / toc_html / word_count / reading_time,
366/// 不计算 summary / status / cover_image,避免无谓开销。
367#[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    // —— extract_asset_paths ——
443
444    #[test]
445    fn extract_asset_paths_from_blur_img_html() {
446        // blur-img 双层结构:src 带 ?w=20,data-src 带 ?w=800,同一张图只应提取一次。
447        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        // 封面与正文同图时去重;外链封面不产生路径。
467        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}