1#![cfg(feature = "server")]
10
11use rmcp::handler::server::tool::Extension;
12use rmcp::handler::server::wrapper::Parameters;
13use rmcp::model::CallToolResult;
14use rmcp::{schemars, tool, tool_router, ErrorData as McpError};
15use serde::Deserialize;
16
17use super::common::{internal, ok_json, require_scope};
18use crate::cache;
19use crate::db::pool::get_conn;
20use crate::models::mcp_token::TokenScope;
21
22#[tool_router(router = comments_router, vis = "pub")]
23impl crate::mcp::server::YggMcpServer {
24 #[tool(
26 description = "列出全部评论(分页,每页 20 条)。可按状态筛选:pending/approved/spam/trash。"
27 )]
28 async fn list_comments(
29 &self,
30 Parameters(p): Parameters<ListCommentsParams>,
31 Extension(parts): Extension<http::request::Parts>,
32 ) -> Result<CallToolResult, McpError> {
33 let _principal = require_scope(&parts, "list_comments", TokenScope::Write)?;
34
35 let page = p.page.unwrap_or(1).max(1);
36 let per_page: i64 = 20;
37 let offset: i64 = (page as i64 - 1) * per_page;
38
39 let client = get_conn().await.map_err(|e| internal(e, "db connection"))?;
40
41 let (total, rows) = match p.status.as_deref() {
42 Some(s) if !s.is_empty() => {
43 let total: i64 = client
44 .query_one(
45 "SELECT COUNT(*) FROM comments WHERE status = $1 AND deleted_at IS NULL",
46 &[&s],
47 )
48 .await
49 .map_err(|e| internal(e, "count comments"))?
50 .get(0);
51 let rows = client
52 .query(
53 "SELECT c.id, c.post_id, c.parent_id, c.depth, c.author_name, \
54 c.author_email, c.author_url, c.content_md, c.status, c.created_at, \
55 p.title as post_title, p.slug as post_slug \
56 FROM comments c JOIN posts p ON c.post_id = p.id \
57 WHERE c.status = $1 AND c.deleted_at IS NULL \
58 ORDER BY c.created_at DESC LIMIT $2 OFFSET $3",
59 &[&s, &per_page, &offset],
60 )
61 .await
62 .map_err(|e| internal(e, "query comments"))?;
63 (total, rows)
64 }
65 _ => {
66 let total: i64 = client
67 .query_one(
68 "SELECT COUNT(*) FROM comments WHERE deleted_at IS NULL",
69 &[],
70 )
71 .await
72 .map_err(|e| internal(e, "count comments"))?
73 .get(0);
74 let rows = client
75 .query(
76 "SELECT c.id, c.post_id, c.parent_id, c.depth, c.author_name, \
77 c.author_email, c.author_url, c.content_md, c.status, c.created_at, \
78 p.title as post_title, p.slug as post_slug \
79 FROM comments c JOIN posts p ON c.post_id = p.id \
80 WHERE c.deleted_at IS NULL \
81 ORDER BY c.created_at DESC LIMIT $1 OFFSET $2",
82 &[&per_page, &offset],
83 )
84 .await
85 .map_err(|e| internal(e, "query comments"))?;
86 (total, rows)
87 }
88 };
89
90 let comments: Vec<CommentItem> = rows
91 .iter()
92 .map(|r| CommentItem {
93 id: r.get("id"),
94 post_id: r.get("post_id"),
95 post_title: r.get("post_title"),
96 post_slug: r.get("post_slug"),
97 parent_id: r.get("parent_id"),
98 depth: r.get("depth"),
99 author_name: r.get("author_name"),
100 author_url: r.get("author_url"),
101 content_md: r.get("content_md"),
102 status: r.get("status"),
103 created_at: r
104 .get::<_, chrono::DateTime<chrono::Utc>>("created_at")
105 .to_rfc3339(),
106 })
107 .collect();
108
109 ok_json(CommentsList {
110 comments,
111 total,
112 page,
113 per_page,
114 })
115 }
116
117 #[tool(description = "通过指定评论。同时递归通过所有 pending 的祖先评论,确保嵌套链可见。")]
119 async fn approve_comment(
120 &self,
121 Parameters(p): Parameters<CommentIdParams>,
122 Extension(parts): Extension<http::request::Parts>,
123 ) -> Result<CallToolResult, McpError> {
124 let _principal = require_scope(&parts, "approve_comment", TokenScope::Write)?;
125
126 let client = get_conn().await.map_err(|e| internal(e, "db connection"))?;
127
128 let row = client
129 .query_opt(
130 "SELECT post_id FROM comments WHERE id = $1 AND deleted_at IS NULL",
131 &[&p.comment_id],
132 )
133 .await
134 .map_err(|e| internal(e, "select comment"))?;
135 let post_id: i32 = match row {
136 Some(r) => r.get(0),
137 None => {
138 return Err(McpError::invalid_request("评论不存在", None));
139 }
140 };
141
142 client
144 .execute(
145 "UPDATE comments SET status = 'approved', approved_at = NOW() WHERE id = $1",
146 &[&p.comment_id],
147 )
148 .await
149 .map_err(|e| internal(e, "approve comment"))?;
150
151 client
153 .execute(
154 "WITH RECURSIVE ancestors AS ( \
155 SELECT parent_id FROM comments WHERE id = $1 \
156 UNION ALL \
157 SELECT c.parent_id FROM comments c JOIN ancestors a ON c.id = a.parent_id WHERE a.parent_id IS NOT NULL \
158 ) \
159 UPDATE comments SET status = 'approved', approved_at = NOW() \
160 WHERE id IN (SELECT parent_id FROM ancestors WHERE parent_id IS NOT NULL) AND status = 'pending'",
161 &[&p.comment_id],
162 )
163 .await
164 .map_err(|e| internal(e, "approve ancestors"))?;
165
166 cache::invalidate_comments_by_post(post_id).await;
167 cache::invalidate_pending_count().await;
168
169 ok_json(CommentResult {
170 success: true,
171 message: "已通过".into(),
172 })
173 }
174
175 #[tool(description = "删除指定评论(移入回收站,软删除)。")]
177 async fn delete_comment(
178 &self,
179 Parameters(p): Parameters<CommentIdParams>,
180 Extension(parts): Extension<http::request::Parts>,
181 ) -> Result<CallToolResult, McpError> {
182 let _principal = require_scope(&parts, "delete_comment", TokenScope::Write)?;
183
184 let client = get_conn().await.map_err(|e| internal(e, "db connection"))?;
185
186 let row = client
187 .query_opt(
188 "SELECT post_id FROM comments WHERE id = $1 AND deleted_at IS NULL",
189 &[&p.comment_id],
190 )
191 .await
192 .map_err(|e| internal(e, "select comment"))?;
193 if let Some(r) = row {
194 let post_id: i32 = r.get(0);
195 client
196 .execute(
197 "UPDATE comments SET status = 'trash', deleted_at = NOW() WHERE id = $1",
198 &[&p.comment_id],
199 )
200 .await
201 .map_err(|e| internal(e, "trash comment"))?;
202 cache::invalidate_comments_by_post(post_id).await;
203 cache::invalidate_pending_count().await;
204 }
205
206 ok_json(CommentResult {
207 success: true,
208 message: "已删除".into(),
209 })
210 }
211
212 #[tool(description = "设置评论审核状态。status 可选 approved/spam/trash。trash 会软删除。")]
214 async fn set_comment_status(
215 &self,
216 Parameters(p): Parameters<SetCommentStatusParams>,
217 Extension(parts): Extension<http::request::Parts>,
218 ) -> Result<CallToolResult, McpError> {
219 let _principal = require_scope(&parts, "set_comment_status", TokenScope::Write)?;
220
221 let normalized = p.status.trim().to_lowercase();
222 if !matches!(normalized.as_str(), "approved" | "spam" | "trash") {
223 return Err(McpError::invalid_request(
224 "status must be one of: approved, spam, trash",
225 None,
226 ));
227 }
228
229 let client = get_conn().await.map_err(|e| internal(e, "db connection"))?;
230
231 let row = client
232 .query_opt(
233 "SELECT post_id, status FROM comments WHERE id = $1 AND deleted_at IS NULL",
234 &[&p.comment_id],
235 )
236 .await
237 .map_err(|e| internal(e, "select comment"))?;
238 match row {
239 Some(r) => {
240 let post_id: i32 = r.get(0);
241 let old_status: String = r.get(1);
242
243 match normalized.as_str() {
244 "approved" => {
245 client
246 .execute(
247 "UPDATE comments SET status = 'approved', approved_at = NOW() WHERE id = $1",
248 &[&p.comment_id],
249 )
250 .await
251 .map_err(|e| internal(e, "set approved"))?;
252 }
253 "spam" => {
254 client
255 .execute(
256 "UPDATE comments SET status = 'spam' WHERE id = $1 AND deleted_at IS NULL",
257 &[&p.comment_id],
258 )
259 .await
260 .map_err(|e| internal(e, "set spam"))?;
261 }
262 "trash" => {
263 client
264 .execute(
265 "UPDATE comments SET status = 'trash', deleted_at = NOW() WHERE id = $1",
266 &[&p.comment_id],
267 )
268 .await
269 .map_err(|e| internal(e, "set trash"))?;
270 }
271 _ => unreachable!("validated above"),
272 }
273
274 if old_status == "approved" || normalized == "approved" {
276 cache::invalidate_comments_by_post(post_id).await;
277 }
278 cache::invalidate_pending_count().await;
279 }
280 None => {
281 return Err(McpError::invalid_request("评论不存在", None));
282 }
283 }
284
285 ok_json(CommentResult {
286 success: true,
287 message: format!("状态已设为 {normalized}"),
288 })
289 }
290}
291
292#[derive(Debug, Deserialize, schemars::JsonSchema)]
297pub struct ListCommentsParams {
298 #[serde(default)]
300 pub page: Option<i32>,
301 #[serde(default)]
303 pub status: Option<String>,
304}
305
306#[derive(Debug, Deserialize, schemars::JsonSchema)]
307pub struct CommentIdParams {
308 pub comment_id: i64,
310}
311
312#[derive(Debug, Deserialize, schemars::JsonSchema)]
313pub struct SetCommentStatusParams {
314 pub comment_id: i64,
316 pub status: String,
318}
319
320#[derive(Debug, serde::Serialize)]
321struct CommentItem {
322 id: i64,
323 post_id: i32,
324 post_title: String,
325 post_slug: String,
326 parent_id: Option<i64>,
327 depth: i32,
328 author_name: String,
329 author_url: Option<String>,
330 content_md: String,
331 status: String,
332 created_at: String,
333}
334
335#[derive(Debug, serde::Serialize)]
336struct CommentsList {
337 comments: Vec<CommentItem>,
338 total: i64,
339 page: i32,
340 per_page: i64,
341}
342
343#[derive(Debug, serde::Serialize)]
344struct CommentResult {
345 success: bool,
346 message: String,
347}