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(
38 description = "创建一篇新文章。渲染 Markdown 为 HTML,同步标签与素材引用。返回 post_id/slug。"
39 )]
40 async fn create_post(
41 &self,
42 Parameters(p): Parameters<CreatePostParams>,
43 Extension(parts): Extension<http::request::Parts>,
44 ) -> Result<CallToolResult, McpError> {
45 let principal = require_scope(&parts, "create_post", TokenScope::Write)?;
46
47 if p.title.trim().is_empty() {
48 return Err(McpError::invalid_request("title must not be empty", None));
49 }
50 if p.content_md.trim().is_empty() {
51 return Err(McpError::invalid_request(
52 "content_md must not be empty",
53 None,
54 ));
55 }
56
57 let base_slug = match &p.slug {
59 Some(s) if !s.trim().is_empty() => {
60 let s = s.trim();
61 if !crate::api::slug::is_valid_slug(s) {
62 return Err(McpError::invalid_request(
63 "slug 格式无效,只能包含字母、数字、连字符和下划线",
64 None,
65 ));
66 }
67 s.to_string()
68 }
69 _ => crate::api::slug::slugify(&p.title),
70 };
71
72 let fields = crate::api::posts::helpers::render_post_fields(
74 &p.content_md,
75 &p.status,
76 p.cover_image.as_deref(),
77 )
78 .await
79 .map_err(|_| internal("markdown render", "render_post_fields"))?;
80 let summary = p
81 .summary
82 .as_deref()
83 .map(str::trim)
84 .filter(|s| !s.is_empty())
85 .map(|s| s.to_string())
86 .unwrap_or(fields.auto_summary);
87 let explicit_published_at = match &p.published_at {
88 Some(s) if !s.trim().is_empty() => {
89 let Some(dt) = parse_date_opt(s) else {
90 return Err(McpError::invalid_request(
91 "published_at 格式无效,支持 YYYY-MM-DD 或 ISO 8601",
92 None,
93 ));
94 };
95 Some(dt)
96 }
97 _ => None,
98 };
99 let published_at = if fields.status == PostStatus::Published {
100 explicit_published_at.or_else(|| Some(chrono::Utc::now()))
101 } else {
102 None
103 };
104 let created_at = explicit_published_at.unwrap_or_else(chrono::Utc::now);
105
106 let mut client = get_conn().await.map_err(|e| internal(e, "db connection"))?;
107 let tx = client
108 .transaction()
109 .await
110 .map_err(|e| internal(e, "begin txn"))?;
111
112 let final_slug = crate::api::slug::ensure_unique_slug(&tx, &base_slug, None)
113 .await
114 .map_err(|e| internal(e, "ensure_unique_slug"))?;
115
116 let row = tx
117 .query_one(
118 "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)
119 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
120 RETURNING id",
121 &[
122 &principal.user_id,
123 &p.title.trim(),
124 &final_slug,
125 &summary,
126 &p.content_md,
127 &fields.content_html,
128 &fields.toc_html,
129 &fields.status.as_str(),
130 &published_at,
131 &fields.cover_image,
132 &fields.word_count,
133 &fields.reading_time,
134 &created_at,
135 ],
136 )
137 .await
138 .map_err(|e| internal(e, "insert post"))?;
139 let post_id: i32 = row.get(0);
140
141 let tags_cleaned = crate::api::posts::helpers::clean_tags(&p.tags);
142 crate::api::posts::helpers::sync_tags(&tx, post_id, &tags_cleaned)
143 .await
144 .map_err(|_| internal("tag sync", "sync_tags"))?;
145 crate::api::posts::helpers::sync_asset_refs(
146 &tx,
147 post_id,
148 &fields.content_html,
149 fields.cover_image.as_deref(),
150 )
151 .await
152 .map_err(|_| internal("asset_refs sync", "sync_asset_refs"))?;
153
154 tx.commit().await.map_err(|e| internal(e, "commit"))?;
155
156 cache::invalidate_for_post_write(std::slice::from_ref(&final_slug), &tags_cleaned).await;
158
159 ok_json(PostResult {
160 success: true,
161 message: "创建成功".into(),
162 post_id: Some(post_id),
163 slug: Some(final_slug),
164 })
165 }
166
167 #[tool(
170 description = "部分更新一篇已有文章(PATCH 语义)。仅更新提供的字段:未提供 content_md 时跳过重新渲染;未提供 summary 时随 content_md 联动(自动提取或保留旧值)。仅文章原作者可更新。"
171 )]
172 async fn update_post(
173 &self,
174 Parameters(p): Parameters<UpdatePostParams>,
175 Extension(parts): Extension<http::request::Parts>,
176 ) -> Result<CallToolResult, McpError> {
177 let principal = require_scope(&parts, "update_post", TokenScope::Write)?;
178
179 use tokio_postgres::types::ToSql;
180
181 let any_change = p.title.is_some()
183 || p.content_md.is_some()
184 || p.summary.is_some()
185 || p.slug.is_some()
186 || p.tags.is_some()
187 || p.status.is_some()
188 || p.cover_image.is_some()
189 || p.published_at.is_some();
190 if !any_change {
191 return Err(McpError::invalid_request("至少提供一个可更新字段", None));
192 }
193 if matches!(&p.title, Some(t) if t.trim().is_empty()) {
195 return Err(McpError::invalid_request("title must not be empty", None));
196 }
197 if matches!(&p.content_md, Some(c) if c.trim().is_empty()) {
198 return Err(McpError::invalid_request(
199 "content_md must not be empty",
200 None,
201 ));
202 }
203
204 let mut client = get_conn().await.map_err(|e| internal(e, "db connection"))?;
205 let tx = client
206 .transaction()
207 .await
208 .map_err(|e| internal(e, "begin txn"))?;
209
210 let old_row = tx
212 .query_opt(
213 "SELECT slug, status, published_at, cover_image FROM posts \
214 WHERE id = $1 AND author_id = $2 AND deleted_at IS NULL",
215 &[&p.post_id, &principal.user_id],
216 )
217 .await
218 .map_err(|e| internal(e, "select post"))?;
219 let Some(old_row) = old_row else {
220 return Err(McpError::invalid_request("文章不存在或无权限", None));
221 };
222 let old_slug: String = old_row.get(0);
223 let old_status: String = old_row.get(1);
224 let old_published_at: Option<chrono::DateTime<chrono::Utc>> = old_row.get(2);
225 let old_cover: Option<String> = old_row.get(3);
226
227 let rendered: Option<crate::api::posts::helpers::RenderedFields> = match &p.content_md {
229 Some(md) => {
230 let status_for_render = p.status.as_deref().unwrap_or(&old_status);
231 let cover_for_render: Option<&str> = p
232 .cover_image
233 .as_deref()
234 .map(str::trim)
235 .filter(|s| !s.is_empty())
236 .or(old_cover.as_deref());
237 Some(
238 crate::api::posts::helpers::render_post_fields(
239 md,
240 status_for_render,
241 cover_for_render,
242 )
243 .await
244 .map_err(|_| internal("markdown render", "render_post_fields"))?,
245 )
246 }
247 None => None,
248 };
249
250 let summary_value: Option<String> = match (&p.summary, &rendered) {
253 (Some(s), r) => {
254 let t = s.trim();
255 if t.is_empty() {
256 r.as_ref().map(|f| f.auto_summary.clone())
257 } else {
258 Some(t.to_string())
259 }
260 }
261 (None, Some(f)) => Some(f.auto_summary.clone()),
262 (None, None) => None,
263 };
264
265 let new_slug: Option<String> = match &p.slug {
267 Some(s) => {
268 let t = s.trim();
269 if t.is_empty() {
270 None
271 } else if !crate::api::slug::is_valid_slug(t) {
272 return Err(McpError::invalid_request("slug 格式无效", None));
273 } else {
274 Some(t.to_string())
275 }
276 }
277 None => None,
278 };
279 let final_slug: Option<String> = match new_slug {
280 Some(base) => Some(
281 crate::api::slug::ensure_unique_slug(&tx, &base, Some(p.post_id))
282 .await
283 .map_err(|e| internal(e, "ensure_unique_slug"))?,
284 ),
285 None => None,
286 };
287
288 let explicit_published_at = match &p.published_at {
290 Some(s) if !s.trim().is_empty() => {
291 let Some(dt) = parse_date_opt(s) else {
292 return Err(McpError::invalid_request(
293 "published_at 格式无效,支持 YYYY-MM-DD 或 ISO 8601",
294 None,
295 ));
296 };
297 Some(Some(dt))
298 }
299 Some(_) => Some(None), None => None,
301 };
302
303 let new_status: Option<PostStatus> = p
305 .status
306 .as_deref()
307 .map(|s| PostStatus::from_str(s).unwrap_or(PostStatus::Draft));
308 let published_at: Option<Option<chrono::DateTime<chrono::Utc>>> =
309 if let Some(epa) = explicit_published_at {
310 Some(epa)
311 } else {
312 match &new_status {
313 Some(PostStatus::Published) => Some(if old_status == "published" {
314 old_published_at
315 } else {
316 Some(chrono::Utc::now())
317 }),
318 Some(PostStatus::Draft) => Some(old_published_at),
319 None => None,
320 }
321 };
322
323 let new_cover: Option<String> = p
325 .cover_image
326 .as_deref()
327 .map(str::trim)
328 .filter(|s| !s.is_empty())
329 .map(|s| s.to_string());
330 let cover_changed = p.cover_image.is_some();
331
332 let mut sets: Vec<String> = Vec::new();
334 let mut params: Vec<Box<dyn ToSql + Sync + Send>> = Vec::new();
335 let mut idx = 1usize;
336 macro_rules! push {
337 ($col:expr, $val:expr) => {{
338 sets.push(format!("{} = ${}", $col, idx));
339 params.push(Box::new($val));
340 idx += 1;
341 }};
342 }
343 if let Some(t) = &p.title {
344 push!("title", t.trim().to_string());
345 }
346 if let Some(c) = &p.content_md {
347 push!("content_md", c.clone());
348 }
349 if let Some(f) = &rendered {
350 push!("content_html", f.content_html.clone());
351 push!("toc_html", f.toc_html.clone());
352 push!("word_count", f.word_count);
353 push!("reading_time", f.reading_time);
354 }
355 if let Some(s) = &summary_value {
356 push!("summary", s.clone());
357 }
358 if let Some(s) = &final_slug {
359 push!("slug", s.clone());
360 }
361 if let Some(st) = &new_status {
362 push!("status", st.as_str().to_string());
363 }
364 if let Some(pa) = published_at {
365 push!("published_at", pa);
366 if let Some(dt) = pa {
367 push!("created_at", dt);
368 }
369 }
370 if cover_changed {
371 push!("cover_image", new_cover.clone());
372 }
373 sets.push("updated_at = NOW()".to_string());
374
375 let sql = format!("UPDATE posts SET {} WHERE id = ${}", sets.join(", "), idx);
376 params.push(Box::new(p.post_id));
377 let refs: Vec<&(dyn ToSql + Sync)> = params
378 .iter()
379 .map(|b| b.as_ref() as &(dyn ToSql + Sync))
380 .collect();
381 let updated = tx
382 .execute(&sql, &refs)
383 .await
384 .map_err(|e| internal(e, "update post"))?;
385 if updated == 0 {
386 return Err(McpError::invalid_request("文章不存在或无权限", None));
387 }
388
389 let tags_changed = p.tags.is_some();
391 let mut old_tags: Vec<String> = Vec::new();
392 if tags_changed {
393 old_tags = crate::api::posts::helpers::fetch_post_tags(&tx, p.post_id)
394 .await
395 .map_err(|_| internal("select old tags", "fetch_post_tags"))?;
396 let tags_cleaned = crate::api::posts::helpers::clean_tags(p.tags.as_ref().unwrap());
397 tx.execute("DELETE FROM post_tags WHERE post_id = $1", &[&p.post_id])
398 .await
399 .map_err(|e| internal(e, "delete old post_tags"))?;
400 crate::api::posts::helpers::sync_tags(&tx, p.post_id, &tags_cleaned)
401 .await
402 .map_err(|_| internal("tag sync", "sync_tags"))?;
403 }
404
405 if rendered.is_some() || cover_changed {
407 let content_html: String = match &rendered {
408 Some(f) => f.content_html.clone(),
409 None => tx
410 .query_one(
411 "SELECT content_html FROM posts WHERE id = $1",
412 &[&p.post_id],
413 )
414 .await
415 .map_err(|e| internal(e, "select content_html"))?
416 .get::<_, String>(0),
417 };
418 let cover_for_sync = new_cover.as_deref().or(old_cover.as_deref());
419 crate::api::posts::helpers::sync_asset_refs(
420 &tx,
421 p.post_id,
422 &content_html,
423 cover_for_sync,
424 )
425 .await
426 .map_err(|_| internal("asset_refs sync", "sync_asset_refs"))?;
427 }
428
429 tx.commit().await.map_err(|e| internal(e, "commit"))?;
430
431 let effective_slug = final_slug.clone().unwrap_or_else(|| old_slug.clone());
433
434 cache::invalidate_post_metadata();
435 cache::invalidate_post_by_slug(&effective_slug).await;
436
437 if let Some(new) = &final_slug {
438 if new != &old_slug {
439 cache::invalidate_post_by_slug(&old_slug).await;
440 ssr_cache::invalidate_ssr_route(&format!("/post/{old_slug}"));
441 ssr_cache::invalidate_post_preview(&old_slug);
442 }
443 }
444 ssr_cache::invalidate_ssr_route(&format!("/post/{effective_slug}"));
445 ssr_cache::invalidate_post_preview(&effective_slug);
446 ssr_cache::invalidate_ssr_all_public();
447 ssr_cache::bump_global_generation();
448
449 if tags_changed {
450 let new_tags = crate::api::posts::helpers::clean_tags(p.tags.as_ref().unwrap());
451 let mut all: std::collections::HashSet<String> = old_tags.into_iter().collect();
452 all.extend(new_tags);
453 let all_tags: Vec<String> = all.into_iter().collect();
454 cache::invalidate_tag_posts_for(&all_tags).await;
455 }
456
457 ok_json(PostResult {
458 success: true,
459 message: "更新成功".into(),
460 post_id: Some(p.post_id),
461 slug: Some(effective_slug),
462 })
463 }
464
465 #[tool(
467 description = "发布一篇草稿文章。设置 status=published,若首次发布则填充 published_at。"
468 )]
469 async fn publish_post(
470 &self,
471 Parameters(p): Parameters<PostIdParams>,
472 Extension(parts): Extension<http::request::Parts>,
473 ) -> Result<CallToolResult, McpError> {
474 let principal = require_scope(&parts, "publish_post", TokenScope::Write)?;
475
476 let client = get_conn().await.map_err(|e| internal(e, "db connection"))?;
477
478 let row = client
480 .query_opt(
481 "SELECT slug FROM posts WHERE id = $1 AND author_id = $2 AND deleted_at IS NULL",
482 &[&p.post_id, &principal.user_id],
483 )
484 .await
485 .map_err(|e| internal(e, "select post"))?;
486 let slug: String = match row {
487 Some(r) => r.get(0),
488 None => {
489 return Err(McpError::invalid_request("文章不存在或无权限", None));
490 }
491 };
492
493 let tags = crate::api::posts::helpers::fetch_post_tags(&client, p.post_id)
496 .await
497 .map_err(|_| internal("select tags", "select tags"))?;
498
499 let result = client
500 .execute(
501 "UPDATE posts SET status = 'published', \
502 published_at = COALESCE(published_at, NOW()), updated_at = NOW() \
503 WHERE id = $1 AND deleted_at IS NULL",
504 &[&p.post_id],
505 )
506 .await
507 .map_err(|e| internal(e, "publish post"))?;
508 if result == 0 {
509 return Err(McpError::invalid_request("文章不存在", None));
510 }
511
512 cache::invalidate_for_post_write(std::slice::from_ref(&slug), &tags).await;
514
515 ok_json(PostResult {
516 success: true,
517 message: "发布成功".into(),
518 post_id: Some(p.post_id),
519 slug: Some(slug),
520 })
521 }
522
523 #[tool(description = "将文章移入回收站(软删除)。可通过恢复操作还原。")]
525 async fn trash_post(
526 &self,
527 Parameters(p): Parameters<PostIdParams>,
528 Extension(parts): Extension<http::request::Parts>,
529 ) -> Result<CallToolResult, McpError> {
530 let principal = require_scope(&parts, "trash_post", TokenScope::Write)?;
531
532 let mut client = get_conn().await.map_err(|e| internal(e, "db connection"))?;
533 let tx = client
534 .transaction()
535 .await
536 .map_err(|e| internal(e, "begin txn"))?;
537
538 let slug_row = tx
539 .query_opt(
540 "SELECT slug FROM posts WHERE id = $1 AND author_id = $2 AND deleted_at IS NULL FOR UPDATE",
541 &[&p.post_id, &principal.user_id],
542 )
543 .await
544 .map_err(|e| internal(e, "select post"))?;
545 let Some(slug_row) = slug_row else {
546 return Err(McpError::invalid_request("文章不存在", None));
547 };
548 let slug: String = slug_row.get(0);
549
550 let tags = crate::api::posts::helpers::fetch_post_tags(&tx, p.post_id)
551 .await
552 .map_err(|_| internal("select tags", "select tags"))?;
553
554 let result = tx
555 .execute(
556 "UPDATE posts SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL",
557 &[&p.post_id],
558 )
559 .await
560 .map_err(|e| internal(e, "soft delete"))?;
561 if result == 0 {
562 return Err(McpError::invalid_request("文章不存在", None));
563 }
564
565 tx.commit().await.map_err(|e| internal(e, "commit"))?;
566
567 cache::invalidate_for_post_write(std::slice::from_ref(&slug), &tags).await;
569
570 ok_json(PostResult {
571 success: true,
572 message: "已移入回收站".into(),
573 post_id: Some(p.post_id),
574 slug: Some(slug),
575 })
576 }
577
578 #[tool(
580 description = "彻底删除文章(物理删除,不可恢复)。post_tags 关联因外键 CASCADE 自动清理。"
581 )]
582 async fn delete_post(
583 &self,
584 Parameters(p): Parameters<PostIdParams>,
585 Extension(parts): Extension<http::request::Parts>,
586 ) -> Result<CallToolResult, McpError> {
587 let principal = require_scope(&parts, "delete_post", TokenScope::Write)?;
588
589 let mut client = get_conn().await.map_err(|e| internal(e, "db connection"))?;
590 let tx = client
591 .transaction()
592 .await
593 .map_err(|e| internal(e, "begin txn"))?;
594
595 let slug_row = tx
596 .query_opt(
597 "SELECT slug FROM posts WHERE id = $1 AND author_id = $2 FOR UPDATE",
598 &[&p.post_id, &principal.user_id],
599 )
600 .await
601 .map_err(|e| internal(e, "select post"))?;
602 let Some(slug_row) = slug_row else {
603 return Err(McpError::invalid_request("文章不存在", None));
604 };
605 let slug: String = slug_row.get(0);
606
607 let tags = crate::api::posts::helpers::fetch_post_tags(&tx, p.post_id)
608 .await
609 .map_err(|_| internal("select tags", "select tags"))?;
610
611 let result = tx
612 .execute("DELETE FROM posts WHERE id = $1", &[&p.post_id])
613 .await
614 .map_err(|e| internal(e, "hard delete"))?;
615 if result == 0 {
616 return Err(McpError::invalid_request("文章不存在", None));
617 }
618
619 tx.commit().await.map_err(|e| internal(e, "commit"))?;
620
621 cache::invalidate_for_post_write(std::slice::from_ref(&slug), &tags).await;
623
624 ok_json(PostResult {
625 success: true,
626 message: "已彻底删除".into(),
627 post_id: Some(p.post_id),
628 slug: Some(slug),
629 })
630 }
631}
632
633#[derive(Debug, Deserialize, schemars::JsonSchema)]
638pub struct CreatePostParams {
639 pub title: String,
641 pub content_md: String,
643 #[serde(default)]
645 pub summary: Option<String>,
646 #[serde(default)]
648 pub slug: Option<String>,
649 #[serde(default)]
651 pub tags: Vec<String>,
652 #[serde(default = "default_status")]
654 pub status: String,
655 #[serde(default)]
657 pub cover_image: Option<String>,
658 #[serde(default)]
660 pub published_at: Option<String>,
661}
662
663#[derive(Debug, Deserialize, schemars::JsonSchema)]
664pub struct UpdatePostParams {
665 pub post_id: i32,
667 #[serde(default)]
669 pub title: Option<String>,
670 #[serde(default)]
672 pub content_md: Option<String>,
673 #[serde(default)]
675 pub summary: Option<String>,
676 #[serde(default)]
678 pub slug: Option<String>,
679 #[serde(default)]
681 pub tags: Option<Vec<String>>,
682 #[serde(default)]
684 pub status: Option<String>,
685 #[serde(default)]
687 pub cover_image: Option<String>,
688 #[serde(default)]
690 pub published_at: Option<String>,
691}
692
693#[derive(Debug, Deserialize, schemars::JsonSchema)]
694pub struct PostIdParams {
695 pub post_id: i32,
697}
698
699#[derive(Debug, serde::Serialize)]
700struct PostResult {
701 success: bool,
702 message: String,
703 post_id: Option<i32>,
704 slug: Option<String>,
705}
706
707fn default_status() -> String {
708 "draft".to_string()
709}
710
711fn parse_date_opt(s: &str) -> Option<chrono::DateTime<chrono::Utc>> {
712 let s = s.trim();
713 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
714 return Some(dt.with_timezone(&chrono::Utc));
715 }
716 if let Ok(nd) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") {
717 if let Some(ndt) = nd.and_hms_opt(0, 0, 0) {
718 return Some(chrono::DateTime::from_naive_utc_and_offset(
719 ndt,
720 chrono::Utc,
721 ));
722 }
723 }
724 if let Ok(ndt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") {
725 return Some(chrono::DateTime::from_naive_utc_and_offset(
726 ndt,
727 chrono::Utc,
728 ));
729 }
730 None
731}