261 lines
8.2 KiB
Rust
261 lines
8.2 KiB
Rust
//! 文章模块内部辅助函数。
|
||
//!
|
||
//! 提供数据库行到 `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()]
|
||
);
|
||
}
|
||
}
|