1use crate::api::comments::types::*;
11use dioxus::prelude::*;
12
13#[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 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 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 if let Err(e) = validate_comment_honeypot(&honeypot) {
60 return Ok(CommentResponse::error("spam_detected", e));
61 }
62
63 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 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 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 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 let content_hash = compute_content_hash(post_id, parent_id, &author_key, &content_md);
180
181 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 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 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 tx.rollback().await.ok();
236 return Ok(CommentResponse::error(
237 "duplicate",
238 "请勿重复提交".to_string(),
239 ));
240 }
241
242 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 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 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}