Skip to main content

yggdrasil/api/posts/
batch.rs

1//! 后台文章的批量操作:发布、转为草稿、移到回收站。
2//!
3//! 逐篇走文章写入业务(各自事务与缓存失效),返回成功与失败篇数;某一篇失败不影响其余。
4//! Leptos server function,注册在 `/api` 路径下。
5
6use 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/// 单次批量操作的篇数上限。
14#[cfg(feature = "server")]
15const MAX_BATCH: usize = 200;
16
17/// 对多篇文章执行同一操作。需要 admin 权限。
18#[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        // 筛选:状态、标签、标题;LIKE 通配符按字面匹配;新建在前。
121        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        // 转为草稿保留首次发布时间;移到回收站后不再出现在列表。
140        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        // 发布草稿:补上首次发布时间。
162        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        // 已在回收站的文章不能再转为草稿。
186        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}