diff --git a/src/api/posts/create.rs b/src/api/posts/create.rs index 8e26d81..4b40383 100644 --- a/src/api/posts/create.rs +++ b/src/api/posts/create.rs @@ -1,3 +1,10 @@ +//! 创建文章接口。 +//! +//! 校验标题、内容与 slug,生成唯一 slug 并渲染 Markdown, +//! 在事务中写入 posts 表并同步标签关联,最后失效相关缓存。 +//! Dioxus server function,注册在 `/api` 路径下。 +//! 仅在 `feature = "server"` 启用的服务端构建中写入数据库。 + #![allow(clippy::too_many_arguments)] use dioxus::prelude::*; @@ -10,6 +17,10 @@ use crate::api::error::AppError; use crate::db::pool::get_conn; use crate::models::post::PostStatus; +/// 创建一篇新文章。 +/// +/// 校验标题与内容非空、slug 格式合法;未提供 slug 时从标题自动生成。 +/// 首次发布时填充 published_at,并同步标签与缓存失效。 #[server(CreatePost, "/api")] pub async fn create_post( title: String, @@ -22,6 +33,7 @@ pub async fn create_post( ) -> Result { let user = get_current_admin_user().await?; + // 标题不能为空。 if title.trim().is_empty() { return Ok(CreatePostResponse { success: false, @@ -31,6 +43,7 @@ pub async fn create_post( }); } + // 内容不能为空。 if content_md.trim().is_empty() { return Ok(CreatePostResponse { success: false, @@ -40,6 +53,7 @@ pub async fn create_post( }); } + // 确定基础 slug:用户传入时校验格式,否则由标题生成。 let base_slug = match slug { Some(ref s) if !s.trim().is_empty() => { let s = s.trim(); @@ -60,7 +74,9 @@ pub async fn create_post( { let mut client = get_conn().await.map_err(AppError::db_conn)?; + // 保证 slug 全局唯一,若冲突则追加数字后缀。 let final_slug = crate::api::slug::ensure_unique_slug(&client, &base_slug, None).await?; + // 渲染 Markdown 为 HTML,并提取目录。 let rendered = crate::api::markdown::render_markdown_enhanced(&content_md); let content_html = rendered.html; let toc_html = if rendered.toc_html.is_empty() { @@ -68,12 +84,14 @@ pub async fn create_post( } else { Some(rendered.toc_html) }; + // 未填写摘要时自动从正文提取。 let summary = summary .filter(|s| !s.trim().is_empty()) .unwrap_or_else(|| crate::utils::text::auto_summary(&content_md)); let post_status = PostStatus::from_str(&status).unwrap_or(PostStatus::Draft); let cover_image = cover_image.filter(|s| !s.trim().is_empty()); + // 发布状态的文章设置当前发布时间;草稿则为 None。 let published_at = if post_status == PostStatus::Published { Some(chrono::Utc::now()) } else { @@ -82,6 +100,7 @@ pub async fn create_post( let tx = client.transaction().await.map_err(AppError::tx)?; + // 插入文章记录。 let row = tx .query_one( "INSERT INTO posts (author_id, title, slug, summary, content_md, content_html, toc_html, status, published_at, cover_image) @@ -105,15 +124,18 @@ pub async fn create_post( let post_id: i32 = row.get(0); + // 清洗标签并在事务中同步 post_tags 关联。 let tags_cleaned = clean_tags(&tags); sync_tags(&tx, post_id, &tags_cleaned).await?; tx.commit().await.map_err(AppError::tx)?; + // 写入成功后失效文章列表、标签与统计缓存。 crate::cache::invalidate_post_lists(); crate::cache::invalidate_all_tags(); crate::cache::invalidate_post_stats(); + // 失效该文章涉及的所有标签缓存。 for tag_name in &tags_cleaned { crate::cache::invalidate_posts_by_tag(tag_name).await; } diff --git a/src/api/posts/delete.rs b/src/api/posts/delete.rs index 710d90f..c8dfec5 100644 --- a/src/api/posts/delete.rs +++ b/src/api/posts/delete.rs @@ -1,3 +1,10 @@ +//! 删除文章接口。 +//! +//! 采用软删除方式,将 posts.deleted_at 设置为当前时间, +//! 同时清空所有文章相关缓存。 +//! Dioxus server function,注册在 `/api` 路径下。 +//! 仅在 `feature = "server"` 启用的服务端构建中执行删除与缓存失效。 + use dioxus::prelude::*; #[cfg(feature = "server")] @@ -7,6 +14,10 @@ use super::types::CreatePostResponse; use crate::api::error::AppError; use crate::db::pool::get_conn; +/// 删除指定文章。 +/// +/// 仅 admin 可调用;通过设置 deleted_at 实现软删除, +/// 成功后清空全部文章缓存。 #[server(DeletePost, "/api")] pub async fn delete_post(post_id: i32) -> Result { let _user = get_current_admin_user().await?; @@ -15,6 +26,7 @@ pub async fn delete_post(post_id: i32) -> Result Result = row .try_get::<_, Vec>("tags") .unwrap_or_default() @@ -49,6 +60,10 @@ pub(super) async fn row_to_post_list( } } +/// 将数据库行转换为完整文章详情。 +/// +/// 相比列表项额外包含上一篇/下一篇导航, +/// 并在 content_html 为空时重新渲染 Markdown 以兼容旧数据。 #[cfg(feature = "server")] pub(super) async fn row_to_post_full( _client: &tokio_postgres::Client, @@ -58,6 +73,7 @@ pub(super) async fn row_to_post_full( let role_str: String = row.get("status"); let status = PostStatus::from_str(&role_str).unwrap_or(PostStatus::Draft); + // 聚合标签并过滤空字符串。 let tags: Vec = row .try_get::<_, Vec>("tags") .unwrap_or_default() @@ -65,6 +81,7 @@ pub(super) async fn row_to_post_full( .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 { @@ -78,6 +95,7 @@ pub(super) async fn row_to_post_full( 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 { @@ -94,6 +112,7 @@ pub(super) async fn row_to_post_full( let content_html: Option = row.get("content_html"); let toc_html_row: Option = row.get("toc_html"); + // 若数据库中未渲染 HTML(旧数据兼容),则现场渲染 Markdown。 let (content_html, toc_html) = if let Some(html) = content_html { (html, toc_html_row) } else { @@ -134,6 +153,10 @@ pub(super) async fn row_to_post_full( } } +/// 在事务中同步文章的标签关联。 +/// +/// 对传入的每个标签:若不存在则插入 tags 表,否则查询已有 id, +/// 然后在 post_tags 表中建立关联。不会删除旧关联,调用方需先清理。 #[cfg(feature = "server")] pub(super) async fn sync_tags( tx: &deadpool_postgres::Transaction<'_>, @@ -142,6 +165,7 @@ pub(super) async fn sync_tags( ) -> 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", @@ -153,6 +177,7 @@ pub(super) async fn sync_tags( 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 @@ -174,6 +199,9 @@ pub(super) async fn sync_tags( Ok(()) } +/// 清洗标签列表:去头尾空白并过滤空字符串。 +/// +/// 注意:该函数保留重复标签,由数据库唯一索引或调用方决定去重。 #[cfg(feature = "server")] pub(super) fn clean_tags(tags: &[String]) -> Vec { tags.iter() diff --git a/src/api/posts/list.rs b/src/api/posts/list.rs index 6549e47..73b4260 100644 --- a/src/api/posts/list.rs +++ b/src/api/posts/list.rs @@ -1,3 +1,10 @@ +//! 文章列表查询接口。 +//! +//! 提供已发布文章分页、管理员全量列表、以及按标签筛选三种查询能力, +//! 均通过缓存层减少重复数据库访问。 +//! Dioxus server function,注册在 `/api` 路径下。 +//! 仅在 `feature = "server"` 启用的服务端构建中查询数据库。 + use dioxus::prelude::*; #[cfg(feature = "server")] @@ -7,6 +14,9 @@ use super::types::PostListResponse; use crate::api::error::AppError; use crate::db::pool::get_conn; +/// 获取已发布文章分页列表。 +/// +/// 优先命中缓存;未命中时查询总数与分页记录,并按 published_at 降序排列。 #[server(ListPublishedPosts, "/api")] pub async fn list_published_posts( page: i32, @@ -24,7 +34,7 @@ pub async fn list_published_posts( let client = get_conn().await.map_err(AppError::db_conn)?; - // Get total count from cache or query + // 优先读取缓存中的已发布文章总数,否则查询数据库并回填缓存。 let total = if let Some(cached_total) = crate::cache::get_total_published_posts().await { cached_total } else { @@ -78,6 +88,9 @@ pub async fn list_published_posts( } } +/// 获取管理员视角的全部文章列表(含草稿与已发布)。 +/// +/// 需要 admin 权限;结果按创建时间降序,不走缓存。 #[server(ListPosts, "/api")] pub async fn list_posts(page: i32, per_page: i32) -> Result { let _user = get_current_admin_user().await?; @@ -129,6 +142,9 @@ pub async fn list_posts(page: i32, per_page: i32) -> Result Result { #[cfg(feature = "server")] @@ -143,6 +159,7 @@ pub async fn get_posts_by_tag(tag_name: String) -> Result Result Result { let _user = get_current_admin_user().await?; @@ -45,6 +55,9 @@ pub async fn get_post_by_id(post_id: i32) -> Result Result { #[cfg(feature = "server")] @@ -55,6 +68,7 @@ pub async fn get_post_by_slug(slug: String) -> Result Result { let _user = get_current_admin_user().await?; @@ -18,6 +31,7 @@ pub async fn rebuild_content_html(rebuild_all: bool) -> Result Result Result 0 || failed > 0 { crate::cache::invalidate_all_post_caches(); } diff --git a/src/api/posts/search.rs b/src/api/posts/search.rs index 21342bc..66106c9 100644 --- a/src/api/posts/search.rs +++ b/src/api/posts/search.rs @@ -1,3 +1,10 @@ +//! 文章全文搜索接口。 +//! +//! 基于 PostgreSQL 的 pg_trgm 扩展,通过 word_similarity 对 search_text 做模糊匹配, +//! 按相似度与发布时间降序返回最多 50 篇已发布文章。 +//! Dioxus server function,注册在 `/api` 路径下。 +//! 仅在 `feature = "server"` 启用的服务端构建中查询数据库。 + use dioxus::prelude::*; #[cfg(feature = "server")] @@ -7,6 +14,10 @@ use super::types::PostListResponse; use crate::api::error::AppError; use crate::db::pool::get_conn; +/// 搜索已发布文章。 +/// +/// 空查询直接返回空结果;非空查询使用 `word_similarity` 计算相关度, +/// 并限制返回 50 条记录。当前未缓存,每次均查询数据库。 #[server(SearchPosts, "/api")] pub async fn search_posts(query: String) -> Result { #[cfg(feature = "server")] @@ -21,6 +32,7 @@ pub async fn search_posts(query: String) -> Result Result { let _user = get_current_admin_user().await?; @@ -20,12 +29,14 @@ pub async fn get_post_stats() -> Result { let client = get_conn().await.map_err(AppError::db_conn)?; + // 统计未删除文章总数。 let total: i64 = client .query_one("SELECT COUNT(*) FROM posts WHERE deleted_at IS NULL", &[]) .await .map_err(AppError::query)? .get(0); + // 统计草稿数量。 let drafts: i64 = client .query_one( "SELECT COUNT(*) FROM posts WHERE deleted_at IS NULL AND status = 'draft'", @@ -35,6 +46,7 @@ pub async fn get_post_stats() -> Result { .map_err(AppError::query)? .get(0); + // 统计已发布数量。 let published: i64 = client .query_one( "SELECT COUNT(*) FROM posts WHERE deleted_at IS NULL AND status = 'published'", diff --git a/src/api/posts/tags.rs b/src/api/posts/tags.rs index bf364a6..cfe7035 100644 --- a/src/api/posts/tags.rs +++ b/src/api/posts/tags.rs @@ -1,3 +1,9 @@ +//! 标签列表接口。 +//! +//! 返回所有标签及其关联的已发布文章数量,用于标签云与侧边栏。 +//! Dioxus server function,注册在 `/api` 路径下。 +//! 仅在 `feature = "server"` 启用的服务端构建中查询数据库。 + use dioxus::prelude::*; use super::types::TagListResponse; @@ -6,6 +12,9 @@ use crate::api::error::AppError; use crate::db::pool::get_conn; use crate::models::post::Tag; +/// 获取全部标签列表。 +/// +/// 优先命中缓存;未命中时聚合每个标签关联的已发布文章数量,并按标签名升序排列。 #[server(ListTags, "/api")] pub async fn list_tags() -> Result { #[cfg(feature = "server")] @@ -16,6 +25,7 @@ pub async fn list_tags() -> Result { let client = get_conn().await.map_err(AppError::db_conn)?; + // 聚合标签对应的已发布、未删除文章数量。 let rows = client .query( "SELECT t.id, t.name, COUNT(pt.post_id) as post_count diff --git a/src/api/posts/types.rs b/src/api/posts/types.rs index 247bb91..24f5092 100644 --- a/src/api/posts/types.rs +++ b/src/api/posts/types.rs @@ -1,49 +1,77 @@ +//! 文章 API 的请求与响应数据结构。 + use crate::models::post::{Post, PostStats, Tag}; +/// 创建/更新文章请求体(客户端使用)。 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[allow(dead_code)] pub struct CreatePostRequest { + /// 文章标题。 pub title: String, + /// 自定义 slug,为空时由标题自动生成。 pub slug: Option, + /// 文章摘要,为空时自动从正文提取。 pub summary: Option, + /// Markdown 格式正文。 pub content_md: String, + /// 文章状态(如 draft / published)。 pub status: String, + /// 标签列表。 pub tags: Vec, + /// 封面图 URL。 pub cover_image: Option, } +/// 创建/更新/删除文章的统一响应结构。 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct CreatePostResponse { + /// 操作是否成功。 pub success: bool, + /// 提示信息。 pub message: String, + /// 新文章 id,失败时为 None。 pub post_id: Option, + /// 最终 slug,失败时为 None。 pub slug: Option, } +/// 文章列表响应。 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct PostListResponse { + /// 文章列表。 pub posts: Vec, + /// 符合查询条件的总数。 pub total: i64, } +/// 标签列表响应。 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct TagListResponse { + /// 标签列表。 pub tags: Vec, } +/// 文章统计响应。 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct PostStatsResponse { + /// 文章统计信息。 pub stats: PostStats, } +/// 单篇文章详情响应。 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct SinglePostResponse { + /// 文章详情,不存在时为 None。 pub post: Option, } +/// Markdown 重建结果响应。 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct RebuildResult { + /// 成功重建的文章数量。 pub rebuilt: u64, + /// 重建失败的文章数量。 pub failed: u64, + /// 失败信息摘要(最多 5 条)。 pub errors: Vec, } diff --git a/src/api/posts/update.rs b/src/api/posts/update.rs index b0bd05c..a27e6e8 100644 --- a/src/api/posts/update.rs +++ b/src/api/posts/update.rs @@ -1,3 +1,10 @@ +//! 更新文章接口。 +//! +//! 校验管理员权限与文章归属,重新生成唯一 slug、渲染 Markdown, +//! 在事务中更新 posts 表并同步标签,最后失效相关缓存。 +//! Dioxus server function,注册在 `/api` 路径下。 +//! 仅在 `feature = "server"` 启用的服务端构建中写入数据库。 + #![allow(clippy::too_many_arguments)] use dioxus::prelude::*; @@ -10,6 +17,10 @@ use crate::api::error::AppError; use crate::db::pool::get_conn; use crate::models::post::PostStatus; +/// 更新指定文章。 +/// +/// 校验文章存在且属于当前 admin;处理 slug 变更、发布状态转换、标签同步, +/// 并失效文章详情、列表、标签与统计缓存。 #[server(UpdatePost, "/api")] pub async fn update_post( post_id: i32, @@ -27,12 +38,14 @@ pub async fn update_post( { let mut client = get_conn().await.map_err(AppError::db_conn)?; + // 查询旧 slug,用于后续缓存失效。 let old_slug: Option = client .query_opt("SELECT slug FROM posts WHERE id = $1", &[&post_id]) .await .map_err(AppError::query)? .map(|r| r.get(0)); + // 校验文章存在、未删除且归属当前用户。 let exists: bool = client .query_opt( "SELECT 1 FROM posts WHERE id = $1 AND author_id = $2 AND deleted_at IS NULL", @@ -51,6 +64,7 @@ pub async fn update_post( }); } + // 确定基础 slug:用户传入时校验格式,否则由标题生成。 let base_slug = match slug { Some(ref s) if !s.trim().is_empty() => { let s = s.trim(); @@ -67,8 +81,10 @@ pub async fn update_post( _ => crate::api::slug::slugify(&title), }; + // 保证 slug 全局唯一,排除当前文章自身。 let final_slug = crate::api::slug::ensure_unique_slug(&client, &base_slug, Some(post_id)).await?; + // 重新渲染 Markdown 与目录。 let rendered = crate::api::markdown::render_markdown_enhanced(&content_md); let content_html = rendered.html; let toc_html = if rendered.toc_html.is_empty() { @@ -76,6 +92,7 @@ pub async fn update_post( } else { Some(rendered.toc_html) }; + // 未填写摘要时自动从正文提取。 let summary = summary .filter(|s| !s.trim().is_empty()) .unwrap_or_else(|| crate::utils::text::auto_summary(&content_md)); @@ -84,6 +101,7 @@ pub async fn update_post( let tx = client.transaction().await.map_err(AppError::tx)?; + // 获取文章旧标签,用于后续失效标签缓存。 let old_tags: Vec = { let rows = tx .query( @@ -95,6 +113,7 @@ pub async fn update_post( rows.iter().map(|r| r.get(0)).collect() }; + // 获取旧状态与旧发布时间,用于决定是否需要更新 published_at。 let old_status_row = tx .query_opt( "SELECT status, published_at FROM posts WHERE id = $1", @@ -103,6 +122,8 @@ pub async fn update_post( .await .map_err(AppError::query)?; + // 发布时:若之前已发布则保留原时间,否则使用当前时间。 + // 非发布时:保留原有 published_at(若为草稿可能为 None)。 let published_at = if post_status == PostStatus::Published { let was_published = old_status_row .as_ref() @@ -123,6 +144,7 @@ pub async fn update_post( old_status_row.and_then(|r| r.get(1)) }; + // 更新文章主表。 tx.execute( "UPDATE posts SET title = $1, slug = $2, summary = $3, content_md = $4, content_html = $5, toc_html = $6, status = $7, published_at = $8, cover_image = $9, updated_at = NOW() WHERE id = $10", @@ -145,6 +167,7 @@ pub async fn update_post( let tags_cleaned = clean_tags(&tags); let tags_for_invalidation = tags_cleaned.clone(); + // 先清除旧标签关联,再重新同步新标签。 tx.execute("DELETE FROM post_tags WHERE post_id = $1", &[&post_id]) .await .map_err(AppError::tx)?; @@ -153,11 +176,13 @@ pub async fn update_post( tx.commit().await.map_err(AppError::tx)?; + // 失效文章列表、标签、当前 slug 与统计缓存。 crate::cache::invalidate_post_lists(); crate::cache::invalidate_all_tags(); crate::cache::invalidate_post_by_slug(&final_slug).await; crate::cache::invalidate_post_stats(); + // 合并旧标签与新标签,统一失效标签下的文章列表缓存。 let all_tags_to_invalidate: std::collections::HashSet = old_tags .into_iter() .chain(tags_for_invalidation.into_iter()) @@ -166,6 +191,7 @@ pub async fn update_post( crate::cache::invalidate_posts_by_tag(tag_name).await; } + // 若 slug 发生变更,额外失效旧 slug 缓存。 if let Some(ref old) = old_slug { if old != &final_slug { crate::cache::invalidate_post_by_slug(old).await;