Skip to main content

yggdrasil/api/posts/
read.rs

1//! 文章详情查询接口。
2//!
3//! 提供按 id(管理员)与按 slug(公开)两种方式获取文章,
4//! 其中按 slug 查询包含上下篇导航并启用缓存。
5//! Leptos server function,注册在 `/api` 路径下。
6//! 仅在 `feature = "server"` 启用的服务端构建中查询数据库。
7
8use crate::api::error::ApiError;
9use leptos::prelude::*;
10
11#[cfg(feature = "server")]
12use super::helpers::{get_current_admin_user, row_to_post_full};
13use super::types::{PublicPostResponse, SinglePostResponse};
14#[cfg(feature = "server")]
15use crate::api::error::AppError;
16#[cfg(feature = "server")]
17use crate::db::pool::get_conn;
18
19/// 根据文章 id 获取详情。
20///
21/// 需要 admin 权限;不缓存,用于管理后台编辑等场景。
22#[server(GetPostById, endpoint = "get_post_by_id", input = leptos::server_fn::codec::Json)]
23pub async fn get_post_by_id(post_id: i32) -> Result<SinglePostResponse, ApiError> {
24    let _user = get_current_admin_user().await?;
25
26    #[cfg(feature = "server")]
27    {
28        let client = get_conn().await.map_err(AppError::db_conn)?;
29
30        let row = client
31            .query_opt(
32                "SELECT
33                    p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.content_html, p.toc_html,
34                    p.status, p.published_at, p.created_at, p.updated_at, p.cover_image,
35                    p.word_count, p.reading_time,
36                    COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags
37                 FROM posts p
38                 LEFT JOIN post_tags pt ON p.id = pt.post_id
39                 LEFT JOIN tags t ON pt.tag_id = t.id
40                 WHERE p.id = $1 AND p.deleted_at IS NULL
41                 GROUP BY p.id",
42                &[&post_id],
43            )
44            .await
45            .map_err(AppError::query)?;
46
47        let post = match row {
48            Some(row) => Some(row_to_post_full(&row).await?),
49            None => None,
50        };
51
52        Ok(SinglePostResponse { post })
53    }
54
55    #[cfg(not(feature = "server"))]
56    {
57        Ok(SinglePostResponse { post: None })
58    }
59}
60
61/// 根据 slug 获取公开文章详情。
62///
63/// 优先命中缓存;未命中时查询数据库,并附带基于 published_at 的上一篇/下一篇导航。
64#[server(GetPostBySlug, endpoint = "get_post_by_slug", input = leptos::server_fn::codec::Json)]
65pub async fn get_post_by_slug(slug: String) -> Result<SinglePostResponse, ApiError> {
66    #[cfg(feature = "server")]
67    {
68        if let Some(cached) = crate::cache::get_post_by_slug(&slug).await {
69            return Ok(SinglePostResponse { post: cached });
70        }
71
72        let client = get_conn().await.map_err(AppError::db_conn)?;
73
74        // 使用 LATERAL JOIN 查询按 published_at 排序的相邻文章。
75        let row = client
76            .query_opt(
77                "SELECT
78                    p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.content_html, p.toc_html,
79                    p.status, p.published_at, p.created_at, p.updated_at, p.cover_image,
80                    p.word_count, p.reading_time,
81                    COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags,
82                    prev.title as prev_title, prev.slug as prev_slug,
83                    next.title as next_title, next.slug as next_slug
84                 FROM posts p
85                 LEFT JOIN post_tags pt ON p.id = pt.post_id
86                 LEFT JOIN tags t ON pt.tag_id = t.id
87                 LEFT JOIN LATERAL (
88                     SELECT title, slug FROM posts 
89                     WHERE published_at < p.published_at 
90                       AND status = 'published' 
91                       AND deleted_at IS NULL
92                     ORDER BY published_at DESC
93                     LIMIT 1
94                 ) prev ON true
95                 LEFT JOIN LATERAL (
96                     SELECT title, slug FROM posts 
97                     WHERE published_at > p.published_at 
98                       AND status = 'published' 
99                       AND deleted_at IS NULL
100                     ORDER BY published_at ASC
101                     LIMIT 1
102                 ) next ON true
103                 WHERE p.slug = $1 AND p.status = 'published' AND p.deleted_at IS NULL
104                 GROUP BY p.id, prev.title, prev.slug, next.title, next.slug",
105                &[&slug],
106            )
107            .await
108            .map_err(AppError::query)?;
109
110        let post = match row {
111            Some(row) => Some(row_to_post_full(&row).await?),
112            None => None,
113        };
114
115        if post.is_some() {
116            crate::cache::set_post_by_slug(&slug, post.clone()).await;
117        }
118        Ok(SinglePostResponse { post })
119    }
120
121    #[cfg(not(feature = "server"))]
122    {
123        Ok(SinglePostResponse { post: None })
124    }
125}
126
127/// 阅读页使用独立协议,避免新部署破坏已打开页面的旧客户端。
128#[server(GetPublicPostBySlug, endpoint = "get_public_post_by_slug", input = leptos::server_fn::codec::Json)]
129pub async fn get_public_post_by_slug(slug: String) -> Result<PublicPostResponse, ApiError> {
130    let response = get_post_by_slug(slug).await?;
131    Ok(PublicPostResponse {
132        post: response.post.map(Into::into),
133    })
134}
135
136/// 管理员按 slug 预览文章(含草稿),不缓存、不带上下篇导航。
137///
138/// 镜像 [`get_post_by_id`] 的查询,但按 slug 取、不做 `status = 'published'`
139/// 过滤,故草稿可被预览。回收站(软删除)文章返回 `None`。草稿绝不进公开缓存
140/// (`cache::get/set_post_by_slug`),因此预览路径只走一次直查。
141#[server(GetPostPreview, endpoint = "get_post_preview", input = leptos::server_fn::codec::Json)]
142pub async fn get_post_preview(slug: String) -> Result<SinglePostResponse, ApiError> {
143    let _user = get_current_admin_user().await?;
144
145    #[cfg(feature = "server")]
146    {
147        let client = get_conn().await.map_err(AppError::db_conn)?;
148
149        let row = client
150            .query_opt(
151                "SELECT
152                    p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.content_html, p.toc_html,
153                    p.status, p.published_at, p.created_at, p.updated_at, p.cover_image,
154                    p.word_count, p.reading_time,
155                    COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags
156                 FROM posts p
157                 LEFT JOIN post_tags pt ON p.id = pt.post_id
158                 LEFT JOIN tags t ON pt.tag_id = t.id
159                 WHERE p.slug = $1 AND p.deleted_at IS NULL
160                 GROUP BY p.id",
161                &[&slug],
162            )
163            .await
164            .map_err(AppError::query)?;
165
166        let post = match row {
167            Some(row) => Some(row_to_post_full(&row).await?),
168            None => None,
169        };
170
171        Ok(SinglePostResponse { post })
172    }
173
174    #[cfg(not(feature = "server"))]
175    {
176        Ok(SinglePostResponse { post: None })
177    }
178}