yggdrasil/api/posts/
trash.rs1use 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#[cfg(feature = "server")]
23const PRECISE_INVALIDATION_LIMIT: usize = 50;
24
25#[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#[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 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 let new_slug = ensure_unique_slug(&tx, ¤t_slug, Some(post_id)).await?;
61
62 let tags = super::helpers::fetch_post_tags(&tx, post_id).await?;
63
64 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 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#[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 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 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#[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 let use_precise = post_ids.len() <= PRECISE_INVALIDATION_LIMIT;
165
166 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, ¤t_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 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 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#[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 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 crate::cache::invalidate_for_post_write(&slugs, &tags).await;
295 } else {
296 crate::cache::invalidate_all_post_caches();
298 crate::cache::invalidate_search_results();
299 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#[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 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 crate::cache::invalidate_for_post_write(&slugs, &tags).await;
358 } else {
359 crate::cache::invalidate_all_post_caches();
361 crate::cache::invalidate_search_results();
362 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}