From 9921f8eebfd6f3d696c253eef02027af50ce7da4 Mon Sep 17 00:00:00 2001 From: xfy Date: Fri, 12 Jun 2026 18:39:56 +0800 Subject: [PATCH] =?UTF-8?q?docs(comments):=20=E8=A1=A5=E5=85=85=E4=B8=AD?= =?UTF-8?q?=E6=96=87=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/comments/check.rs | 15 +++++++++++++++ src/api/comments/create.rs | 22 ++++++++++++++++++++++ src/api/comments/helpers.rs | 15 +++++++++++++++ src/api/comments/list.rs | 15 +++++++++++++++ src/api/comments/markdown.rs | 17 +++++++++++++++++ src/api/comments/mod.rs | 29 +++++++++++++++++++++++++++-- src/api/comments/read.rs | 11 +++++++++++ src/api/comments/types.rs | 33 +++++++++++++++++++++++++++++++++ src/api/comments/update.rs | 24 ++++++++++++++++++++++++ 9 files changed, 179 insertions(+), 2 deletions(-) diff --git a/src/api/comments/check.rs b/src/api/comments/check.rs index a6d331a..d6c0aad 100644 --- a/src/api/comments/check.rs +++ b/src/api/comments/check.rs @@ -1,18 +1,32 @@ +//! 评论审核状态批量查询。 +//! +//! 用于前端轮询刚刚提交的评论是否已通过审核,支持传入多个 id。 +//! 仅在 `feature = "server"` 启用的服务端构建中查询数据库。 + use dioxus::prelude::*; +/// 单个评论的待审核状态结果。 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct PendingStatusItem { + /// 评论 id。 pub id: i64, + /// 当前状态:approved / pending / rejected / spam / trash / gone。 pub status: String, } +/// 查询一组评论的当前审核状态。 +/// +/// 如果某 id 不存在,则返回状态 `"gone"`。 +/// Dioxus server function,注册在 `/api` 路径下。 #[server(CheckPendingStatus, "/api")] pub async fn check_pending_status(ids: Vec) -> Result, ServerFnError> { + // 仅在服务端构建中执行 SQL 查询。 #[cfg(feature = "server")] { use crate::api::error::AppError; use crate::db::pool::get_conn; + // 空列表直接返回空结果,避免无意义的数据库查询。 if ids.is_empty() { return Ok(vec![]); } @@ -27,6 +41,7 @@ pub async fn check_pending_status(ids: Vec) -> Result = rows .iter() .map(|r| (r.get::<_, i64>(0), r.get::<_, String>(1))) diff --git a/src/api/comments/create.rs b/src/api/comments/create.rs index fa31cb0..ef6a233 100644 --- a/src/api/comments/create.rs +++ b/src/api/comments/create.rs @@ -1,6 +1,18 @@ +//! 发表评论接口。 +//! +//! 校验作者信息、父评论与目标文章,生成内容哈希防止重复提交, +//! 新评论默认进入 pending 状态等待审核。 +//! Dioxus server function,注册在 `/api` 路径下。 +//! 仅在 `feature = "server"` 启用的服务端构建中写入数据库。 + use crate::api::comments::types::*; use dioxus::prelude::*; +/// 创建一条新评论。 +/// +/// 对作者昵称、邮箱、网址与内容进行基础校验; +/// 若目标文章未发布或父评论未通过审核,则拒绝提交; +/// 成功后将评论置为 pending,并清空相关缓存。 #[server(CreateComment, "/api")] pub async fn create_comment( post_id: i32, @@ -20,6 +32,7 @@ pub async fn create_comment( use crate::cache; use crate::db::pool::get_conn; + // 从 FullstackContext 获取客户端 IP,并进行评论频率限流。 if let Some(ctx) = dioxus::fullstack::FullstackContext::current() { let parts = ctx.parts_mut(); let ip = crate::api::rate_limit::get_client_ip(&parts.headers); @@ -35,6 +48,7 @@ pub async fn create_comment( } } + // 依次校验昵称、邮箱、网址与评论内容。 if let Err(e) = validate_comment_name(&author_name) { return Ok(CommentResponse { success: false, @@ -80,6 +94,7 @@ pub async fn create_comment( let client = get_conn().await.map_err(AppError::db_conn)?; + // 确认目标文章存在且处于已发布状态。 let post_row = client .query_opt( "SELECT status, deleted_at FROM posts WHERE id = $1", @@ -115,6 +130,7 @@ pub async fn create_comment( } } + // 若存在父评论,校验其归属文章与审核状态,并计算当前评论的嵌套深度。 let mut depth: i32 = 0; if let Some(pid) = parent_id { let parent_row = client @@ -177,6 +193,7 @@ pub async fn create_comment( } } + // 基于文章、父评论、作者与内容计算哈希,防止短时间重复提交。 let content_hash = compute_content_hash(post_id, parent_id, &author_name, &content_md); let dup: Option = client @@ -199,8 +216,10 @@ pub async fn create_comment( }); } + // 将 Markdown 渲染为 HTML,并通过 sanitizer 过滤危险标签。 let content_html = crate::api::comments::markdown::render_comment_markdown(&content_md); + // 获取客户端 IP 与 User-Agent,用于反垃圾与审计。 let ip_address = if let Some(ctx) = dioxus::fullstack::FullstackContext::current() { let parts = ctx.parts_mut(); Some(crate::api::rate_limit::get_client_ip(&parts.headers)) @@ -219,6 +238,7 @@ pub async fn create_comment( None }; + // 插入评论,默认状态为 pending,等待管理员审核。 let row = client .query_one( "INSERT INTO comments \ @@ -248,8 +268,10 @@ pub async fn create_comment( let comment_id: i64 = row.get(0); + // 根据邮箱生成 Gravatar 头像链接。 let avatar_url = crate::api::comments::helpers::gravatar_url(&author_email); + // 新评论可能影响文章评论列表与计数,清空相关缓存。 cache::invalidate_comments_by_post(post_id).await; cache::invalidate_comment_count(post_id).await; diff --git a/src/api/comments/helpers.rs b/src/api/comments/helpers.rs index c27aa5b..6c26fae 100644 --- a/src/api/comments/helpers.rs +++ b/src/api/comments/helpers.rs @@ -1,8 +1,14 @@ +//! 评论模块的辅助函数:数据转换、校验、哈希与头像生成。 +//! +//! 大部分工具函数仅在 `feature = "server"` 启用的服务端构建中使用; +//! 校验函数同时在前端构建中保留签名,避免编译器提示未使用。 + #![allow(clippy::unused_unit, deprecated)] #[cfg(feature = "server")] use crate::models::comment::{AdminComment, CommentStatus, PublicComment}; +/// 计算字符串的 MD5 哈希,用于 Gravatar。 #[cfg(feature = "server")] pub fn md5_hash(input: &str) -> String { use md5::Digest; @@ -10,12 +16,14 @@ pub fn md5_hash(input: &str) -> String { hex::encode(hash) } +/// 根据邮箱生成 Cravatar(Gravatar 国内镜像)头像 URL。 #[cfg(feature = "server")] pub fn gravatar_url(email: &str) -> String { let hash = md5_hash(&email.trim().to_lowercase()); format!("https://cravatar.cn/avatar/{}?d=mp&s=80", hash) } +/// 将数据库行转换为前端展示的公开评论结构。 #[cfg(feature = "server")] pub fn row_to_public_comment(row: &tokio_postgres::Row) -> PublicComment { let email: String = row.get("author_email"); @@ -36,6 +44,7 @@ pub fn row_to_public_comment(row: &tokio_postgres::Row) -> PublicComment { } } +/// 将数据库行转换为后台管理使用的评论结构。 #[cfg(feature = "server")] pub fn row_to_admin_comment(row: &tokio_postgres::Row) -> AdminComment { let status_str: String = row.get("status"); @@ -58,6 +67,7 @@ pub fn row_to_admin_comment(row: &tokio_postgres::Row) -> AdminComment { } } +/// 将 UTC 时间格式化为相对时间(刚刚 / N 分钟前 / N 小时前 / N 天前 / 日期)。 pub fn format_relative_time(dt: chrono::DateTime) -> String { let now = chrono::Utc::now(); let diff = now.signed_duration_since(dt); @@ -75,6 +85,7 @@ pub fn format_relative_time(dt: chrono::DateTime) -> String { } } +/// 校验评论作者昵称:非空且不超过 50 字符。 #[allow(dead_code)] pub fn validate_comment_name(name: &str) -> Result<(), String> { let trimmed = name.trim(); @@ -87,6 +98,7 @@ pub fn validate_comment_name(name: &str) -> Result<(), String> { Ok(()) } +/// 校验评论作者邮箱格式。 #[allow(dead_code)] pub fn validate_comment_email(email: &str) -> Result<(), String> { let re = regex::Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$").unwrap(); @@ -96,6 +108,7 @@ pub fn validate_comment_email(email: &str) -> Result<(), String> { Ok(()) } +/// 校验评论作者网址:为空时允许,非空时必须以 http:// 或 https:// 开头且不超过 200 字符。 #[allow(dead_code)] pub fn validate_comment_url(url: &str) -> Result<(), String> { let trimmed = url.trim(); @@ -112,6 +125,7 @@ pub fn validate_comment_url(url: &str) -> Result<(), String> { Ok(()) } +/// 校验评论内容:非空且不超过 10000 字符。 #[allow(dead_code)] pub fn validate_comment_content(content: &str) -> Result<(), String> { let trimmed = content.trim(); @@ -124,6 +138,7 @@ pub fn validate_comment_content(content: &str) -> Result<(), String> { Ok(()) } +/// 计算评论内容哈希,用于检测短时间内的重复提交。 pub fn compute_content_hash( post_id: i32, parent_id: Option, diff --git a/src/api/comments/list.rs b/src/api/comments/list.rs index 5f06e57..fb5f354 100644 --- a/src/api/comments/list.rs +++ b/src/api/comments/list.rs @@ -1,6 +1,14 @@ +//! 评论列表查询接口:后台管理用的待审核列表、全部评论列表与待审核计数。 +//! +//! 所有接口均需管理员身份,Dioxus server function 注册在 `/api` 路径下。 +//! 仅在 `feature = "server"` 启用的服务端构建中查询数据库。 + use crate::api::comments::types::*; use dioxus::prelude::*; +/// 获取待审核评论分页列表。 +/// +/// 每页 20 条,按创建时间倒序排列,并返回总数用于分页。 #[server(GetPendingComments, "/api")] pub async fn get_pending_comments(page: i32) -> Result { #[cfg(feature = "server")] @@ -48,6 +56,9 @@ pub async fn get_pending_comments(page: i32) -> Result Result { #[cfg(feature = "server")] @@ -82,6 +93,9 @@ pub async fn get_pending_count() -> Result unreachable!() } +/// 获取全部评论分页列表。 +/// +/// 支持按状态筛选;未指定状态时返回所有未删除评论。 #[server(GetAllComments, "/api")] pub async fn get_all_comments( page: i32, @@ -102,6 +116,7 @@ pub async fn get_all_comments( let client = get_conn().await.map_err(AppError::db_conn)?; + // 根据是否传入状态参数,分别构造 SQL 与查询条件。 let (total, rows) = match status.as_deref() { Some(s) if !s.is_empty() => { let total: i64 = client diff --git a/src/api/comments/markdown.rs b/src/api/comments/markdown.rs index f391246..315f9e1 100644 --- a/src/api/comments/markdown.rs +++ b/src/api/comments/markdown.rs @@ -1,5 +1,11 @@ +//! 评论 Markdown 渲染与 HTML 清洗。 +//! +//! 对评论内容做轻量 Markdown 解析,限制标签白名单并转义危险字符。 +//! 仅在 `feature = "server"` 启用的服务端构建中实际执行渲染。 + #![allow(clippy::unused_unit, deprecated)] +/// 转义 HTML 特殊字符,用于无语言信息的代码块。 #[cfg(feature = "server")] fn html_escape(s: &str) -> String { s.replace('&', "&") @@ -8,11 +14,19 @@ fn html_escape(s: &str) -> String { .replace('"', """) } +/// 清洗评论 HTML,移除危险标签与属性。 +/// +/// 实际委托给 `crate::api::sanitizer::clean_comment_html` 实现。 #[cfg(feature = "server")] pub fn clean_comment_html(input: &str) -> String { crate::api::sanitizer::clean_comment_html(input) } +/// 将评论 Markdown 渲染为安全的 HTML。 +/// +/// 支持表格与删除线;标题统一渲染为 `` 以避免层级混乱; +/// 代码块若指定语言则调用服务端高亮,否则转义 HTML; +/// 最终调用 `clean_comment_html` 过滤危险内容。 #[cfg(feature = "server")] pub fn render_comment_markdown(md: &str) -> String { use pulldown_cmark::{CodeBlockKind, Event, Options, Tag, TagEnd}; @@ -25,9 +39,11 @@ pub fn render_comment_markdown(md: &str) -> String { let mut code_lang: Option = None; let mut code_buffer = String::new(); + // 逐事件处理 Markdown AST,转换标题并收集代码块内容。 for event in parser { match event { Event::Start(Tag::Heading { .. }) => { + // 评论中不保留标题层级,统一加粗。 events.push(Event::Start(Tag::Strong)); } Event::End(TagEnd::Heading(_)) => { @@ -45,6 +61,7 @@ pub fn render_comment_markdown(md: &str) -> String { code_buffer.push_str(&text); } Event::End(TagEnd::CodeBlock) => { + // 根据是否有语言信息决定高亮或转义。 let html = if let Some(ref lang) = code_lang { let highlighted = crate::highlight::server::highlight_code(&code_buffer, Some(lang)); diff --git a/src/api/comments/mod.rs b/src/api/comments/mod.rs index 06942c4..09bc7ad 100644 --- a/src/api/comments/mod.rs +++ b/src/api/comments/mod.rs @@ -1,3 +1,8 @@ +//! 评论模块:提供评论的 CRUD、Markdown 渲染、审核状态流转与分页查询。 +//! +//! 所有 Dioxus server function 均注册在 `/api` 路径下,供前端与服务端调用。 +//! 仅在 `feature = "server"` 启用的服务端构建中执行数据库操作与缓存失效。 + #![allow(clippy::unused_unit, deprecated)] mod check; @@ -9,16 +14,36 @@ mod read; mod types; mod update; +/// 查询一组评论的当前审核状态。 pub use check::check_pending_status; +/// 创建一条新评论。 pub use create::create_comment; +/// 获取全部评论分页列表。 #[allow(unused_imports)] -pub use list::{get_all_comments, get_pending_comments, get_pending_count}; +pub use list::get_all_comments; +/// 获取待审核评论分页列表。 +#[allow(unused_imports)] +pub use list::get_pending_comments; +/// 获取待审核评论总数。 +#[allow(unused_imports)] +pub use list::get_pending_count; +/// 获取指定文章的已审核评论数量。 #[allow(unused_imports)] pub use read::get_comment_count; +/// 获取指定文章的已审核评论列表。 pub use read::get_comments; +/// 评论 API 的请求与响应数据结构。 pub use types::*; -pub use update::{approve_comment, batch_update_comment_status, spam_comment, trash_comment}; +/// 通过指定评论。 +pub use update::approve_comment; +/// 批量更新评论状态。 +pub use update::batch_update_comment_status; +/// 将指定评论标记为垃圾评论。 +pub use update::spam_comment; +/// 将指定评论移入回收站。 +pub use update::trash_comment; #[cfg(feature = "server")] +/// 将评论 Markdown 渲染为安全的 HTML。 #[allow(unused_imports)] pub use markdown::render_comment_markdown; diff --git a/src/api/comments/read.rs b/src/api/comments/read.rs index 3970d98..67907f3 100644 --- a/src/api/comments/read.rs +++ b/src/api/comments/read.rs @@ -1,6 +1,14 @@ +//! 前端评论读取接口:已审核评论列表与评论计数。 +//! +//! 结果按文章 id 缓存,Dioxus server function 注册在 `/api` 路径下。 +//! 仅在 `feature = "server"` 启用的服务端构建中查询数据库。 + use crate::api::comments::types::*; use dioxus::prelude::*; +/// 获取指定文章的已审核评论列表。 +/// +/// 优先命中缓存;按 id 升序返回,便于前端构建嵌套树。 #[server(GetComments, "/api")] pub async fn get_comments(post_id: i32) -> Result { #[cfg(feature = "server")] @@ -42,6 +50,9 @@ pub async fn get_comments(post_id: i32) -> Result Result { #[cfg(feature = "server")] diff --git a/src/api/comments/types.rs b/src/api/comments/types.rs index 638450f..cfd4c4e 100644 --- a/src/api/comments/types.rs +++ b/src/api/comments/types.rs @@ -1,61 +1,94 @@ +//! 评论 API 的请求与响应数据结构。 + use crate::models::comment::{AdminComment, PublicComment}; use serde::{Deserialize, Serialize}; +/// 创建评论请求体(客户端使用)。 #[derive(Debug, Clone, Serialize, Deserialize)] #[allow(dead_code)] pub struct CreateCommentRequest { + /// 目标文章 id。 pub post_id: i32, + /// 父评论 id,顶层评论为 None。 pub parent_id: Option, + /// 评论者昵称。 pub author_name: String, + /// 评论者邮箱。 pub author_email: String, + /// 评论者个人网址。 pub author_url: Option, + /// 评论 Markdown 原文。 pub content_md: String, } +/// 创建/审核/删除评论的统一响应结构。 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CommentResponse { + /// 操作是否成功。 pub success: bool, + /// 提示信息。 pub message: String, + /// 错误码,成功时为 None。 pub error_code: Option, + /// 新评论 id。 #[serde(default)] pub comment_id: Option, + /// 评论者头像 URL。 #[serde(default)] pub avatar_url: Option, + /// 评论嵌套深度。 #[serde(default)] pub depth: Option, } +/// 评论树响应:包含文章下的全部已审核评论。 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CommentTreeResponse { + /// 评论列表。 pub comments: Vec, + /// 评论总数。 pub count: i64, } +/// 评论计数响应。 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CommentCountResponse { + /// 评论数量。 pub count: i64, } +/// 待审核评论列表响应。 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PendingCommentsResponse { + /// 待审核评论列表。 pub comments: Vec, + /// 总数。 pub total: i64, } +/// 全部评论列表响应。 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AllCommentsResponse { + /// 评论列表。 pub comments: Vec, + /// 总数。 pub total: i64, } +/// 待审核评论计数响应。 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PendingCountResponse { + /// 待审核数量。 pub count: i64, } +/// 批量更新状态响应。 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BatchStatusResponse { + /// 操作是否成功。 pub success: bool, + /// 实际更新的行数。 pub updated_count: i64, + /// 提示信息。 pub message: String, } diff --git a/src/api/comments/update.rs b/src/api/comments/update.rs index d6f6dcd..6ce6119 100644 --- a/src/api/comments/update.rs +++ b/src/api/comments/update.rs @@ -1,6 +1,15 @@ +//! 评论审核状态更新接口:通过、垃圾、删除与批量更新。 +//! +//! 所有接口均需管理员身份,Dioxus server function 注册在 `/api` 路径下。 +//! 状态变更后需要清空文章评论缓存、计数缓存与待审核计数缓存。 +//! 仅在 `feature = "server"` 启用的服务端构建中写入数据库。 + use crate::api::comments::types::*; use dioxus::prelude::*; +/// 通过指定评论。 +/// +/// 同时递归将该评论的所有 pending 父评论一并通过,确保嵌套链可见。 #[server(ApproveComment, "/api")] pub async fn approve_comment(id: i64) -> Result { #[cfg(feature = "server")] @@ -36,6 +45,7 @@ pub async fn approve_comment(id: i64) -> Result } }; + // 直接通过目标评论并记录通过时间。 client .execute( "UPDATE comments SET status = 'approved', approved_at = NOW() WHERE id = $1", @@ -44,6 +54,7 @@ pub async fn approve_comment(id: i64) -> Result .await .map_err(AppError::query)?; + // 递归向上查找所有 pending 父评论并同步通过,避免子评论可见但父评论被隐藏。 client .execute( "WITH RECURSIVE ancestors AS ( \ @@ -75,6 +86,9 @@ pub async fn approve_comment(id: i64) -> Result unreachable!() } +/// 将指定评论标记为垃圾评论。 +/// +/// 若原状态为 approved,则需要清空该文章相关缓存。 #[server(SpamComment, "/api")] pub async fn spam_comment(id: i64) -> Result { #[cfg(feature = "server")] @@ -128,6 +142,9 @@ pub async fn spam_comment(id: i64) -> Result { unreachable!() } +/// 将指定评论移入回收站(软删除)。 +/// +/// 软删除会设置 deleted_at 与状态为 trash,并清空相关缓存。 #[server(TrashComment, "/api")] pub async fn trash_comment(id: i64) -> Result { #[cfg(feature = "server")] @@ -178,6 +195,10 @@ pub async fn trash_comment(id: i64) -> Result { unreachable!() } +/// 批量更新评论状态。 +/// +/// 仅接受 approved / spam / trash 三种状态;trash 会软删除并设置 deleted_at, +/// approved 会设置 approved_at。 #[server(BatchUpdateCommentStatus, "/api")] pub async fn batch_update_comment_status( ids: Vec, @@ -192,6 +213,7 @@ pub async fn batch_update_comment_status( let _admin = get_current_admin_user().await?; + // 限制可批量操作的状态,防止非法状态写入数据库。 if !matches!(status.as_str(), "approved" | "spam" | "trash") { return Ok(BatchStatusResponse { success: false, @@ -202,6 +224,7 @@ pub async fn batch_update_comment_status( let client = get_conn().await.map_err(AppError::db_conn)?; + // 收集受影响的文章 id,用于后续批量失效缓存。 let post_ids: Vec = client .query( "SELECT DISTINCT post_id FROM comments WHERE id = ANY($1)", @@ -213,6 +236,7 @@ pub async fn batch_update_comment_status( .map(|r| r.get("post_id")) .collect(); + // 根据目标状态设置不同的附加字段:trash 软删除,approved 记录通过时间。 let result = if status == "trash" { client .execute(