From 65e090da27c6c46645f809b6092ae8a51fd59a89 Mon Sep 17 00:00:00 2001 From: xfy Date: Tue, 16 Jun 2026 12:31:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(api):=20=E6=96=B0=E5=A2=9E=E5=9B=9E?= =?UTF-8?q?=E6=94=B6=E7=AB=99=E6=81=A2=E5=A4=8D/=E5=BD=BB=E5=BA=95?= =?UTF-8?q?=E5=88=A0=E9=99=A4/=E6=89=B9=E9=87=8F/=E6=B8=85=E7=A9=BA?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/posts/mod.rs | 4 + src/api/posts/trash.rs | 286 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 290 insertions(+) create mode 100644 src/api/posts/trash.rs diff --git a/src/api/posts/mod.rs b/src/api/posts/mod.rs index f261514..a998ed3 100644 --- a/src/api/posts/mod.rs +++ b/src/api/posts/mod.rs @@ -16,6 +16,7 @@ mod stats; mod tags; mod types; mod update; +mod trash; /// 创建新文章。 #[allow(unused_imports)] @@ -44,6 +45,9 @@ pub use types::*; /// 更新指定文章。 #[allow(unused_imports)] pub use update::update_post; +/// 恢复已删除文章。 +#[allow(unused_imports)] +pub use trash::{batch_purge_posts, batch_restore_posts, empty_trash, purge_post, restore_post}; /// 将 Markdown 渲染为增强 HTML(含目录)。 #[cfg(feature = "server")] diff --git a/src/api/posts/trash.rs b/src/api/posts/trash.rs new file mode 100644 index 0000000..b81fc6f --- /dev/null +++ b/src/api/posts/trash.rs @@ -0,0 +1,286 @@ +//! 回收站操作接口:恢复、彻底删除、批量操作与一键清空。 +//! +//! 所有接口需要 admin 权限,操作后清空全部文章相关缓存。 +//! Dioxus server function,注册在 `/api` 路径下。 +//! 仅在 `feature = "server"` 启用的服务端构建中执行数据库操作。 + +use dioxus::prelude::*; + +#[cfg(feature = "server")] +use super::helpers::get_current_admin_user; +use super::types::CreatePostResponse; +#[cfg(feature = "server")] +use crate::api::error::AppError; +#[cfg(feature = "server")] +use crate::api::slug::ensure_unique_slug; +#[cfg(feature = "server")] +use crate::db::pool::get_conn; + +/// 恢复一篇已删除的文章(将 deleted_at 置空)。 +/// +/// 若该文章原始 slug 已被其他未删除文章占用,自动追加数字后缀。 +#[server(RestorePost, "/api")] +pub async fn restore_post(post_id: i32) -> Result { + let _user = get_current_admin_user().await?; + + #[cfg(feature = "server")] + { + let client = get_conn().await.map_err(AppError::db_conn)?; + + // 读取待恢复文章的当前 slug 与是否确已删除。 + let row = client + .query_opt( + "SELECT slug FROM posts WHERE id = $1 AND deleted_at IS NOT NULL", + &[&post_id], + ) + .await + .map_err(AppError::query)?; + + let Some(row) = row else { + return Ok(CreatePostResponse { + success: false, + message: "文章不在回收站".to_string(), + post_id: None, + slug: None, + }); + }; + + let current_slug: String = row.get("slug"); + + // 恢复时确保 slug 在未删除文章中唯一(自动加后缀)。 + let new_slug = ensure_unique_slug(&client, ¤t_slug, Some(post_id)).await?; + + // 置空 deleted_at,并更新 slug(可能已加后缀)。 + let result = client + .execute( + "UPDATE posts SET deleted_at = NULL, slug = $1 WHERE id = $2 AND deleted_at IS NOT NULL", + &[&new_slug, &post_id], + ) + .await + .map_err(AppError::query)?; + + if result == 0 { + return Ok(CreatePostResponse { + success: false, + message: "文章不在回收站".to_string(), + post_id: None, + slug: None, + }); + } + + crate::cache::invalidate_all_post_caches(); + + Ok(CreatePostResponse { + success: true, + message: "恢复成功".to_string(), + post_id: Some(post_id), + slug: Some(new_slug), + }) + } + + #[cfg(not(feature = "server"))] + { + Ok(CreatePostResponse { + success: false, + message: "server only".to_string(), + post_id: None, + slug: None, + }) + } +} + +/// 彻底删除一篇已删除的文章(物理删除,不可恢复)。 +/// +/// 注意:仅删除数据库记录,不删除已上传的图片文件。 +/// post_tags 关联因外键 ON DELETE CASCADE 自动清理。 +#[server(PurgePost, "/api")] +pub async fn purge_post(post_id: i32) -> Result { + let _user = get_current_admin_user().await?; + + #[cfg(feature = "server")] + { + let client = get_conn().await.map_err(AppError::db_conn)?; + + let result = client + .execute( + "DELETE FROM posts WHERE id = $1 AND deleted_at IS NOT NULL", + &[&post_id], + ) + .await + .map_err(AppError::query)?; + + if result == 0 { + return Ok(CreatePostResponse { + success: false, + message: "文章不在回收站".to_string(), + post_id: None, + slug: None, + }); + } + + crate::cache::invalidate_all_post_caches(); + + Ok(CreatePostResponse { + success: true, + message: "彻底删除成功".to_string(), + post_id: Some(post_id), + slug: None, + }) + } + + #[cfg(not(feature = "server"))] + { + Ok(CreatePostResponse { + success: false, + message: "server only".to_string(), + post_id: None, + slug: None, + }) + } +} + +/// 批量恢复文章。 +#[server(BatchRestorePosts, "/api")] +pub async fn batch_restore_posts(post_ids: Vec) -> Result { + let _user = get_current_admin_user().await?; + + #[cfg(feature = "server")] + { + if post_ids.is_empty() { + return Ok(CreatePostResponse { + success: true, + message: "无操作".to_string(), + post_id: None, + slug: None, + }); + } + + let client = get_conn().await.map_err(AppError::db_conn)?; + + // 逐条恢复,slug 冲突时自动加后缀。 + let mut restored = 0u64; + for id in &post_ids { + let row = client + .query_opt( + "SELECT slug FROM posts WHERE id = $1 AND deleted_at IS NOT NULL", + &[&id], + ) + .await + .map_err(AppError::query)?; + if let Some(row) = row { + let current_slug: String = row.get("slug"); + let new_slug = ensure_unique_slug(&client, ¤t_slug, Some(*id)).await?; + let n = client + .execute( + "UPDATE posts SET deleted_at = NULL, slug = $1 WHERE id = $2 AND deleted_at IS NOT NULL", + &[&new_slug, &id], + ) + .await + .map_err(AppError::query)?; + restored += n; + } + } + + crate::cache::invalidate_all_post_caches(); + + Ok(CreatePostResponse { + success: true, + message: format!("已恢复 {restored} 篇"), + post_id: None, + slug: None, + }) + } + + #[cfg(not(feature = "server"))] + { + Ok(CreatePostResponse { + success: false, + message: "server only".to_string(), + post_id: None, + slug: None, + }) + } +} + +/// 批量彻底删除文章。 +#[server(BatchPurgePosts, "/api")] +pub async fn batch_purge_posts(post_ids: Vec) -> Result { + let _user = get_current_admin_user().await?; + + #[cfg(feature = "server")] + { + if post_ids.is_empty() { + return Ok(CreatePostResponse { + success: true, + message: "无操作".to_string(), + post_id: None, + slug: None, + }); + } + + let client = get_conn().await.map_err(AppError::db_conn)?; + + let total = post_ids.len() as i64; + let result = client + .execute( + "DELETE FROM posts WHERE id = ANY($1) AND deleted_at IS NOT NULL", + &[&post_ids], + ) + .await + .map_err(AppError::query)?; + + crate::cache::invalidate_all_post_caches(); + + Ok(CreatePostResponse { + success: true, + message: format!("已彻底删除 {result}/{total} 篇"), + post_id: None, + slug: None, + }) + } + + #[cfg(not(feature = "server"))] + { + Ok(CreatePostResponse { + success: false, + message: "server only".to_string(), + post_id: None, + slug: None, + }) + } +} + +/// 清空回收站:彻底删除所有已软删除的文章。 +#[server(EmptyTrash, "/api")] +pub async fn empty_trash() -> Result { + let _user = get_current_admin_user().await?; + + #[cfg(feature = "server")] + { + let client = get_conn().await.map_err(AppError::db_conn)?; + + let result = client + .execute("DELETE FROM posts WHERE deleted_at IS NOT NULL", &[]) + .await + .map_err(AppError::query)?; + + crate::cache::invalidate_all_post_caches(); + + Ok(CreatePostResponse { + success: true, + message: format!("已清空回收站({result} 篇)"), + post_id: None, + slug: None, + }) + } + + #[cfg(not(feature = "server"))] + { + Ok(CreatePostResponse { + success: false, + message: "server only".to_string(), + post_id: None, + slug: None, + }) + } +}