yggdrasil/api/posts/
trash.rs1use 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#[cfg(feature = "server")]
24const PRECISE_INVALIDATION_LIMIT: usize = 50;
25
26#[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#[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 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 let new_slug = ensure_unique_slug(&tx, ¤t_slug, Some(post_id)).await?;
62
63 let tags = super::helpers::fetch_post_tags(&tx, post_id).await?;
64
65 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 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#[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#[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 let use_precise = post_ids.len() <= PRECISE_INVALIDATION_LIMIT;
121
122 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, ¤t_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 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 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 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#[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 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 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 crate::cache::invalidate_for_post_write(&slugs, &tags).await;
247 } else {
248 crate::cache::invalidate_all_post_caches();
250 crate::cache::invalidate_search_results();
251 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#[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 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 crate::cache::invalidate_for_post_write(&slugs, &tags).await;
310 } else {
311 crate::cache::invalidate_all_post_caches();
313 crate::cache::invalidate_search_results();
314 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}