Skip to main content

yggdrasil/api/posts/
trash.rs

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