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 #[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 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 let new_slug = ensure_unique_slug(&tx, ¤t_slug, Some(post_id)).await?;
54
55 let tags = super::helpers::fetch_post_tags(&tx, post_id).await?;
56
57 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 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#[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 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 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#[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 let use_precise = post_ids.len() <= PRECISE_INVALIDATION_LIMIT;
164
165 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, ¤t_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 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 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#[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 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 crate::cache::invalidate_for_post_write(&slugs, &tags).await;
294 } else {
295 crate::cache::invalidate_all_post_caches();
297 crate::cache::invalidate_search_results();
298 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#[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 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 crate::cache::invalidate_for_post_write(&slugs, &tags).await;
357 } else {
358 crate::cache::invalidate_all_post_caches();
360 crate::cache::invalidate_search_results();
361 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}