Skip to main content

yggdrasil/api/posts/
read.rs

1//! 文章详情查询接口。
2//!
3//! 提供按 id(管理员)与按 slug(公开)两种方式获取文章,
4//! 其中按 slug 查询包含上下篇导航并启用缓存。
5//! Dioxus server function,注册在 `/api` 路径下。
6//! 仅在 `feature = "server"` 启用的服务端构建中查询数据库。
7
8use dioxus::prelude::*;
9
10#[cfg(feature = "server")]
11use super::helpers::{get_current_admin_user, row_to_post_full};
12use super::types::SinglePostResponse;
13#[cfg(feature = "server")]
14use crate::api::error::AppError;
15#[cfg(feature = "server")]
16use crate::db::pool::get_conn;
17
18/// 根据文章 id 获取详情。
19///
20/// 需要 admin 权限;不缓存,用于管理后台编辑等场景。
21#[server(GetPostById, "/api")]
22pub async fn get_post_by_id(post_id: i32) -> Result<SinglePostResponse, ServerFnError> {
23    let _user = get_current_admin_user().await?;
24
25    #[cfg(feature = "server")]
26    {
27        let client = get_conn().await.map_err(AppError::db_conn)?;
28
29        let row = client
30            .query_opt(
31                "SELECT
32                    p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.content_html, p.toc_html,
33                    p.status, p.published_at, p.created_at, p.updated_at, p.cover_image,
34                    p.word_count, p.reading_time,
35                    COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags
36                 FROM posts p
37                 LEFT JOIN post_tags pt ON p.id = pt.post_id
38                 LEFT JOIN tags t ON pt.tag_id = t.id
39                 WHERE p.id = $1 AND p.deleted_at IS NULL
40                 GROUP BY p.id",
41                &[&post_id],
42            )
43            .await
44            .map_err(AppError::query)?;
45
46        let post = match row {
47            Some(row) => Some(row_to_post_full(&row).await),
48            None => None,
49        };
50
51        Ok(SinglePostResponse { post })
52    }
53
54    #[cfg(not(feature = "server"))]
55    {
56        Ok(SinglePostResponse { post: None })
57    }
58}
59
60/// 根据 slug 获取公开文章详情。
61///
62/// 优先命中缓存;未命中时查询数据库,并附带基于 published_at 的上一篇/下一篇导航。
63#[server(GetPostBySlug, "/api")]
64pub async fn get_post_by_slug(slug: String) -> Result<SinglePostResponse, ServerFnError> {
65    #[cfg(feature = "server")]
66    {
67        if let Some(cached) = crate::cache::get_post_by_slug(&slug).await {
68            return Ok(SinglePostResponse { post: cached });
69        }
70
71        let client = get_conn().await.map_err(AppError::db_conn)?;
72
73        // 使用 LATERAL JOIN 查询按 published_at 排序的相邻文章。
74        let row = client
75            .query_opt(
76                "SELECT
77                    p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.content_html, p.toc_html,
78                    p.status, p.published_at, p.created_at, p.updated_at, p.cover_image,
79                    p.word_count, p.reading_time,
80                    COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags,
81                    prev.title as prev_title, prev.slug as prev_slug,
82                    next.title as next_title, next.slug as next_slug
83                 FROM posts p
84                 LEFT JOIN post_tags pt ON p.id = pt.post_id
85                 LEFT JOIN tags t ON pt.tag_id = t.id
86                 LEFT JOIN LATERAL (
87                     SELECT title, slug FROM posts 
88                     WHERE published_at < p.published_at 
89                       AND status = 'published' 
90                       AND deleted_at IS NULL
91                     ORDER BY published_at DESC
92                     LIMIT 1
93                 ) prev ON true
94                 LEFT JOIN LATERAL (
95                     SELECT title, slug FROM posts 
96                     WHERE published_at > p.published_at 
97                       AND status = 'published' 
98                       AND deleted_at IS NULL
99                     ORDER BY published_at ASC
100                     LIMIT 1
101                 ) next ON true
102                 WHERE p.slug = $1 AND p.status = 'published' AND p.deleted_at IS NULL
103                 GROUP BY p.id, prev.title, prev.slug, next.title, next.slug",
104                &[&slug],
105            )
106            .await
107            .map_err(AppError::query)?;
108
109        let post = match row {
110            Some(row) => Some(row_to_post_full(&row).await),
111            None => None,
112        };
113
114        if post.is_some() {
115            crate::cache::set_post_by_slug(&slug, post.clone()).await;
116        }
117        Ok(SinglePostResponse { post })
118    }
119
120    #[cfg(not(feature = "server"))]
121    {
122        Ok(SinglePostResponse { post: None })
123    }
124}