Skip to main content

yggdrasil/api/posts/
service.rs

1//! 文章写入业务:已鉴权入口传入作者,统一执行校验、事务和提交后缓存失效。
2//!
3//! 完整更新与部分更新通过内部命令区分;Cookie 与 MCP 的协议和错误映射由入口负责。
4
5use 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/// 可选字段的写入意图;清空与不修改在业务层中有不同含义。
27#[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
141/// 完整更新:校验草稿并在事务外渲染正文,返回补丁与渲染结果。
142///
143/// 渲染直接使用草稿里已校验的正文与状态,避免事后从 `Option` 字段取回。
144async 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    // 与 PATCH 同规则(validate_patch):由标题生成的 slug 同样须通过格式校验。
149    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        // 完整更新的正文与元信息已齐全,沿用浏览器接口在事务外渲染的行为。
220        UpdatePost::Replace(draft) => {
221            let (p, fields) = replacement(draft).await?;
222            (p, Some(fields), false)
223        }
224        // PATCH 缺省字段(状态、封面)需从锁定行读取;渲染只依赖正文,与 Replace 一样在加锁前完成。
225        // 版本冲突仍由下方 FOR UPDATE 行上的 expected_updated_at 判定。
226        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    // 空摘要只在同时提供正文时重新提取;单独传空摘要沿用已有 PATCH 行为。
272    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    // 已由 validate_patch / replacement 校验,非法值走不到这里。
283    let status = p.status.as_deref().and_then(PostStatus::from_str);
284    // created_at 只跟随调用方显式给出的发布时间(与 create 一致);
285    // 仅改状态时推导出的 published_at 不能改写创建时间。
286    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    /// 转回草稿:保留首次发布时间,重新发布时沿用。
393    Unpublish,
394    Trash,
395    Delete,
396    Purge,
397}
398
399/// 管理员可传 None 操作全站文章;令牌入口传 Some(author_id) 限制到原作者。
400pub(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}