yggdrasil/src/api/posts/search.rs

94 lines
3.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 文章全文搜索接口。
//!
//! 基于 PostgreSQL 的 pg_trgm 扩展,通过 word_similarity 对 search_text 做模糊匹配,
//! 按相似度与发布时间降序返回最多 50 篇已发布文章。
//! Dioxus server function注册在 `/api` 路径下。
//! 仅在 `feature = "server"` 启用的服务端构建中查询数据库。
use dioxus::prelude::*;
#[cfg(feature = "server")]
use super::helpers::row_to_post_list_item;
use super::types::PostListResponse;
#[cfg(feature = "server")]
use crate::api::error::AppError;
#[cfg(feature = "server")]
use crate::db::pool::get_conn;
/// 搜索已发布文章。
///
/// 空查询直接返回空结果;非空查询使用 `word_similarity` 计算相关度,
/// 并限制返回 50 条记录。当前未缓存,每次均查询数据库。
#[server(SearchPosts, "/api")]
pub async fn search_posts(query: String) -> Result<PostListResponse, ServerFnError> {
#[cfg(feature = "server")]
{
use crate::api::rate_limit;
// 对搜索接口进行严格限流,防止滥用 expensive 查询。
if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
let parts = ctx.parts_mut();
let ip = rate_limit::get_client_ip(&parts.headers);
if let Err(_msg) = rate_limit::check_strict_limit(&ip) {
return Ok(PostListResponse {
posts: Vec::new(),
total: 0,
});
}
}
let client = get_conn().await.map_err(AppError::db_conn)?;
let q = query.trim();
if q.is_empty() || q.chars().count() > 200 {
return Ok(PostListResponse {
posts: Vec::new(),
total: 0,
});
}
// 转义 SQL LIKE 通配符,避免用户输入 % / _ 导致全表扫描。
let escaped = q
.replace('\\', "\\\\")
.replace('%', "\\%")
.replace('_', "\\_");
// 使用 ILIKE 做前缀模糊匹配,并按 word_similarity 降序、发布时间降序排序。
let rows = client
.query(
"SELECT
p.id, p.author_id, p.title, p.slug, p.summary, p.content_md,
p.status, p.published_at, p.created_at, p.updated_at, p.cover_image,
COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags,
word_similarity(p.search_text, $2) AS sml
FROM posts p
LEFT JOIN post_tags pt ON p.id = pt.post_id
LEFT JOIN tags t ON pt.tag_id = t.id
WHERE p.status = 'published' AND p.deleted_at IS NULL
AND p.search_text ILIKE '%' || $1 || '%' ESCAPE '\\'
GROUP BY p.id, p.search_text
ORDER BY sml DESC, p.published_at DESC
LIMIT 50",
&[&escaped, &q],
)
.await
.map_err(AppError::query)?;
let mut posts = Vec::new();
for row in &rows {
posts.push(row_to_post_list_item(row));
}
let total = posts.len() as i64;
Ok(PostListResponse { posts, total })
}
#[cfg(not(feature = "server"))]
{
Ok(PostListResponse {
posts: Vec::new(),
total: 0,
})
}
}