yggdrasil/api/posts/
update.rs1#![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#[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 let fields = render_post_fields(&content_md, &status, cover_image.as_deref()).await?;
47 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 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 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 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 let final_slug =
89 crate::api::slug::ensure_unique_slug(&tx, &base_slug, Some(post_id)).await?;
90
91 let old_tags = super::helpers::fetch_post_tags(&tx, post_id).await?;
93
94 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 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 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 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 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 crate::cache::invalidate_post_metadata();
175 crate::cache::invalidate_post_by_slug(&final_slug).await;
176
177 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 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 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}