yggdrasil/src/api/posts/helpers.rs

261 lines
8.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 文章模块内部辅助函数。
//!
//! 提供数据库行到 `Post` 模型的转换、标签同步与标签清洗等工具函数。
//! 仅在 `feature = "server"` 启用的服务端构建中可用。
#[cfg(feature = "server")]
use crate::api::error::AppError;
#[cfg(feature = "server")]
use crate::models::post::{Post, PostListItem, PostStatus};
#[cfg(feature = "server")]
use crate::utils::text::{count_words, reading_time};
/// 复用认证模块的当前 admin 用户获取逻辑。
#[cfg(feature = "server")]
pub(super) use crate::api::auth::get_current_admin_user;
/// 将数据库行转换为轻量列表项 DTO。
///
/// 不包含 `content_md`/`content_html`;字数与阅读时长直接读取已持久化的列。
/// 同步函数,不依赖数据库连接。
#[cfg(feature = "server")]
pub(super) fn row_to_post_list_item(row: &tokio_postgres::Row) -> PostListItem {
let id: i32 = row.get("id");
let status_str: String = row.get("status");
let status = PostStatus::from_str(&status_str).unwrap_or(PostStatus::Draft);
// 聚合标签并过滤空字符串。
let tags: Vec<String> = row
.try_get::<_, Vec<String>>("tags")
.unwrap_or_default()
.into_iter()
.filter(|t| !t.is_empty())
.collect();
let word_count: i32 = row.get("word_count");
let reading_time: i32 = row.get("reading_time");
PostListItem {
id,
author_id: row.get("author_id"),
title: row.get("title"),
slug: row.get("slug"),
summary: row.get("summary"),
status,
published_at: row.get("published_at"),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
deleted_at: row.try_get("deleted_at").ok(),
tags,
cover_image: row.get("cover_image"),
reading_time: reading_time.max(1) as u32,
word_count: word_count.max(0) as u32,
}
}
/// 将数据库行转换为完整文章详情。
///
/// 相比列表项额外包含上一篇/下一篇导航,
/// 并在 content_html 为空时重新渲染 Markdown 以兼容旧数据。
#[cfg(feature = "server")]
pub(super) async fn row_to_post_full(
_client: &tokio_postgres::Client,
row: &tokio_postgres::Row,
) -> Post {
let id: i32 = row.get("id");
let role_str: String = row.get("status");
let status = PostStatus::from_str(&role_str).unwrap_or(PostStatus::Draft);
// 聚合标签并过滤空字符串。
let tags: Vec<String> = row
.try_get::<_, Vec<String>>("tags")
.unwrap_or_default()
.into_iter()
.filter(|t| !t.is_empty())
.collect();
// 解析上一篇文章导航。
let prev_post = if let Ok(prev_title) = row.try_get::<_, String>("prev_title") {
if let Ok(prev_slug) = row.try_get::<_, String>("prev_slug") {
Some(crate::models::post::PostNav {
title: prev_title,
slug: prev_slug,
})
} else {
None
}
} else {
None
};
// 解析下一篇文章导航。
let next_post = if let Ok(next_title) = row.try_get::<_, String>("next_title") {
if let Ok(next_slug) = row.try_get::<_, String>("next_slug") {
Some(crate::models::post::PostNav {
title: next_title,
slug: next_slug,
})
} else {
None
}
} else {
None
};
// 读取正文与已持久化的字数/阅读时长;若列尚未回填(旧数据为 0则现场计算。
let content_md: String = row.get("content_md");
let stored_word_count: i32 = row.get("word_count");
let stored_reading_time: i32 = row.get("reading_time");
let (word_count, reading_time) = if stored_word_count > 0 && stored_reading_time > 0 {
(stored_word_count as u32, stored_reading_time as u32)
} else {
let wc = count_words(&content_md);
(wc, reading_time(wc))
};
let content_html: Option<String> = row.get("content_html");
let toc_html_row: Option<String> = row.get("toc_html");
// 若数据库中未渲染 HTML旧数据兼容则现场渲染 Markdown。
let (content_html, toc_html) = if let Some(html) = content_html {
(html, toc_html_row)
} else {
let rendered = crate::api::markdown::render_markdown_enhanced(&content_md);
(
rendered.html,
if rendered.toc_html.is_empty() {
None
} else {
Some(rendered.toc_html)
},
)
};
Post {
id,
author_id: row.get("author_id"),
title: row.get("title"),
slug: row.get("slug"),
summary: row.get("summary"),
content_md,
content_html: Some(content_html),
status,
published_at: row.get("published_at"),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
deleted_at: row.try_get("deleted_at").ok(),
tags,
cover_image: row.get("cover_image"),
reading_time,
word_count,
toc_html,
prev_post,
next_post,
}
}
/// 在事务中同步文章的标签关联。
///
/// 对传入的每个标签:若不存在则插入 tags 表,否则查询已有 id
/// 然后在 post_tags 表中建立关联。不会删除旧关联,调用方需先清理。
#[cfg(feature = "server")]
pub(super) async fn sync_tags(
tx: &deadpool_postgres::Transaction<'_>,
post_id: i32,
tags: &[String],
) -> Result<(), AppError> {
for tag_name in tags {
let tag_id: i32 = {
// 先尝试插入,若已存在则返回空。
let row = tx
.query_opt(
"INSERT INTO tags (name) VALUES ($1) ON CONFLICT (name) DO NOTHING RETURNING id",
&[&tag_name.as_str()],
)
.await
.map_err(AppError::tx)?;
match row {
Some(r) => r.get(0),
None => {
// 插入冲突时回查标签 id。
let row = tx
.query_opt("SELECT id FROM tags WHERE name = $1", &[&tag_name.as_str()])
.await
.map_err(AppError::query)?;
row.map(|r| r.get(0))
.ok_or(AppError::NotFound("标签不存在"))?
}
}
};
tx.execute(
"INSERT INTO post_tags (post_id, tag_id) VALUES ($1, $2)",
&[&post_id, &tag_id],
)
.await
.map_err(AppError::tx)?;
}
Ok(())
}
/// 清洗标签列表:去头尾空白、过滤空字符串并去重(保留原始顺序)。
#[cfg(feature = "server")]
pub(super) fn clean_tags(tags: &[String]) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
tags.iter()
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.filter(|t| seen.insert(t.to_lowercase()))
.collect()
}
#[cfg(all(test, feature = "server"))]
mod tests {
use super::clean_tags;
#[test]
fn clean_tags_trims_whitespace() {
let input = vec![" rust ".to_string(), "\t\nwasm\t".to_string()];
assert_eq!(
clean_tags(&input),
vec!["rust".to_string(), "wasm".to_string()]
);
}
#[test]
fn clean_tags_filters_empty_strings() {
let input = vec![
"".to_string(),
" ".to_string(),
"\t".to_string(),
"valid".to_string(),
];
assert_eq!(clean_tags(&input), vec!["valid".to_string()]);
}
#[test]
fn clean_tags_removes_duplicates_case_insensitive() {
let input = vec![
"rust".to_string(),
" rust ".to_string(),
"Rust".to_string(),
"wasm".to_string(),
];
assert_eq!(
clean_tags(&input),
vec!["rust".to_string(), "wasm".to_string()]
);
}
#[test]
fn clean_tags_keeps_already_clean_input() {
let input = vec!["rust".to_string(), "wasm".to_string(), "dioxus".to_string()];
assert_eq!(
clean_tags(&input),
vec!["rust".to_string(), "wasm".to_string(), "dioxus".to_string()]
);
}
}