1#![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#[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 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 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 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 #[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 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 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 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 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 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 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 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), None => None,
376 };
377
378 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 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 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 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 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 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 #[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 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 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 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 #[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 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 #[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 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#[derive(Debug, Deserialize, schemars::JsonSchema)]
716pub struct CreatePostParams {
717 pub title: String,
719 pub content_md: String,
721 #[serde(default)]
723 pub summary: Option<String>,
724 #[serde(default)]
726 pub slug: Option<String>,
727 #[serde(default)]
729 pub tags: Vec<String>,
730 #[serde(default = "default_status")]
732 pub status: String,
733 #[serde(default)]
735 pub cover_image: Option<String>,
736 #[serde(default)]
738 pub published_at: Option<String>,
739}
740
741#[derive(Debug, Deserialize, schemars::JsonSchema)]
742pub struct UpdatePostParams {
743 #[serde(default)]
745 pub expected_updated_at: Option<String>,
746 pub post_id: i32,
748 #[serde(default)]
750 pub title: Option<String>,
751 #[serde(default)]
753 pub content_md: Option<String>,
754 #[serde(default)]
756 pub summary: Option<String>,
757 #[serde(default)]
759 pub slug: Option<String>,
760 #[serde(default)]
762 pub tags: Option<Vec<String>>,
763 #[serde(default)]
765 pub status: Option<String>,
766 #[serde(default)]
768 pub cover_image: Option<String>,
769 #[serde(default)]
771 pub published_at: Option<String>,
772}
773
774#[derive(Debug, Deserialize, schemars::JsonSchema)]
775pub struct PostIdParams {
776 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 #[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 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 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}