Skip to main content

yggdrasil/mcp/tools/
comments.rs

1//! MCP 写作用域工具:评论审核管理。
2//!
3//! 镜像 `src/api/comments/{list,update}.rs` 的 server-fn 逻辑,
4//! 但用 bearer-token 鉴权,不走 cookie。
5//! 状态变更后执行与 web 后台一致的评论缓存失效。
6//!
7//! 本模块仅 `feature = "server"` 编译。
8
9#![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    /// 列出评论(分页,可按状态筛选)。要求 write 作用域。
25    #[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    /// 通过指定评论(同时递归通过所有 pending 祖先评论)。要求 write 作用域。
118    #[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        // 通过目标评论。
143        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        // 递归向上查找所有 pending 父评论并同步通过。
152        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    /// 删除指定评论(软删除:设置 deleted_at 与 status=trash)。要求 write 作用域。
176    #[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    /// 设置评论状态(approved/spam/trash)。要求 write 作用域。
213    #[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                // 与 web 后台一致:仅当旧状态是 approved 时需失效评论列表缓存。
275                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// ---------------------------------------------------------------------------
293// 参数与输出结构
294// ---------------------------------------------------------------------------
295
296#[derive(Debug, Deserialize, schemars::JsonSchema)]
297pub struct ListCommentsParams {
298    /// 页码(从 1 开始,默认 1)。
299    #[serde(default)]
300    pub page: Option<i32>,
301    /// 按状态筛选:pending / approved / spam / trash。不传则返回全部。
302    #[serde(default)]
303    pub status: Option<String>,
304}
305
306#[derive(Debug, Deserialize, schemars::JsonSchema)]
307pub struct CommentIdParams {
308    /// 评论 id。
309    pub comment_id: i64,
310}
311
312#[derive(Debug, Deserialize, schemars::JsonSchema)]
313pub struct SetCommentStatusParams {
314    /// 评论 id。
315    pub comment_id: i64,
316    /// 目标状态:approved / spam / trash。
317    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}