文章写操作后总要一起失效 lists/tags/stats/search 四项元数据缓存。 新增 invalidate_post_metadata() 封装这个固定组合,替换 create/delete/ update/trash 中的 8 处重复 4 行序列。 保留定向失效(invalidate_post_by_slug / invalidate_tag_posts_for) 和批量回退路径(invalidate_all_post_caches + invalidate_search_results) 的显式调用——它们是按实际涉及数据细粒度控制的,不应被封装吞掉。
219 lines
8.0 KiB
Rust
219 lines
8.0 KiB
Rust
//! 更新文章接口。
|
||
//!
|
||
//! 校验管理员权限与文章归属,重新生成唯一 slug、渲染 Markdown,
|
||
//! 在事务中更新 posts 表并同步标签,最后失效相关缓存。
|
||
//! Dioxus server function,注册在 `/api` 路径下。
|
||
//! 仅在 `feature = "server"` 启用的服务端构建中写入数据库。
|
||
|
||
#![allow(clippy::too_many_arguments)]
|
||
|
||
use dioxus::prelude::*;
|
||
|
||
#[cfg(feature = "server")]
|
||
use super::helpers::{clean_tags, get_current_admin_user, sync_tags};
|
||
use super::types::CreatePostResponse;
|
||
#[cfg(feature = "server")]
|
||
use crate::api::error::AppError;
|
||
#[cfg(feature = "server")]
|
||
use crate::db::pool::get_conn;
|
||
#[cfg(feature = "server")]
|
||
use crate::models::post::PostStatus;
|
||
|
||
/// 更新指定文章。
|
||
///
|
||
/// 校验文章存在且属于当前 admin;处理 slug 变更、发布状态转换、标签同步,
|
||
/// 并失效文章详情、列表、标签与统计缓存。
|
||
#[server(UpdatePost, "/api")]
|
||
pub async fn update_post(
|
||
post_id: i32,
|
||
title: String,
|
||
slug: Option<String>,
|
||
summary: Option<String>,
|
||
content_md: String,
|
||
status: String,
|
||
tags: Vec<String>,
|
||
cover_image: Option<String>,
|
||
) -> Result<CreatePostResponse, ServerFnError> {
|
||
let user = get_current_admin_user().await?;
|
||
|
||
#[cfg(feature = "server")]
|
||
{
|
||
let mut client = get_conn().await.map_err(AppError::db_conn)?;
|
||
|
||
// Markdown 渲染移到阻塞线程池执行。
|
||
let md_for_render = content_md.clone();
|
||
let rendered = tokio::task::spawn_blocking(move || {
|
||
crate::api::markdown::render_markdown_enhanced(&md_for_render)
|
||
})
|
||
.await
|
||
.map_err(|_| AppError::Internal("Markdown 渲染任务失败"))?;
|
||
let content_html = rendered.html;
|
||
let toc_html = if rendered.toc_html.is_empty() {
|
||
None::<String>
|
||
} else {
|
||
Some(rendered.toc_html)
|
||
};
|
||
// 未填写摘要时自动从正文提取。
|
||
let summary = summary
|
||
.filter(|s| !s.trim().is_empty())
|
||
.unwrap_or_else(|| crate::utils::text::auto_summary(&content_md));
|
||
let post_status = PostStatus::from_str(&status).unwrap_or(PostStatus::Draft);
|
||
let cover_image = cover_image.filter(|s| !s.trim().is_empty());
|
||
|
||
// 重新计算字数与阅读时长,保持与正文同步。
|
||
let word_count = crate::utils::text::count_words(&content_md);
|
||
let reading_time = crate::utils::text::reading_time(word_count);
|
||
|
||
let tx = client.transaction().await.map_err(AppError::tx)?;
|
||
|
||
// 查询旧 slug,用于后续缓存失效。
|
||
let old_slug: Option<String> = tx
|
||
.query_opt("SELECT slug FROM posts WHERE id = $1", &[&post_id])
|
||
.await
|
||
.map_err(AppError::query)?
|
||
.map(|r| r.get(0));
|
||
|
||
// 校验文章存在、未删除且归属当前用户。
|
||
let exists: bool = tx
|
||
.query_opt(
|
||
"SELECT 1 FROM posts WHERE id = $1 AND author_id = $2 AND deleted_at IS NULL",
|
||
&[&post_id, &user.id],
|
||
)
|
||
.await
|
||
.map_err(AppError::query)?
|
||
.is_some();
|
||
|
||
if !exists {
|
||
return Ok(CreatePostResponse::err("文章不存在或无权限".to_string()));
|
||
}
|
||
|
||
// 确定基础 slug:用户传入时校验格式,否则由标题生成。
|
||
let base_slug = match slug {
|
||
Some(ref s) if !s.trim().is_empty() => {
|
||
let s = s.trim();
|
||
if !crate::api::slug::is_valid_slug(s) {
|
||
return Ok(CreatePostResponse::err("slug 格式无效".to_string()));
|
||
}
|
||
s.to_string()
|
||
}
|
||
_ => crate::api::slug::slugify(&title),
|
||
};
|
||
|
||
// 保证 slug 全局唯一,排除当前文章自身;在事务内检查避免并发竞态。
|
||
let final_slug =
|
||
crate::api::slug::ensure_unique_slug(&tx, &base_slug, Some(post_id)).await?;
|
||
|
||
// 获取文章旧标签,用于后续失效标签缓存。
|
||
let old_tags: Vec<String> = {
|
||
let rows = tx
|
||
.query(
|
||
"SELECT t.name FROM tags t JOIN post_tags pt ON t.id = pt.tag_id WHERE pt.post_id = $1",
|
||
&[&post_id],
|
||
)
|
||
.await
|
||
.map_err(AppError::query)?;
|
||
rows.iter().map(|r| r.get(0)).collect()
|
||
};
|
||
|
||
// 获取旧状态与旧发布时间,用于决定是否需要更新 published_at。
|
||
let old_status_row = tx
|
||
.query_opt(
|
||
"SELECT status, published_at FROM posts WHERE id = $1",
|
||
&[&post_id],
|
||
)
|
||
.await
|
||
.map_err(AppError::query)?;
|
||
|
||
// 发布时:若之前已发布则保留原时间,否则使用当前时间。
|
||
// 非发布时:保留原有 published_at(若为草稿可能为 None)。
|
||
let published_at = if post_status == PostStatus::Published {
|
||
let was_published = old_status_row
|
||
.as_ref()
|
||
.map(|r| {
|
||
let s: String = r.get(0);
|
||
s == "published"
|
||
})
|
||
.unwrap_or(false);
|
||
let existing_published: Option<chrono::DateTime<chrono::Utc>> =
|
||
old_status_row.as_ref().and_then(|r| r.get(1));
|
||
|
||
if was_published {
|
||
existing_published
|
||
} else {
|
||
Some(chrono::Utc::now())
|
||
}
|
||
} else {
|
||
old_status_row.and_then(|r| r.get(1))
|
||
};
|
||
|
||
// 更新文章主表。
|
||
let updated = tx
|
||
.execute(
|
||
"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()
|
||
WHERE id = $12",
|
||
&[
|
||
&title.trim(),
|
||
&final_slug,
|
||
&summary,
|
||
&content_md,
|
||
&content_html,
|
||
&toc_html,
|
||
&post_status.as_str(),
|
||
&published_at,
|
||
&cover_image,
|
||
&(word_count as i32),
|
||
&(reading_time as i32),
|
||
&post_id,
|
||
],
|
||
)
|
||
.await
|
||
.map_err(AppError::tx)?;
|
||
|
||
if updated == 0 {
|
||
return Ok(CreatePostResponse::err("文章不存在或无权限".to_string()));
|
||
}
|
||
|
||
let tags_cleaned = clean_tags(&tags);
|
||
let tags_for_invalidation = tags_cleaned.clone();
|
||
|
||
// 先清除旧标签关联,再重新同步新标签。
|
||
tx.execute("DELETE FROM post_tags WHERE post_id = $1", &[&post_id])
|
||
.await
|
||
.map_err(AppError::tx)?;
|
||
|
||
sync_tags(&tx, post_id, &tags_cleaned).await?;
|
||
|
||
tx.commit().await.map_err(AppError::tx)?;
|
||
|
||
// 失效文章列表、标签、当前 slug 与统计缓存。
|
||
crate::cache::invalidate_post_metadata();
|
||
crate::cache::invalidate_post_by_slug(&final_slug).await;
|
||
|
||
// 合并旧标签与新标签,统一失效标签下的文章列表缓存。
|
||
let all_tags_to_invalidate: Vec<String> = old_tags
|
||
.into_iter()
|
||
.chain(tags_for_invalidation.into_iter())
|
||
.collect::<std::collections::HashSet<_>>()
|
||
.into_iter()
|
||
.collect();
|
||
crate::cache::invalidate_tag_posts_for(&all_tags_to_invalidate).await;
|
||
|
||
// 若 slug 发生变更,额外失效旧 slug 缓存。
|
||
if let Some(ref old) = old_slug {
|
||
if old != &final_slug {
|
||
crate::cache::invalidate_post_by_slug(old).await;
|
||
}
|
||
}
|
||
|
||
// 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。
|
||
crate::ssr_cache::bump_global_generation();
|
||
|
||
Ok(CreatePostResponse::ok("更新成功".to_string(), post_id, final_slug))
|
||
}
|
||
|
||
#[cfg(not(feature = "server"))]
|
||
{
|
||
Ok(CreatePostResponse::err("server only".to_string()))
|
||
}
|
||
}
|