1use crate::api::comments::types::*;
9use dioxus::prelude::*;
10
11#[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 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 if let Err(e) = validate_comment_honeypot(&honeypot) {
47 return Ok(CommentResponse::error("spam_detected", e));
48 }
49
50 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 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 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 let content_hash = compute_content_hash(post_id, parent_id, &author_name, &content_md);
145
146 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 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 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 tx.rollback().await.ok();
201 return Ok(CommentResponse::error(
202 "duplicate",
203 "请勿重复提交".to_string(),
204 ));
205 }
206
207 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 let avatar_url = crate::api::comments::helpers::gravatar_url(&author_email);
238
239 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}