feat(auto): 智能搜索

This commit is contained in:
irony 2025-12-06 20:57:38 +08:00
parent 49bb95604b
commit 8af87cd9f4
9 changed files with 308 additions and 16 deletions

12
pom.xml
View File

@ -11,6 +11,9 @@
<properties> <properties>
<java.version>1.8</java.version> <java.version>1.8</java.version>
<spring.boot.version>2.7.18</spring.boot.version> <spring.boot.version>2.7.18</spring.boot.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<maven.compiler.encoding>UTF-8</maven.compiler.encoding>
<!-- <maven.compiler.release>8</maven.compiler.release>--> <!-- <maven.compiler.release>8</maven.compiler.release>-->
</properties> </properties>
@ -82,9 +85,12 @@
<groupId>org.apache.maven.plugins</groupId> <groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId> <artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version> <version>3.11.0</version>
<!--<configuration> <configuration>
<release>${maven.compiler.release}</release> <encoding>UTF-8</encoding>
</configuration>--> <source>1.8</source>
<target>1.8</target>
<!--<release>${maven.compiler.release}</release>-->
</configuration>
</plugin> </plugin>
</plugins> </plugins>
</build> </build>

View File

@ -9,6 +9,7 @@ import org.springframework.data.domain.Sort;
import org.springframework.data.mongodb.core.MongoTemplate; import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.index.Index; import org.springframework.data.mongodb.core.index.Index;
import org.springframework.data.mongodb.core.index.IndexOperations; import org.springframework.data.mongodb.core.index.IndexOperations;
import org.springframework.data.mongodb.core.index.TextIndexDefinition;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
/** /**
@ -76,7 +77,23 @@ public class MongoIndexConfig {
.named("idx_tags") .named("idx_tags")
.background()); .background());
log.info("Novel索引已确保: idx_name, idx_author, idx_tags"); // 全文搜索索引 - 支持智能搜索可选如果MongoDB版本支持
// 注意MongoDB全文搜索需要特定版本支持如果失败不影响基本搜索功能
try {
TextIndexDefinition.TextIndexDefinitionBuilder builder =
new TextIndexDefinition.TextIndexDefinitionBuilder();
builder.onField("name", 10.0f); // 书名权重最高
builder.onField("author", 5.0f); // 作者权重中等
builder.onField("synopsis", 1.0f); // 简介权重最低
builder.onField("tags", 3.0f); // 标签权重
TextIndexDefinition textIndex = builder.build();
indexOps.ensureIndex(textIndex);
log.info("全文搜索索引已创建");
} catch (Exception e) {
log.warn("创建全文搜索索引失败可能MongoDB版本不支持将使用普通搜索: {}", e.getMessage());
}
log.info("Novel索引已确保: idx_name, idx_author, idx_tags, text_index");
} }
private void createHistoryIndexes() { private void createHistoryIndexes() {

View File

@ -41,6 +41,14 @@ public class NovelController {
.orElse(ResponseEntity.notFound().build()); .orElse(ResponseEntity.notFound().build());
} }
@GetMapping("/suggestions")
@Operation(summary = "获取搜索建议", description = "根据输入的关键词返回搜索建议")
public ResponseEntity<List<NovelService.SearchSuggestion>> getSearchSuggestions(
@RequestParam String keyword,
@RequestParam(defaultValue = "10") int limit) {
return ResponseEntity.ok(novelService.getSearchSuggestions(keyword, limit));
}
@GetMapping("/{id}/chapters") @GetMapping("/{id}/chapters")
@Operation(summary = "获取章节列表") @Operation(summary = "获取章节列表")
public ResponseEntity<Page<ChapterHead>> getChapters( public ResponseEntity<Page<ChapterHead>> getChapters(

View File

@ -8,6 +8,7 @@ import com.novelreader.repository.ChapterRepository;
import com.novelreader.repository.NovelRepository; import com.novelreader.repository.NovelRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.data.mongodb.core.MongoTemplate; import org.springframework.data.mongodb.core.MongoTemplate;
@ -15,9 +16,7 @@ import org.springframework.data.mongodb.core.aggregation.Aggregation;
import org.springframework.data.mongodb.core.aggregation.AggregationResults; import org.springframework.data.mongodb.core.aggregation.AggregationResults;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.Collections; import java.util.*;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@Service @Service
@ -28,16 +27,18 @@ public class NovelService {
private final ChapterRepository chapterRepository; private final ChapterRepository chapterRepository;
private final MongoTemplate mongoTemplate; private final MongoTemplate mongoTemplate;
/**
* 智能搜索小说 - 支持相关性排序和多种匹配策略
*/
public Page<NovelBrief> searchNovels(String keyword, String tag, int page, int size) { public Page<NovelBrief> searchNovels(String keyword, String tag, int page, int size) {
Pageable pageable = PageRequest.of(page, size); Pageable pageable = PageRequest.of(page, size);
Page<NovelDO> novels; Page<NovelDO> novels;
List<String> tags = tag != null && !tag.isEmpty() ? Collections.singletonList(tag) : null; List<String> tags = tag != null && !tag.isEmpty() ? Collections.singletonList(tag) : null;
if (keyword != null && !keyword.isEmpty() && tags != null) { if (keyword != null && !keyword.isEmpty()) {
novels = novelRepository.findByNameOrAuthorAndTags(keyword, tags, pageable); // 智能搜索优先精确匹配然后模糊匹配
} else if (keyword != null && !keyword.isEmpty()) { novels = intelligentSearch(keyword, tags, pageable);
novels = novelRepository.findByNameOrAuthorContainingIgnoreCase(keyword, pageable);
} else if (tags != null) { } else if (tags != null) {
novels = novelRepository.findByTagsIn(tags, pageable); novels = novelRepository.findByTagsIn(tags, pageable);
} else { } else {
@ -47,6 +48,122 @@ public class NovelService {
return novels.map(this::toBrief); return novels.map(this::toBrief);
} }
/**
* 智能搜索实现 - 多策略搜索并合并结果
*/
private Page<NovelDO> intelligentSearch(String keyword, List<String> tags, Pageable pageable) {
keyword = keyword.trim();
// 策略1: 书名精确匹配最高优先级
List<NovelDO> exactNameMatches = novelRepository.findByNameStartingWithIgnoreCase(keyword);
// 策略2: 书名包含匹配
List<NovelDO> nameContainsMatches = novelRepository.findByNameContainingIgnoreCase(keyword);
// 策略3: 作者精确匹配
List<NovelDO> exactAuthorMatches = novelRepository.findByAuthor(keyword);
// 策略4: 书名或作者模糊匹配原有逻辑
Page<NovelDO> fuzzyMatches = novelRepository.findByNameOrAuthorContainingIgnoreCase(keyword, pageable);
// 合并结果去重按优先级排序
Set<String> seenIds = new HashSet<>();
List<NovelDO> mergedResults = new ArrayList<>();
// 1. 书名精确匹配权重最高
for (NovelDO novel : exactNameMatches) {
if (!seenIds.contains(novel.getId()) && (tags == null || novel.getTags() != null && new HashSet<>(novel.getTags()).containsAll(tags))) {
mergedResults.add(novel);
seenIds.add(novel.getId());
}
}
// 2. 书名包含匹配
for (NovelDO novel : nameContainsMatches) {
if (!seenIds.contains(novel.getId()) && (tags == null || novel.getTags() != null && new HashSet<>(novel.getTags()).containsAll(tags))) {
mergedResults.add(novel);
seenIds.add(novel.getId());
}
}
// 3. 作者精确匹配
for (NovelDO novel : exactAuthorMatches) {
if (!seenIds.contains(novel.getId()) && (tags == null || novel.getTags() != null && new HashSet<>(novel.getTags()).containsAll(tags))) {
mergedResults.add(novel);
seenIds.add(novel.getId());
}
}
// 4. 模糊匹配结果如果还有空间
int remaining = pageable.getPageSize() - mergedResults.size();
if (remaining > 0 && fuzzyMatches.hasContent()) {
for (NovelDO novel : fuzzyMatches.getContent()) {
if (!seenIds.contains(novel.getId()) && (tags == null || novel.getTags() != null && new HashSet<>(novel.getTags()).containsAll(tags))) {
mergedResults.add(novel);
seenIds.add(novel.getId());
if (--remaining <= 0) break;
}
}
}
// 转换为分页结果
int total = mergedResults.size();
int start = (int) pageable.getOffset();
int end = Math.min(start + pageable.getPageSize(), total);
List<NovelDO> pageContent = start < total ? mergedResults.subList(start, end) : Collections.emptyList();
return new PageImpl<>(pageContent, pageable, total);
}
/**
* 搜索建议 - 返回匹配的书名和作者
*/
public List<SearchSuggestion> getSearchSuggestions(String keyword, int limit) {
if (keyword == null || keyword.trim().isEmpty()) {
return Collections.emptyList();
}
keyword = keyword.trim();
List<SearchSuggestion> suggestions = new java.util.ArrayList<>();
// 书名建议前缀匹配
List<NovelDO> nameMatches = novelRepository.findByNameStartingWithIgnoreCase(keyword);
for (NovelDO novel : nameMatches) {
if (suggestions.size() >= limit) break;
SearchSuggestion suggestion = new SearchSuggestion();
suggestion.setType("书名");
suggestion.setText(novel.getName());
suggestion.setNovelId(novel.getId());
suggestions.add(suggestion);
}
// 作者建议精确匹配
if (suggestions.size() < limit) {
List<NovelDO> authorMatches = novelRepository.findByAuthor(keyword);
java.util.Set<String> seenAuthors = new java.util.HashSet<>();
for (NovelDO novel : authorMatches) {
if (suggestions.size() >= limit) break;
if (!seenAuthors.contains(novel.getAuthor())) {
SearchSuggestion suggestion = new SearchSuggestion();
suggestion.setType("作者");
suggestion.setText(novel.getAuthor());
suggestions.add(suggestion);
seenAuthors.add(novel.getAuthor());
}
}
}
return suggestions;
}
// 搜索建议DTO
@lombok.Data
public static class SearchSuggestion {
private String type; // "书名" "作者"
private String text; // 建议文本
private String novelId; // 如果是书名提供novelId
}
public Optional<NovelDetail> getNovelDetail(String id) { public Optional<NovelDetail> getNovelDetail(String id) {
return novelRepository.findById(id).map(novel -> { return novelRepository.findById(id).map(novel -> {
NovelDetail detail = toDetail(novel); NovelDetail detail = toDetail(novel);

View File

@ -379,7 +379,69 @@
padding: var(--spacing-md); padding: var(--spacing-md);
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
max-height: 40vh; max-height: 40vh;
}
/* 搜索建议 */
.search-suggestions {
position: absolute;
top: calc(var(--header-height) + 1px);
left: 0;
right: 0;
background: var(--bg-primary);
border-bottom: 1px solid var(--border-color);
max-height: 60vh;
overflow-y: auto; overflow-y: auto;
z-index: 100;
}
.suggestions-list {
padding: var(--spacing-sm) 0;
}
.suggestion-item {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-md);
cursor: pointer;
transition: background var(--transition-fast);
}
.suggestion-item:active {
background: var(--bg-hover);
}
.suggestion-type {
font-size: var(--font-size-xs);
color: var(--text-muted);
padding: 2px 8px;
background: var(--bg-hover);
border-radius: var(--radius-sm);
white-space: nowrap;
}
.suggestion-text {
flex: 1;
color: var(--text-primary);
font-size: var(--font-size-md);
}
.suggestion-text mark {
background: var(--primary);
color: var(--bg-primary);
padding: 0 2px;
border-radius: 2px;
font-weight: 600;
}
/* 搜索结果高亮 */
.novel-title mark,
.novel-author mark {
background: var(--primary);
color: var(--bg-primary);
padding: 0 2px;
border-radius: 2px;
font-weight: 600;
} }
.search-section { .search-section {

View File

@ -65,6 +65,15 @@ const API = (function() {
return get('/novels', params); return get('/novels', params);
} }
/**
* 获取搜索建议
* @param {string} keyword
* @param {number} limit
*/
function getSearchSuggestions(keyword, limit = 10) {
return get('/novels/suggestions', { keyword, limit });
}
/** /**
* 获取小说详情 * 获取小说详情
* @param {string} novelId * @param {string} novelId
@ -255,6 +264,7 @@ const API = (function() {
return { return {
// 小说 // 小说
searchNovels, searchNovels,
getSearchSuggestions,
getNovelDetail, getNovelDetail,
getChapters, getChapters,
getPopularTags, getPopularTags,

View File

@ -162,17 +162,37 @@ const App = (function() {
// 搜索面板 // 搜索面板
$('#search-back').on('click', hideSearch); $('#search-back').on('click', hideSearch);
// 搜索输入 - 实时搜索 // 搜索输入 - 实时搜索建议
$('#search-input').on('input', debounce(function() { let searchSuggestionTimer = null;
$('#search-input').on('input', function() {
const keyword = $(this).val().trim(); const keyword = $(this).val().trim();
if (keyword) { if (keyword) {
$('#search-clear').removeClass('hidden'); $('#search-clear').removeClass('hidden');
doSearch(keyword); // 显示搜索建议
clearTimeout(searchSuggestionTimer);
searchSuggestionTimer = setTimeout(() => {
loadSearchSuggestions(keyword);
}, 300);
} else { } else {
$('#search-clear').addClass('hidden'); $('#search-clear').addClass('hidden');
$('#search-results').html('<div class="search-hint">输入关键词搜索小说</div>'); $('#search-results').html('<div class="search-hint">输入关键词搜索小说</div>');
$('#search-suggestions').html('').addClass('hidden');
} }
}, 500)); });
// 搜索输入 - 回车确认搜索
$('#search-input').on('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
const keyword = $(this).val().trim();
if (keyword) {
$('#search-suggestions').html('').addClass('hidden');
doSearch(keyword);
}
} else if (e.key === 'Escape') {
$('#search-suggestions').html('').addClass('hidden');
}
});
// 搜索输入 - 回车确认搜索 // 搜索输入 - 回车确认搜索
$('#search-input').on('keypress', function(e) { $('#search-input').on('keypress', function(e) {
@ -413,11 +433,57 @@ const App = (function() {
$('#search-panel').removeClass('hidden'); $('#search-panel').removeClass('hidden');
$('#search-input').val('').focus(); $('#search-input').val('').focus();
$('#search-results').html('<div class="search-hint">输入关键词搜索小说</div>'); $('#search-results').html('<div class="search-hint">输入关键词搜索小说</div>');
$('#search-suggestions').html('').addClass('hidden');
// 渲染搜索历史和热门标签 // 渲染搜索历史和热门标签
renderSearchTags(); renderSearchTags();
} }
// 加载搜索建议
async function loadSearchSuggestions(keyword) {
if (!keyword || keyword.length < 1) {
$('#search-suggestions').html('').addClass('hidden');
return;
}
try {
const suggestions = await API.getSearchSuggestions(keyword, 8);
if (suggestions.length === 0) {
$('#search-suggestions').html('').addClass('hidden');
return;
}
let html = '<div class="suggestions-list">';
suggestions.forEach(suggestion => {
const highlightText = highlightKeyword(suggestion.text, keyword);
if (suggestion.novelId) {
html += `<div class="suggestion-item" data-novel-id="${suggestion.novelId}">
<span class="suggestion-type">${suggestion.type}</span>
<span class="suggestion-text">${highlightText}</span>
</div>`;
} else {
html += `<div class="suggestion-item" data-author="${suggestion.text}">
<span class="suggestion-type">${suggestion.type}</span>
<span class="suggestion-text">${highlightText}</span>
</div>`;
}
});
html += '</div>';
$('#search-suggestions').html(html).removeClass('hidden');
} catch (error) {
console.error('加载搜索建议失败:', error);
$('#search-suggestions').html('').addClass('hidden');
}
}
// 高亮关键词
function highlightKeyword(text, keyword) {
if (!text || !keyword) return text;
const regex = new RegExp(`(${keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
return text.replace(regex, '<mark>$1</mark>');
}
async function renderSearchTags() { async function renderSearchTags() {
let html = ''; let html = '';

View File

@ -687,6 +687,9 @@ const Pages = (function() {
const $results = $('#search-results'); const $results = $('#search-results');
$results.html('<div class="chapter-loading"><div class="loading-spinner"></div><span>搜索中...</span></div>'); $results.html('<div class="chapter-loading"><div class="loading-spinner"></div><span>搜索中...</span></div>');
// 保存当前搜索关键词用于高亮
currentSearchKeyword = keyword;
try { try {
const data = await API.searchNovels({ keyword, page: 0, size: 30 }); const data = await API.searchNovels({ keyword, page: 0, size: 30 });
const novels = data.content || []; const novels = data.content || [];
@ -699,7 +702,7 @@ const Pages = (function() {
let html = '<div class="novel-list">'; let html = '<div class="novel-list">';
novels.forEach(novel => { novels.forEach(novel => {
const isInShelf = Store.isInShelfCache(novel.id); const isInShelf = Store.isInShelfCache(novel.id);
html += renderNovelCard(novel, isInShelf); html += renderNovelCard(novel, isInShelf, keyword);
}); });
html += '</div>'; html += '</div>';

View File

@ -275,6 +275,9 @@
<div id="search-tags" class="search-tags"> <div id="search-tags" class="search-tags">
<!-- 热门标签 --> <!-- 热门标签 -->
</div> </div>
<div id="search-suggestions" class="search-suggestions hidden">
<!-- 搜索建议 -->
</div>
<div id="search-results" class="search-results"> <div id="search-results" class="search-results">
<!-- 搜索结果 --> <!-- 搜索结果 -->
</div> </div>