Skip to main content

yggdrasil/api/posts/
stats.rs

1//! 文章统计接口。
2//!
3//! 返回文章总数、草稿数、已发布数与回收站(软删除)数量,供管理后台仪表盘与
4//! 文章列表页使用,结果缓存。Leptos server function,注册在 `/api` 路径下。
5//! 仅在 `feature = "server"` 启用的服务端构建中查询数据库。
6
7use crate::api::error::ApiError;
8use leptos::prelude::*;
9
10#[cfg(feature = "server")]
11use super::helpers::get_current_admin_user;
12use super::types::PostStatsResponse;
13#[cfg(feature = "server")]
14use crate::api::error::AppError;
15#[cfg(feature = "server")]
16use crate::db::pool::get_conn;
17#[cfg(feature = "server")]
18use crate::models::post::{MonthCount, PostStats};
19
20/// 获取文章统计信息。
21///
22/// 需要 admin 权限;优先命中缓存,未命中时通过单次条件聚合查询同时统计
23/// 未删除文章总数、草稿数、已发布数与回收站(软删除)数量。
24#[server(GetPostStats, endpoint = "get_post_stats", input = leptos::server_fn::codec::Json)]
25pub async fn get_post_stats() -> Result<PostStatsResponse, ApiError> {
26    let _user = get_current_admin_user().await?;
27
28    #[cfg(feature = "server")]
29    {
30        let stats = load_post_stats().await?;
31        Ok(PostStatsResponse { stats })
32    }
33
34    #[cfg(not(feature = "server"))]
35    {
36        Ok(PostStatsResponse {
37            stats: PostStats {
38                total: 0,
39                drafts: 0,
40                published: 0,
41                trash: 0,
42                recent_30d: 0,
43                monthly_published: Vec::new(),
44            },
45        })
46    }
47}
48
49/// 读取文章统计:优先命中缓存,未命中时查询并写回。侧栏数量与仪表盘共用。
50/// 调用方须先完成管理员鉴权。
51#[cfg(feature = "server")]
52pub(crate) async fn load_post_stats() -> Result<PostStats, AppError> {
53    if let Some(cached) = crate::cache::get_post_stats().await {
54        return Ok(cached);
55    }
56
57    let client = get_conn().await.map_err(AppError::db_conn)?;
58
59    // 通过单次条件聚合查询同时统计总数、草稿数、已发布数与回收站数量。
60    let row = client
61        .query_one(
62            "SELECT
63                COUNT(*) FILTER (WHERE deleted_at IS NULL) AS total,
64                COUNT(*) FILTER (WHERE deleted_at IS NULL AND status = 'draft') AS drafts,
65                COUNT(*) FILTER (WHERE deleted_at IS NULL AND status = 'published') AS published,
66                COUNT(*) FILTER (WHERE deleted_at IS NOT NULL) AS trash,
67                COUNT(*) FILTER (WHERE deleted_at IS NULL AND created_at > now() - interval '30 days') AS recent_30d
68             FROM posts",
69            &[],
70        )
71        .await
72        .map_err(AppError::query)?;
73
74    let stats = PostStats {
75        total: row.get("total"),
76        drafts: row.get("drafts"),
77        published: row.get("published"),
78        trash: row.get("trash"),
79        recent_30d: row.get("recent_30d"),
80        monthly_published: Vec::new(),
81    };
82
83    // 近 12 个月每月发布篇数:generate_series 补齐没有文章的月(0),
84    // 恰好 12 行、按月升序。
85    let month_rows = client
86        .query(
87            "SELECT EXTRACT(YEAR FROM gs.m)::int AS year, EXTRACT(MONTH FROM gs.m)::int AS month,
88                    COUNT(p.id) AS cnt
89             FROM generate_series(
90                    date_trunc('month', now() AT TIME ZONE 'UTC') - interval '11 months',
91                    date_trunc('month', now() AT TIME ZONE 'UTC'),
92                    interval '1 month'
93                  ) AS gs(m)
94             LEFT JOIN posts p
95               ON p.status = 'published'
96              AND p.deleted_at IS NULL
97              AND date_trunc('month', p.published_at AT TIME ZONE 'UTC') = gs.m
98             GROUP BY gs.m
99             ORDER BY gs.m",
100            &[],
101        )
102        .await
103        .map_err(AppError::query)?;
104    let monthly_published = month_rows
105        .iter()
106        .map(|r| MonthCount {
107            year: r.get("year"),
108            month: r.get::<_, i32>("month") as u32,
109            count: r.get("cnt"),
110        })
111        .collect();
112
113    let stats = PostStats {
114        monthly_published,
115        ..stats
116    };
117    crate::cache::set_post_stats(stats.clone()).await;
118    Ok(stats)
119}