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    /// 创建一篇新文章(草稿或直接发布)。要求 write 作用域。
37    #[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        // 确定基础 slug。
58        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        // Markdown 渲染 + 度量派生收敛到 helper(R4)。
73        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        // 与 web 后台一致的缓存失效(moka + SSR)。
157        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    /// 更新指定文章(PATCH 语义:仅更新提供的字段)。要求 write 作用域。
168    /// 仅文章原作者可更新。
169    #[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        // 至少一个可更新字段。
182        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        // 提供时的非空校验。
194        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        // 校验存在、未删除、归属,并取旧值(slug/status/published_at/cover)。
211        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        // 渲染(仅当 content_md 提供时)。status/cover 用新值或回退旧值。
228        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        // summary 决策(随 content_md 联动):
251        //  提供了 summary → 用之(空则回退自动提取);未提供但 content_md 变了 → 自动提取;都未变 → 不动。
252        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        // slug 决策(仅显式提供才动;空串视为未提供)。
266        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        // published_at 显式指定或依据 status 转换联动。
289        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), // 空串清空 published_at
300            None => None,
301        };
302
303        // status + published_at 决策(首发 published 填 published_at;转 draft 保留旧值)。
304        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        // cover 决策(空串 → 清空 None)。
324        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        // 动态构建 UPDATE(仅 SET 提供的字段)。
333        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        // 标签同步(仅当 tags 提供时)。先取旧标签供缓存失效,再完全替换。
390        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        // 素材引用同步(content_html 或 cover 变了)。
406        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        // 缓存失效(moka + SSR)。
432        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    /// 发布指定文章(设置 status=published 与 published_at)。要求 write 作用域。
466    #[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        // 校验存在、未删除、归属当前用户,并取 slug 用于缓存失效。
479        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        // M6 修复:发布后文章出现在公开标签列表页,须失效标签缓存(api update.rs:202
494        // 会失效,此 MCP 路径此前漏掉 → 标签页新发文陈旧 ≤120s)。
495        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        // 发布后失效文章详情、列表、标签与 SSR 缓存(moka + SSR)。
513        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    /// 将指定文章移入回收站(软删除:设置 deleted_at)。要求 write 作用域。
524    #[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        // 移入回收站后失效相关缓存(moka + SSR)。
568        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    /// 彻底删除指定文章(物理删除,不可恢复)。要求 write 作用域。
579    #[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        // 彻底删除后失效相关缓存(moka + SSR)。
622        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// ---------------------------------------------------------------------------
634// 参数与输出结构
635// ---------------------------------------------------------------------------
636
637#[derive(Debug, Deserialize, schemars::JsonSchema)]
638pub struct CreatePostParams {
639    /// 文章标题(必填,非空)。
640    pub title: String,
641    /// Markdown 正文(必填,非空)。
642    pub content_md: String,
643    /// 摘要;未提供时自动从正文提取。
644    #[serde(default)]
645    pub summary: Option<String>,
646    /// URL slug;未提供时从标题自动生成。
647    #[serde(default)]
648    pub slug: Option<String>,
649    /// 标签列表。
650    #[serde(default)]
651    pub tags: Vec<String>,
652    /// 状态:`draft`(默认)或 `published`。
653    #[serde(default = "default_status")]
654    pub status: String,
655    /// 封面图 URL。
656    #[serde(default)]
657    pub cover_image: Option<String>,
658    /// 发布时间(ISO 8601 或 YYYY-MM-DD);仅在 status=published 时生效,未提供则使用当前时间。
659    #[serde(default)]
660    pub published_at: Option<String>,
661}
662
663#[derive(Debug, Deserialize, schemars::JsonSchema)]
664pub struct UpdatePostParams {
665    /// 要更新的文章 id。
666    pub post_id: i32,
667    /// 新标题。未提供则不修改(且不联动 slug)。
668    #[serde(default)]
669    pub title: Option<String>,
670    /// 新 Markdown 正文。未提供则不重新渲染(content_html/toc/度量保持不变)。
671    #[serde(default)]
672    pub content_md: Option<String>,
673    /// 新摘要。content_md 变化且未提供时自动从正文提取;content_md 未变且未提供则保留旧值。
674    #[serde(default)]
675    pub summary: Option<String>,
676    /// 新 slug。未提供则不修改;提供则校验格式并自动去重。
677    #[serde(default)]
678    pub slug: Option<String>,
679    /// 新标签列表。未提供则不修改;提供空列表则清空标签(完全替换旧标签)。
680    #[serde(default)]
681    pub tags: Option<Vec<String>>,
682    /// 新状态:`draft` / `published`。未提供则不修改。
683    #[serde(default)]
684    pub status: Option<String>,
685    /// 新封面图 URL。未提供则不修改;空字符串清空封面。
686    #[serde(default)]
687    pub cover_image: Option<String>,
688    /// 发布时间(ISO 8601 或 YYYY-MM-DD);未提供则不修改。
689    #[serde(default)]
690    pub published_at: Option<String>,
691}
692
693#[derive(Debug, Deserialize, schemars::JsonSchema)]
694pub struct PostIdParams {
695    /// 文章 id。
696    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}