Skip to main content

yggdrasil/api/posts/
stats.rs

1//! 文章统计接口。
2//!
3//! 返回文章总数、草稿数、已发布数与回收站(软删除)数量,供管理后台仪表盘与
4//! 文章列表页使用,结果缓存。Dioxus server function,注册在 `/api` 路径下。
5//! 仅在 `feature = "server"` 启用的服务端构建中查询数据库。
6
7use dioxus::prelude::*;
8
9#[cfg(feature = "server")]
10use super::helpers::get_current_admin_user;
11use super::types::PostStatsResponse;
12#[cfg(feature = "server")]
13use crate::api::error::AppError;
14#[cfg(feature = "server")]
15use crate::db::pool::get_conn;
16#[cfg(feature = "server")]
17use crate::models::post::PostStats;
18
19/// 获取文章统计信息。
20///
21/// 需要 admin 权限;优先命中缓存,未命中时通过单次条件聚合查询同时统计
22/// 未删除文章总数、草稿数、已发布数与回收站(软删除)数量。
23#[server(GetPostStats, "/api")]
24pub async fn get_post_stats() -> Result<PostStatsResponse, ServerFnError> {
25    let _user = get_current_admin_user().await?;
26
27    #[cfg(feature = "server")]
28    {
29        if let Some(cached) = crate::cache::get_post_stats().await {
30            return Ok(PostStatsResponse { stats: cached });
31        }
32
33        let client = get_conn().await.map_err(AppError::db_conn)?;
34
35        // 通过单次条件聚合查询同时统计总数、草稿数、已发布数与回收站数量。
36        let row = client
37            .query_one(
38                "SELECT
39                    COUNT(*) FILTER (WHERE deleted_at IS NULL) AS total,
40                    COUNT(*) FILTER (WHERE deleted_at IS NULL AND status = 'draft') AS drafts,
41                    COUNT(*) FILTER (WHERE deleted_at IS NULL AND status = 'published') AS published,
42                    COUNT(*) FILTER (WHERE deleted_at IS NOT NULL) AS trash,
43                    COUNT(*) FILTER (WHERE deleted_at IS NULL AND created_at > now() - interval '30 days') AS recent_30d
44                 FROM posts",
45                &[],
46            )
47            .await
48            .map_err(AppError::query)?;
49
50        let stats = PostStats {
51            total: row.get("total"),
52            drafts: row.get("drafts"),
53            published: row.get("published"),
54            trash: row.get("trash"),
55            recent_30d: row.get("recent_30d"),
56            activity_30d: Vec::new(),
57        };
58
59        // 近 30 个自然日的每日新建数:generate_series 补齐无文章的日(0),
60        // 恰好 30 行、按日升序,供 sparkline 直接消费。
61        let activity_rows = client
62            .query(
63                "SELECT gs.day::date AS day, COUNT(p.id) AS cnt
64                 FROM generate_series(
65                        (now() - interval '29 days')::date,
66                        now()::date,
67                        interval '1 day'
68                      ) AS gs(day)
69                 LEFT JOIN posts p
70                   ON p.created_at::date = gs.day::date
71                  AND p.deleted_at IS NULL
72                 GROUP BY gs.day
73                 ORDER BY gs.day",
74                &[],
75            )
76            .await
77            .map_err(AppError::query)?;
78        let activity_30d = activity_rows
79            .iter()
80            .map(|r| r.get::<_, i64>("cnt"))
81            .collect();
82
83        let stats = PostStats {
84            activity_30d,
85            ..stats
86        };
87        crate::cache::set_post_stats(stats.clone()).await;
88        Ok(PostStatsResponse { stats })
89    }
90
91    #[cfg(not(feature = "server"))]
92    {
93        Ok(PostStatsResponse {
94            stats: PostStats {
95                total: 0,
96                drafts: 0,
97                published: 0,
98                trash: 0,
99                recent_30d: 0,
100                activity_30d: Vec::new(),
101            },
102        })
103    }
104}