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) -> Post {
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        let rendered = crate::api::markdown::render_markdown_enhanced(&content_md);
113        (
114            rendered.html,
115            if rendered.toc_html.is_empty() {
116                None
117            } else {
118                Some(rendered.toc_html)
119            },
120        )
121    };
122
123    Post {
124        id,
125        author_id: row.get("author_id"),
126        title: row.get("title"),
127        slug: row.get("slug"),
128        summary: row.get("summary"),
129        content_md,
130        content_html: Some(content_html),
131        status,
132        published_at: row.get("published_at"),
133        created_at: row.get("created_at"),
134        updated_at: row.get("updated_at"),
135        deleted_at: row.try_get("deleted_at").ok(),
136        tags,
137        cover_image: row.get("cover_image"),
138        reading_time,
139        word_count,
140        toc_html,
141        prev_post,
142        next_post,
143    }
144}
145
146/// 在事务中同步文章的标签关联。
147///
148/// 对传入的每个标签:若不存在则插入 tags 表,否则查询已有 id,
149/// 然后在 post_tags 表中建立关联。不会删除旧关联,调用方需先清理。
150#[cfg(feature = "server")]
151pub(crate) async fn sync_tags(
152    tx: &deadpool_postgres::Transaction<'_>,
153    post_id: i32,
154    tags: &[String],
155) -> Result<(), AppError> {
156    for tag_name in tags {
157        let tag_id: i32 = {
158            // 先尝试插入,若已存在则返回空。
159            let row = tx
160                .query_opt(
161                    "INSERT INTO tags (name) VALUES ($1) ON CONFLICT (name) DO NOTHING RETURNING id",
162                    &[&tag_name.as_str()],
163                )
164                .await
165                .map_err(AppError::tx)?;
166
167            match row {
168                Some(r) => r.get(0),
169                None => {
170                    // 插入冲突时回查标签 id。
171                    let row = tx
172                        .query_opt("SELECT id FROM tags WHERE name = $1", &[&tag_name.as_str()])
173                        .await
174                        .map_err(AppError::query)?;
175                    row.map(|r| r.get(0))
176                        .ok_or(AppError::NotFound("标签不存在"))?
177                }
178            }
179        };
180
181        tx.execute(
182            "INSERT INTO post_tags (post_id, tag_id) VALUES ($1, $2)",
183            &[&post_id, &tag_id],
184        )
185        .await
186        .map_err(AppError::tx)?;
187    }
188
189    Ok(())
190}
191
192/// 清洗标签列表:去头尾空白、过滤空字符串并去重(保留原始顺序)。
193#[cfg(feature = "server")]
194pub(crate) fn clean_tags(tags: &[String]) -> Vec<String> {
195    let mut seen = std::collections::HashSet::new();
196    tags.iter()
197        .map(|t| t.trim().to_string())
198        .filter(|t| !t.is_empty())
199        .filter(|t| seen.insert(t.to_lowercase()))
200        .collect()
201}
202#[cfg(feature = "server")]
203/// 取指定文章的全部标签名(按 post_tags 关联)。R5:此前 9+ 处重复同一条 SQL。
204pub(crate) async fn fetch_post_tags(
205    client: &impl deadpool_postgres::GenericClient,
206    post_id: i32,
207) -> Result<Vec<String>, AppError> {
208    let rows = client
209        .query(
210            "SELECT t.name FROM tags t JOIN post_tags pt ON t.id = pt.tag_id WHERE pt.post_id = $1",
211            &[&post_id],
212        )
213        .await
214        .map_err(AppError::query)?;
215    Ok(rows.iter().map(|r| r.get(0)).collect())
216}
217#[cfg(feature = "server")]
218/// 批量取多篇文章的标签名并集(去重)。用于批量删除/清空回收站的缓存失效。
219pub(crate) async fn fetch_post_tags_batch(
220    client: &impl deadpool_postgres::GenericClient,
221    post_ids: &[i32],
222) -> Result<Vec<String>, AppError> {
223    let rows = client
224        .query(
225            "SELECT DISTINCT t.name FROM tags t JOIN post_tags pt ON t.id = pt.tag_id WHERE pt.post_id = ANY($1)",
226            &[&post_ids],
227        )
228        .await
229        .map_err(AppError::query)?;
230    Ok(rows.iter().map(|r| r.get(0)).collect())
231}
232
233/// 匹配 HTML/Markdown 中出现的本地上传图片路径,捕获组为相对路径
234/// (如 `2026/07/24/153000.<uuid>.webp`,不含 /uploads/ 前缀与 query)。
235/// 覆盖 blur-img 双层结构的 src 与 data-src。
236#[cfg(feature = "server")]
237static ASSET_PATH_RE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
238    regex::Regex::new(r#"/uploads/(\d{4}/\d{2}/\d{2}/[^\"'\s?#\)]+)"#)
239        .expect("ASSET_PATH_RE 正则模式应在编译期通过校验")
240});
241
242/// 从文章 HTML 与封面 URL 中提取全部引用的本地上传图片相对路径(去重)。
243///
244/// 外链图(非 /uploads/ 路径)与无法识别的路径自然被忽略。
245/// pub(crate):重建素材索引(api::assets::rebuild)复用同一提取逻辑。
246#[cfg(feature = "server")]
247pub(crate) fn extract_asset_paths(content_html: &str, cover_image: Option<&str>) -> Vec<String> {
248    let mut seen = std::collections::HashSet::new();
249    let mut paths: Vec<String> = ASSET_PATH_RE
250        .captures_iter(content_html)
251        .filter_map(|c| c.get(1).map(|m| m.as_str().to_string()))
252        .filter(|p| seen.insert(p.clone()))
253        .collect();
254    if let Some(cover) = cover_image {
255        if let Some(rel) = cover
256            .strip_prefix("/uploads/")
257            .map(|p| p.split('?').next().unwrap_or(p))
258        {
259            if seen.insert(rel.to_string()) {
260                paths.push(rel.to_string());
261            }
262        }
263    }
264    paths
265}
266
267/// 在事务中同步文章的素材引用关联(asset_refs)。
268///
269/// 语义镜像 [`sync_tags`]:调用方需在事务内先删除旧关联(本函数自带 DELETE),
270/// 再按 content_html + cover_image 中出现的 /uploads/ 路径重建。
271/// 未登记到 assets 表的路径(如回填前的旧图)静默跳过,由重建索引兜底。
272#[cfg(feature = "server")]
273pub(crate) async fn sync_asset_refs(
274    tx: &deadpool_postgres::Transaction<'_>,
275    post_id: i32,
276    content_html: &str,
277    cover_image: Option<&str>,
278) -> Result<(), AppError> {
279    tx.execute("DELETE FROM asset_refs WHERE post_id = $1", &[&post_id])
280        .await
281        .map_err(AppError::tx)?;
282
283    let paths = extract_asset_paths(content_html, cover_image);
284    if !paths.is_empty() {
285        tx.execute(
286            "INSERT INTO asset_refs (asset_id, post_id) \
287             SELECT id, $1 FROM assets WHERE path = ANY($2) \
288             ON CONFLICT DO NOTHING",
289            &[&post_id, &paths],
290        )
291        .await
292        .map_err(AppError::tx)?;
293    }
294    Ok(())
295}
296
297/// Markdown 渲染 + 度量派生的完整结果。
298///
299/// `auto_summary` 为正文自动摘要(非用户填写值),调用方负责与用户 summary 合并。
300/// `word_count` / `reading_time` 已转为 `i32` 以直接绑定 SQL 参数。
301#[cfg(feature = "server")]
302pub(crate) struct RenderedFields {
303    pub content_html: String,
304    pub toc_html: Option<String>,
305    pub auto_summary: String,
306    pub status: PostStatus,
307    pub cover_image: Option<String>,
308    pub word_count: i32,
309    pub reading_time: i32,
310}
311
312/// 渲染 Markdown 并派生全部度量字段(R4)。
313///
314/// 收敛 create / update / MCP 写操作中重复的「spawn_blocking 渲染 → 7 步派生」:
315/// content_html、toc_html(空则 None)、auto_summary、status(from_str 回退 Draft)、
316/// cover_image(trim 后非空则保留)、word_count、reading_time。
317#[cfg(feature = "server")]
318pub(crate) async fn render_post_fields(
319    content_md: &str,
320    status_str: &str,
321    cover_image: Option<&str>,
322) -> Result<RenderedFields, AppError> {
323    let md_for_render = content_md.to_string();
324    let rendered = tokio::task::spawn_blocking(move || {
325        crate::api::markdown::render_markdown_enhanced(&md_for_render)
326    })
327    .await
328    .map_err(|_| AppError::Internal("Markdown 渲染任务失败"))?;
329
330    let toc_html = if rendered.toc_html.is_empty() {
331        None
332    } else {
333        Some(rendered.toc_html)
334    };
335
336    let auto_summary = auto_summary(content_md);
337    let status = PostStatus::from_str(status_str).unwrap_or(PostStatus::Draft);
338    let cover_image = cover_image
339        .filter(|s| !s.trim().is_empty())
340        .map(str::to_string);
341
342    let word_count = count_words(content_md);
343    let reading_time = reading_time(word_count);
344
345    Ok(RenderedFields {
346        content_html: rendered.html,
347        toc_html,
348        auto_summary,
349        status,
350        cover_image,
351        word_count: word_count as i32,
352        reading_time: reading_time as i32,
353    })
354}
355
356/// 渲染 Markdown 并仅派生重建所需的最小字段(R4)。
357///
358/// rebuild 仅需 content_html / toc_html / word_count / reading_time,
359/// 不计算 summary / status / cover_image,避免无谓开销。
360#[cfg(feature = "server")]
361pub(crate) async fn render_post_fields_minimal(
362    content_md: &str,
363) -> Result<(String, Option<String>, i32, i32), AppError> {
364    let md_for_render = content_md.to_string();
365    let rendered = tokio::task::spawn_blocking(move || {
366        crate::api::markdown::render_markdown_enhanced(&md_for_render)
367    })
368    .await
369    .map_err(|_| AppError::Internal("Markdown 渲染任务失败"))?;
370
371    let toc_html = if rendered.toc_html.is_empty() {
372        None
373    } else {
374        Some(rendered.toc_html)
375    };
376
377    let word_count = count_words(content_md);
378    let reading_time = reading_time(word_count);
379
380    Ok((
381        rendered.html,
382        toc_html,
383        word_count as i32,
384        reading_time as i32,
385    ))
386}
387
388#[cfg(all(test, feature = "server"))]
389mod tests {
390    use super::{clean_tags, extract_asset_paths};
391
392    #[test]
393    fn clean_tags_trims_whitespace() {
394        let input = vec!["  rust ".to_string(), "\t\nwasm\t".to_string()];
395        assert_eq!(
396            clean_tags(&input),
397            vec!["rust".to_string(), "wasm".to_string()]
398        );
399    }
400
401    #[test]
402    fn clean_tags_filters_empty_strings() {
403        let input = vec![
404            "".to_string(),
405            "  ".to_string(),
406            "\t".to_string(),
407            "valid".to_string(),
408        ];
409        assert_eq!(clean_tags(&input), vec!["valid".to_string()]);
410    }
411
412    #[test]
413    fn clean_tags_removes_duplicates_case_insensitive() {
414        let input = vec![
415            "rust".to_string(),
416            "  rust  ".to_string(),
417            "Rust".to_string(),
418            "wasm".to_string(),
419        ];
420        assert_eq!(
421            clean_tags(&input),
422            vec!["rust".to_string(), "wasm".to_string()]
423        );
424    }
425
426    #[test]
427    fn clean_tags_keeps_already_clean_input() {
428        let input = vec!["rust".to_string(), "wasm".to_string(), "dioxus".to_string()];
429        assert_eq!(
430            clean_tags(&input),
431            vec!["rust".to_string(), "wasm".to_string(), "dioxus".to_string()]
432        );
433    }
434
435    // —— extract_asset_paths ——
436
437    #[test]
438    fn extract_asset_paths_from_blur_img_html() {
439        // blur-img 双层结构:src 带 ?w=20,data-src 带 ?w=800,同一张图只应提取一次。
440        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>"#;
441        assert_eq!(
442            extract_asset_paths(html, None),
443            vec!["2026/07/24/a.webp".to_string()]
444        );
445    }
446
447    #[test]
448    fn extract_asset_paths_multiple_and_cover() {
449        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>"#;
450        let paths = extract_asset_paths(html, Some("/uploads/2026/07/24/cover.jpg?w=600"));
451        assert_eq!(paths.len(), 3);
452        assert!(paths.contains(&"2026/07/24/a.webp".to_string()));
453        assert!(paths.contains(&"2026/06/01/b.png".to_string()));
454        assert!(paths.contains(&"2026/07/24/cover.jpg".to_string()));
455    }
456
457    #[test]
458    fn extract_asset_paths_cover_dedup_and_external() {
459        // 封面与正文同图时去重;外链封面不产生路径。
460        let html = r#"<img src="/uploads/2026/07/24/a.webp">"#;
461        assert_eq!(
462            extract_asset_paths(html, Some("/uploads/2026/07/24/a.webp")),
463            vec!["2026/07/24/a.webp".to_string()]
464        );
465        assert!(extract_asset_paths(html, Some("https://example.com/c.webp")).len() == 1);
466        assert!(extract_asset_paths(html, None).len() == 1);
467    }
468
469    #[test]
470    fn extract_asset_paths_empty() {
471        assert!(extract_asset_paths("<p>no image</p>", None).is_empty());
472    }
473}