Skip to main content

yggdrasil/api/posts/
create.rs

1//! 创建文章接口。
2//!
3//! 校验标题、内容与 slug,生成唯一 slug 并渲染 Markdown,
4//! 在事务中写入 posts 表并同步标签关联,最后失效相关缓存。
5//! Dioxus server function,注册在 `/api` 路径下。
6//! 仅在 `feature = "server"` 启用的服务端构建中写入数据库。
7
8#![allow(clippy::too_many_arguments)]
9
10use dioxus::prelude::*;
11
12#[cfg(feature = "server")]
13use super::helpers::{
14    clean_tags, get_current_admin_user, render_post_fields, sync_asset_refs, sync_tags,
15};
16use super::types::CreatePostResponse;
17#[cfg(feature = "server")]
18use crate::api::error::AppError;
19#[cfg(feature = "server")]
20use crate::db::pool::get_conn;
21#[cfg(feature = "server")]
22use crate::models::post::PostStatus;
23
24/// 创建一篇新文章。
25///
26/// 校验标题与内容非空、slug 格式合法;未提供 slug 时从标题自动生成。
27/// 首次发布时填充 published_at,并同步标签与缓存失效。
28#[server(CreatePost, "/api")]
29pub async fn create_post(
30    title: String,
31    slug: Option<String>,
32    summary: Option<String>,
33    content_md: String,
34    status: String,
35    tags: Vec<String>,
36    cover_image: Option<String>,
37) -> Result<CreatePostResponse, ServerFnError> {
38    let user = get_current_admin_user().await?;
39
40    // 标题不能为空。
41    if title.trim().is_empty() {
42        return Ok(CreatePostResponse::err("标题不能为空".to_string()));
43    }
44
45    // 内容不能为空。
46    if content_md.trim().is_empty() {
47        return Ok(CreatePostResponse::err("内容不能为空".to_string()));
48    }
49
50    // 确定基础 slug:用户传入时校验格式,否则由标题生成。
51    let base_slug = match slug {
52        Some(ref s) if !s.trim().is_empty() => {
53            let s = s.trim();
54            if !crate::api::slug::is_valid_slug(s) {
55                return Ok(CreatePostResponse::err(
56                    "slug 格式无效,只能包含字母、数字、连字符和下划线".to_string(),
57                ));
58            }
59            s.to_string()
60        }
61        _ => crate::api::slug::slugify(&title),
62    };
63
64    #[cfg(feature = "server")]
65    {
66        let mut client = get_conn().await.map_err(AppError::db_conn)?;
67
68        // Markdown 渲染 + 度量派生收敛到 helper(R4)。
69        let fields = render_post_fields(&content_md, &status, cover_image.as_deref()).await?;
70        // 未填写摘要时用自动摘要兜底。
71        let summary = summary
72            .filter(|s| !s.trim().is_empty())
73            .unwrap_or(fields.auto_summary);
74
75        // 发布状态的文章设置当前发布时间;草稿则为 None。
76        let published_at = if fields.status == PostStatus::Published {
77            Some(chrono::Utc::now())
78        } else {
79            None
80        };
81
82        let tx = client.transaction().await.map_err(AppError::tx)?;
83
84        // 保证 slug 全局唯一,若冲突则追加数字后缀;在事务内检查避免并发竞态。
85        let final_slug = crate::api::slug::ensure_unique_slug(&tx, &base_slug, None).await?;
86
87        // 插入文章记录。
88        let row = tx
89            .query_one(
90                "INSERT INTO posts (author_id, title, slug, summary, content_md, content_html, toc_html, status, published_at, cover_image, word_count, reading_time)
91                 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
92                 RETURNING id",
93                &[
94                    &user.id,
95                    &title.trim(),
96                    &final_slug,
97                    &summary,
98                    &content_md,
99                    &fields.content_html,
100                    &fields.toc_html,
101                    &fields.status.as_str(),
102                    &published_at,
103                    &fields.cover_image,
104                    &fields.word_count,
105                    &fields.reading_time,
106                ],
107            )
108            .await
109            .map_err(AppError::tx)?;
110
111        let post_id: i32 = row.get(0);
112
113        // 清洗标签并在事务中同步 post_tags 关联。
114        let tags_cleaned = clean_tags(&tags);
115        sync_tags(&tx, post_id, &tags_cleaned).await?;
116
117        // 同步素材引用关联(asset_refs):内部自带 DELETE 再重建。
118        sync_asset_refs(
119            &tx,
120            post_id,
121            &fields.content_html,
122            fields.cover_image.as_deref(),
123        )
124        .await?;
125
126        tx.commit().await.map_err(AppError::tx)?;
127
128        // 写入成功后按粒度失效相关缓存。
129        crate::cache::invalidate_post_metadata();
130        // 失效按 slug 缓存,避免之前缓存的 404 继续命中。
131        crate::cache::invalidate_post_by_slug(&final_slug).await;
132        // 失效该文章涉及的所有标签下文章列表缓存。
133        crate::cache::invalidate_tag_posts_for(&tags_cleaned).await;
134
135        // SSR:新文章会出现在首页/分页/归档/标签等所有列表页,全量失效。
136        crate::ssr_cache::invalidate_ssr_all_public();
137        crate::ssr_cache::bump_global_generation();
138
139        Ok(CreatePostResponse::ok(
140            "创建成功".to_string(),
141            post_id,
142            final_slug,
143        ))
144    }
145
146    #[cfg(not(feature = "server"))]
147    {
148        Ok(CreatePostResponse::err("server only".to_string()))
149    }
150}