Skip to main content

tdm_server_rust/service/
search_service.rs

1//! 全局搜索服务。
2//!
3//! 负责关键词校验、分页规范化、权限上下文构建和多分区结果聚合。
4
5use crate::{
6    app::AppState,
7    entity::{
8        member::Member,
9        search::{GlobalSearchQuery, GlobalSearchResponse, GlobalSearchSectionCount},
10    },
11    repository::search_repo::{SearchKind, SearchPermission, SearchRepository},
12};
13
14/// 全局搜索服务。
15pub struct SearchService;
16
17impl SearchService {
18    /// 执行全局搜索。
19    #[tracing::instrument(skip_all, level = "debug")]
20    pub async fn search(
21        state: &AppState,
22        query: GlobalSearchQuery,
23        member: Option<Member>,
24    ) -> crate::error::ApiResult<GlobalSearchResponse> {
25        let keyword = query.q.trim().to_string();
26        let search_type = SearchKind::parse(query.search_type.as_deref());
27        let page = query.page.max(1);
28        let page_size = query.page_size.clamp(1, 50);
29        if keyword.is_empty() {
30            return Ok(empty_response(keyword, search_type, page, page_size));
31        }
32
33        let repo = SearchRepository::new(state.db.clone());
34        let permission = build_permission(member.as_ref());
35        let section_kinds = all_section_kinds(permission);
36        let mut sections = Vec::with_capacity(section_kinds.len());
37        let mut hits = Vec::new();
38
39        for kind in section_kinds {
40            let fetch_hits = search_type == SearchKind::All || search_type == kind;
41            let (limit, offset) = section_fetch_window(search_type, page, page_size, fetch_hits);
42            let section = repo
43                .search_section(kind, &keyword, limit, offset, permission)
44                .await?;
45            sections.push(GlobalSearchSectionCount {
46                kind: section.kind.as_str().to_string(),
47                label: section.kind.label().to_string(),
48                total: section.total,
49            });
50            if fetch_hits {
51                hits.extend(section.hits);
52            }
53        }
54
55        hits.sort_by(|a, b| {
56            b.score
57                .partial_cmp(&a.score)
58                .unwrap_or(std::cmp::Ordering::Equal)
59                .then_with(|| b.updated_at.cmp(&a.updated_at))
60        });
61        if search_type == SearchKind::All {
62            let start = ((page - 1) * page_size) as usize;
63            hits = hits
64                .into_iter()
65                .skip(start)
66                .take(page_size as usize)
67                .collect();
68        }
69        let total = response_total(&sections, search_type);
70
71        Ok(GlobalSearchResponse {
72            query: keyword,
73            search_type: search_type.as_str().to_string(),
74            page,
75            page_size,
76            total,
77            sections,
78            hits,
79        })
80    }
81}
82
83/// 构建搜索权限上下文。
84fn build_permission(member: Option<&Member>) -> SearchPermission {
85    let member_id = member.map(|m| m.id);
86    let is_admin = member.is_some_and(Member::has_reviewer_permission);
87    SearchPermission {
88        member_id,
89        is_admin,
90    }
91}
92
93/// 侧边栏始终展示的全部分区(含权限过滤)。
94fn all_section_kinds(permission: SearchPermission) -> Vec<SearchKind> {
95    let mut kinds = vec![
96        SearchKind::Manga,
97        SearchKind::Episode,
98        SearchKind::Member,
99        SearchKind::Glossary,
100        SearchKind::Reward,
101    ];
102    if permission.member_id.is_some() {
103        kinds.push(SearchKind::Evaluation);
104    }
105    if permission.is_admin {
106        kinds.push(SearchKind::Questionnaire);
107    }
108    kinds
109}
110
111/// 计算单分区查询窗口:非当前结果分区仅拉 1 条拿 COUNT 窗口总数。
112fn section_fetch_window(
113    search_type: SearchKind,
114    page: i32,
115    page_size: i32,
116    fetch_hits: bool,
117) -> (i64, i64) {
118    if !fetch_hits {
119        return (1, 0);
120    }
121    if search_type == SearchKind::All {
122        return ((page * page_size) as i64, 0);
123    }
124    (page_size as i64, ((page - 1) * page_size) as i64)
125}
126
127/// 当前视图下的总命中数。
128fn response_total(sections: &[GlobalSearchSectionCount], search_type: SearchKind) -> i64 {
129    if search_type == SearchKind::All {
130        return sections.iter().map(|section| section.total).sum();
131    }
132    sections
133        .iter()
134        .find(|section| section.kind == search_type.as_str())
135        .map(|section| section.total)
136        .unwrap_or(0)
137}
138
139/// 空搜索响应。
140fn empty_response(
141    keyword: String,
142    search_type: SearchKind,
143    page: i32,
144    page_size: i32,
145) -> GlobalSearchResponse {
146    GlobalSearchResponse {
147        query: keyword,
148        search_type: search_type.as_str().to_string(),
149        page,
150        page_size,
151        total: 0,
152        sections: Vec::new(),
153        hits: Vec::new(),
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::{response_total, section_fetch_window};
160    use crate::{entity::search::GlobalSearchSectionCount, repository::search_repo::SearchKind};
161
162    #[test]
163    fn all_mode_fetch_enough_for_page() {
164        assert_eq!(section_fetch_window(SearchKind::All, 3, 20, true), (60, 0));
165    }
166
167    #[test]
168    fn count_only_probe() {
169        assert_eq!(
170            section_fetch_window(SearchKind::Episode, 2, 20, false),
171            (1, 0)
172        );
173    }
174
175    #[test]
176    fn response_total_single_section() {
177        let sections = vec![GlobalSearchSectionCount {
178            kind: "episode".into(),
179            label: "话数".into(),
180            total: 100,
181        }];
182        assert_eq!(response_total(&sections, SearchKind::Episode), 100);
183    }
184}