Skip to main content

yggdrasil/api/posts/
suggest.rs

1//! 搜索面板(⌘K)的文章建议接口。
2//!
3//! 与全文搜索不同:只匹配标题、摘要与标签名,最多返回 8 条,按「标题命中 >
4//! 摘要命中 > 标签命中」再按发布时间排序。面板逐字查询,所以另配一档宽松限流
5//! (见 `rate_limit::check_suggest_limit`),结果写入短 TTL 缓存,并随文章写操作失效。
6//! Leptos server function,注册在 `/api` 路径下。
7
8use crate::api::error::ApiError;
9use leptos::prelude::*;
10
11use super::types::PostSuggestionResponse;
12#[cfg(feature = "server")]
13use super::types::{PostSuggestion, SUGGESTION_LIMIT};
14#[cfg(feature = "server")]
15use crate::api::error::AppError;
16#[cfg(feature = "server")]
17use crate::db::pool::get_conn;
18
19/// 查询长度上限:超出时视为无效查询,直接返回空。
20#[cfg(feature = "server")]
21const MAX_QUERY_CHARS: usize = 100;
22
23/// 文章建议的 SQL:`$1` 为已转义 LIKE 通配符的查询词。
24#[cfg(feature = "server")]
25const SUGGEST_SQL: &str = "SELECT p.slug, p.title, p.summary, p.published_at,
26        COALESCE(array_agg(t.name ORDER BY t.name) FILTER (WHERE t.name IS NOT NULL), '{}') AS tags,
27        CASE
28            WHEN p.title ILIKE '%' || $1 || '%' ESCAPE '\\' THEN 0
29            WHEN COALESCE(p.summary, '') ILIKE '%' || $1 || '%' ESCAPE '\\' THEN 1
30            ELSE 2
31        END AS rank
32     FROM posts p
33     LEFT JOIN post_tags pt ON p.id = pt.post_id
34     LEFT JOIN tags t ON pt.tag_id = t.id
35     WHERE p.status = 'published' AND p.deleted_at IS NULL
36       AND (p.title ILIKE '%' || $1 || '%' ESCAPE '\\'
37            OR COALESCE(p.summary, '') ILIKE '%' || $1 || '%' ESCAPE '\\'
38            OR EXISTS (
39                SELECT 1 FROM post_tags pt2 JOIN tags t2 ON t2.id = pt2.tag_id
40                WHERE pt2.post_id = p.id AND t2.name ILIKE '%' || $1 || '%' ESCAPE '\\'))
41     GROUP BY p.id
42     ORDER BY rank, p.published_at DESC NULLS LAST
43     LIMIT $2";
44
45/// 执行建议查询(不经缓存与限流),供接口与数据库回归测试共用。
46#[cfg(feature = "server")]
47pub(crate) async fn query_suggestions(
48    client: &deadpool_postgres::Client,
49    query: &str,
50) -> Result<Vec<PostSuggestion>, AppError> {
51    let q = query.trim();
52    if q.is_empty() || q.chars().count() > MAX_QUERY_CHARS {
53        return Ok(Vec::new());
54    }
55    let escaped = crate::utils::server::escape_like_pattern(q);
56    let rows = client
57        .query(SUGGEST_SQL, &[&escaped, &SUGGESTION_LIMIT])
58        .await
59        .map_err(AppError::query)?;
60    Ok(rows
61        .iter()
62        .map(|r| PostSuggestion {
63            slug: r.get("slug"),
64            title: r.get("title"),
65            summary: r.get("summary"),
66            published_at: r.get("published_at"),
67            tags: r.get("tags"),
68        })
69        .collect())
70}
71
72/// 搜索面板的文章建议。
73///
74/// 空查询或超长查询返回空结果;被限流时同样返回空,让面板保留上一次结果。
75#[server(SuggestPosts, endpoint = "suggest_posts", input = leptos::server_fn::codec::Json)]
76pub async fn suggest_posts(query: String) -> Result<PostSuggestionResponse, ApiError> {
77    #[cfg(feature = "server")]
78    {
79        use crate::api::rate_limit;
80
81        if let Some(headers) = crate::request_ctx::request_headers() {
82            let ip = rate_limit::get_client_ip(&headers).await;
83            if rate_limit::check_suggest_limit(&ip).is_err() {
84                return Ok(PostSuggestionResponse { posts: Vec::new() });
85            }
86        }
87
88        let q = query.trim();
89        if q.is_empty() || q.chars().count() > MAX_QUERY_CHARS {
90            return Ok(PostSuggestionResponse { posts: Vec::new() });
91        }
92        if let Some(posts) = crate::cache::get_suggestions(q).await {
93            return Ok(PostSuggestionResponse { posts });
94        }
95        let client = get_conn().await.map_err(AppError::db_conn)?;
96        let posts = query_suggestions(&client, q).await?;
97        crate::cache::set_suggestions(q, posts.clone()).await;
98        Ok(PostSuggestionResponse { posts })
99    }
100
101    #[cfg(not(feature = "server"))]
102    {
103        let _ = query;
104        Ok(PostSuggestionResponse { posts: Vec::new() })
105    }
106}
107
108#[cfg(all(test, feature = "server"))]
109mod tests {
110    use super::*;
111
112    /// 与文章写入回归共用一次性数据库 ygg_mcp_test。
113    #[test]
114    #[ignore = "requires disposable DATABASE_URL database ygg_mcp_test"]
115    fn suggestions_rank_and_filter_database() {
116        crate::db::TEST_DATABASE_RUNTIME.block_on(suggestions_rank_and_filter_impl());
117    }
118
119    async fn suggestions_rank_and_filter_impl() {
120        let _guard = crate::db::TEST_DATABASE_LOCK.lock().await;
121        let mut client = get_conn().await.unwrap();
122        let database: String = client
123            .query_one("SELECT current_database()", &[])
124            .await
125            .unwrap()
126            .get(0);
127        assert_eq!(database, "ygg_mcp_test", "requires isolated test database");
128        crate::db::migrate::run_on_conn(&mut client).await.unwrap();
129        client
130            .batch_execute(
131                "TRUNCATE users RESTART IDENTITY CASCADE;
132                 DELETE FROM tags WHERE name LIKE 'suggest-%';
133                 INSERT INTO users(id,username,email,password_hash,role)
134                     VALUES (1,'suggest-owner','suggest@test.invalid','unused','admin');
135                 INSERT INTO posts(author_id,title,slug,summary,content_md,status,published_at) VALUES
136                     (1,'从 Future 到 Waker','by-title',NULL,'正文','published','2026-01-01'),
137                     (1,'执行器','by-summary','谈谈 waker 的唤醒','正文','published','2026-03-01'),
138                     (1,'调度笔记','by-tag','无关摘要','正文','published','2026-05-01'),
139                     (1,'Waker 草稿','draft','未发布','正文','draft',NULL),
140                     (1,'完成度 100%','percent','含百分号','正文','published','2025-12-01');
141                 INSERT INTO tags(name) VALUES ('suggest-waker');
142                 INSERT INTO post_tags(post_id, tag_id)
143                     SELECT p.id, t.id FROM posts p, tags t
144                     WHERE p.slug = 'by-tag' AND t.name = 'suggest-waker';",
145            )
146            .await
147            .unwrap();
148
149        let slugs =
150            |posts: Vec<PostSuggestion>| posts.into_iter().map(|p| p.slug).collect::<Vec<_>>();
151        // 标题命中优先,其次摘要,最后标签;草稿不出现,大小写不敏感。
152        let found = query_suggestions(&client, "waker").await.unwrap();
153        assert_eq!(found[2].tags, vec!["suggest-waker".to_string()]);
154        assert_eq!(slugs(found), ["by-title", "by-summary", "by-tag"]);
155        // LIKE 通配符按字面匹配。
156        assert_eq!(
157            slugs(query_suggestions(&client, "%").await.unwrap()),
158            ["percent"]
159        );
160        assert!(query_suggestions(&client, "  ").await.unwrap().is_empty());
161        assert!(query_suggestions(&client, &"长".repeat(101))
162            .await
163            .unwrap()
164            .is_empty());
165
166        client
167            .batch_execute(
168                "TRUNCATE users RESTART IDENTITY CASCADE; DELETE FROM tags WHERE name LIKE 'suggest-%';",
169            )
170            .await
171            .unwrap();
172    }
173}
174
175/// 404 页「也许你在找」:从地址里拆出的词。
176#[cfg_attr(not(feature = "server"), allow(dead_code))]
177#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
178pub struct PathRecommendations {
179    /// 参与匹配的词(去掉了路由前缀、数字与过短的片段)。
180    pub words: Vec<String>,
181    pub posts: Vec<super::types::PostSuggestion>,
182}
183
184/// 404 推荐最多列出的文章数。
185#[cfg(feature = "server")]
186const PATH_RECOMMEND_LIMIT: i64 = 3;
187
188/// 地址里不代表内容的段:路由前缀与常见文件名。
189#[cfg(any(feature = "server", test))]
190const ROUTE_WORDS: [&str; 10] = [
191    "post", "posts", "page", "tags", "tag", "notes", "book", "about", "index", "html",
192];
193
194/// 把地址拆成用于匹配的词:按 `/ - _ .` 与空白切分,解码百分号编码,
195/// 去掉路由前缀、纯数字与不足两个字符的片段,最多保留 5 个、去重。
196#[cfg(any(feature = "server", test))]
197fn path_words(path: &str) -> Vec<String> {
198    let path = path.split(['?', '#']).next().unwrap_or_default();
199    let decoded = percent_decode(path);
200    let mut words: Vec<String> = Vec::new();
201    for word in decoded.split(|c: char| "/-_.".contains(c) || c.is_whitespace()) {
202        let word = word.trim().to_lowercase();
203        if word.chars().count() < 2
204            || word.chars().all(|c| c.is_ascii_digit())
205            || ROUTE_WORDS.contains(&word.as_str())
206            || words.contains(&word)
207        {
208            continue;
209        }
210        words.push(word);
211        if words.len() == 5 {
212            break;
213        }
214    }
215    words
216}
217
218/// 解码 `%XX`;不是合法 UTF-8 时退回原文。
219#[cfg(any(feature = "server", test))]
220fn percent_decode(text: &str) -> String {
221    let bytes = text.as_bytes();
222    let mut out = Vec::with_capacity(bytes.len());
223    let mut i = 0;
224    while i < bytes.len() {
225        if bytes[i] == b'%' && i + 2 < bytes.len() {
226            if let Some(byte) = text
227                .get(i + 1..i + 3)
228                .and_then(|hex| u8::from_str_radix(hex, 16).ok())
229            {
230                out.push(byte);
231                i += 3;
232                continue;
233            }
234        }
235        out.push(bytes[i]);
236        i += 1;
237    }
238    String::from_utf8(out).unwrap_or_else(|_| text.to_string())
239}
240
241/// 按地址里的词匹配已发布文章的标题与 slug,命中词多的排前,最多 3 篇。
242/// 地址里没有可用的词时不查询。公开接口,结果不缓存(只在 404 时调用)。
243#[server(RecommendForPath, endpoint = "recommend_for_path", input = leptos::server_fn::codec::Json)]
244pub async fn recommend_for_path(path: String) -> Result<PathRecommendations, ApiError> {
245    #[cfg(feature = "server")]
246    {
247        let words = path_words(&path);
248        if words.is_empty() {
249            return Ok(PathRecommendations {
250                words,
251                posts: Vec::new(),
252            });
253        }
254        let patterns: Vec<String> = words
255            .iter()
256            .map(|w| format!("%{}%", crate::utils::server::escape_like_pattern(w)))
257            .collect();
258        let client = get_conn().await.map_err(AppError::db_conn)?;
259        let rows = client
260            .query(
261                "SELECT p.slug, p.title, p.summary, p.published_at,
262                        COALESCE(array_agg(t.name ORDER BY t.name) FILTER (WHERE t.name IS NOT NULL), '{}') AS tags,
263                        (SELECT COUNT(*) FROM unnest($1::text[]) w
264                         WHERE p.title ILIKE w ESCAPE '\\' OR p.slug ILIKE w ESCAPE '\\') AS hits
265                 FROM posts p
266                 LEFT JOIN post_tags pt ON p.id = pt.post_id
267                 LEFT JOIN tags t ON pt.tag_id = t.id
268                 WHERE p.status = 'published' AND p.deleted_at IS NULL
269                   AND (p.title ILIKE ANY($1) OR p.slug ILIKE ANY($1))
270                 GROUP BY p.id
271                 ORDER BY hits DESC, p.published_at DESC NULLS LAST
272                 LIMIT $2",
273                &[&patterns, &PATH_RECOMMEND_LIMIT],
274            )
275            .await
276            .map_err(AppError::query)?;
277        let posts = rows
278            .iter()
279            .map(|r| PostSuggestion {
280                slug: r.get("slug"),
281                title: r.get("title"),
282                summary: r.get("summary"),
283                published_at: r.get("published_at"),
284                tags: r.get("tags"),
285            })
286            .collect();
287        Ok(PathRecommendations { words, posts })
288    }
289    #[cfg(not(feature = "server"))]
290    {
291        let _ = path;
292        Ok(PathRecommendations {
293            words: Vec::new(),
294            posts: Vec::new(),
295        })
296    }
297}
298
299#[cfg(test)]
300mod path_tests {
301    use super::path_words;
302
303    #[test]
304    fn path_words_drop_route_prefixes_numbers_and_short_bits() {
305        assert_eq!(path_words("/post/hesper-apple-1"), ["hesper", "apple"]);
306        assert_eq!(path_words("/page/2"), Vec::<String>::new());
307        assert_eq!(path_words("/tags/Rust?x=1"), ["rust"]);
308        assert_eq!(
309            path_words("/%E5%BC%82%E6%AD%A5-rust/rust"),
310            ["异步", "rust"]
311        );
312        assert_eq!(path_words("/a/b/c-d"), Vec::<String>::new());
313        assert_eq!(path_words("/one/two/three/four/five/six").len(), 5);
314    }
315}