Skip to main content

yggdrasil/api/comments/
update.rs

1//! 评论审核状态更新接口:通过、垃圾、删除与批量更新。
2//!
3//! 所有接口均需管理员身份,Leptos server function 注册在 `/api` 路径下。
4//! 状态变更后需要清空文章评论缓存、计数缓存与待审核计数缓存。
5//! 仅在 `feature = "server"` 启用的服务端构建中写入数据库。
6
7use crate::api::comments::types::*;
8use crate::api::error::ApiError;
9use leptos::prelude::*;
10
11/// 通过指定评论。
12///
13/// 同时递归将该评论的所有 pending 父评论一并通过,确保嵌套链可见。
14#[server(ApproveComment, endpoint = "approve_comment", input = leptos::server_fn::codec::Json)]
15pub async fn approve_comment(id: i64) -> Result<CommentResponse, ApiError> {
16    #[cfg(feature = "server")]
17    {
18        use crate::api::auth::get_current_admin_user;
19
20        let _admin = get_current_admin_user().await?;
21        if !approve_comment_impl(id).await? {
22            return Ok(CommentResponse::error(
23                "not_found",
24                "评论不存在".to_string(),
25            ));
26        }
27
28        Ok(CommentResponse::ok("已通过".to_string()))
29    }
30    #[cfg(not(feature = "server"))]
31    unreachable!()
32}
33
34/// 将指定评论标记为垃圾评论。
35///
36/// 若原状态为 approved,则需要清空该文章相关缓存。
37#[server(SpamComment, endpoint = "spam_comment", input = leptos::server_fn::codec::Json)]
38pub async fn spam_comment(id: i64) -> Result<CommentResponse, ApiError> {
39    #[cfg(feature = "server")]
40    {
41        use crate::api::auth::get_current_admin_user;
42
43        let _admin = get_current_admin_user().await?;
44        spam_comment_impl(id).await?;
45
46        Ok(CommentResponse::ok("已标记为垃圾".to_string()))
47    }
48    #[cfg(not(feature = "server"))]
49    unreachable!()
50}
51
52/// 将指定评论移入回收站(软删除)。
53///
54/// 软删除会设置 deleted_at 与状态为 trash,并清空相关缓存。
55#[server(TrashComment, endpoint = "trash_comment", input = leptos::server_fn::codec::Json)]
56pub async fn trash_comment(id: i64) -> Result<CommentResponse, ApiError> {
57    #[cfg(feature = "server")]
58    {
59        use crate::api::auth::get_current_admin_user;
60
61        let _admin = get_current_admin_user().await?;
62        trash_comment_impl(id).await?;
63
64        Ok(CommentResponse::ok("已删除".to_string()))
65    }
66    #[cfg(not(feature = "server"))]
67    unreachable!()
68}
69
70/// 批量更新评论状态。
71///
72/// 仅接受 approved / spam / trash 三种状态;trash 会软删除并设置 deleted_at,
73/// approved 会设置 approved_at。
74#[server(BatchUpdateCommentStatus, endpoint = "batch_update_comment_status", input = leptos::server_fn::codec::Json)]
75pub async fn batch_update_comment_status(
76    ids: Vec<i64>,
77    status: String,
78) -> Result<BatchStatusResponse, ApiError> {
79    #[cfg(feature = "server")]
80    {
81        use crate::api::auth::get_current_admin_user;
82        use crate::api::error::AppError;
83        use crate::cache;
84        use crate::db::pool::get_conn;
85
86        let _admin = get_current_admin_user().await?;
87
88        // 限制可批量操作的状态,防止非法状态写入数据库。
89        if !matches!(status.as_str(), "approved" | "spam" | "trash") {
90            return Ok(BatchStatusResponse {
91                success: false,
92                updated_count: 0,
93                message: "无效的状态".to_string(),
94            });
95        }
96
97        let client = get_conn().await.map_err(AppError::db_conn)?;
98
99        // 收集受影响的文章 id,用于后续批量失效缓存。
100        let post_ids: Vec<i32> = client
101            .query(
102                "SELECT DISTINCT post_id FROM comments WHERE id = ANY($1)",
103                &[&ids],
104            )
105            .await
106            .map_err(AppError::query)?
107            .iter()
108            .map(|r| r.get("post_id"))
109            .collect();
110
111        // 根据目标状态设置不同的附加字段:trash 软删除,approved 记录通过时间。
112        let result = if status == "trash" {
113            client
114                .execute(
115                    "UPDATE comments SET status = $1, deleted_at = NOW() WHERE id = ANY($2)",
116                    &[&status, &ids],
117                )
118                .await
119                .map_err(AppError::query)?
120        } else if status == "approved" {
121            client
122                .execute(
123                    "UPDATE comments SET status = $1, approved_at = NOW() WHERE id = ANY($2)",
124                    &[&status, &ids],
125                )
126                .await
127                .map_err(AppError::query)?
128        } else {
129            client
130                .execute(
131                    "UPDATE comments SET status = $1 WHERE id = ANY($2)",
132                    &[&status, &ids],
133                )
134                .await
135                .map_err(AppError::query)?
136        };
137
138        cache::invalidate_pending_count().await;
139        for pid in post_ids {
140            cache::invalidate_comments_by_post(pid).await;
141        }
142
143        Ok(BatchStatusResponse {
144            success: true,
145            updated_count: result as i64,
146            message: format!("已更新 {} 条评论", result),
147        })
148    }
149    #[cfg(not(feature = "server"))]
150    unreachable!()
151}
152
153/// 从回收站恢复评论:清除软删除并回到待审,由管理员重新决定是否通过。
154#[server(RestoreComments, endpoint = "restore_comments", input = leptos::server_fn::codec::Json)]
155pub async fn restore_comments(ids: Vec<i64>) -> Result<BatchStatusResponse, ApiError> {
156    #[cfg(feature = "server")]
157    {
158        use crate::api::auth::get_current_admin_user;
159        use crate::api::error::AppError;
160        use crate::cache;
161        use crate::db::pool::get_conn;
162
163        let _admin = get_current_admin_user().await?;
164        let client = get_conn().await.map_err(AppError::db_conn)?;
165        let rows = client
166            .query(
167                "UPDATE comments SET deleted_at = NULL, status = 'pending' \
168                 WHERE id = ANY($1) AND deleted_at IS NOT NULL RETURNING post_id",
169                &[&ids],
170            )
171            .await
172            .map_err(AppError::query)?;
173        let mut post_ids: Vec<i32> = rows.iter().map(|r| r.get("post_id")).collect();
174        post_ids.sort_unstable();
175        post_ids.dedup();
176        cache::invalidate_pending_count().await;
177        for pid in post_ids {
178            cache::invalidate_comments_by_post(pid).await;
179        }
180        Ok(BatchStatusResponse {
181            success: true,
182            updated_count: rows.len() as i64,
183            message: format!("已恢复 {} 条评论,等待审核", rows.len()),
184        })
185    }
186    #[cfg(not(feature = "server"))]
187    {
188        let _ = ids;
189        unreachable!()
190    }
191}
192
193/// 通过评论并递归通过其 pending 祖先;评论不存在返回 `Ok(false)`。
194///
195/// 不做鉴权:调用方(web server fn 与 MCP 工具)各自鉴权。缓存失效在此完成。
196#[cfg(feature = "server")]
197pub(crate) async fn approve_comment_impl(id: i64) -> Result<bool, crate::api::error::AppError> {
198    use crate::api::error::AppError;
199    use crate::cache;
200    use crate::db::pool::get_conn;
201
202    let client = get_conn().await.map_err(AppError::db_conn)?;
203
204    let row = client
205        .query_opt(
206            "SELECT post_id FROM comments WHERE id = $1 AND deleted_at IS NULL",
207            &[&id],
208        )
209        .await
210        .map_err(AppError::query)?;
211
212    let post_id: i32 = match row {
213        Some(r) => r.get("post_id"),
214        None => return Ok(false),
215    };
216
217    // 直接通过目标评论并记录通过时间。
218    client
219        .execute(
220            "UPDATE comments SET status = 'approved', approved_at = NOW() WHERE id = $1",
221            &[&id],
222        )
223        .await
224        .map_err(AppError::query)?;
225
226    // 递归向上查找所有 pending 父评论并同步通过,避免子评论可见但父评论被隐藏。
227    client
228        .execute(
229            "WITH RECURSIVE ancestors AS ( \
230                 SELECT parent_id FROM comments WHERE id = $1 \
231                 UNION ALL \
232                 SELECT c.parent_id FROM comments c JOIN ancestors a ON c.id = a.parent_id WHERE a.parent_id IS NOT NULL \
233             ) \
234             UPDATE comments SET status = 'approved', approved_at = NOW() \
235             WHERE id IN (SELECT parent_id FROM ancestors WHERE parent_id IS NOT NULL) AND status = 'pending'",
236            &[&id],
237        )
238        .await
239        .map_err(AppError::query)?;
240
241    cache::invalidate_comments_by_post(post_id).await;
242    cache::invalidate_pending_count().await;
243    Ok(true)
244}
245
246/// 将评论标记为垃圾;评论不存在返回 `Ok(false)`。仅旧状态为 approved 时失效评论缓存。
247///
248/// 不做鉴权:调用方各自鉴权。
249#[cfg(feature = "server")]
250pub(crate) async fn spam_comment_impl(id: i64) -> Result<bool, crate::api::error::AppError> {
251    use crate::api::error::AppError;
252    use crate::cache;
253    use crate::db::pool::get_conn;
254
255    let client = get_conn().await.map_err(AppError::db_conn)?;
256
257    let row = client
258        .query_opt(
259            "SELECT post_id, status FROM comments WHERE id = $1 AND deleted_at IS NULL",
260            &[&id],
261        )
262        .await
263        .map_err(AppError::query)?;
264
265    let Some(r) = row else {
266        return Ok(false);
267    };
268    let post_id: i32 = r.get("post_id");
269    let old_status: String = r.get("status");
270
271    client
272        .execute(
273            "UPDATE comments SET status = 'spam' WHERE id = $1 AND deleted_at IS NULL",
274            &[&id],
275        )
276        .await
277        .map_err(AppError::query)?;
278
279    if old_status == "approved" {
280        cache::invalidate_comments_by_post(post_id).await;
281    }
282    cache::invalidate_pending_count().await;
283    Ok(true)
284}
285
286/// 将评论移入回收站(软删除);评论不存在返回 `Ok(false)`。
287///
288/// 不做鉴权:调用方各自鉴权。
289#[cfg(feature = "server")]
290pub(crate) async fn trash_comment_impl(id: i64) -> Result<bool, crate::api::error::AppError> {
291    use crate::api::error::AppError;
292    use crate::cache;
293    use crate::db::pool::get_conn;
294
295    let client = get_conn().await.map_err(AppError::db_conn)?;
296
297    let row = client
298        .query_opt(
299            "SELECT post_id FROM comments WHERE id = $1 AND deleted_at IS NULL",
300            &[&id],
301        )
302        .await
303        .map_err(AppError::query)?;
304
305    let Some(r) = row else {
306        return Ok(false);
307    };
308    let post_id: i32 = r.get("post_id");
309
310    client
311        .execute(
312            "UPDATE comments SET status = 'trash', deleted_at = NOW() WHERE id = $1",
313            &[&id],
314        )
315        .await
316        .map_err(AppError::query)?;
317
318    cache::invalidate_comments_by_post(post_id).await;
319    cache::invalidate_pending_count().await;
320    Ok(true)
321}