Skip to main content

yggdrasil/api/posts/
search.rs

1//! 文章全文搜索接口。
2//!
3//! 通过 `ILIKE` 对 search_text 做子串模糊匹配(两侧 `%`),由 posts.search_text 上的
4//! trigram GIN 索引(gin_trgm_ops,见 migrations/019)加速;<3 字符的查询 trigram 不足,
5//! 可能退回顺序扫,靠 LIMIT 50 与搜索限流兜底。结果按 pg_trgm 的 `word_similarity`
6//! 相似度与发布时间降序返回最多 50 篇已发布文章。
7//! Dioxus server function,注册在 `/api` 路径下。
8//! 仅在 `feature = "server"` 启用的服务端构建中查询数据库。
9
10use dioxus::prelude::*;
11
12#[cfg(feature = "server")]
13use super::helpers::row_to_post_list_item;
14use super::types::PostListResponse;
15#[cfg(feature = "server")]
16use crate::api::error::AppError;
17#[cfg(feature = "server")]
18use crate::cache;
19#[cfg(feature = "server")]
20use crate::db::pool::get_conn;
21
22/// 搜索已发布文章。
23///
24/// 空查询直接返回空结果;非空查询使用 `word_similarity` 计算相关度,
25/// 并限制返回 50 条记录。结果写入短 TTL 内存缓存以减轻 DB 压力。
26#[server(SearchPosts, "/api")]
27pub async fn search_posts(query: String) -> Result<PostListResponse, ServerFnError> {
28    #[cfg(feature = "server")]
29    {
30        use crate::api::rate_limit;
31
32        // 对搜索接口进行严格限流,防止滥用 expensive 查询。
33        if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
34            let parts = ctx.parts_mut();
35            let ip = rate_limit::get_client_ip(&parts.headers);
36            if let Err(_msg) = rate_limit::check_strict_limit(&ip) {
37                return Ok(PostListResponse {
38                    posts: Vec::new(),
39                    total: 0,
40                });
41            }
42        }
43
44        let client = get_conn().await.map_err(AppError::db_conn)?;
45
46        let q = query.trim();
47        if q.is_empty() || q.chars().count() > 200 {
48            return Ok(PostListResponse {
49                posts: Vec::new(),
50                total: 0,
51            });
52        }
53
54        // 先检查短 TTL 的搜索结果缓存。
55        let cache_key = cache::normalize_search_key(q);
56        if let Some((posts, total)) = cache::get_search_results(&cache_key).await {
57            return Ok(PostListResponse { posts, total });
58        }
59
60        // 转义 SQL LIKE 通配符,避免用户输入 % / _ 导致全表扫描。
61        let escaped = crate::utils::server::escape_like_pattern(q);
62
63        // 使用 ILIKE 做子串模糊匹配(双侧 %),走 posts.search_text 上的 trigram GIN 索引
64        // (见 migrations/019)。<3 字符查询 trigram 不足时可能退回顺序扫,由 LIMIT 50 + 限流兜底。
65        let rows = client
66            .query(
67                "SELECT
68                    p.id, p.author_id, p.title, p.slug, p.summary, p.status,
69                    p.published_at, p.created_at, p.updated_at, p.cover_image,
70                    p.word_count, p.reading_time,
71                    COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags,
72                    word_similarity(p.search_text, $2) AS sml
73                 FROM posts p
74                 LEFT JOIN post_tags pt ON p.id = pt.post_id
75                 LEFT JOIN tags t ON pt.tag_id = t.id
76                 WHERE p.status = 'published' AND p.deleted_at IS NULL
77                   AND p.search_text ILIKE '%' || $1 || '%' ESCAPE '\\'
78                 GROUP BY p.id, p.search_text
79                 ORDER BY sml DESC, p.published_at DESC
80                 LIMIT 50",
81                &[&escaped, &q],
82            )
83            .await
84            .map_err(AppError::query)?;
85
86        let posts: Vec<_> = rows.iter().map(row_to_post_list_item).collect();
87
88        let total = posts.len() as i64;
89        cache::set_search_results(&cache_key, posts.clone(), total).await;
90        Ok(PostListResponse { posts, total })
91    }
92
93    #[cfg(not(feature = "server"))]
94    {
95        Ok(PostListResponse {
96            posts: Vec::new(),
97            total: 0,
98        })
99    }
100}