1use crate::api::comments::types::*;
11use crate::api::error::ApiError;
12use leptos::prelude::*;
13
14#[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 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 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 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 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 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 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 tx.rollback().await.ok();
232 return Ok(CommentResponse::error(
233 "duplicate",
234 "请勿重复提交".to_string(),
235 ));
236 }
237
238 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 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 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 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}