Skip to main content

yggdrasil/api/comments/
create.rs

1//! 发表评论接口。
2//!
3//! 校验作者信息、父评论与目标文章,生成内容哈希防止重复提交,
4//! 新评论默认进入 pending 状态等待审核。
5//! Dioxus server function,注册在 `/api` 路径下。
6//! 仅在 `feature = "server"` 启用的服务端构建中写入数据库。
7
8use crate::api::comments::types::*;
9use dioxus::prelude::*;
10
11/// 创建一条新评论。
12///
13/// 对作者昵称、邮箱、网址与内容进行基础校验;
14/// 若目标文章未发布或父评论未通过审核,则拒绝提交;
15/// 成功后将评论置为 pending,并清空相关缓存。
16#[server(CreateComment, "/api")]
17pub async fn create_comment(
18    post_id: i32,
19    parent_id: Option<i64>,
20    author_name: String,
21    author_email: String,
22    author_url: Option<String>,
23    content_md: String,
24    honeypot: String,
25) -> Result<CommentResponse, ServerFnError> {
26    #[cfg(feature = "server")]
27    {
28        use crate::api::comments::helpers::{
29            compute_content_hash, validate_comment_content, validate_comment_email,
30            validate_comment_honeypot, validate_comment_name, validate_comment_url,
31        };
32        use crate::api::error::AppError;
33        use crate::cache;
34        use crate::db::pool::get_conn;
35
36        // 从 FullstackContext 获取客户端 IP,并进行评论频率限流。
37        if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
38            let parts = ctx.parts_mut();
39            let ip = crate::api::rate_limit::get_client_ip(&parts.headers);
40            if let Err(msg) = crate::api::rate_limit::check_comment_limit(&ip) {
41                return Ok(CommentResponse::error("rate_limited", msg));
42            }
43        }
44
45        // 蜜罐字段二次校验:禁用 JS 的机器人可能绕过前端拦截,这里作为服务端防线。
46        if let Err(e) = validate_comment_honeypot(&honeypot) {
47            return Ok(CommentResponse::error("spam_detected", e));
48        }
49
50        // 依次校验昵称、邮箱、网址与评论内容。
51        if let Err(e) = validate_comment_name(&author_name) {
52            return Ok(CommentResponse::error("invalid_input", e));
53        }
54        if let Err(e) = validate_comment_email(&author_email) {
55            return Ok(CommentResponse::error("invalid_input", e));
56        }
57        if let Some(ref url) = author_url {
58            if let Err(e) = validate_comment_url(url) {
59                return Ok(CommentResponse::error("invalid_input", e));
60            }
61        }
62        if let Err(e) = validate_comment_content(&content_md) {
63            return Ok(CommentResponse::error("invalid_input", e));
64        }
65
66        let mut client = get_conn().await.map_err(AppError::db_conn)?;
67
68        // 确认目标文章存在且处于已发布状态。
69        let post_row = client
70            .query_opt(
71                "SELECT status, deleted_at FROM posts WHERE id = $1",
72                &[&post_id],
73            )
74            .await
75            .map_err(AppError::query)?;
76
77        match post_row {
78            None => {
79                return Ok(CommentResponse::error(
80                    "post_not_found",
81                    "文章不存在".to_string(),
82                ));
83            }
84            Some(row) => {
85                let status: String = row.get("status");
86                let deleted_at: Option<chrono::DateTime<chrono::Utc>> = row.get("deleted_at");
87                if status != "published" || deleted_at.is_some() {
88                    return Ok(CommentResponse::error(
89                        "post_not_found",
90                        "文章不存在".to_string(),
91                    ));
92                }
93            }
94        }
95
96        // 若存在父评论,校验其归属文章与审核状态,并计算当前评论的嵌套深度。
97        let mut depth: i32 = 0;
98        if let Some(pid) = parent_id {
99            let parent_row = client
100                .query_opt(
101                    "SELECT post_id, status, depth FROM comments WHERE id = $1 AND deleted_at IS NULL",
102                    &[&pid],
103                )
104                .await
105                .map_err(AppError::query)?;
106
107            match parent_row {
108                None => {
109                    return Ok(CommentResponse::error(
110                        "parent_not_found",
111                        "父评论不存在".to_string(),
112                    ));
113                }
114                Some(row) => {
115                    let parent_post_id: i32 = row.get("post_id");
116                    let parent_status: String = row.get("status");
117                    let parent_depth: i32 = row.get("depth");
118
119                    if parent_post_id != post_id {
120                        return Ok(CommentResponse::error(
121                            "parent_not_found",
122                            "父评论不存在".to_string(),
123                        ));
124                    }
125                    if parent_status != "approved" {
126                        return Ok(CommentResponse::error(
127                            "parent_not_approved",
128                            "父评论未通过审核".to_string(),
129                        ));
130                    }
131
132                    depth = parent_depth + 1;
133                    if depth > 20 {
134                        return Ok(CommentResponse::error(
135                            "too_deep",
136                            "评论嵌套层级过深".to_string(),
137                        ));
138                    }
139                }
140            }
141        }
142
143        // 基于文章、父评论、作者与内容计算哈希,防止短时间重复提交。
144        let content_hash = compute_content_hash(post_id, parent_id, &author_name, &content_md);
145
146        // Markdown 渲染(含 syntect 高亮 + KaTeX)是 CPU 密集任务,移到阻塞线程池执行,
147        // 避免阻塞 async runtime(M4)。content_md 下游 INSERT 仍需使用,故先克隆再移入闭包。
148        let md_for_render = content_md.clone();
149        let content_html = tokio::task::spawn_blocking(move || {
150            crate::api::comments::markdown::render_comment_markdown(&md_for_render)
151        })
152        .await
153        .map_err(|_| AppError::Internal("Markdown 渲染任务失败"))?;
154        let author_name_safe = crate::utils::html::escape_html(author_name.trim());
155        let author_url_safe = author_url
156            .as_ref()
157            .map(|u| crate::utils::html::escape_html(u.trim()))
158            .filter(|u| !u.is_empty());
159        let ip_address = if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
160            let parts = ctx.parts_mut();
161            Some(crate::api::rate_limit::get_client_ip(&parts.headers))
162        } else {
163            None
164        };
165        let user_agent = if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
166            let parts = ctx.parts_mut();
167            parts
168                .headers
169                .get("user-agent")
170                .and_then(|v| v.to_str().ok())
171                .map(|s| s.to_string())
172        } else {
173            None
174        };
175
176        // 查重与插入在同一事务内,并用 advisory lock 串行化相同内容的并发提交(M4)。
177        // 仅靠普通 SELECT+事务在 Read Committed 下无法阻止并发重复(两个事务都看不到
178        // 对方未提交的 INSERT);pg_advisory_xact_lock 以内容哈希派生的 key 加事务级
179        // 排他锁,使相同内容的并发请求在锁上排队,第二个提交时查重必然命中前一个。
180        // key 取 content_hash 前 16 个 hex 字符(8 字节)解析为 i64。
181        let lock_key: i64 = i64::from_str_radix(&content_hash[..16], 16).unwrap_or(0);
182
183        let tx = client.transaction().await.map_err(AppError::query)?;
184        // 事务级 advisory 锁:随事务结束自动释放,无需显式 unlock。
185        tx.execute("SELECT pg_advisory_xact_lock($1)", &[&lock_key])
186            .await
187            .map_err(AppError::query)?;
188
189        let dup: Option<i64> = tx
190            .query_opt(
191                "SELECT id FROM comments WHERE post_id = $1 AND content_hash = $2 AND created_at > NOW() - INTERVAL '5 minutes'",
192                &[&post_id, &content_hash],
193            )
194            .await
195            .map_err(AppError::query)?
196            .map(|r| r.get(0));
197
198        if dup.is_some() {
199            // 重复:回滚(释放 advisory 锁)后返回。
200            tx.rollback().await.ok();
201            return Ok(CommentResponse::error(
202                "duplicate",
203                "请勿重复提交".to_string(),
204            ));
205        }
206
207        // 插入评论,默认状态为 pending,等待管理员审核。
208        let row = tx
209            .query_one(
210                "INSERT INTO comments \
211                 (post_id, parent_id, depth, author_name, author_email, author_url, \
212                  content_md, content_html, content_hash, status, ip_address, user_agent) \
213                  VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending', $10, $11) \
214                  RETURNING id",
215                &[
216                    &post_id,
217                    &parent_id,
218                    &depth,
219                    &author_name_safe,
220                    &author_email.trim(),
221                    &author_url_safe,
222                    &content_md,
223                    &content_html,
224                    &content_hash,
225                    &ip_address,
226                    &user_agent,
227                ],
228            )
229            .await
230            .map_err(AppError::query)?;
231
232        tx.commit().await.map_err(AppError::query)?;
233
234        let comment_id: i64 = row.get(0);
235
236        // 根据邮箱生成 Gravatar 头像链接。
237        let avatar_url = crate::api::comments::helpers::gravatar_url(&author_email);
238
239        // 新评论可能影响文章评论列表与待审核计数,清空相关缓存。
240        cache::invalidate_comments_by_post(post_id).await;
241        cache::invalidate_pending_count().await;
242
243        Ok(CommentResponse::created(
244            "评论已提交,等待审核".to_string(),
245            comment_id,
246            avatar_url,
247            depth,
248        ))
249    }
250    #[cfg(not(feature = "server"))]
251    unreachable!()
252}