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