Skip to main content

yggdrasil/api/posts/
update.rs

1//! 更新文章接口。
2//!
3//! 校验管理员权限与文章归属,重新生成唯一 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/// 校验文章存在且属于当前 admin;处理 slug 变更、发布状态转换、标签同步,
27/// 并失效文章详情、列表、标签与统计缓存。
28#[server(UpdatePost, "/api")]
29pub async fn update_post(
30    post_id: i32,
31    title: String,
32    slug: Option<String>,
33    summary: Option<String>,
34    content_md: String,
35    status: String,
36    tags: Vec<String>,
37    cover_image: Option<String>,
38) -> Result<CreatePostResponse, ServerFnError> {
39    let user = get_current_admin_user().await?;
40
41    #[cfg(feature = "server")]
42    {
43        let mut client = get_conn().await.map_err(AppError::db_conn)?;
44
45        // Markdown 渲染 + 度量派生收敛到 helper(R4)。
46        let fields = render_post_fields(&content_md, &status, cover_image.as_deref()).await?;
47        // 未填写摘要时用自动摘要兜底。
48        let summary = summary
49            .filter(|s| !s.trim().is_empty())
50            .unwrap_or(fields.auto_summary);
51
52        let tx = client.transaction().await.map_err(AppError::tx)?;
53
54        // 查询旧 slug,用于后续缓存失效。
55        let old_slug: Option<String> = tx
56            .query_opt("SELECT slug FROM posts WHERE id = $1", &[&post_id])
57            .await
58            .map_err(AppError::query)?
59            .map(|r| r.get(0));
60
61        // 校验文章存在、未删除且归属当前用户。
62        let exists: bool = tx
63            .query_opt(
64                "SELECT 1 FROM posts WHERE id = $1 AND author_id = $2 AND deleted_at IS NULL",
65                &[&post_id, &user.id],
66            )
67            .await
68            .map_err(AppError::query)?
69            .is_some();
70
71        if !exists {
72            return Ok(CreatePostResponse::err("文章不存在或无权限".to_string()));
73        }
74
75        // 确定基础 slug:用户传入时校验格式,否则由标题生成。
76        let base_slug = match slug {
77            Some(ref s) if !s.trim().is_empty() => {
78                let s = s.trim();
79                if !crate::api::slug::is_valid_slug(s) {
80                    return Ok(CreatePostResponse::err("slug 格式无效".to_string()));
81                }
82                s.to_string()
83            }
84            _ => crate::api::slug::slugify(&title),
85        };
86
87        // 保证 slug 全局唯一,排除当前文章自身;在事务内检查避免并发竞态。
88        let final_slug =
89            crate::api::slug::ensure_unique_slug(&tx, &base_slug, Some(post_id)).await?;
90
91        // 获取文章旧标签,用于后续失效标签缓存。
92        let old_tags = super::helpers::fetch_post_tags(&tx, post_id).await?;
93
94        // 获取旧状态与旧发布时间,用于决定是否需要更新 published_at。
95        let old_status_row = tx
96            .query_opt(
97                "SELECT status, published_at FROM posts WHERE id = $1",
98                &[&post_id],
99            )
100            .await
101            .map_err(AppError::query)?;
102
103        // 发布时:若之前已发布则保留原时间,否则使用当前时间。
104        // 非发布时:保留原有 published_at(若为草稿可能为 None)。
105        let published_at = if fields.status == PostStatus::Published {
106            let was_published = old_status_row
107                .as_ref()
108                .map(|r| {
109                    let s: String = r.get(0);
110                    s == "published"
111                })
112                .unwrap_or(false);
113            let existing_published: Option<chrono::DateTime<chrono::Utc>> =
114                old_status_row.as_ref().and_then(|r| r.get(1));
115
116            if was_published {
117                existing_published
118            } else {
119                Some(chrono::Utc::now())
120            }
121        } else {
122            old_status_row.and_then(|r| r.get(1))
123        };
124
125        // 更新文章主表。
126        let updated = tx
127            .execute(
128                "UPDATE posts SET title = $1, slug = $2, summary = $3, content_md = $4, content_html = $5, toc_html = $6, status = $7, published_at = $8, cover_image = $9, word_count = $10, reading_time = $11, updated_at = NOW()
129                 WHERE id = $12",
130                &[
131                    &title.trim(),
132                    &final_slug,
133                    &summary,
134                    &content_md,
135                    &fields.content_html,
136                    &fields.toc_html,
137                    &fields.status.as_str(),
138                    &published_at,
139                    &fields.cover_image,
140                    &fields.word_count,
141                    &fields.reading_time,
142                    &post_id,
143                ],
144            )
145            .await
146            .map_err(AppError::tx)?;
147
148        if updated == 0 {
149            return Ok(CreatePostResponse::err("文章不存在或无权限".to_string()));
150        }
151
152        let tags_cleaned = clean_tags(&tags);
153        let tags_for_invalidation = tags_cleaned.clone();
154
155        // 先清除旧标签关联,再重新同步新标签。
156        tx.execute("DELETE FROM post_tags WHERE post_id = $1", &[&post_id])
157            .await
158            .map_err(AppError::tx)?;
159
160        sync_tags(&tx, post_id, &tags_cleaned).await?;
161
162        // 同步素材引用关联(asset_refs):内部自带 DELETE 再重建。
163        sync_asset_refs(
164            &tx,
165            post_id,
166            &fields.content_html,
167            fields.cover_image.as_deref(),
168        )
169        .await?;
170
171        tx.commit().await.map_err(AppError::tx)?;
172
173        // 失效文章列表、标签、当前 slug 与统计缓存。
174        crate::cache::invalidate_post_metadata();
175        crate::cache::invalidate_post_by_slug(&final_slug).await;
176
177        // 合并旧标签与新标签,统一失效标签下的文章列表缓存。
178        let all_tags_to_invalidate: Vec<String> = old_tags
179            .into_iter()
180            .chain(tags_for_invalidation.into_iter())
181            .collect::<std::collections::HashSet<_>>()
182            .into_iter()
183            .collect();
184        crate::cache::invalidate_tag_posts_for(&all_tags_to_invalidate).await;
185
186        // 若 slug 发生变更,额外失效旧 slug 缓存。
187        if let Some(ref old) = old_slug {
188            if old != &final_slug {
189                crate::cache::invalidate_post_by_slug(old).await;
190                crate::ssr_cache::invalidate_ssr_route(&format!("/post/{old}"));
191            }
192        }
193
194        // SSR:内容/标签/摘要变化影响详情页与所有列表页。
195        crate::ssr_cache::invalidate_ssr_route(&format!("/post/{final_slug}"));
196        crate::ssr_cache::invalidate_ssr_all_public();
197        crate::ssr_cache::bump_global_generation();
198
199        Ok(CreatePostResponse::ok(
200            "更新成功".to_string(),
201            post_id,
202            final_slug,
203        ))
204    }
205
206    #[cfg(not(feature = "server"))]
207    {
208        Ok(CreatePostResponse::err("server only".to_string()))
209    }
210}