Skip to main content

yggdrasil/api/comments/
create.rs

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