Skip to main content

yggdrasil/api/comments/
create.rs

1//! 发表评论接口。
2//!
3//! 校验作者信息、父评论与目标文章,生成内容哈希防止重复提交。
4//! 匿名评论默认进入 pending 状态等待审核;**登录用户**(有效会话)发表的评论
5//! 以账号身份直发 approved——身份字段取自 users 表(显示名回退用户名),
6//! 跳过昵称/邮箱/网址校验与蜜罐,记录 `user_id` 供读取侧 JOIN 实时展示。
7//! Leptos server function,注册在 `/api` 路径下。
8//! 仅在 `feature = "server"` 启用的服务端构建中写入数据库。
9
10use crate::api::comments::types::*;
11use crate::api::error::ApiError;
12use leptos::prelude::*;
13
14/// 创建一条新评论。
15///
16/// 对作者昵称、邮箱、网址与内容进行基础校验;
17/// 若目标文章未发布或父评论未通过审核,则拒绝提交;
18/// 成功后将评论置为 pending,并清空相关缓存。
19#[server(CreateComment, endpoint = "create_comment", input = leptos::server_fn::codec::Json)]
20pub async fn create_comment(
21    post_id: i32,
22    parent_id: Option<i64>,
23    author_name: String,
24    author_email: String,
25    author_url: Option<String>,
26    content_md: String,
27    honeypot: String,
28) -> Result<CommentResponse, ApiError> {
29    #[cfg(feature = "server")]
30    {
31        use crate::api::auth::get_user_by_token;
32        use crate::api::comments::helpers::{
33            compute_content_hash, validate_comment_content, validate_comment_email,
34            validate_comment_honeypot, validate_comment_name, validate_comment_url,
35        };
36        use crate::api::error::AppError;
37        use crate::auth::session::get_session_from_ctx;
38        use crate::cache;
39        use crate::db::pool::get_conn;
40
41        // 从请求上下文获取客户端 IP,并进行评论频率限流。
42        if let Some(headers) = crate::request_ctx::request_headers() {
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?,
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 asset_paths = crate::api::posts::helpers::extract_asset_paths(&content_html, None);
190        let author_name_safe = crate::utils::html::escape_html(final_name.trim());
191        let author_url_safe = final_url
192            .as_ref()
193            .map(|u| crate::utils::html::escape_html(u.trim()))
194            .filter(|u| !u.is_empty());
195        let request_headers = crate::request_ctx::request_headers();
196        let ip_address = match &request_headers {
197            Some(headers) => Some(crate::api::rate_limit::get_client_ip(headers).await),
198            None => None,
199        };
200        let user_agent = request_headers.as_ref().and_then(|headers| {
201            headers
202                .get("user-agent")
203                .and_then(|v| v.to_str().ok())
204                .map(|s| s.to_string())
205        });
206
207        // 查重与插入在同一事务内,并用 advisory lock 串行化相同内容的并发提交(M4)。
208        // 仅靠普通 SELECT+事务在 Read Committed 下无法阻止并发重复(两个事务都看不到
209        // 对方未提交的 INSERT);pg_advisory_xact_lock 以内容哈希派生的 key 加事务级
210        // 排他锁,使相同内容的并发请求在锁上排队,第二个提交时查重必然命中前一个。
211        // key 取 content_hash 前 16 个 hex 字符(8 字节)解析为 i64。
212        let lock_key: i64 = i64::from_str_radix(&content_hash[..16], 16).unwrap_or(0);
213
214        let tx = client.transaction().await.map_err(AppError::query)?;
215        // 事务级 advisory 锁:随事务结束自动释放,无需显式 unlock。
216        tx.execute("SELECT pg_advisory_xact_lock($1)", &[&lock_key])
217            .await
218            .map_err(AppError::query)?;
219
220        let dup: Option<i64> = tx
221            .query_opt(
222                "SELECT id FROM comments WHERE post_id = $1 AND content_hash = $2 AND created_at > NOW() - INTERVAL '5 minutes'",
223                &[&post_id, &content_hash],
224            )
225            .await
226            .map_err(AppError::query)?
227            .map(|r| r.get(0));
228
229        if dup.is_some() {
230            // 重复:回滚(释放 advisory 锁)后返回。
231            tx.rollback().await.ok();
232            return Ok(CommentResponse::error(
233                "duplicate",
234                "请勿重复提交".to_string(),
235            ));
236        }
237
238        // 锁定正文引用的素材;任一缺失则拒绝,避免写入指向已删素材的评论。
239        let locked = crate::api::assets::refs::lock_assets_by_path(&tx, &asset_paths).await?;
240        if locked.missing {
241            tx.rollback().await.ok();
242            return Ok(CommentResponse::error(
243                "missing_asset",
244                crate::api::assets::refs::MISSING_COMMENT_ASSET.to_string(),
245            ));
246        }
247
248        // 登录用户直发 approved(记录 approved_at);匿名进入 pending 等待审核。
249        let initial_status: &str = if session_user.is_some() {
250            "approved"
251        } else {
252            "pending"
253        };
254        let approved_at: Option<chrono::DateTime<chrono::Utc>> =
255            session_user.is_some().then(chrono::Utc::now);
256
257        let row = tx
258            .query_one(
259                "INSERT INTO comments \
260                 (post_id, parent_id, depth, author_name, author_email, author_url, \
261                  content_md, content_html, content_hash, status, ip_address, user_agent, \
262                  user_id, approved_at) \
263                  VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) \
264                  RETURNING id",
265                &[
266                    &post_id,
267                    &parent_id,
268                    &depth,
269                    &author_name_safe,
270                    &final_email.trim(),
271                    &author_url_safe,
272                    &content_md,
273                    &content_html,
274                    &content_hash,
275                    &initial_status,
276                    &ip_address,
277                    &user_agent,
278                    &user_id,
279                    &approved_at,
280                ],
281            )
282            .await
283            .map_err(AppError::query)?;
284
285        let comment_id: i64 = row.get(0);
286        crate::api::assets::refs::insert_comment_refs(&tx, comment_id, &locked.ids).await?;
287
288        tx.commit().await.map_err(AppError::query)?;
289
290        // 响应头像:登录用户用账号头像(未设置回退邮箱 Gravatar),匿名用邮箱 Gravatar。
291        let avatar_url = match &session_user {
292            Some(u) => u
293                .avatar_url
294                .clone()
295                .unwrap_or_else(|| crate::api::comments::helpers::gravatar_url(&u.email)),
296            None => crate::api::comments::helpers::gravatar_url(&final_email),
297        };
298
299        // 新评论影响文章评论列表缓存;仅匿名 pending 评论才影响待审核计数。
300        cache::invalidate_comments_by_post(post_id).await;
301        if session_user.is_none() {
302            cache::invalidate_pending_count().await;
303        }
304
305        let message = if session_user.is_some() {
306            "评论已发布"
307        } else {
308            "评论已提交,等待审核"
309        };
310
311        Ok(CommentResponse::created(
312            message.to_string(),
313            comment_id,
314            avatar_url,
315            depth,
316        ))
317    }
318    #[cfg(not(feature = "server"))]
319    unreachable!()
320}