Skip to main content

yggdrasil/api/posts/
trash.rs

1//! 回收站操作接口:恢复、彻底删除、批量操作与一键清空。
2//!
3//! 所有接口需要 admin 权限,操作后按影响范围精准失效缓存;
4//! 仅在影响集很大(如批量清空)时才回退到全量缓存失效。
5//! Leptos server function,注册在 `/api` 路径下。
6//! 仅在 `feature = "server"` 启用的服务端构建中执行数据库操作。
7
8use crate::api::error::ApiError;
9use leptos::prelude::*;
10
11#[cfg(feature = "server")]
12use super::helpers::get_current_admin_user;
13use super::types::CreatePostResponse;
14#[cfg(feature = "server")]
15use crate::api::error::AppError;
16#[cfg(feature = "server")]
17use crate::api::slug::ensure_unique_slug;
18#[cfg(feature = "server")]
19use crate::db::pool::get_conn;
20
21/// 批量/清空操作使用精准失效的最大记录数阈值。
22/// 超过该阈值时回退到 `invalidate_all_post_caches()`,避免大量串行缓存操作。
23#[cfg(feature = "server")]
24const PRECISE_INVALIDATION_LIMIT: usize = 50;
25
26/// 恢复一篇已删除的文章(将 deleted_at 置空)。
27///
28/// 若该文章原始 slug 已被其他未删除文章占用,自动追加数字后缀。
29#[server(RestorePost, endpoint = "restore_post", input = leptos::server_fn::codec::Json)]
30pub async fn restore_post(post_id: i32) -> Result<CreatePostResponse, ApiError> {
31    let _user = get_current_admin_user().await?;
32
33    restore_post_impl(post_id, None).await
34}
35
36/// 已鉴权入口共用的恢复逻辑;Some(author_id) 将访问限制到指定作者。
37#[cfg(feature = "server")]
38pub(crate) async fn restore_post_impl(
39    post_id: i32,
40    author_id: Option<i32>,
41) -> Result<CreatePostResponse, ApiError> {
42    let mut client = get_conn().await.map_err(AppError::db_conn)?;
43    let tx = client.transaction().await.map_err(AppError::tx)?;
44
45    // 在事务内锁定行并读取当前 slug、标签与是否确已删除。
46    let row = tx
47        .query_opt(
48            "SELECT slug FROM posts WHERE id = $1 AND deleted_at IS NOT NULL AND ($2::int IS NULL OR author_id = $2) FOR UPDATE",
49            &[&post_id, &author_id],
50        )
51        .await
52        .map_err(AppError::query)?;
53
54    let Some(row) = row else {
55        return Ok(CreatePostResponse::err("文章不在回收站".to_string()));
56    };
57
58    let current_slug: String = row.get("slug");
59
60    // 恢复时确保 slug 在未删除文章中唯一(自动加后缀);在事务内检查避免并发竞态。
61    let new_slug = ensure_unique_slug(&tx, &current_slug, Some(post_id)).await?;
62
63    let tags = super::helpers::fetch_post_tags(&tx, post_id).await?;
64
65    // 置空 deleted_at,并更新 slug(可能已加后缀)。
66    let result = tx
67        .execute(
68            "UPDATE posts SET deleted_at = NULL, slug = $1 WHERE id = $2 AND deleted_at IS NOT NULL",
69            &[&new_slug, &post_id],
70        )
71        .await
72        .map_err(AppError::tx)?;
73
74    if result == 0 {
75        return Ok(CreatePostResponse::err("文章不在回收站".to_string()));
76    }
77
78    tx.commit().await.map_err(AppError::tx)?;
79
80    // 精准失效:列表、标签云、统计、旧/新 slug 与相关标签文章(moka + SSR)。
81    let restore_slugs = [current_slug, new_slug.clone()];
82    crate::cache::invalidate_for_post_write(&restore_slugs, &tags).await;
83
84    Ok(CreatePostResponse::ok(
85        "恢复成功".to_string(),
86        post_id,
87        new_slug,
88    ))
89}
90
91/// 彻底删除一篇已删除的文章(物理删除,不可恢复)。
92///
93/// 注意:仅删除数据库记录,不删除已上传的图片文件。
94/// post_tags 关联因外键 ON DELETE CASCADE 自动清理。
95#[server(PurgePost, endpoint = "purge_post", input = leptos::server_fn::codec::Json)]
96pub async fn purge_post(post_id: i32) -> Result<CreatePostResponse, ApiError> {
97    let _user = get_current_admin_user().await?;
98
99    super::types::write_response(
100        super::service::act(post_id, None, super::service::PostAction::Purge).await,
101        "彻底删除成功",
102    )
103}
104
105/// 批量恢复文章。
106#[server(BatchRestorePosts, endpoint = "batch_restore_posts", input = leptos::server_fn::codec::Json)]
107pub async fn batch_restore_posts(post_ids: Vec<i32>) -> Result<CreatePostResponse, ApiError> {
108    let _user = get_current_admin_user().await?;
109
110    #[cfg(feature = "server")]
111    {
112        if post_ids.is_empty() {
113            return Ok(CreatePostResponse::ok_msg("无操作".to_string()));
114        }
115
116        let mut client = get_conn().await.map_err(AppError::db_conn)?;
117        let tx = client.transaction().await.map_err(AppError::tx)?;
118
119        // 记录数较少时使用精准失效;否则回退到全量失效。
120        let use_precise = post_ids.len() <= PRECISE_INVALIDATION_LIMIT;
121
122        // 逐条恢复,slug 冲突时自动加后缀;同时收集受影响的 slug 与文章 id。
123        let mut restored = 0u64;
124        let mut affected_slugs: Vec<String> = Vec::with_capacity(post_ids.len() * 2);
125        let mut affected_ids: Vec<i32> = Vec::with_capacity(post_ids.len());
126
127        for id in &post_ids {
128            let row = tx
129                .query_opt(
130                    "SELECT slug FROM posts WHERE id = $1 AND deleted_at IS NOT NULL FOR UPDATE",
131                    &[&id],
132                )
133                .await
134                .map_err(AppError::query)?;
135            if let Some(row) = row {
136                let current_slug: String = row.get("slug");
137                let new_slug = ensure_unique_slug(&tx, &current_slug, Some(*id)).await?;
138
139                let n = tx
140                    .execute(
141                        "UPDATE posts SET deleted_at = NULL, slug = $1 WHERE id = $2 AND deleted_at IS NOT NULL",
142                        &[&new_slug, &id],
143                    )
144                    .await
145                    .map_err(AppError::tx)?;
146                restored += n;
147
148                if use_precise {
149                    affected_ids.push(*id);
150                    affected_slugs.push(current_slug);
151                    affected_slugs.push(new_slug);
152                }
153            }
154        }
155
156        // 恢复不改动标签关联,事务内一次查询取全部受影响文章的标签并集。
157        let affected_tags = if use_precise {
158            super::helpers::fetch_post_tags_batch(&tx, &affected_ids).await?
159        } else {
160            Vec::new()
161        };
162
163        tx.commit().await.map_err(AppError::tx)?;
164
165        if use_precise {
166            // 精准失效:先去重 slug,再统一失效列表/标签云/统计/单篇/SSR。
167            let unique_slugs: Vec<String> = affected_slugs
168                .into_iter()
169                .collect::<std::collections::HashSet<_>>()
170                .into_iter()
171                .collect();
172            crate::cache::invalidate_for_post_write(&unique_slugs, &affected_tags).await;
173        } else {
174            // 影响集过大时回退到全量失效,避免大量串行缓存操作。
175            crate::cache::invalidate_all_post_caches();
176            crate::cache::invalidate_search_results();
177            crate::ssr_cache::invalidate_ssr_all_public();
178            crate::ssr_cache::bump_global_generation();
179        }
180
181        Ok(CreatePostResponse::ok_msg(format!("已恢复 {restored} 篇")))
182    }
183
184    #[cfg(not(feature = "server"))]
185    {
186        Ok(CreatePostResponse::err("server only".to_string()))
187    }
188}
189
190/// 批量彻底删除文章。
191#[server(BatchPurgePosts, endpoint = "batch_purge_posts", input = leptos::server_fn::codec::Json)]
192pub async fn batch_purge_posts(post_ids: Vec<i32>) -> Result<CreatePostResponse, ApiError> {
193    let _user = get_current_admin_user().await?;
194
195    #[cfg(feature = "server")]
196    {
197        if post_ids.is_empty() {
198            return Ok(CreatePostResponse::ok_msg("无操作".to_string()));
199        }
200
201        let mut client = get_conn().await.map_err(AppError::db_conn)?;
202        let tx = client.transaction().await.map_err(AppError::tx)?;
203        let total = post_ids.len() as i64;
204
205        // 记录数较少时锁定行并读取 slug 与标签,使用精准失效;否则回退到全量失效。
206        let use_precise = post_ids.len() <= PRECISE_INVALIDATION_LIMIT;
207        let (slugs, tags) = if use_precise {
208            let mut slugs: Vec<String> = Vec::with_capacity(post_ids.len());
209            let mut ids = Vec::with_capacity(post_ids.len());
210
211            for id in &post_ids {
212                let slug_row = tx
213                    .query_opt(
214                        "SELECT slug FROM posts WHERE id = $1 AND deleted_at IS NOT NULL FOR UPDATE",
215                        &[&id],
216                    )
217                    .await
218                    .map_err(AppError::query)?;
219
220                if let Some(slug_row) = slug_row {
221                    slugs.push(slug_row.get(0));
222                    ids.push(*id);
223                }
224            }
225
226            // 必须在 DELETE 之前读取:post_tags 随文章 ON DELETE CASCADE 清除。
227            let tags = super::helpers::fetch_post_tags_batch(&tx, &ids).await?;
228
229            (slugs, tags)
230        } else {
231            (Vec::new(), Vec::new())
232        };
233
234        let result = tx
235            .execute(
236                "DELETE FROM posts WHERE id = ANY($1) AND deleted_at IS NOT NULL",
237                &[&post_ids],
238            )
239            .await
240            .map_err(AppError::tx)?;
241
242        tx.commit().await.map_err(AppError::tx)?;
243
244        if use_precise {
245            // 精准失效(M5:批量删除影响详情页与所有列表页,逐 slug 物理失效 SSR + 全量公开页)。
246            crate::cache::invalidate_for_post_write(&slugs, &tags).await;
247        } else {
248            // 影响集过大时回退到全量失效,避免大量串行缓存操作。
249            crate::cache::invalidate_all_post_caches();
250            crate::cache::invalidate_search_results();
251            // M5 修复:回退路径同样须物理失效 SSR。
252            crate::ssr_cache::invalidate_ssr_all_public();
253            crate::ssr_cache::bump_global_generation();
254        }
255
256        Ok(CreatePostResponse::ok_msg(format!(
257            "已彻底删除 {result}/{total} 篇"
258        )))
259    }
260
261    #[cfg(not(feature = "server"))]
262    {
263        Ok(CreatePostResponse::err("server only".to_string()))
264    }
265}
266
267/// 清空回收站:彻底删除所有已软删除的文章。
268#[server(EmptyTrash, endpoint = "empty_trash", input = leptos::server_fn::codec::Json)]
269pub async fn empty_trash() -> Result<CreatePostResponse, ApiError> {
270    let _user = get_current_admin_user().await?;
271
272    #[cfg(feature = "server")]
273    {
274        let mut client = get_conn().await.map_err(AppError::db_conn)?;
275        let tx = client.transaction().await.map_err(AppError::tx)?;
276
277        // 在事务内锁定所有待删除行并读取 id/slug,用于后续精准失效;
278        // 同时根据数量决定使用精准失效还是回退到全量失效。
279        let deleted_rows = tx
280            .query(
281                "SELECT id, slug FROM posts WHERE deleted_at IS NOT NULL FOR UPDATE",
282                &[],
283            )
284            .await
285            .map_err(AppError::query)?;
286        let use_precise =
287            !deleted_rows.is_empty() && deleted_rows.len() <= PRECISE_INVALIDATION_LIMIT;
288
289        let (slugs, tags) = if use_precise {
290            let slugs: Vec<String> = deleted_rows.iter().map(|r| r.get("slug")).collect();
291            let ids: Vec<i32> = deleted_rows.iter().map(|r| r.get("id")).collect();
292
293            let tags = super::helpers::fetch_post_tags_batch(&tx, &ids).await?;
294
295            (slugs, tags)
296        } else {
297            (Vec::new(), Vec::new())
298        };
299
300        let result = tx
301            .execute("DELETE FROM posts WHERE deleted_at IS NOT NULL", &[])
302            .await
303            .map_err(AppError::tx)?;
304
305        tx.commit().await.map_err(AppError::tx)?;
306
307        if use_precise {
308            // 精准失效(M5:清空回收站影响详情页与所有列表页,逐 slug 物理失效 SSR + 全量公开页)。
309            crate::cache::invalidate_for_post_write(&slugs, &tags).await;
310        } else {
311            // 影响集过大时回退到全量失效,避免大量串行缓存操作。
312            crate::cache::invalidate_all_post_caches();
313            crate::cache::invalidate_search_results();
314            // M5 修复:回退路径同样须物理失效 SSR。
315            crate::ssr_cache::invalidate_ssr_all_public();
316            crate::ssr_cache::bump_global_generation();
317        }
318
319        Ok(CreatePostResponse::ok_msg(format!(
320            "已清空回收站({result} 篇)"
321        )))
322    }
323
324    #[cfg(not(feature = "server"))]
325    {
326        Ok(CreatePostResponse::err("server only".to_string()))
327    }
328}