1use crate::api::error::ApiError;
7use leptos::prelude::*;
8
9#[cfg(feature = "server")]
10use super::helpers::get_current_admin_user;
11use super::types::{BatchPostAction, BatchPostResult};
12
13#[cfg(feature = "server")]
15const MAX_BATCH: usize = 200;
16
17#[server(BatchPosts, endpoint = "batch_posts", input = leptos::server_fn::codec::Json)]
19pub async fn batch_posts(
20 post_ids: Vec<i32>,
21 action: BatchPostAction,
22) -> Result<BatchPostResult, ApiError> {
23 #[cfg(feature = "server")]
24 {
25 use super::service::{act, PostAction};
26
27 let _user = get_current_admin_user().await?;
28 if post_ids.len() > MAX_BATCH {
29 return Err(ApiError::BadRequest(format!("一次最多操作 {MAX_BATCH} 篇")));
30 }
31 let mut ids = post_ids;
32 ids.sort_unstable();
33 ids.dedup();
34 let mut result = BatchPostResult { done: 0, failed: 0 };
35 for id in ids {
36 let action = match action {
37 BatchPostAction::Publish => PostAction::Publish,
38 BatchPostAction::Unpublish => PostAction::Unpublish,
39 BatchPostAction::Trash => PostAction::Trash,
40 };
41 match act(id, None, action).await {
42 Ok(_) => result.done += 1,
43 Err(_) => result.failed += 1,
44 }
45 }
46 Ok(result)
47 }
48 #[cfg(not(feature = "server"))]
49 {
50 let _ = (post_ids, action);
51 unreachable!()
52 }
53}
54
55#[cfg(all(test, feature = "server"))]
56mod tests {
57 use super::*;
58 use crate::api::posts::{list_posts, AdminPostQuery};
59 use crate::db::pool::get_conn;
60 use crate::models::post::PostStatus;
61
62 fn web_context() -> http::request::Parts {
63 let (parts, _) = http::Request::builder()
64 .header("cookie", "session=post-batch-regression")
65 .body(())
66 .unwrap()
67 .into_parts();
68 parts
69 }
70
71 #[test]
72 #[ignore = "requires disposable DATABASE_URL database ygg_mcp_test"]
73 fn admin_post_list_and_batch_database() {
74 crate::db::TEST_DATABASE_RUNTIME.block_on(admin_post_list_and_batch_database_impl());
75 }
76
77 async fn admin_post_list_and_batch_database_impl() {
78 let _guard = crate::db::TEST_DATABASE_LOCK.lock().await;
79 let mut client = get_conn().await.unwrap();
80 let database: String = client
81 .query_one("SELECT current_database()", &[])
82 .await
83 .unwrap()
84 .get(0);
85 assert_eq!(database, "ygg_mcp_test", "requires isolated test database");
86 crate::db::migrate::run_on_conn(&mut client).await.unwrap();
87 client
88 .batch_execute(
89 "TRUNCATE users RESTART IDENTITY CASCADE;
90 TRUNCATE tags RESTART IDENTITY CASCADE;
91 INSERT INTO users(id,username,email,password_hash,role)
92 VALUES (1,'batch-owner','batch@test.invalid','unused','admin');
93 INSERT INTO posts(id,author_id,title,slug,content_md,status,published_at,created_at) VALUES
94 (1,1,'Rust 入门','rust-intro','正文','published',NOW()-INTERVAL '3 days',NOW()-INTERVAL '3 days'),
95 (2,1,'异步 Rust','async-rust','正文','published',NOW()-INTERVAL '2 days',NOW()-INTERVAL '2 days'),
96 (3,1,'草稿 100%_完成','draft-escape','正文','draft',NULL,NOW()-INTERVAL '1 day');
97 SELECT setval('posts_id_seq', 3);
98 INSERT INTO tags(name) VALUES ('Rust');
99 INSERT INTO post_tags(post_id,tag_id) SELECT 2, id FROM tags WHERE name='Rust';",
100 )
101 .await
102 .unwrap();
103 let hash = crate::auth::session::hash_token("post-batch-regression");
104 client
105 .execute(
106 "INSERT INTO sessions(user_id,token_hash,expires_at) VALUES(1,$1,NOW()+INTERVAL '1 hour')",
107 &[&hash],
108 )
109 .await
110 .unwrap();
111 let list = |query: AdminPostQuery| async move {
112 crate::request_ctx::scope_with_request(web_context(), list_posts(1, 20, query))
113 .await
114 .unwrap()
115 };
116 let ids = |res: &crate::api::posts::PostListResponse| {
117 res.posts.iter().map(|p| p.id).collect::<Vec<_>>()
118 };
119
120 assert_eq!(ids(&list(AdminPostQuery::default()).await), [3, 2, 1]);
122 let drafts = list(AdminPostQuery {
123 status: Some(PostStatus::Draft),
124 ..Default::default()
125 })
126 .await;
127 assert_eq!((ids(&drafts), drafts.total), (vec![3], 1));
128 let tagged = list(AdminPostQuery {
129 tag: Some("Rust".into()),
130 ..Default::default()
131 })
132 .await;
133 assert_eq!(ids(&tagged), [2]);
134 assert_eq!(tagged.posts[0].tags, ["Rust"]);
135 assert_eq!(ids(&list(AdminPostQuery::search("rust")).await), [2, 1]);
136 assert_eq!(ids(&list(AdminPostQuery::search("100%_")).await), [3]);
137 assert!(list(AdminPostQuery::search("0%x")).await.posts.is_empty());
138
139 let first_published: chrono::DateTime<chrono::Utc> = client
141 .query_one("SELECT published_at FROM posts WHERE id=1", &[])
142 .await
143 .unwrap()
144 .get(0);
145 let res = crate::request_ctx::scope_with_request(
146 web_context(),
147 batch_posts(vec![1, 1, 2, 99], BatchPostAction::Unpublish),
148 )
149 .await
150 .unwrap();
151 assert_eq!(res, BatchPostResult { done: 2, failed: 1 });
152 let row = client
153 .query_one("SELECT status, published_at FROM posts WHERE id=1", &[])
154 .await
155 .unwrap();
156 assert_eq!(row.get::<_, String>("status"), "draft");
157 assert_eq!(
158 row.get::<_, Option<chrono::DateTime<chrono::Utc>>>("published_at"),
159 Some(first_published)
160 );
161 let res = crate::request_ctx::scope_with_request(
163 web_context(),
164 batch_posts(vec![3], BatchPostAction::Publish),
165 )
166 .await
167 .unwrap();
168 assert_eq!(res, BatchPostResult { done: 1, failed: 0 });
169 let row = client
170 .query_one("SELECT status, published_at FROM posts WHERE id=3", &[])
171 .await
172 .unwrap();
173 assert_eq!(row.get::<_, String>("status"), "published");
174 assert!(row
175 .get::<_, Option<chrono::DateTime<chrono::Utc>>>("published_at")
176 .is_some());
177 let res = crate::request_ctx::scope_with_request(
178 web_context(),
179 batch_posts(vec![2, 3], BatchPostAction::Trash),
180 )
181 .await
182 .unwrap();
183 assert_eq!(res, BatchPostResult { done: 2, failed: 0 });
184 assert_eq!(ids(&list(AdminPostQuery::default()).await), [1]);
185 let res = crate::request_ctx::scope_with_request(
187 web_context(),
188 batch_posts(vec![2], BatchPostAction::Unpublish),
189 )
190 .await
191 .unwrap();
192 assert_eq!(res, BatchPostResult { done: 0, failed: 1 });
193 }
194}