1use chrono::{DateTime, Utc};
6use tokio_postgres::types::ToSql;
7
8use super::{helpers, validation};
9use crate::api::error::AppError;
10use crate::db::pool::get_conn;
11use crate::models::post::PostStatus;
12
13#[derive(Debug)]
14pub(crate) enum WriteError {
15 Invalid(String),
16 Conflict(DateTime<Utc>),
17 Failed(AppError),
18}
19
20impl From<AppError> for WriteError {
21 fn from(value: AppError) -> Self {
22 Self::Failed(value)
23 }
24}
25
26#[derive(Debug, Default)]
28pub(crate) enum FieldChange<T> {
29 #[default]
30 Keep,
31 Set(T),
32 Clear,
33}
34
35#[derive(Debug)]
36pub(crate) struct PostDraft {
37 pub title: String,
38 pub slug: Option<String>,
39 pub summary: Option<String>,
40 pub content_md: String,
41 pub status: String,
42 pub tags: Vec<String>,
43 pub cover_image: Option<String>,
44}
45
46#[derive(Debug, Default)]
47pub(crate) struct PostPatch {
48 pub title: Option<String>,
49 pub slug: Option<String>,
50 pub summary: Option<String>,
51 pub content_md: Option<String>,
52 pub status: Option<String>,
53 pub tags: Option<Vec<String>>,
54 pub cover_image: FieldChange<String>,
55 pub published_at: FieldChange<DateTime<Utc>>,
56 pub expected_updated_at: Option<DateTime<Utc>>,
57}
58
59pub(crate) enum UpdatePost {
60 Replace(PostDraft),
61 Patch(PostPatch),
62}
63
64#[derive(Debug)]
65pub(crate) struct WriteOutcome {
66 pub post_id: i32,
67 pub slug: String,
68 pub updated_at: DateTime<Utc>,
69}
70
71fn normalize_cover(value: Option<&str>) -> Option<String> {
72 value
73 .map(str::trim)
74 .filter(|s| !s.is_empty())
75 .map(str::to_owned)
76}
77
78pub(crate) async fn create(
79 author_id: i32,
80 draft: PostDraft,
81 explicit_published_at: Option<DateTime<Utc>>,
82) -> Result<WriteOutcome, WriteError> {
83 validation::title(&draft.title)?;
84 validation::content(&draft.content_md)?;
85 let base_slug = validation::base_slug(&draft.title, draft.slug.as_deref())?;
86 if let Some(s) = &draft.summary {
87 validation::summary(s)?;
88 }
89 if let Some(c) = &draft.cover_image {
90 validation::cover(c)?;
91 }
92 validation::tags(&draft.tags)?;
93 validation::status(&draft.status)?;
94 let fields = helpers::render_post_fields(
95 &draft.content_md,
96 &draft.status,
97 draft.cover_image.as_deref(),
98 )
99 .await?;
100 let summary = draft
101 .summary
102 .as_deref()
103 .map(str::trim)
104 .filter(|s| !s.is_empty())
105 .map(str::to_owned)
106 .unwrap_or(fields.auto_summary);
107 let now = Utc::now();
108 let published_at =
109 (fields.status == PostStatus::Published).then(|| explicit_published_at.unwrap_or(now));
110 let created_at = explicit_published_at.unwrap_or(now);
111 let mut client = get_conn().await.map_err(AppError::db_conn)?;
112 let tx = client.transaction().await.map_err(AppError::tx)?;
113 let slug = crate::api::slug::ensure_unique_slug(&tx, &base_slug, None).await?;
114 let row = tx.query_one(
115 "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)
116 VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING id, updated_at",
117 &[&author_id, &draft.title.trim(), &slug, &summary, &draft.content_md,
118 &fields.content_html, &fields.toc_html, &fields.status.as_str(), &published_at,
119 &fields.cover_image, &fields.word_count, &fields.reading_time, &created_at],
120 ).await.map_err(AppError::tx)?;
121 let post_id = row.get("id");
122 let updated_at = row.get("updated_at");
123 let tags = helpers::clean_tags(&draft.tags);
124 helpers::sync_tags(&tx, post_id, &tags).await?;
125 helpers::sync_asset_refs(
126 &tx,
127 post_id,
128 &fields.content_html,
129 fields.cover_image.as_deref(),
130 )
131 .await?;
132 tx.commit().await.map_err(AppError::tx)?;
133 crate::cache::invalidate_for_post_write(std::slice::from_ref(&slug), &tags).await;
134 Ok(WriteOutcome {
135 post_id,
136 slug,
137 updated_at,
138 })
139}
140
141async fn replacement(draft: PostDraft) -> Result<(PostPatch, helpers::RenderedFields), WriteError> {
145 validation::title(&draft.title)?;
146 validation::content(&draft.content_md)?;
147 let slug = validation::base_slug(&draft.title, draft.slug.as_deref())?;
148 let trimmed_slug = slug.trim();
150 if !trimmed_slug.is_empty() {
151 validation::slug(trimmed_slug)?;
152 }
153 if let Some(s) = &draft.summary {
154 validation::summary(s)?;
155 }
156 if let Some(c) = &draft.cover_image {
157 validation::cover(c)?;
158 }
159 validation::tags(&draft.tags)?;
160 validation::status(&draft.status)?;
161 let cover = normalize_cover(draft.cover_image.as_deref());
162 let fields =
163 helpers::render_post_fields(&draft.content_md, &draft.status, cover.as_deref()).await?;
164 let patch = PostPatch {
165 title: Some(draft.title),
166 slug: Some(slug),
167 summary: Some(draft.summary.unwrap_or_default()),
168 content_md: Some(draft.content_md),
169 status: Some(draft.status),
170 tags: Some(draft.tags),
171 cover_image: cover.map(FieldChange::Set).unwrap_or(FieldChange::Clear),
172 ..Default::default()
173 };
174 Ok((patch, fields))
175}
176
177fn validate_patch(p: &PostPatch) -> Result<(), WriteError> {
178 if p.title.is_none()
179 && p.slug.is_none()
180 && p.summary.is_none()
181 && p.content_md.is_none()
182 && p.status.is_none()
183 && p.tags.is_none()
184 && matches!(p.cover_image, FieldChange::Keep)
185 && matches!(p.published_at, FieldChange::Keep)
186 {
187 return Err(WriteError::Invalid("至少提供一个可更新字段".into()));
188 }
189 if let Some(title) = &p.title {
190 validation::title(title)?;
191 }
192 if let Some(content) = &p.content_md {
193 validation::content(content)?;
194 }
195 if let Some(slug) = p.slug.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
196 validation::slug(slug)?;
197 }
198 if let Some(s) = &p.summary {
199 validation::summary(s)?;
200 }
201 if let FieldChange::Set(c) = &p.cover_image {
202 validation::cover(c)?;
203 }
204 if let Some(t) = &p.tags {
205 validation::tags(t)?;
206 }
207 if let Some(s) = &p.status {
208 validation::status(s)?;
209 }
210 Ok(())
211}
212
213pub(crate) async fn update(
214 author_id: i32,
215 post_id: i32,
216 command: UpdatePost,
217) -> Result<WriteOutcome, WriteError> {
218 let (p, prepared, align_created_at) = match command {
219 UpdatePost::Replace(draft) => {
221 let (p, fields) = replacement(draft).await?;
222 (p, Some(fields), false)
223 }
224 UpdatePost::Patch(patch) => {
227 validate_patch(&patch)?;
228 let prepared = match &patch.content_md {
229 Some(md) => Some(
230 helpers::render_post_fields(
231 md,
232 patch.status.as_deref().unwrap_or("draft"),
233 None,
234 )
235 .await?,
236 ),
237 None => None,
238 };
239 (patch, prepared, true)
240 }
241 };
242 let mut client = get_conn().await.map_err(AppError::db_conn)?;
243 let tx = client.transaction().await.map_err(AppError::tx)?;
244 let old = tx
245 .query_opt(
246 "SELECT slug, status, published_at, cover_image, updated_at, content_html FROM posts
247 WHERE id=$1 AND author_id=$2 AND deleted_at IS NULL FOR UPDATE",
248 &[&post_id, &author_id],
249 )
250 .await
251 .map_err(AppError::query)?
252 .ok_or_else(|| WriteError::Invalid("文章不存在或无权限".into()))?;
253 let current_updated_at: DateTime<Utc> = old.get("updated_at");
254 if p.expected_updated_at
255 .is_some_and(|expected| expected != current_updated_at)
256 {
257 return Err(WriteError::Conflict(current_updated_at));
258 }
259 let old_slug: String = old.get("slug");
260 let old_status: String = old.get("status");
261 let old_published_at: Option<DateTime<Utc>> = old.get("published_at");
262 let old_cover: Option<String> = old.get("cover_image");
263 let old_tags = helpers::fetch_post_tags(&tx, post_id).await?;
264 let cover_changed = !matches!(p.cover_image, FieldChange::Keep);
265 let effective_cover = match &p.cover_image {
266 FieldChange::Keep => old_cover,
267 FieldChange::Set(value) => normalize_cover(Some(value)),
268 FieldChange::Clear => None,
269 };
270 let rendered = prepared;
271 let summary = match (&p.summary, &rendered) {
273 (Some(s), fields) if s.trim().is_empty() => fields.as_ref().map(|f| f.auto_summary.clone()),
274 (Some(s), _) => Some(s.trim().to_owned()),
275 (None, Some(fields)) => Some(fields.auto_summary.clone()),
276 (None, None) => None,
277 };
278 let slug = match p.slug.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
279 Some(base) => crate::api::slug::ensure_unique_slug(&tx, base, Some(post_id)).await?,
280 None => old_slug.clone(),
281 };
282 let status = p.status.as_deref().and_then(PostStatus::from_str);
284 let align_created_at = align_created_at && matches!(p.published_at, FieldChange::Set(_));
287 let published_at = match p.published_at {
288 FieldChange::Set(value) => Some(Some(value)),
289 FieldChange::Clear => Some(None),
290 FieldChange::Keep => status.as_ref().map(|s| {
291 if *s == PostStatus::Published && old_status != "published" {
292 Some(Utc::now())
293 } else {
294 old_published_at
295 }
296 }),
297 };
298 let mut sets = Vec::new();
299 let mut params: Vec<Box<dyn ToSql + Sync + Send>> = Vec::new();
300 macro_rules! push {
301 ($col:expr, $value:expr) => {{
302 params.push(Box::new($value));
303 sets.push(format!("{} = ${}", $col, params.len()));
304 }};
305 }
306 if let Some(title) = p.title {
307 push!("title", title.trim().to_owned());
308 }
309 if let Some(md) = p.content_md {
310 push!("content_md", md);
311 }
312 if let Some(f) = &rendered {
313 push!("content_html", f.content_html.clone());
314 push!("toc_html", f.toc_html.clone());
315 push!("word_count", f.word_count);
316 push!("reading_time", f.reading_time);
317 }
318 if let Some(summary) = summary {
319 push!("summary", summary);
320 }
321 if p.slug.is_some() {
322 push!("slug", slug.clone());
323 }
324 if let Some(status) = status {
325 push!("status", status.as_str().to_owned());
326 }
327 if let Some(date) = published_at {
328 push!("published_at", date);
329 if align_created_at {
330 if let Some(date) = date {
331 push!("created_at", date);
332 }
333 }
334 }
335 if cover_changed {
336 push!("cover_image", effective_cover.clone());
337 }
338 sets.push(
339 "updated_at = GREATEST(clock_timestamp(), updated_at + INTERVAL '1 microsecond')".into(),
340 );
341 params.push(Box::new(post_id));
342 let refs: Vec<&(dyn ToSql + Sync)> = params
343 .iter()
344 .map(|p| p.as_ref() as &(dyn ToSql + Sync))
345 .collect();
346 let row = tx
347 .query_one(
348 &format!(
349 "UPDATE posts SET {} WHERE id=${} RETURNING updated_at",
350 sets.join(", "),
351 params.len()
352 ),
353 &refs,
354 )
355 .await
356 .map_err(AppError::tx)?;
357 let updated_at = row.get("updated_at");
358 let tags = if let Some(tags) = p.tags {
359 let tags = helpers::clean_tags(&tags);
360 tx.execute("DELETE FROM post_tags WHERE post_id=$1", &[&post_id])
361 .await
362 .map_err(AppError::tx)?;
363 helpers::sync_tags(&tx, post_id, &tags).await?;
364 tags
365 } else {
366 old_tags.clone()
367 };
368 if rendered.is_some() || cover_changed {
369 let html = rendered.map(|f| f.content_html).unwrap_or_else(|| {
370 old.get::<_, Option<String>>("content_html")
371 .unwrap_or_default()
372 });
373 helpers::sync_asset_refs(&tx, post_id, &html, effective_cover.as_deref()).await?;
374 }
375 tx.commit().await.map_err(AppError::tx)?;
376 let all_tags: Vec<String> = old_tags
377 .into_iter()
378 .chain(tags)
379 .collect::<std::collections::HashSet<_>>()
380 .into_iter()
381 .collect();
382 crate::cache::invalidate_for_post_write(&[old_slug, slug.clone()], &all_tags).await;
383 Ok(WriteOutcome {
384 post_id,
385 slug,
386 updated_at,
387 })
388}
389
390pub(crate) enum PostAction {
391 Publish,
392 Unpublish,
394 Trash,
395 Delete,
396 Purge,
397}
398
399pub(crate) async fn act(
401 post_id: i32,
402 author_id: Option<i32>,
403 action: PostAction,
404) -> Result<WriteOutcome, WriteError> {
405 let mut client = get_conn().await.map_err(AppError::db_conn)?;
406 let tx = client.transaction().await.map_err(AppError::tx)?;
407 let condition = match action {
408 PostAction::Publish | PostAction::Unpublish | PostAction::Trash => "AND deleted_at IS NULL",
409 PostAction::Purge => "AND deleted_at IS NOT NULL",
410 PostAction::Delete => "",
411 };
412 let row = tx.query_opt(&format!("SELECT slug, updated_at FROM posts WHERE id=$1 AND ($2::int IS NULL OR author_id=$2) {condition} FOR UPDATE"), &[&post_id, &author_id])
413 .await.map_err(AppError::query)?.ok_or_else(|| WriteError::Invalid("文章不存在或无权限".into()))?;
414 let slug = row.get("slug");
415 let tags = helpers::fetch_post_tags(&tx, post_id).await?;
416 let sql = match action {
417 PostAction::Publish => "UPDATE posts SET status='published', published_at=COALESCE(published_at, NOW()), updated_at=GREATEST(clock_timestamp(), updated_at + INTERVAL '1 microsecond') WHERE id=$1 RETURNING updated_at",
418 PostAction::Unpublish => "UPDATE posts SET status='draft', updated_at=GREATEST(clock_timestamp(), updated_at + INTERVAL '1 microsecond') WHERE id=$1 RETURNING updated_at",
419 PostAction::Trash => "UPDATE posts SET deleted_at=NOW(), updated_at=GREATEST(clock_timestamp(), updated_at + INTERVAL '1 microsecond') WHERE id=$1 RETURNING updated_at",
420 PostAction::Delete | PostAction::Purge => "DELETE FROM posts WHERE id=$1 RETURNING updated_at",
421 };
422 let updated = tx.query_one(sql, &[&post_id]).await.map_err(AppError::tx)?;
423 let updated_at = updated.get("updated_at");
424 tx.commit().await.map_err(AppError::tx)?;
425 crate::cache::invalidate_for_post_write(std::slice::from_ref(&slug), &tags).await;
426 Ok(WriteOutcome {
427 post_id,
428 slug,
429 updated_at,
430 })
431}