1use 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#[cfg(feature = "server")]
21const MAX_QUERY_CHARS: usize = 100;
22
23#[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#[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#[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 #[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 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 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#[cfg_attr(not(feature = "server"), allow(dead_code))]
177#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
178pub struct PathRecommendations {
179 pub words: Vec<String>,
181 pub posts: Vec<super::types::PostSuggestion>,
182}
183
184#[cfg(feature = "server")]
186const PATH_RECOMMEND_LIMIT: i64 = 3;
187
188#[cfg(any(feature = "server", test))]
190const ROUTE_WORDS: [&str; 10] = [
191 "post", "posts", "page", "tags", "tag", "notes", "book", "about", "index", "html",
192];
193
194#[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#[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#[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}