tdm_server_rust/service/
search_service.rs1use crate::{
6 app::AppState,
7 entity::{
8 member::Member,
9 search::{GlobalSearchQuery, GlobalSearchResponse, GlobalSearchSectionCount},
10 },
11 repository::search_repo::{SearchKind, SearchPermission, SearchRepository},
12};
13
14pub struct SearchService;
16
17impl SearchService {
18 #[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(§ions, 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
83fn 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
93fn 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
111fn 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
127fn 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
139fn 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(§ions, SearchKind::Episode), 100);
183 }
184}