Skip to main content

yggdrasil/mcp/tools/
posts.rs

1//! MCP 写作用域工具:文章 CRUD。
2//!
3//! 镜像 `src/api/posts/{create,update,trash,delete}.rs` 的 server-fn 逻辑,
4//! 但用 bearer-token 鉴权(`principal.user_id` 作 author_id),不走 cookie。
5//! 每个写操作后执行与 web 后台完全一致的缓存失效(moka + SSR)。
6//!
7//! 本模块仅 `feature = "server"` 编译;`server.rs` 在最终装配时把 `posts_router`
8//! 组合进单一 `ServerHandler`。
9
10#![cfg(feature = "server")]
11#![allow(clippy::too_many_arguments)]
12
13use rmcp::handler::server::tool::Extension;
14use rmcp::handler::server::wrapper::Parameters;
15use rmcp::model::CallToolResult;
16use rmcp::{schemars, tool, tool_router, ErrorData as McpError};
17use serde::Deserialize;
18
19use super::common::{internal, ok_json, require_scope};
20use crate::cache;
21use crate::db::pool::get_conn;
22use crate::models::mcp_token::TokenScope;
23use crate::models::post::PostStatus;
24use crate::ssr_cache;
25
26// ---------------------------------------------------------------------------
27// 结构体
28// ---------------------------------------------------------------------------
29
30// ---------------------------------------------------------------------------
31// 工具
32// ---------------------------------------------------------------------------
33
34#[tool_router(router = posts_router, vis = "pub")]
35impl crate::mcp::server::YggMcpServer {
36    #[tool(
37        description = "分页查询当前令牌用户的文章(含草稿)。需要 write 权限;status 可选 draft/published,省略返回全部未删除文章。query 按标题搜索,page 从 1 开始,per_page 为 1..=50。"
38    )]
39    async fn list_posts(
40        &self,
41        Parameters(p): Parameters<ListPostsParams>,
42        Extension(parts): Extension<http::request::Parts>,
43    ) -> Result<CallToolResult, McpError> {
44        let principal = require_scope(&parts, "list_posts", TokenScope::Write)?;
45        list_owned_posts(principal.user_id, p, false).await
46    }
47
48    #[tool(
49        description = "按 ID 读取当前令牌用户的文章,包含草稿、Markdown 正文、封面、标签、状态和 updated_at。需要 write 权限,不读取回收站文章。"
50    )]
51    async fn get_post_by_id(
52        &self,
53        Parameters(p): Parameters<PostIdParams>,
54        Extension(parts): Extension<http::request::Parts>,
55    ) -> Result<CallToolResult, McpError> {
56        let principal = require_scope(&parts, "get_post_by_id", TokenScope::Write)?;
57        let client = get_conn().await.map_err(|e| internal(e, "db connection"))?;
58        let row = client.query_opt(
59            "SELECT p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.content_html,
60                p.toc_html, p.status, p.published_at, p.created_at, p.updated_at, p.cover_image,
61                p.word_count, p.reading_time,
62                COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags
63                FROM posts p LEFT JOIN post_tags pt ON p.id = pt.post_id
64                LEFT JOIN tags t ON pt.tag_id = t.id
65                WHERE p.id = $1 AND p.author_id = $2 AND p.deleted_at IS NULL GROUP BY p.id",
66            &[&p.post_id, &principal.user_id],
67        ).await.map_err(|e| internal(e, "read post"))?;
68        let row = row.ok_or_else(|| McpError::invalid_request("文章不存在或无权限", None))?;
69        let post = crate::api::posts::helpers::row_to_post_full(&row)
70            .await
71            .map_err(|e| internal(format!("{e:?}"), "read post"))?;
72        ok_json(post)
73    }
74
75    #[tool(
76        description = "分页查询当前令牌用户的回收站文章,按删除时间倒序。支持 status、query、page、per_page,与 list_posts 相同。需要 write 权限。"
77    )]
78    async fn list_trashed_posts(
79        &self,
80        Parameters(p): Parameters<ListPostsParams>,
81        Extension(parts): Extension<http::request::Parts>,
82    ) -> Result<CallToolResult, McpError> {
83        let principal = require_scope(&parts, "list_trashed_posts", TokenScope::Write)?;
84        list_owned_posts(principal.user_id, p, true).await
85    }
86
87    #[tool(
88        description = "恢复当前令牌用户的回收站文章,保留原发布状态。slug 被占用时自动追加数字后缀,返回恢复后的 slug。需要 write 权限。"
89    )]
90    async fn restore_post(
91        &self,
92        Parameters(p): Parameters<PostIdParams>,
93        Extension(parts): Extension<http::request::Parts>,
94    ) -> Result<CallToolResult, McpError> {
95        let principal = require_scope(&parts, "restore_post", TokenScope::Write)?;
96        let result = crate::api::posts::restore_post_impl(p.post_id, Some(principal.user_id))
97            .await
98            .map_err(|e| internal(e, "restore post"))?;
99        ok_json(result)
100    }
101
102    #[tool(
103        description = "创建一篇新文章。渲染 Markdown 为 HTML,同步标签与素材引用。返回 post_id/slug。"
104    )]
105    async fn create_post(
106        &self,
107        Parameters(p): Parameters<CreatePostParams>,
108        Extension(parts): Extension<http::request::Parts>,
109    ) -> Result<CallToolResult, McpError> {
110        let principal = require_scope(&parts, "create_post", TokenScope::Write)?;
111
112        if p.title.trim().is_empty() {
113            return Err(McpError::invalid_request("title must not be empty", None));
114        }
115        if p.content_md.trim().is_empty() {
116            return Err(McpError::invalid_request(
117                "content_md must not be empty",
118                None,
119            ));
120        }
121
122        // 确定基础 slug。
123        let base_slug = match &p.slug {
124            Some(s) if !s.trim().is_empty() => {
125                let s = s.trim();
126                if !crate::api::slug::is_valid_slug(s) {
127                    return Err(McpError::invalid_request(
128                        "slug 格式无效,只能包含字母、数字、连字符和下划线",
129                        None,
130                    ));
131                }
132                s.to_string()
133            }
134            _ => crate::api::slug::slugify(&p.title),
135        };
136
137        // Markdown 渲染 + 度量派生收敛到 helper(R4)。
138        let fields = crate::api::posts::helpers::render_post_fields(
139            &p.content_md,
140            &p.status,
141            p.cover_image.as_deref(),
142        )
143        .await
144        .map_err(|_| internal("markdown render", "render_post_fields"))?;
145        let summary = p
146            .summary
147            .as_deref()
148            .map(str::trim)
149            .filter(|s| !s.is_empty())
150            .map(|s| s.to_string())
151            .unwrap_or(fields.auto_summary);
152        let explicit_published_at = match &p.published_at {
153            Some(s) if !s.trim().is_empty() => {
154                let Some(dt) = parse_date_opt(s) else {
155                    return Err(McpError::invalid_request(
156                        "published_at 格式无效,支持 YYYY-MM-DD 或 ISO 8601",
157                        None,
158                    ));
159                };
160                Some(dt)
161            }
162            _ => None,
163        };
164        let published_at = if fields.status == PostStatus::Published {
165            explicit_published_at.or_else(|| Some(chrono::Utc::now()))
166        } else {
167            None
168        };
169        let created_at = explicit_published_at.unwrap_or_else(chrono::Utc::now);
170
171        let mut client = get_conn().await.map_err(|e| internal(e, "db connection"))?;
172        let tx = client
173            .transaction()
174            .await
175            .map_err(|e| internal(e, "begin txn"))?;
176
177        let final_slug = crate::api::slug::ensure_unique_slug(&tx, &base_slug, None)
178            .await
179            .map_err(|e| internal(e, "ensure_unique_slug"))?;
180
181        let row = tx
182            .query_one(
183                "INSERT INTO posts (author_id, title, slug, summary, content_md, content_html, toc_html, status, published_at, cover_image, word_count, reading_time, created_at)
184                 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
185                 RETURNING id",
186                &[
187                    &principal.user_id,
188                    &p.title.trim(),
189                    &final_slug,
190                    &summary,
191                    &p.content_md,
192                    &fields.content_html,
193                    &fields.toc_html,
194                    &fields.status.as_str(),
195                    &published_at,
196                    &fields.cover_image,
197                    &fields.word_count,
198                    &fields.reading_time,
199                    &created_at,
200                ],
201            )
202            .await
203            .map_err(|e| internal(e, "insert post"))?;
204        let post_id: i32 = row.get(0);
205
206        let tags_cleaned = crate::api::posts::helpers::clean_tags(&p.tags);
207        crate::api::posts::helpers::sync_tags(&tx, post_id, &tags_cleaned)
208            .await
209            .map_err(|_| internal("tag sync", "sync_tags"))?;
210        crate::api::posts::helpers::sync_asset_refs(
211            &tx,
212            post_id,
213            &fields.content_html,
214            fields.cover_image.as_deref(),
215        )
216        .await
217        .map_err(|_| internal("asset_refs sync", "sync_asset_refs"))?;
218
219        tx.commit().await.map_err(|e| internal(e, "commit"))?;
220
221        // 与 web 后台一致的缓存失效(moka + SSR)。
222        cache::invalidate_for_post_write(std::slice::from_ref(&final_slug), &tags_cleaned).await;
223
224        ok_json(PostResult {
225            success: true,
226            message: "创建成功".into(),
227            post_id: Some(post_id),
228            slug: Some(final_slug),
229        })
230    }
231
232    /// 更新指定文章(PATCH 语义:仅更新提供的字段)。要求 write 作用域。
233    /// 仅文章原作者可更新。
234    #[tool(
235        description = "部分更新一篇已有文章(PATCH 语义)。仅更新提供的字段:未提供 content_md 时跳过重新渲染;未提供 summary 时随 content_md 联动(自动提取或保留旧值)。仅文章原作者可更新。可传 expected_updated_at(RFC 3339)检测冲突;成功返回新 updated_at。"
236    )]
237    async fn update_post(
238        &self,
239        Parameters(p): Parameters<UpdatePostParams>,
240        Extension(parts): Extension<http::request::Parts>,
241    ) -> Result<CallToolResult, McpError> {
242        let principal = require_scope(&parts, "update_post", TokenScope::Write)?;
243
244        use tokio_postgres::types::ToSql;
245        let expected_updated_at = parse_expected_updated_at(p.expected_updated_at.as_deref())?;
246
247        // 至少一个可更新字段。
248        let any_change = p.title.is_some()
249            || p.content_md.is_some()
250            || p.summary.is_some()
251            || p.slug.is_some()
252            || p.tags.is_some()
253            || p.status.is_some()
254            || p.cover_image.is_some()
255            || p.published_at.is_some();
256        if !any_change {
257            return Err(McpError::invalid_request("至少提供一个可更新字段", None));
258        }
259        // 提供时的非空校验。
260        if matches!(&p.title, Some(t) if t.trim().is_empty()) {
261            return Err(McpError::invalid_request("title must not be empty", None));
262        }
263        if matches!(&p.content_md, Some(c) if c.trim().is_empty()) {
264            return Err(McpError::invalid_request(
265                "content_md must not be empty",
266                None,
267            ));
268        }
269
270        let mut client = get_conn().await.map_err(|e| internal(e, "db connection"))?;
271        let tx = client
272            .transaction()
273            .await
274            .map_err(|e| internal(e, "begin txn"))?;
275
276        // 校验存在、未删除、归属,并取旧值(slug/status/published_at/cover)。
277        let old_row = tx
278            .query_opt(
279                "SELECT slug, status, published_at, cover_image, updated_at FROM posts \
280                 WHERE id = $1 AND author_id = $2 AND deleted_at IS NULL FOR UPDATE",
281                &[&p.post_id, &principal.user_id],
282            )
283            .await
284            .map_err(|e| internal(e, "select post"))?;
285        let Some(old_row) = old_row else {
286            return Err(McpError::invalid_request("文章不存在或无权限", None));
287        };
288        let current_updated_at: chrono::DateTime<chrono::Utc> = old_row.get("updated_at");
289        if expected_updated_at.is_some_and(|expected| expected != current_updated_at) {
290            return Err(McpError::invalid_request(
291                "conflict: 文章已被修改,请重新读取后合并更改",
292                Some(
293                    serde_json::json!({"code": "conflict", "current_updated_at": current_updated_at}),
294                ),
295            ));
296        }
297        let old_slug: String = old_row.get(0);
298        let old_status: String = old_row.get(1);
299        let old_published_at: Option<chrono::DateTime<chrono::Utc>> = old_row.get(2);
300        let old_cover: Option<String> = old_row.get(3);
301
302        // 渲染(仅当 content_md 提供时)。status/cover 用新值或回退旧值。
303        let rendered: Option<crate::api::posts::helpers::RenderedFields> = match &p.content_md {
304            Some(md) => {
305                let status_for_render = p.status.as_deref().unwrap_or(&old_status);
306                let cover_for_render: Option<&str> = p
307                    .cover_image
308                    .as_deref()
309                    .map(str::trim)
310                    .filter(|s| !s.is_empty())
311                    .or(old_cover.as_deref());
312                Some(
313                    crate::api::posts::helpers::render_post_fields(
314                        md,
315                        status_for_render,
316                        cover_for_render,
317                    )
318                    .await
319                    .map_err(|_| internal("markdown render", "render_post_fields"))?,
320                )
321            }
322            None => None,
323        };
324
325        // summary 决策(随 content_md 联动):
326        //  提供了 summary → 用之(空则回退自动提取);未提供但 content_md 变了 → 自动提取;都未变 → 不动。
327        let summary_value: Option<String> = match (&p.summary, &rendered) {
328            (Some(s), r) => {
329                let t = s.trim();
330                if t.is_empty() {
331                    r.as_ref().map(|f| f.auto_summary.clone())
332                } else {
333                    Some(t.to_string())
334                }
335            }
336            (None, Some(f)) => Some(f.auto_summary.clone()),
337            (None, None) => None,
338        };
339
340        // slug 决策(仅显式提供才动;空串视为未提供)。
341        let new_slug: Option<String> = match &p.slug {
342            Some(s) => {
343                let t = s.trim();
344                if t.is_empty() {
345                    None
346                } else if !crate::api::slug::is_valid_slug(t) {
347                    return Err(McpError::invalid_request("slug 格式无效", None));
348                } else {
349                    Some(t.to_string())
350                }
351            }
352            None => None,
353        };
354        let final_slug: Option<String> = match new_slug {
355            Some(base) => Some(
356                crate::api::slug::ensure_unique_slug(&tx, &base, Some(p.post_id))
357                    .await
358                    .map_err(|e| internal(e, "ensure_unique_slug"))?,
359            ),
360            None => None,
361        };
362
363        // published_at 显式指定或依据 status 转换联动。
364        let explicit_published_at = match &p.published_at {
365            Some(s) if !s.trim().is_empty() => {
366                let Some(dt) = parse_date_opt(s) else {
367                    return Err(McpError::invalid_request(
368                        "published_at 格式无效,支持 YYYY-MM-DD 或 ISO 8601",
369                        None,
370                    ));
371                };
372                Some(Some(dt))
373            }
374            Some(_) => Some(None), // 空串清空 published_at
375            None => None,
376        };
377
378        // status + published_at 决策(首发 published 填 published_at;转 draft 保留旧值)。
379        let new_status: Option<PostStatus> = p
380            .status
381            .as_deref()
382            .map(|s| PostStatus::from_str(s).unwrap_or(PostStatus::Draft));
383        let published_at: Option<Option<chrono::DateTime<chrono::Utc>>> =
384            if let Some(epa) = explicit_published_at {
385                Some(epa)
386            } else {
387                match &new_status {
388                    Some(PostStatus::Published) => Some(if old_status == "published" {
389                        old_published_at
390                    } else {
391                        Some(chrono::Utc::now())
392                    }),
393                    Some(PostStatus::Draft) => Some(old_published_at),
394                    None => None,
395                }
396            };
397
398        // cover 决策(空串 → 清空 None)。
399        let new_cover: Option<String> = p
400            .cover_image
401            .as_deref()
402            .map(str::trim)
403            .filter(|s| !s.is_empty())
404            .map(|s| s.to_string());
405        let cover_changed = p.cover_image.is_some();
406
407        // 动态构建 UPDATE(仅 SET 提供的字段)。
408        let mut sets: Vec<String> = Vec::new();
409        let mut params: Vec<Box<dyn ToSql + Sync + Send>> = Vec::new();
410        let mut idx = 1usize;
411        macro_rules! push {
412            ($col:expr, $val:expr) => {{
413                sets.push(format!("{} = ${}", $col, idx));
414                params.push(Box::new($val));
415                idx += 1;
416            }};
417        }
418        if let Some(t) = &p.title {
419            push!("title", t.trim().to_string());
420        }
421        if let Some(c) = &p.content_md {
422            push!("content_md", c.clone());
423        }
424        if let Some(f) = &rendered {
425            push!("content_html", f.content_html.clone());
426            push!("toc_html", f.toc_html.clone());
427            push!("word_count", f.word_count);
428            push!("reading_time", f.reading_time);
429        }
430        if let Some(s) = &summary_value {
431            push!("summary", s.clone());
432        }
433        if let Some(s) = &final_slug {
434            push!("slug", s.clone());
435        }
436        if let Some(st) = &new_status {
437            push!("status", st.as_str().to_string());
438        }
439        if let Some(pa) = published_at {
440            push!("published_at", pa);
441            if let Some(dt) = pa {
442                push!("created_at", dt);
443            }
444        }
445        if cover_changed {
446            push!("cover_image", new_cover.clone());
447        }
448        sets.push(
449            "updated_at = GREATEST(clock_timestamp(), updated_at + INTERVAL '1 microsecond')"
450                .to_string(),
451        );
452
453        let sql = format!(
454            "UPDATE posts SET {} WHERE id = ${} RETURNING updated_at",
455            sets.join(", "),
456            idx
457        );
458        params.push(Box::new(p.post_id));
459        let refs: Vec<&(dyn ToSql + Sync)> = params
460            .iter()
461            .map(|b| b.as_ref() as &(dyn ToSql + Sync))
462            .collect();
463        let updated = tx
464            .query_one(&sql, &refs)
465            .await
466            .map_err(|e| internal(e, "update post"))?;
467        let updated_at: chrono::DateTime<chrono::Utc> = updated.get("updated_at");
468
469        // 标签同步(仅当 tags 提供时)。先取旧标签供缓存失效,再完全替换。
470        let tags_changed = p.tags.is_some();
471        let mut old_tags: Vec<String> = Vec::new();
472        if tags_changed {
473            old_tags = crate::api::posts::helpers::fetch_post_tags(&tx, p.post_id)
474                .await
475                .map_err(|_| internal("select old tags", "fetch_post_tags"))?;
476            let tags_cleaned = crate::api::posts::helpers::clean_tags(p.tags.as_ref().unwrap());
477            tx.execute("DELETE FROM post_tags WHERE post_id = $1", &[&p.post_id])
478                .await
479                .map_err(|e| internal(e, "delete old post_tags"))?;
480            crate::api::posts::helpers::sync_tags(&tx, p.post_id, &tags_cleaned)
481                .await
482                .map_err(|_| internal("tag sync", "sync_tags"))?;
483        }
484
485        // 素材引用同步(content_html 或 cover 变了)。
486        if rendered.is_some() || cover_changed {
487            let content_html: String = match &rendered {
488                Some(f) => f.content_html.clone(),
489                None => tx
490                    .query_one(
491                        "SELECT content_html FROM posts WHERE id = $1",
492                        &[&p.post_id],
493                    )
494                    .await
495                    .map_err(|e| internal(e, "select content_html"))?
496                    .get::<_, String>(0),
497            };
498            let cover_for_sync = new_cover.as_deref().or(old_cover.as_deref());
499            crate::api::posts::helpers::sync_asset_refs(
500                &tx,
501                p.post_id,
502                &content_html,
503                cover_for_sync,
504            )
505            .await
506            .map_err(|_| internal("asset_refs sync", "sync_asset_refs"))?;
507        }
508
509        tx.commit().await.map_err(|e| internal(e, "commit"))?;
510
511        // 缓存失效(moka + SSR)。
512        let effective_slug = final_slug.clone().unwrap_or_else(|| old_slug.clone());
513
514        cache::invalidate_post_metadata();
515        cache::invalidate_post_by_slug(&effective_slug).await;
516
517        if let Some(new) = &final_slug {
518            if new != &old_slug {
519                cache::invalidate_post_by_slug(&old_slug).await;
520                ssr_cache::invalidate_ssr_route(&format!("/post/{old_slug}"));
521                ssr_cache::invalidate_post_preview(&old_slug);
522            }
523        }
524        ssr_cache::invalidate_ssr_route(&format!("/post/{effective_slug}"));
525        ssr_cache::invalidate_post_preview(&effective_slug);
526        ssr_cache::invalidate_ssr_all_public();
527        ssr_cache::bump_global_generation();
528
529        if tags_changed {
530            let new_tags = crate::api::posts::helpers::clean_tags(p.tags.as_ref().unwrap());
531            let mut all: std::collections::HashSet<String> = old_tags.into_iter().collect();
532            all.extend(new_tags);
533            let all_tags: Vec<String> = all.into_iter().collect();
534            cache::invalidate_tag_posts_for(&all_tags).await;
535        }
536
537        ok_json(serde_json::json!({
538            "success": true, "message": "更新成功", "post_id": p.post_id,
539            "slug": effective_slug, "updated_at": updated_at,
540        }))
541    }
542
543    /// 发布指定文章(设置 status=published 与 published_at)。要求 write 作用域。
544    #[tool(
545        description = "发布一篇草稿文章。设置 status=published,若首次发布则填充 published_at。"
546    )]
547    async fn publish_post(
548        &self,
549        Parameters(p): Parameters<PostIdParams>,
550        Extension(parts): Extension<http::request::Parts>,
551    ) -> Result<CallToolResult, McpError> {
552        let principal = require_scope(&parts, "publish_post", TokenScope::Write)?;
553
554        let client = get_conn().await.map_err(|e| internal(e, "db connection"))?;
555
556        // 校验存在、未删除、归属当前用户,并取 slug 用于缓存失效。
557        let row = client
558            .query_opt(
559                "SELECT slug FROM posts WHERE id = $1 AND author_id = $2 AND deleted_at IS NULL",
560                &[&p.post_id, &principal.user_id],
561            )
562            .await
563            .map_err(|e| internal(e, "select post"))?;
564        let slug: String = match row {
565            Some(r) => r.get(0),
566            None => {
567                return Err(McpError::invalid_request("文章不存在或无权限", None));
568            }
569        };
570
571        // M6 修复:发布后文章出现在公开标签列表页,须失效标签缓存(api update.rs:202
572        // 会失效,此 MCP 路径此前漏掉 → 标签页新发文陈旧 ≤120s)。
573        let tags = crate::api::posts::helpers::fetch_post_tags(&client, p.post_id)
574            .await
575            .map_err(|_| internal("select tags", "select tags"))?;
576
577        let result = client
578            .execute(
579                "UPDATE posts SET status = 'published', \
580                 published_at = COALESCE(published_at, NOW()), updated_at = NOW() \
581                 WHERE id = $1 AND deleted_at IS NULL",
582                &[&p.post_id],
583            )
584            .await
585            .map_err(|e| internal(e, "publish post"))?;
586        if result == 0 {
587            return Err(McpError::invalid_request("文章不存在", None));
588        }
589
590        // 发布后失效文章详情、列表、标签与 SSR 缓存(moka + SSR)。
591        cache::invalidate_for_post_write(std::slice::from_ref(&slug), &tags).await;
592
593        ok_json(PostResult {
594            success: true,
595            message: "发布成功".into(),
596            post_id: Some(p.post_id),
597            slug: Some(slug),
598        })
599    }
600
601    /// 将指定文章移入回收站(软删除:设置 deleted_at)。要求 write 作用域。
602    #[tool(description = "将文章移入回收站(软删除)。可通过恢复操作还原。")]
603    async fn trash_post(
604        &self,
605        Parameters(p): Parameters<PostIdParams>,
606        Extension(parts): Extension<http::request::Parts>,
607    ) -> Result<CallToolResult, McpError> {
608        let principal = require_scope(&parts, "trash_post", TokenScope::Write)?;
609
610        let mut client = get_conn().await.map_err(|e| internal(e, "db connection"))?;
611        let tx = client
612            .transaction()
613            .await
614            .map_err(|e| internal(e, "begin txn"))?;
615
616        let slug_row = tx
617            .query_opt(
618                "SELECT slug FROM posts WHERE id = $1 AND author_id = $2 AND deleted_at IS NULL FOR UPDATE",
619                &[&p.post_id, &principal.user_id],
620            )
621            .await
622            .map_err(|e| internal(e, "select post"))?;
623        let Some(slug_row) = slug_row else {
624            return Err(McpError::invalid_request("文章不存在", None));
625        };
626        let slug: String = slug_row.get(0);
627
628        let tags = crate::api::posts::helpers::fetch_post_tags(&tx, p.post_id)
629            .await
630            .map_err(|_| internal("select tags", "select tags"))?;
631
632        let result = tx
633            .execute(
634                "UPDATE posts SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL",
635                &[&p.post_id],
636            )
637            .await
638            .map_err(|e| internal(e, "soft delete"))?;
639        if result == 0 {
640            return Err(McpError::invalid_request("文章不存在", None));
641        }
642
643        tx.commit().await.map_err(|e| internal(e, "commit"))?;
644
645        // 移入回收站后失效相关缓存(moka + SSR)。
646        cache::invalidate_for_post_write(std::slice::from_ref(&slug), &tags).await;
647
648        ok_json(PostResult {
649            success: true,
650            message: "已移入回收站".into(),
651            post_id: Some(p.post_id),
652            slug: Some(slug),
653        })
654    }
655
656    /// 彻底删除指定文章(物理删除,不可恢复)。要求 write 作用域。
657    #[tool(
658        description = "彻底删除文章(物理删除,不可恢复)。post_tags 关联因外键 CASCADE 自动清理。"
659    )]
660    async fn delete_post(
661        &self,
662        Parameters(p): Parameters<PostIdParams>,
663        Extension(parts): Extension<http::request::Parts>,
664    ) -> Result<CallToolResult, McpError> {
665        let principal = require_scope(&parts, "delete_post", TokenScope::Write)?;
666
667        let mut client = get_conn().await.map_err(|e| internal(e, "db connection"))?;
668        let tx = client
669            .transaction()
670            .await
671            .map_err(|e| internal(e, "begin txn"))?;
672
673        let slug_row = tx
674            .query_opt(
675                "SELECT slug FROM posts WHERE id = $1 AND author_id = $2 FOR UPDATE",
676                &[&p.post_id, &principal.user_id],
677            )
678            .await
679            .map_err(|e| internal(e, "select post"))?;
680        let Some(slug_row) = slug_row else {
681            return Err(McpError::invalid_request("文章不存在", None));
682        };
683        let slug: String = slug_row.get(0);
684
685        let tags = crate::api::posts::helpers::fetch_post_tags(&tx, p.post_id)
686            .await
687            .map_err(|_| internal("select tags", "select tags"))?;
688
689        let result = tx
690            .execute("DELETE FROM posts WHERE id = $1", &[&p.post_id])
691            .await
692            .map_err(|e| internal(e, "hard delete"))?;
693        if result == 0 {
694            return Err(McpError::invalid_request("文章不存在", None));
695        }
696
697        tx.commit().await.map_err(|e| internal(e, "commit"))?;
698
699        // 彻底删除后失效相关缓存(moka + SSR)。
700        cache::invalidate_for_post_write(std::slice::from_ref(&slug), &tags).await;
701
702        ok_json(PostResult {
703            success: true,
704            message: "已彻底删除".into(),
705            post_id: Some(p.post_id),
706            slug: Some(slug),
707        })
708    }
709}
710
711// ---------------------------------------------------------------------------
712// 参数与输出结构
713// ---------------------------------------------------------------------------
714
715#[derive(Debug, Deserialize, schemars::JsonSchema)]
716pub struct CreatePostParams {
717    /// 文章标题(必填,非空)。
718    pub title: String,
719    /// Markdown 正文(必填,非空)。
720    pub content_md: String,
721    /// 摘要;未提供时自动从正文提取。
722    #[serde(default)]
723    pub summary: Option<String>,
724    /// URL slug;未提供时从标题自动生成。
725    #[serde(default)]
726    pub slug: Option<String>,
727    /// 标签列表。
728    #[serde(default)]
729    pub tags: Vec<String>,
730    /// 状态:`draft`(默认)或 `published`。
731    #[serde(default = "default_status")]
732    pub status: String,
733    /// 封面图 URL。
734    #[serde(default)]
735    pub cover_image: Option<String>,
736    /// 发布时间(ISO 8601 或 YYYY-MM-DD);仅在 status=published 时生效,未提供则使用当前时间。
737    #[serde(default)]
738    pub published_at: Option<String>,
739}
740
741#[derive(Debug, Deserialize, schemars::JsonSchema)]
742pub struct UpdatePostParams {
743    /// 上次读取的 updated_at(RFC 3339)。提供时原子校验版本,不匹配返回 conflict;省略保留原有行为。
744    #[serde(default)]
745    pub expected_updated_at: Option<String>,
746    /// 要更新的文章 id。
747    pub post_id: i32,
748    /// 新标题。未提供则不修改(且不联动 slug)。
749    #[serde(default)]
750    pub title: Option<String>,
751    /// 新 Markdown 正文。未提供则不重新渲染(content_html/toc/度量保持不变)。
752    #[serde(default)]
753    pub content_md: Option<String>,
754    /// 新摘要。content_md 变化且未提供时自动从正文提取;content_md 未变且未提供则保留旧值。
755    #[serde(default)]
756    pub summary: Option<String>,
757    /// 新 slug。未提供则不修改;提供则校验格式并自动去重。
758    #[serde(default)]
759    pub slug: Option<String>,
760    /// 新标签列表。未提供则不修改;提供空列表则清空标签(完全替换旧标签)。
761    #[serde(default)]
762    pub tags: Option<Vec<String>>,
763    /// 新状态:`draft` / `published`。未提供则不修改。
764    #[serde(default)]
765    pub status: Option<String>,
766    /// 新封面图 URL。未提供则不修改;空字符串清空封面。
767    #[serde(default)]
768    pub cover_image: Option<String>,
769    /// 发布时间(ISO 8601 或 YYYY-MM-DD);未提供则不修改。
770    #[serde(default)]
771    pub published_at: Option<String>,
772}
773
774#[derive(Debug, Deserialize, schemars::JsonSchema)]
775pub struct PostIdParams {
776    /// 文章 id。
777    pub post_id: i32,
778}
779
780#[derive(Debug, serde::Serialize)]
781struct PostResult {
782    success: bool,
783    message: String,
784    post_id: Option<i32>,
785    slug: Option<String>,
786}
787
788fn default_status() -> String {
789    "draft".to_string()
790}
791
792fn parse_expected_updated_at(
793    value: Option<&str>,
794) -> Result<Option<chrono::DateTime<chrono::Utc>>, McpError> {
795    value
796        .map(|s| {
797            chrono::DateTime::parse_from_rfc3339(s)
798                .map(|date| date.with_timezone(&chrono::Utc))
799                .map_err(|_| McpError::invalid_params("expected_updated_at must be RFC 3339", None))
800        })
801        .transpose()
802}
803
804fn parse_date_opt(s: &str) -> Option<chrono::DateTime<chrono::Utc>> {
805    let s = s.trim();
806    if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
807        return Some(dt.with_timezone(&chrono::Utc));
808    }
809    if let Ok(nd) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") {
810        if let Some(ndt) = nd.and_hms_opt(0, 0, 0) {
811            return Some(chrono::DateTime::from_naive_utc_and_offset(
812                ndt,
813                chrono::Utc,
814            ));
815        }
816    }
817    if let Ok(ndt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") {
818        return Some(chrono::DateTime::from_naive_utc_and_offset(
819            ndt,
820            chrono::Utc,
821        ));
822    }
823    None
824}
825
826#[derive(Debug, Deserialize, schemars::JsonSchema, Default)]
827pub struct ListPostsParams {
828    pub status: Option<String>,
829    pub query: Option<String>,
830    pub page: Option<i32>,
831    pub per_page: Option<i32>,
832}
833
834impl ListPostsParams {
835    fn normalize(&self) -> Result<(i32, i32, String), McpError> {
836        if self
837            .status
838            .as_deref()
839            .is_some_and(|s| !matches!(s, "draft" | "published"))
840        {
841            return Err(McpError::invalid_params(
842                "status must be draft or published",
843                None,
844            ));
845        }
846        let query: String = self
847            .query
848            .as_deref()
849            .unwrap_or("")
850            .trim()
851            .chars()
852            .take(200)
853            .collect();
854        Ok((
855            self.page.unwrap_or(1).max(1),
856            self.per_page.unwrap_or(20).clamp(1, 50),
857            format!("%{}%", crate::utils::server::escape_like_pattern(&query)),
858        ))
859    }
860}
861
862async fn list_owned_posts(
863    user_id: i32,
864    p: ListPostsParams,
865    trashed: bool,
866) -> Result<CallToolResult, McpError> {
867    let (page, per_page, pattern) = p.normalize()?;
868    let client = get_conn().await.map_err(|e| internal(e, "db connection"))?;
869    let offset = (i64::from(page) - 1) * i64::from(per_page);
870    let limit = i64::from(per_page);
871    let deleted = if trashed { "IS NOT NULL" } else { "IS NULL" };
872    let order = if trashed {
873        "p.deleted_at"
874    } else {
875        "p.created_at"
876    };
877    let condition = format!("p.author_id = $1 AND p.deleted_at {deleted} AND ($2::text IS NULL OR p.status = $2) AND p.title ILIKE $3");
878    let total: i64 = client
879        .query_one(
880            &format!("SELECT COUNT(*) FROM posts p WHERE {condition}"),
881            &[&user_id, &p.status, &pattern],
882        )
883        .await
884        .map_err(|e| internal(e, "count posts"))?
885        .get(0);
886    let rows = client
887        .query(
888            &format!(
889                "SELECT p.id, p.author_id, p.title, p.slug, p.summary, p.status,
890                p.published_at, p.created_at, p.updated_at, p.deleted_at, p.cover_image,
891                p.word_count, p.reading_time,
892                COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{{}}') as tags
893                FROM posts p LEFT JOIN post_tags pt ON p.id = pt.post_id
894                LEFT JOIN tags t ON pt.tag_id = t.id WHERE {condition}
895                GROUP BY p.id ORDER BY {order} DESC, p.id DESC LIMIT $4 OFFSET $5"
896            ),
897            &[&user_id, &p.status, &pattern, &limit, &offset],
898        )
899        .await
900        .map_err(|e| internal(e, "list posts"))?;
901    let posts: Vec<_> = rows
902        .iter()
903        .map(crate::api::posts::helpers::row_to_post_list_item)
904        .collect();
905    ok_json(serde_json::json!({"posts": posts, "total": total, "page": page, "per_page": per_page}))
906}
907
908#[cfg(test)]
909mod tests {
910    use super::*;
911    use crate::mcp::{auth::McpPrincipal, server::YggMcpServer};
912
913    fn parts(scope: Option<TokenScope>) -> http::request::Parts {
914        let (mut parts, _) = http::Request::new(()).into_parts();
915        if let Some(scope) = scope {
916            parts.extensions.insert(McpPrincipal {
917                user_id: 1,
918                scope,
919                token_id: "test".into(),
920            });
921        }
922        parts
923    }
924
925    fn result_json(result: CallToolResult) -> serde_json::Value {
926        serde_json::from_str(&result.content[0].as_text().unwrap().text).unwrap()
927    }
928
929    /// Run only against a disposable database named ygg_mcp_test.
930    #[test]
931    #[ignore = "requires disposable DATABASE_URL database ygg_mcp_test"]
932    fn private_post_workflow_database() {
933        crate::db::TEST_DATABASE_RUNTIME.block_on(private_post_workflow_database_impl());
934    }
935
936    async fn private_post_workflow_database_impl() {
937        let _guard = crate::db::TEST_DATABASE_LOCK.lock().await;
938        let mut client = get_conn().await.unwrap();
939        let database: String = client
940            .query_one("SELECT current_database()", &[])
941            .await
942            .unwrap()
943            .get(0);
944        assert_eq!(database, "ygg_mcp_test", "requires isolated test database");
945        crate::db::migrate::run_on_conn(&mut client).await.unwrap();
946        client
947            .batch_execute(
948                "TRUNCATE users CASCADE;
949        INSERT INTO users (id, username, email, password_hash, role) VALUES
950            (1, 'mcp-owner', 'owner@test.invalid', 'unused', 'admin'),
951            (2, 'mcp-other', 'other@test.invalid', 'unused', 'blocked');
952        INSERT INTO posts (id, author_id, title, slug, content_md, content_html, status) VALUES
953            (101, 1, 'Draft 100%_', 'mcp-draft', 'draft body', '<p>draft body</p>', 'draft'),
954            (102, 2, 'Other draft', 'mcp-other', 'private', '<p>private</p>', 'draft'),
955            (103, 1, 'Published', 'mcp-published', 'public', '<p>public</p>', 'published');",
956            )
957            .await
958            .unwrap();
959        let p = ListPostsParams {
960            status: Some("draft".into()),
961            query: Some("100%_".into()),
962            ..Default::default()
963        };
964        let list = result_json(
965            YggMcpServer
966                .list_posts(Parameters(p), Extension(parts(Some(TokenScope::Write))))
967                .await
968                .unwrap(),
969        );
970        assert_eq!(list["total"], 1);
971        assert_eq!(list["posts"][0]["id"], 101);
972        let post = result_json(
973            YggMcpServer
974                .get_post_by_id(
975                    Parameters(PostIdParams { post_id: 101 }),
976                    Extension(parts(Some(TokenScope::Write))),
977                )
978                .await
979                .unwrap(),
980        );
981        assert_eq!(post["content_md"], "draft body");
982        assert!(post["updated_at"].is_string());
983        assert!(YggMcpServer
984            .get_post_by_id(
985                Parameters(PostIdParams { post_id: 102 }),
986                Extension(parts(Some(TokenScope::Write)))
987            )
988            .await
989            .is_err());
990        assert!(crate::mcp::tools::read::get_published_by_slug("mcp-draft")
991            .await
992            .unwrap()
993            .is_none());
994        client
995            .batch_execute(
996                "UPDATE posts SET deleted_at = NOW() WHERE id IN (101, 102);
997        INSERT INTO posts (id, author_id, title, slug, content_md, status) VALUES
998        (104, 1, 'Slug collision', 'mcp-draft', 'body', 'draft');",
999            )
1000            .await
1001            .unwrap();
1002        let trash = result_json(
1003            YggMcpServer
1004                .list_trashed_posts(
1005                    Parameters(ListPostsParams::default()),
1006                    Extension(parts(Some(TokenScope::Write))),
1007                )
1008                .await
1009                .unwrap(),
1010        );
1011        assert_eq!(trash["total"], 1);
1012        assert_eq!(trash["posts"][0]["id"], 101);
1013        assert!(trash["posts"][0]["deleted_at"].is_string());
1014        let denied = result_json(
1015            YggMcpServer
1016                .restore_post(
1017                    Parameters(PostIdParams { post_id: 102 }),
1018                    Extension(parts(Some(TokenScope::Write))),
1019                )
1020                .await
1021                .unwrap(),
1022        );
1023        assert_eq!(denied["success"], false);
1024        let restored = result_json(
1025            YggMcpServer
1026                .restore_post(
1027                    Parameters(PostIdParams { post_id: 101 }),
1028                    Extension(parts(Some(TokenScope::Write))),
1029                )
1030                .await
1031                .unwrap(),
1032        );
1033        assert_eq!(restored["success"], true);
1034        assert_ne!(restored["slug"], "mcp-draft");
1035        let row = client
1036            .query_one("SELECT status, deleted_at FROM posts WHERE id = 101", &[])
1037            .await
1038            .unwrap();
1039        assert_eq!(row.get::<_, String>(0), "draft");
1040        assert!(row
1041            .get::<_, Option<chrono::DateTime<chrono::Utc>>>(1)
1042            .is_none());
1043        let before: chrono::DateTime<chrono::Utc> = client
1044            .query_one("SELECT updated_at FROM posts WHERE id = 101", &[])
1045            .await
1046            .unwrap()
1047            .get(0);
1048        let update = |title: &str, version: chrono::DateTime<chrono::Utc>| {
1049            serde_json::from_value::<UpdatePostParams>(serde_json::json!({
1050                "post_id": 101, "title": title, "expected_updated_at": version.to_rfc3339()
1051            }))
1052            .unwrap()
1053        };
1054        // Two writers using the same version must not both succeed.
1055        let (a, b) = tokio::join!(
1056            YggMcpServer.update_post(
1057                Parameters(update("writer-a", before)),
1058                Extension(parts(Some(TokenScope::Write)))
1059            ),
1060            YggMcpServer.update_post(
1061                Parameters(update("writer-b", before)),
1062                Extension(parts(Some(TokenScope::Write)))
1063            ),
1064        );
1065        assert_ne!(a.is_ok(), b.is_ok());
1066        let (winner, conflict) = match (a, b) {
1067            (Ok(winner), Err(conflict)) | (Err(conflict), Ok(winner)) => (winner, conflict),
1068            _ => panic!("exactly one concurrent writer must succeed"),
1069        };
1070        assert!(conflict.message.contains("conflict"));
1071        let winner = result_json(winner);
1072        let after = client
1073            .query_one("SELECT title, updated_at FROM posts WHERE id = 101", &[])
1074            .await
1075            .unwrap();
1076        assert_eq!(
1077            winner["updated_at"],
1078            serde_json::to_value(after.get::<_, chrono::DateTime<chrono::Utc>>(1)).unwrap()
1079        );
1080        assert!(YggMcpServer
1081            .update_post(
1082                Parameters(update("stale overwrite", before)),
1083                Extension(parts(Some(TokenScope::Write)))
1084            )
1085            .await
1086            .is_err());
1087        assert_eq!(
1088            client
1089                .query_one("SELECT title FROM posts WHERE id = 101", &[])
1090                .await
1091                .unwrap()
1092                .get::<_, String>(0),
1093            after.get::<_, String>(0)
1094        );
1095        // An edit from the web/backend also invalidates the previously read version.
1096        let version = after.get::<_, chrono::DateTime<chrono::Utc>>(1);
1097        client.execute("UPDATE posts SET title = 'web edit', updated_at = clock_timestamp() WHERE id = 101", &[]).await.unwrap();
1098        assert!(YggMcpServer
1099            .update_post(
1100                Parameters(update("stale web overwrite", version)),
1101                Extension(parts(Some(TokenScope::Write)))
1102            )
1103            .await
1104            .is_err());
1105        let legacy = serde_json::from_value::<UpdatePostParams>(
1106            serde_json::json!({"post_id":101,"title":"legacy update"}),
1107        )
1108        .unwrap();
1109        assert!(YggMcpServer
1110            .update_post(
1111                Parameters(legacy),
1112                Extension(parts(Some(TokenScope::Write)))
1113            )
1114            .await
1115            .is_ok());
1116    }
1117
1118    #[test]
1119    fn expected_version_requires_precise_timestamp() {
1120        assert!(parse_expected_updated_at(None).unwrap().is_none());
1121        assert!(parse_expected_updated_at(Some("2026-09-16")).is_err());
1122        assert!(parse_expected_updated_at(Some("invalid")).is_err());
1123        assert_eq!(
1124            parse_expected_updated_at(Some("2026-09-16T08:00:00.123456+08:00")).unwrap(),
1125            parse_expected_updated_at(Some("2026-09-16T00:00:00.123456Z")).unwrap()
1126        );
1127    }
1128
1129    #[test]
1130    fn list_validates_status_bounds_paging_and_escapes_search() {
1131        let mut p = ListPostsParams::default();
1132        assert_eq!(p.normalize().unwrap(), (1, 20, "%".repeat(2)));
1133        p.page = Some(i32::MIN);
1134        p.per_page = Some(i32::MAX);
1135        p.query = Some("  100%_  ".into());
1136        assert_eq!(p.normalize().unwrap(), (1, 50, r"%100\%\_%".into()));
1137        p.status = Some("unknown".into());
1138        assert!(p.normalize().is_err());
1139    }
1140
1141    #[tokio::test]
1142    async fn private_reads_reject_missing_and_read_only_principals_before_io() {
1143        for scope in [None, Some(TokenScope::Read)] {
1144            assert!(YggMcpServer
1145                .list_trashed_posts(
1146                    Parameters(ListPostsParams::default()),
1147                    Extension(parts(scope))
1148                )
1149                .await
1150                .is_err());
1151            assert!(YggMcpServer
1152                .restore_post(
1153                    Parameters(PostIdParams { post_id: 101 }),
1154                    Extension(parts(scope))
1155                )
1156                .await
1157                .is_err());
1158            assert!(YggMcpServer
1159                .list_posts(
1160                    Parameters(ListPostsParams::default()),
1161                    Extension(parts(scope))
1162                )
1163                .await
1164                .is_err());
1165            assert!(YggMcpServer
1166                .get_post_by_id(
1167                    Parameters(PostIdParams { post_id: 1 }),
1168                    Extension(parts(scope))
1169                )
1170                .await
1171                .is_err());
1172        }
1173    }
1174}