From 44addd2a7ba3408bd559fea11136e604c61a1f53 Mon Sep 17 00:00:00 2001 From: tim Date: Fri, 26 Sep 2025 18:03:25 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=90=9C=E7=B4=A2main=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E8=B7=91=E9=80=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openisle/search/OpenSearchIndexer.java | 14 +- .../com/openisle/search/SearchDocument.java | 3 +- .../search/SearchDocumentFactory.java | 16 ++- .../search/SearchIndexInitializer.java | 20 ++- .../com/openisle/service/SearchService.java | 129 +++++++++++++++--- 5 files changed, 143 insertions(+), 39 deletions(-) diff --git a/backend/src/main/java/com/openisle/search/OpenSearchIndexer.java b/backend/src/main/java/com/openisle/search/OpenSearchIndexer.java index 5bffa0364..a677d79cc 100644 --- a/backend/src/main/java/com/openisle/search/OpenSearchIndexer.java +++ b/backend/src/main/java/com/openisle/search/OpenSearchIndexer.java @@ -24,14 +24,12 @@ public class OpenSearchIndexer implements SearchIndexer { builder.index(index).id(document.entityId().toString()).document(document) ); IndexResponse response = client.index(request); - if (log.isDebugEnabled()) { - log.debug( - "Indexed document {} into {} with result {}", - document.entityId(), - index, - response.result() - ); - } + log.info( + "Indexed document {} into {} with result {}", + document.entityId(), + index, + response.result() + ); } catch (IOException e) { log.warn("Failed to index document {} into {}", document.entityId(), index, e); } diff --git a/backend/src/main/java/com/openisle/search/SearchDocument.java b/backend/src/main/java/com/openisle/search/SearchDocument.java index 4dc699f34..08ef0e6e6 100644 --- a/backend/src/main/java/com/openisle/search/SearchDocument.java +++ b/backend/src/main/java/com/openisle/search/SearchDocument.java @@ -1,6 +1,5 @@ package com.openisle.search; -import java.time.Instant; import java.util.List; public record SearchDocument( @@ -12,5 +11,5 @@ public record SearchDocument( String category, List tags, Long postId, - Instant createdAt + Long createdAt ) {} diff --git a/backend/src/main/java/com/openisle/search/SearchDocumentFactory.java b/backend/src/main/java/com/openisle/search/SearchDocumentFactory.java index 786a554e0..26174ac57 100644 --- a/backend/src/main/java/com/openisle/search/SearchDocumentFactory.java +++ b/backend/src/main/java/com/openisle/search/SearchDocumentFactory.java @@ -5,7 +5,6 @@ import com.openisle.model.Comment; import com.openisle.model.Post; import com.openisle.model.Tag; import com.openisle.model.User; -import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneId; import java.util.Collections; @@ -38,7 +37,7 @@ public final class SearchDocumentFactory { post.getCategory() != null ? post.getCategory().getName() : null, tags, post.getId(), - toInstant(post.getCreatedAt()) + toEpochMillis(post.getCreatedAt()) ); } @@ -64,7 +63,7 @@ public final class SearchDocumentFactory { post != null && post.getCategory() != null ? post.getCategory().getName() : null, tags, post != null ? post.getId() : null, - toInstant(comment.getCreatedAt()) + toEpochMillis(comment.getCreatedAt()) ); } @@ -81,7 +80,7 @@ public final class SearchDocumentFactory { null, Collections.emptyList(), null, - toInstant(user.getCreatedAt()) + toEpochMillis(user.getCreatedAt()) ); } @@ -115,11 +114,14 @@ public final class SearchDocumentFactory { null, Collections.emptyList(), null, - toInstant(tag.getCreatedAt()) + toEpochMillis(tag.getCreatedAt()) ); } - private static Instant toInstant(LocalDateTime time) { - return time == null ? null : time.atZone(ZoneId.systemDefault()).toInstant(); + private static Long toEpochMillis(LocalDateTime time) { + if (time == null) { + return null; + } + return time.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); } } diff --git a/backend/src/main/java/com/openisle/search/SearchIndexInitializer.java b/backend/src/main/java/com/openisle/search/SearchIndexInitializer.java index 3f266d6a3..23f30e408 100644 --- a/backend/src/main/java/com/openisle/search/SearchIndexInitializer.java +++ b/backend/src/main/java/com/openisle/search/SearchIndexInitializer.java @@ -53,7 +53,10 @@ public class SearchIndexInitializer { .properties("category", Property.of(p -> p.keyword(k -> k))) .properties("tags", Property.of(p -> p.keyword(k -> k))) .properties("postId", Property.of(p -> p.long_(l -> l))) - .properties("createdAt", Property.of(p -> p.date(d -> d))) + .properties( + "createdAt", + Property.of(p -> p.date(d -> d.format("strict_date_optional_time||epoch_millis"))) + ) ); } @@ -67,7 +70,10 @@ public class SearchIndexInitializer { .properties("category", Property.of(p -> p.keyword(k -> k))) .properties("tags", Property.of(p -> p.keyword(k -> k))) .properties("postId", Property.of(p -> p.long_(l -> l))) - .properties("createdAt", Property.of(p -> p.date(d -> d))) + .properties( + "createdAt", + Property.of(p -> p.date(d -> d.format("strict_date_optional_time||epoch_millis"))) + ) ); } @@ -77,7 +83,10 @@ public class SearchIndexInitializer { .properties("type", Property.of(p -> p.keyword(k -> k))) .properties("title", Property.of(p -> p.text(t -> t))) .properties("content", Property.of(p -> p.text(t -> t))) - .properties("createdAt", Property.of(p -> p.date(d -> d))) + .properties( + "createdAt", + Property.of(p -> p.date(d -> d.format("strict_date_optional_time||epoch_millis"))) + ) ); } @@ -96,7 +105,10 @@ public class SearchIndexInitializer { .properties("type", Property.of(p -> p.keyword(k -> k))) .properties("title", Property.of(p -> p.text(t -> t))) .properties("content", Property.of(p -> p.text(t -> t))) - .properties("createdAt", Property.of(p -> p.date(d -> d))) + .properties( + "createdAt", + Property.of(p -> p.date(d -> d.format("strict_date_optional_time||epoch_millis"))) + ) ); } } diff --git a/backend/src/main/java/com/openisle/service/SearchService.java b/backend/src/main/java/com/openisle/service/SearchService.java index 287815210..417100895 100644 --- a/backend/src/main/java/com/openisle/service/SearchService.java +++ b/backend/src/main/java/com/openisle/service/SearchService.java @@ -176,34 +176,127 @@ public class SearchService { } private List searchWithOpenSearch(String keyword) throws IOException { - OpenSearchClient client = openSearchClient.orElse(null); - if (client == null) { - return List.of(); - } - String trimmed = keyword.trim(); - SearchResponse response = client.search( - builder -> - builder + var client = openSearchClient.orElse(null); + if (client == null) return List.of(); + + final String qRaw = keyword == null ? "" : keyword.trim(); + if (qRaw.isEmpty()) return List.of(); + + final boolean enableWildcard = qRaw.length() >= 2; + final String qsEscaped = escapeForQueryString(qRaw); + + SearchResponse resp = client.search( + b -> + b .index(searchIndices()) - .query(q -> - q.multiMatch(mm -> - mm - .query(trimmed) - .fields(List.of("title^3", "content^2", "author^2", "category", "tags")) - .type(TextQueryType.BestFields) - ) + .trackTotalHits(t -> t.enabled(true)) + .query(qb -> + qb.bool(bool -> { + // 1) 主召回:title/content + bool.should(s -> + s.multiMatch(mm -> + mm + .query(qRaw) + .fields("title^3", "content^2") + .type(TextQueryType.BestFields) + .fuzziness("AUTO") + .minimumShouldMatch("70%") + .lenient(true) + ) + ); + + // 2) 兜底:open* 前缀命中 + if (enableWildcard) { + bool.should(s -> + s.queryString(qs -> + qs + .query("(title:" + qsEscaped + "* OR content:" + qsEscaped + "*)") + .analyzeWildcard(true) + ) + ); + } + + // 3) 结构化字段(keyword) + // term 需要 FieldValue(用 lambda 设置 stringValue) + bool.should(s -> + s.term(t -> + t + .field("author") + .value(v -> v.stringValue(qRaw)) + .boost(2.0f) + ) + ); + + if (enableWildcard) { + // prefix/wildcard 这里的 value 是 String,直接传即可 + bool.should(s -> s.prefix(p -> p.field("category").value(qRaw).boost(1.2f))); + bool.should(s -> + s.wildcard(w -> w.field("category").value("*" + qRaw + "*").boost(1.0f)) + ); + + bool.should(s -> + s.term(t -> + t + .field("tags") + .value(v -> v.stringValue(qRaw)) + .boost(1.2f) + ) + ); + bool.should(s -> + s.wildcard(w -> w.field("tags").value("*" + qRaw + "*").boost(1.0f)) + ); + } + + return bool.minimumShouldMatch("1"); + }) ) .highlight(h -> h .preTags("") .postTags("") - .fields("content", f -> f.fragmentSize(highlightFragmentSize()).numberOfFragments(1)) .fields("title", f -> f.fragmentSize(highlightFragmentSize()).numberOfFragments(1)) + .fields("content", f -> f.fragmentSize(highlightFragmentSize()).numberOfFragments(1)) ) - .size(DEFAULT_OPEN_SEARCH_LIMIT), + .size(DEFAULT_OPEN_SEARCH_LIMIT > 0 ? DEFAULT_OPEN_SEARCH_LIMIT : 10), SearchDocument.class ); - return mapHits(response.hits().hits(), trimmed); + + return mapHits(resp.hits().hits(), qRaw); + } + + /** Lucene query_string 安全转义(保留 * 由我们自己追加) */ + private static String escapeForQueryString(String s) { + if (s == null || s.isEmpty()) return ""; + StringBuilder sb = new StringBuilder(s.length() * 2); + for (char c : s.toCharArray()) { + switch (c) { + case '+': + case '-': + case '=': + case '&': + case '|': + case '>': + case '<': + case '!': + case '(': + case ')': + case '{': + case '}': + case '[': + case ']': + case '^': + case '"': + case '~': /* case '*': */ /* case '?': */ + case ':': + case '\\': + case '/': + sb.append('\\').append(c); + break; + default: + sb.append(c); + } + } + return sb.toString(); } private int highlightFragmentSize() {