Skip to main content

yggdrasil/api/posts/
archive.rs

1//! 固定大小的公开归档分页,与首页列表的 50 条上限独立。
2use crate::api::error::ApiError;
3use leptos::prelude::*;
4
5use crate::models::archive::ArchivePost;
6
7pub const ARCHIVE_PAGE_SIZE: usize = 100;
8
9#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
10pub struct ArchiveResponse {
11    pub posts: Vec<ArchivePost>,
12    pub total: i64,
13}
14
15#[server(ListArchivePosts, endpoint = "list_archive_posts", input = leptos::server_fn::codec::Json)]
16pub async fn list_archive_posts(page: i32) -> Result<ArchiveResponse, ApiError> {
17    #[cfg(feature = "server")]
18    {
19        use crate::api::error::AppError;
20        let page = page.clamp(1, 10_000);
21        if let Some(response) = crate::cache::get_archive(page).await {
22            return Ok(response);
23        }
24        let client = crate::db::pool::get_conn()
25            .await
26            .map_err(AppError::db_conn)?;
27        let total: i64 = client
28            .query_one(
29                "SELECT COUNT(*) FROM posts WHERE status = 'published' AND deleted_at IS NULL",
30                &[],
31            )
32            .await
33            .map_err(AppError::query)?
34            .get(0);
35        let rows = client.query(
36            "SELECT p.id, p.title, p.slug, COALESCE(p.published_at, p.created_at) AS published_at,
37                    p.reading_time, COALESCE(array_agg(t.name ORDER BY t.name) FILTER (WHERE t.name IS NOT NULL), '{}') AS tags
38             FROM posts p LEFT JOIN post_tags pt ON pt.post_id = p.id LEFT JOIN tags t ON t.id = pt.tag_id
39             WHERE p.status = 'published' AND p.deleted_at IS NULL
40             GROUP BY p.id ORDER BY COALESCE(p.published_at, p.created_at) DESC, p.id DESC LIMIT $1 OFFSET $2",
41            &[&(ARCHIVE_PAGE_SIZE as i64), &((i64::from(page) - 1) * ARCHIVE_PAGE_SIZE as i64)],
42        ).await.map_err(AppError::query)?;
43        let posts = rows
44            .iter()
45            .map(|row| ArchivePost {
46                id: row.get("id"),
47                title: row.get("title"),
48                slug: row.get("slug"),
49                published_at: row.get("published_at"),
50                reading_time: row.get::<_, i32>("reading_time").max(1) as u32,
51                tags: row.get("tags"),
52            })
53            .collect();
54        let response = ArchiveResponse { posts, total };
55        crate::cache::set_archive(page, response.clone()).await;
56        Ok(response)
57    }
58    #[cfg(not(feature = "server"))]
59    {
60        let _ = page;
61        Ok(ArchiveResponse {
62            posts: vec![],
63            total: 0,
64        })
65    }
66}
67
68/// 某一年发布的篇数。
69#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
70pub struct YearCount {
71    pub year: i32,
72    pub count: i64,
73}
74
75/// 每年发布的篇数,按年份升序;中间没有文章的年份补 0。关于页的柱状图使用,
76/// 在服务端聚合并随文章列表缓存一起失效。
77#[server(PostsPerYear, endpoint = "posts_per_year", input = leptos::server_fn::codec::Json)]
78pub async fn posts_per_year() -> Result<Vec<YearCount>, ApiError> {
79    #[cfg(feature = "server")]
80    {
81        use crate::api::error::AppError;
82        if let Some(cached) = crate::cache::get_year_counts().await {
83            return Ok(cached);
84        }
85        let client = crate::db::pool::get_conn()
86            .await
87            .map_err(AppError::db_conn)?;
88        let rows = client
89            .query(
90                "SELECT EXTRACT(YEAR FROM COALESCE(published_at, created_at))::int AS year, COUNT(*)
91                 FROM posts WHERE status = 'published' AND deleted_at IS NULL
92                 GROUP BY 1 ORDER BY 1",
93                &[],
94            )
95            .await
96            .map_err(AppError::query)?;
97        let counts = fill_years(rows.iter().map(|r| (r.get(0), r.get(1))).collect());
98        crate::cache::set_year_counts(counts.clone()).await;
99        Ok(counts)
100    }
101    #[cfg(not(feature = "server"))]
102    {
103        Ok(Vec::new())
104    }
105}
106
107/// 补齐首尾之间缺失的年份(篇数为 0),柱状图才不会跳年。
108#[cfg(any(feature = "server", test))]
109fn fill_years(counts: Vec<(i32, i64)>) -> Vec<YearCount> {
110    let (Some(&(first, _)), Some(&(last, _))) = (counts.first(), counts.last()) else {
111        return Vec::new();
112    };
113    (first..=last)
114        .map(|year| YearCount {
115            year,
116            count: counts
117                .iter()
118                .find(|(y, _)| *y == year)
119                .map_or(0, |(_, n)| *n),
120        })
121        .collect()
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn missing_years_are_filled_with_zero() {
130        let years = fill_years(vec![(2020, 1), (2022, 12), (2023, 4)]);
131        let shape: Vec<_> = years.iter().map(|y| (y.year, y.count)).collect();
132        assert_eq!(shape, [(2020, 1), (2021, 0), (2022, 12), (2023, 4)]);
133        assert!(fill_years(Vec::new()).is_empty());
134    }
135}