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 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        // 与 web 后台一致的缓存失效(moka + SSR)。
143        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    /// 更新指定文章(PATCH 语义:仅更新提供的字段)。要求 write 作用域。
154    /// 仅文章原作者可更新。
155    #[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        // 至少一个可更新字段。
168        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        // 提供时的非空校验。
179        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        // 校验存在、未删除、归属,并取旧值(slug/status/published_at/cover)。
196        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        // 渲染(仅当 content_md 提供时)。status/cover 用新值或回退旧值。
213        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        // summary 决策(随 content_md 联动):
236        //  提供了 summary → 用之(空则回退自动提取);未提供但 content_md 变了 → 自动提取;都未变 → 不动。
237        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        // slug 决策(仅显式提供才动;空串视为未提供)。
251        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        // status + published_at 决策(首发 published 填 published_at;转 draft 保留旧值)。
274        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        // cover 决策(空串 → 清空 None)。
289        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        // 动态构建 UPDATE(仅 SET 提供的字段)。
298        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        // 标签同步(仅当 tags 提供时)。先取旧标签供缓存失效,再完全替换。
352        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        // 素材引用同步(content_html 或 cover 变了)。
368        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        // 缓存失效(moka + SSR)。
394        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    /// 发布指定文章(设置 status=published 与 published_at)。要求 write 作用域。
426    #[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        // 校验存在、未删除、归属当前用户,并取 slug 用于缓存失效。
439        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        // M6 修复:发布后文章出现在公开标签列表页,须失效标签缓存(api update.rs:202
454        // 会失效,此 MCP 路径此前漏掉 → 标签页新发文陈旧 ≤120s)。
455        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        // 发布后失效文章详情、列表、标签与 SSR 缓存(moka + SSR)。
473        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    /// 将指定文章移入回收站(软删除:设置 deleted_at)。要求 write 作用域。
484    #[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        // 移入回收站后失效相关缓存(moka + SSR)。
528        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    /// 彻底删除指定文章(物理删除,不可恢复)。要求 write 作用域。
539    #[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        // 彻底删除后失效相关缓存(moka + SSR)。
582        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// ---------------------------------------------------------------------------
594// 参数与输出结构
595// ---------------------------------------------------------------------------
596
597#[derive(Debug, Deserialize, schemars::JsonSchema)]
598pub struct CreatePostParams {
599    /// 文章标题(必填,非空)。
600    pub title: String,
601    /// Markdown 正文(必填,非空)。
602    pub content_md: String,
603    /// 摘要;未提供时自动从正文提取。
604    #[serde(default)]
605    pub summary: Option<String>,
606    /// URL slug;未提供时从标题自动生成。
607    #[serde(default)]
608    pub slug: Option<String>,
609    /// 标签列表。
610    #[serde(default)]
611    pub tags: Vec<String>,
612    /// 状态:`draft`(默认)或 `published`。
613    #[serde(default = "default_status")]
614    pub status: String,
615    /// 封面图 URL。
616    #[serde(default)]
617    pub cover_image: Option<String>,
618}
619
620#[derive(Debug, Deserialize, schemars::JsonSchema)]
621pub struct UpdatePostParams {
622    /// 要更新的文章 id。
623    pub post_id: i32,
624    /// 新标题。未提供则不修改(且不联动 slug)。
625    #[serde(default)]
626    pub title: Option<String>,
627    /// 新 Markdown 正文。未提供则不重新渲染(content_html/toc/度量保持不变)。
628    #[serde(default)]
629    pub content_md: Option<String>,
630    /// 新摘要。content_md 变化且未提供时自动从正文提取;content_md 未变且未提供则保留旧值。
631    #[serde(default)]
632    pub summary: Option<String>,
633    /// 新 slug。未提供则不修改;提供则校验格式并自动去重。
634    #[serde(default)]
635    pub slug: Option<String>,
636    /// 新标签列表。未提供则不修改;提供空列表则清空标签(完全替换旧标签)。
637    #[serde(default)]
638    pub tags: Option<Vec<String>>,
639    /// 新状态:`draft` / `published`。未提供则不修改。
640    #[serde(default)]
641    pub status: Option<String>,
642    /// 新封面图 URL。未提供则不修改;空字符串清空封面。
643    #[serde(default)]
644    pub cover_image: Option<String>,
645}
646
647#[derive(Debug, Deserialize, schemars::JsonSchema)]
648pub struct PostIdParams {
649    /// 文章 id。
650    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}