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 published_at = if fields.status == PostStatus::Published {
88 Some(chrono::Utc::now())
89 } else {
90 None
91 };
92
93 let mut client = get_conn().await.map_err(|e| internal(e, "db connection"))?;
94 let tx = client
95 .transaction()
96 .await
97 .map_err(|e| internal(e, "begin txn"))?;
98
99 let final_slug = crate::api::slug::ensure_unique_slug(&tx, &base_slug, None)
100 .await
101 .map_err(|e| internal(e, "ensure_unique_slug"))?;
102
103 let row = tx
104 .query_one(
105 "INSERT INTO posts (author_id, title, slug, summary, content_md, content_html, toc_html, status, published_at, cover_image, word_count, reading_time)
106 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
107 RETURNING id",
108 &[
109 &principal.user_id,
110 &p.title.trim(),
111 &final_slug,
112 &summary,
113 &p.content_md,
114 &fields.content_html,
115 &fields.toc_html,
116 &fields.status.as_str(),
117 &published_at,
118 &fields.cover_image,
119 &fields.word_count,
120 &fields.reading_time,
121 ],
122 )
123 .await
124 .map_err(|e| internal(e, "insert post"))?;
125 let post_id: i32 = row.get(0);
126
127 let tags_cleaned = crate::api::posts::helpers::clean_tags(&p.tags);
128 crate::api::posts::helpers::sync_tags(&tx, post_id, &tags_cleaned)
129 .await
130 .map_err(|_| internal("tag sync", "sync_tags"))?;
131 crate::api::posts::helpers::sync_asset_refs(
132 &tx,
133 post_id,
134 &fields.content_html,
135 fields.cover_image.as_deref(),
136 )
137 .await
138 .map_err(|_| internal("asset_refs sync", "sync_asset_refs"))?;
139
140 tx.commit().await.map_err(|e| internal(e, "commit"))?;
141
142 cache::invalidate_for_post_write(std::slice::from_ref(&final_slug), &tags_cleaned).await;
144
145 ok_json(PostResult {
146 success: true,
147 message: "创建成功".into(),
148 post_id: Some(post_id),
149 slug: Some(final_slug),
150 })
151 }
152
153 #[tool(
156 description = "部分更新一篇已有文章(PATCH 语义)。仅更新提供的字段:未提供 content_md 时跳过重新渲染;未提供 summary 时随 content_md 联动(自动提取或保留旧值)。仅文章原作者可更新。"
157 )]
158 async fn update_post(
159 &self,
160 Parameters(p): Parameters<UpdatePostParams>,
161 Extension(parts): Extension<http::request::Parts>,
162 ) -> Result<CallToolResult, McpError> {
163 let principal = require_scope(&parts, "update_post", TokenScope::Write)?;
164
165 use tokio_postgres::types::ToSql;
166
167 let any_change = p.title.is_some()
169 || p.content_md.is_some()
170 || p.summary.is_some()
171 || p.slug.is_some()
172 || p.tags.is_some()
173 || p.status.is_some()
174 || p.cover_image.is_some();
175 if !any_change {
176 return Err(McpError::invalid_request("至少提供一个可更新字段", None));
177 }
178 if matches!(&p.title, Some(t) if t.trim().is_empty()) {
180 return Err(McpError::invalid_request("title must not be empty", None));
181 }
182 if matches!(&p.content_md, Some(c) if c.trim().is_empty()) {
183 return Err(McpError::invalid_request(
184 "content_md must not be empty",
185 None,
186 ));
187 }
188
189 let mut client = get_conn().await.map_err(|e| internal(e, "db connection"))?;
190 let tx = client
191 .transaction()
192 .await
193 .map_err(|e| internal(e, "begin txn"))?;
194
195 let old_row = tx
197 .query_opt(
198 "SELECT slug, status, published_at, cover_image FROM posts \
199 WHERE id = $1 AND author_id = $2 AND deleted_at IS NULL",
200 &[&p.post_id, &principal.user_id],
201 )
202 .await
203 .map_err(|e| internal(e, "select post"))?;
204 let Some(old_row) = old_row else {
205 return Err(McpError::invalid_request("文章不存在或无权限", None));
206 };
207 let old_slug: String = old_row.get(0);
208 let old_status: String = old_row.get(1);
209 let old_published_at: Option<chrono::DateTime<chrono::Utc>> = old_row.get(2);
210 let old_cover: Option<String> = old_row.get(3);
211
212 let rendered: Option<crate::api::posts::helpers::RenderedFields> = match &p.content_md {
214 Some(md) => {
215 let status_for_render = p.status.as_deref().unwrap_or(&old_status);
216 let cover_for_render: Option<&str> = p
217 .cover_image
218 .as_deref()
219 .map(str::trim)
220 .filter(|s| !s.is_empty())
221 .or(old_cover.as_deref());
222 Some(
223 crate::api::posts::helpers::render_post_fields(
224 md,
225 status_for_render,
226 cover_for_render,
227 )
228 .await
229 .map_err(|_| internal("markdown render", "render_post_fields"))?,
230 )
231 }
232 None => None,
233 };
234
235 let summary_value: Option<String> = match (&p.summary, &rendered) {
238 (Some(s), r) => {
239 let t = s.trim();
240 if t.is_empty() {
241 r.as_ref().map(|f| f.auto_summary.clone())
242 } else {
243 Some(t.to_string())
244 }
245 }
246 (None, Some(f)) => Some(f.auto_summary.clone()),
247 (None, None) => None,
248 };
249
250 let new_slug: Option<String> = match &p.slug {
252 Some(s) => {
253 let t = s.trim();
254 if t.is_empty() {
255 None
256 } else if !crate::api::slug::is_valid_slug(t) {
257 return Err(McpError::invalid_request("slug 格式无效", None));
258 } else {
259 Some(t.to_string())
260 }
261 }
262 None => None,
263 };
264 let final_slug: Option<String> = match new_slug {
265 Some(base) => Some(
266 crate::api::slug::ensure_unique_slug(&tx, &base, Some(p.post_id))
267 .await
268 .map_err(|e| internal(e, "ensure_unique_slug"))?,
269 ),
270 None => None,
271 };
272
273 let new_status: Option<PostStatus> = p
275 .status
276 .as_deref()
277 .map(|s| PostStatus::from_str(s).unwrap_or(PostStatus::Draft));
278 let published_at: Option<Option<chrono::DateTime<chrono::Utc>>> = match &new_status {
279 Some(PostStatus::Published) => Some(if old_status == "published" {
280 old_published_at
281 } else {
282 Some(chrono::Utc::now())
283 }),
284 Some(PostStatus::Draft) => Some(old_published_at),
285 None => None,
286 };
287
288 let new_cover: Option<String> = p
290 .cover_image
291 .as_deref()
292 .map(str::trim)
293 .filter(|s| !s.is_empty())
294 .map(|s| s.to_string());
295 let cover_changed = p.cover_image.is_some();
296
297 let mut sets: Vec<String> = Vec::new();
299 let mut params: Vec<Box<dyn ToSql + Sync + Send>> = Vec::new();
300 let mut idx = 1usize;
301 macro_rules! push {
302 ($col:expr, $val:expr) => {{
303 sets.push(format!("{} = ${}", $col, idx));
304 params.push(Box::new($val));
305 idx += 1;
306 }};
307 }
308 if let Some(t) = &p.title {
309 push!("title", t.trim().to_string());
310 }
311 if let Some(c) = &p.content_md {
312 push!("content_md", c.clone());
313 }
314 if let Some(f) = &rendered {
315 push!("content_html", f.content_html.clone());
316 push!("toc_html", f.toc_html.clone());
317 push!("word_count", f.word_count);
318 push!("reading_time", f.reading_time);
319 }
320 if let Some(s) = &summary_value {
321 push!("summary", s.clone());
322 }
323 if let Some(s) = &final_slug {
324 push!("slug", s.clone());
325 }
326 if let Some(st) = &new_status {
327 push!("status", st.as_str().to_string());
328 }
329 if let Some(pa) = published_at {
330 push!("published_at", pa);
331 }
332 if cover_changed {
333 push!("cover_image", new_cover.clone());
334 }
335 sets.push("updated_at = NOW()".to_string());
336
337 let sql = format!("UPDATE posts SET {} WHERE id = ${}", sets.join(", "), idx);
338 params.push(Box::new(p.post_id));
339 let refs: Vec<&(dyn ToSql + Sync)> = params
340 .iter()
341 .map(|b| b.as_ref() as &(dyn ToSql + Sync))
342 .collect();
343 let updated = tx
344 .execute(&sql, &refs)
345 .await
346 .map_err(|e| internal(e, "update post"))?;
347 if updated == 0 {
348 return Err(McpError::invalid_request("文章不存在或无权限", None));
349 }
350
351 let tags_changed = p.tags.is_some();
353 let mut old_tags: Vec<String> = Vec::new();
354 if tags_changed {
355 old_tags = crate::api::posts::helpers::fetch_post_tags(&tx, p.post_id)
356 .await
357 .map_err(|_| internal("select old tags", "fetch_post_tags"))?;
358 let tags_cleaned = crate::api::posts::helpers::clean_tags(p.tags.as_ref().unwrap());
359 tx.execute("DELETE FROM post_tags WHERE post_id = $1", &[&p.post_id])
360 .await
361 .map_err(|e| internal(e, "delete old post_tags"))?;
362 crate::api::posts::helpers::sync_tags(&tx, p.post_id, &tags_cleaned)
363 .await
364 .map_err(|_| internal("tag sync", "sync_tags"))?;
365 }
366
367 if rendered.is_some() || cover_changed {
369 let content_html: String = match &rendered {
370 Some(f) => f.content_html.clone(),
371 None => tx
372 .query_one(
373 "SELECT content_html FROM posts WHERE id = $1",
374 &[&p.post_id],
375 )
376 .await
377 .map_err(|e| internal(e, "select content_html"))?
378 .get::<_, String>(0),
379 };
380 let cover_for_sync = new_cover.as_deref().or(old_cover.as_deref());
381 crate::api::posts::helpers::sync_asset_refs(
382 &tx,
383 p.post_id,
384 &content_html,
385 cover_for_sync,
386 )
387 .await
388 .map_err(|_| internal("asset_refs sync", "sync_asset_refs"))?;
389 }
390
391 tx.commit().await.map_err(|e| internal(e, "commit"))?;
392
393 let effective_slug = final_slug.clone().unwrap_or_else(|| old_slug.clone());
395
396 cache::invalidate_post_metadata();
397 cache::invalidate_post_by_slug(&effective_slug).await;
398
399 if let Some(new) = &final_slug {
400 if new != &old_slug {
401 cache::invalidate_post_by_slug(&old_slug).await;
402 ssr_cache::invalidate_ssr_route(&format!("/post/{old_slug}"));
403 }
404 }
405 ssr_cache::invalidate_ssr_route(&format!("/post/{effective_slug}"));
406 ssr_cache::invalidate_ssr_all_public();
407 ssr_cache::bump_global_generation();
408
409 if tags_changed {
410 let new_tags = crate::api::posts::helpers::clean_tags(p.tags.as_ref().unwrap());
411 let mut all: std::collections::HashSet<String> = old_tags.into_iter().collect();
412 all.extend(new_tags);
413 let all_tags: Vec<String> = all.into_iter().collect();
414 cache::invalidate_tag_posts_for(&all_tags).await;
415 }
416
417 ok_json(PostResult {
418 success: true,
419 message: "更新成功".into(),
420 post_id: Some(p.post_id),
421 slug: Some(effective_slug),
422 })
423 }
424
425 #[tool(
427 description = "发布一篇草稿文章。设置 status=published,若首次发布则填充 published_at。"
428 )]
429 async fn publish_post(
430 &self,
431 Parameters(p): Parameters<PostIdParams>,
432 Extension(parts): Extension<http::request::Parts>,
433 ) -> Result<CallToolResult, McpError> {
434 let principal = require_scope(&parts, "publish_post", TokenScope::Write)?;
435
436 let client = get_conn().await.map_err(|e| internal(e, "db connection"))?;
437
438 let row = client
440 .query_opt(
441 "SELECT slug FROM posts WHERE id = $1 AND author_id = $2 AND deleted_at IS NULL",
442 &[&p.post_id, &principal.user_id],
443 )
444 .await
445 .map_err(|e| internal(e, "select post"))?;
446 let slug: String = match row {
447 Some(r) => r.get(0),
448 None => {
449 return Err(McpError::invalid_request("文章不存在或无权限", None));
450 }
451 };
452
453 let tags = crate::api::posts::helpers::fetch_post_tags(&client, p.post_id)
456 .await
457 .map_err(|_| internal("select tags", "select tags"))?;
458
459 let result = client
460 .execute(
461 "UPDATE posts SET status = 'published', \
462 published_at = COALESCE(published_at, NOW()), updated_at = NOW() \
463 WHERE id = $1 AND deleted_at IS NULL",
464 &[&p.post_id],
465 )
466 .await
467 .map_err(|e| internal(e, "publish post"))?;
468 if result == 0 {
469 return Err(McpError::invalid_request("文章不存在", None));
470 }
471
472 cache::invalidate_for_post_write(std::slice::from_ref(&slug), &tags).await;
474
475 ok_json(PostResult {
476 success: true,
477 message: "发布成功".into(),
478 post_id: Some(p.post_id),
479 slug: Some(slug),
480 })
481 }
482
483 #[tool(description = "将文章移入回收站(软删除)。可通过恢复操作还原。")]
485 async fn trash_post(
486 &self,
487 Parameters(p): Parameters<PostIdParams>,
488 Extension(parts): Extension<http::request::Parts>,
489 ) -> Result<CallToolResult, McpError> {
490 let principal = require_scope(&parts, "trash_post", TokenScope::Write)?;
491
492 let mut client = get_conn().await.map_err(|e| internal(e, "db connection"))?;
493 let tx = client
494 .transaction()
495 .await
496 .map_err(|e| internal(e, "begin txn"))?;
497
498 let slug_row = tx
499 .query_opt(
500 "SELECT slug FROM posts WHERE id = $1 AND author_id = $2 AND deleted_at IS NULL FOR UPDATE",
501 &[&p.post_id, &principal.user_id],
502 )
503 .await
504 .map_err(|e| internal(e, "select post"))?;
505 let Some(slug_row) = slug_row else {
506 return Err(McpError::invalid_request("文章不存在", None));
507 };
508 let slug: String = slug_row.get(0);
509
510 let tags = crate::api::posts::helpers::fetch_post_tags(&tx, p.post_id)
511 .await
512 .map_err(|_| internal("select tags", "select tags"))?;
513
514 let result = tx
515 .execute(
516 "UPDATE posts SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL",
517 &[&p.post_id],
518 )
519 .await
520 .map_err(|e| internal(e, "soft delete"))?;
521 if result == 0 {
522 return Err(McpError::invalid_request("文章不存在", None));
523 }
524
525 tx.commit().await.map_err(|e| internal(e, "commit"))?;
526
527 cache::invalidate_for_post_write(std::slice::from_ref(&slug), &tags).await;
529
530 ok_json(PostResult {
531 success: true,
532 message: "已移入回收站".into(),
533 post_id: Some(p.post_id),
534 slug: Some(slug),
535 })
536 }
537
538 #[tool(
540 description = "彻底删除文章(物理删除,不可恢复)。post_tags 关联因外键 CASCADE 自动清理。"
541 )]
542 async fn delete_post(
543 &self,
544 Parameters(p): Parameters<PostIdParams>,
545 Extension(parts): Extension<http::request::Parts>,
546 ) -> Result<CallToolResult, McpError> {
547 let principal = require_scope(&parts, "delete_post", TokenScope::Write)?;
548
549 let mut client = get_conn().await.map_err(|e| internal(e, "db connection"))?;
550 let tx = client
551 .transaction()
552 .await
553 .map_err(|e| internal(e, "begin txn"))?;
554
555 let slug_row = tx
556 .query_opt(
557 "SELECT slug FROM posts WHERE id = $1 AND author_id = $2 FOR UPDATE",
558 &[&p.post_id, &principal.user_id],
559 )
560 .await
561 .map_err(|e| internal(e, "select post"))?;
562 let Some(slug_row) = slug_row else {
563 return Err(McpError::invalid_request("文章不存在", None));
564 };
565 let slug: String = slug_row.get(0);
566
567 let tags = crate::api::posts::helpers::fetch_post_tags(&tx, p.post_id)
568 .await
569 .map_err(|_| internal("select tags", "select tags"))?;
570
571 let result = tx
572 .execute("DELETE FROM posts WHERE id = $1", &[&p.post_id])
573 .await
574 .map_err(|e| internal(e, "hard delete"))?;
575 if result == 0 {
576 return Err(McpError::invalid_request("文章不存在", None));
577 }
578
579 tx.commit().await.map_err(|e| internal(e, "commit"))?;
580
581 cache::invalidate_for_post_write(std::slice::from_ref(&slug), &tags).await;
583
584 ok_json(PostResult {
585 success: true,
586 message: "已彻底删除".into(),
587 post_id: Some(p.post_id),
588 slug: Some(slug),
589 })
590 }
591}
592
593#[derive(Debug, Deserialize, schemars::JsonSchema)]
598pub struct CreatePostParams {
599 pub title: String,
601 pub content_md: String,
603 #[serde(default)]
605 pub summary: Option<String>,
606 #[serde(default)]
608 pub slug: Option<String>,
609 #[serde(default)]
611 pub tags: Vec<String>,
612 #[serde(default = "default_status")]
614 pub status: String,
615 #[serde(default)]
617 pub cover_image: Option<String>,
618}
619
620#[derive(Debug, Deserialize, schemars::JsonSchema)]
621pub struct UpdatePostParams {
622 pub post_id: i32,
624 #[serde(default)]
626 pub title: Option<String>,
627 #[serde(default)]
629 pub content_md: Option<String>,
630 #[serde(default)]
632 pub summary: Option<String>,
633 #[serde(default)]
635 pub slug: Option<String>,
636 #[serde(default)]
638 pub tags: Option<Vec<String>>,
639 #[serde(default)]
641 pub status: Option<String>,
642 #[serde(default)]
644 pub cover_image: Option<String>,
645}
646
647#[derive(Debug, Deserialize, schemars::JsonSchema)]
648pub struct PostIdParams {
649 pub post_id: i32,
651}
652
653#[derive(Debug, serde::Serialize)]
654struct PostResult {
655 success: bool,
656 message: String,
657 post_id: Option<i32>,
658 slug: Option<String>,
659}
660
661fn default_status() -> String {
662 "draft".to_string()
663}