From 4821b77c17acf6b97cf5521cf4500c5a11eaa3cb Mon Sep 17 00:00:00 2001 From: Tim Date: Fri, 26 Sep 2025 15:34:06 +0800 Subject: [PATCH 01/15] feat: add open search support --- backend/open-isle.env.example | 9 + backend/pom.xml | 5 + .../com/openisle/config/OpenSearchConfig.java | 119 ++++++ .../openisle/config/OpenSearchProperties.java | 63 +++ .../java/com/openisle/model/Category.java | 2 + .../main/java/com/openisle/model/Comment.java | 2 + .../main/java/com/openisle/model/Post.java | 2 + .../src/main/java/com/openisle/model/Tag.java | 2 + .../main/java/com/openisle/model/User.java | 2 + .../openisle/search/OpenSearchGateway.java | 368 ++++++++++++++++++ .../search/OpenSearchIndexManager.java | 115 ++++++ .../openisle/search/OpenSearchIndexer.java | 166 ++++++++ .../openisle/search/SearchEntityListener.java | 84 ++++ .../com/openisle/service/SearchService.java | 55 +++ .../src/main/resources/application.properties | 11 + .../openisle/service/SearchServiceTest.java | 4 +- 16 files changed, 1008 insertions(+), 1 deletion(-) create mode 100644 backend/src/main/java/com/openisle/config/OpenSearchConfig.java create mode 100644 backend/src/main/java/com/openisle/config/OpenSearchProperties.java create mode 100644 backend/src/main/java/com/openisle/search/OpenSearchGateway.java create mode 100644 backend/src/main/java/com/openisle/search/OpenSearchIndexManager.java create mode 100644 backend/src/main/java/com/openisle/search/OpenSearchIndexer.java create mode 100644 backend/src/main/java/com/openisle/search/SearchEntityListener.java diff --git a/backend/open-isle.env.example b/backend/open-isle.env.example index a62ac877f..66bd02d0b 100644 --- a/backend/open-isle.env.example +++ b/backend/open-isle.env.example @@ -17,6 +17,15 @@ JWT_EXPIRATION=2592000000 REDIS_HOST= REDIS_PORT= +# === OpenSearch === +OPENSEARCH_ENABLED=false +OPENSEARCH_HOSTS=https://:9200 +# 可选:启用基本认证 +# OPENSEARCH_USERNAME= +# OPENSEARCH_PASSWORD= +# 开发调试时可关闭证书校验 +# OPENSEARCH_INSECURE=true + # === Resend === RESEND_API_KEY=<你的resend-api-key> RESEND_FROM_EMAIL=<你的 resend 发送邮箱> diff --git a/backend/pom.xml b/backend/pom.xml index 97d8c7f65..af6170178 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -132,6 +132,11 @@ springdoc-openapi-starter-webmvc-api 2.2.0 + + org.opensearch.client + opensearch-java + 2.11.0 + diff --git a/backend/src/main/java/com/openisle/config/OpenSearchConfig.java b/backend/src/main/java/com/openisle/config/OpenSearchConfig.java new file mode 100644 index 000000000..7c42e385b --- /dev/null +++ b/backend/src/main/java/com/openisle/config/OpenSearchConfig.java @@ -0,0 +1,119 @@ +package com.openisle.config; + +import jakarta.annotation.PreDestroy; +import java.io.IOException; +import java.util.List; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; +import org.apache.hc.client5.http.auth.AuthScope; +import org.apache.hc.client5.http.auth.UsernamePasswordCredentials; +import org.apache.hc.client5.http.classic.HttpClientBuilder; +import org.apache.hc.client5.http.config.RequestConfig; +import org.apache.hc.client5.http.impl.auth.BasicCredentialsProvider; +import org.apache.hc.core5.http.HttpHost; +import org.opensearch.client.RestClient; +import org.opensearch.client.json.jackson.JacksonJsonpMapper; +import org.opensearch.client.opensearch.OpenSearchClient; +import org.opensearch.client.transport.OpenSearchTransport; +import org.opensearch.client.transport.rest_client.RestClientTransport; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties(OpenSearchProperties.class) +@ConditionalOnProperty(prefix = "opensearch", name = "enabled", havingValue = "true") +public class OpenSearchConfig { + + private RestClient restClient; + + @Bean + public RestClient openSearchRestClient(OpenSearchProperties properties) { + List hosts = properties.getHosts(); + if (hosts == null || hosts.isEmpty()) { + throw new IllegalStateException( + "opensearch.hosts must be configured when OpenSearch is enabled" + ); + } + + HttpHost[] httpHosts = hosts.stream().map(HttpHost::create).toArray(HttpHost[]::new); + + RestClient.Builder builder = RestClient.builder(httpHosts); + + builder.setRequestConfigCallback(requestConfigBuilder -> { + RequestConfig.Builder config = RequestConfig.custom(); + config.setConnectTimeout(properties.getConnectTimeout()); + config.setResponseTimeout(properties.getSocketTimeout()); + return config; + }); + + builder.setHttpClientConfigCallback(clientBuilder -> { + HttpClientBuilder httpClientBuilder = clientBuilder; + if (properties.getUsername() != null && properties.getPassword() != null) { + BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider(); + credentialsProvider.setCredentials( + new AuthScope(null, -1), + new UsernamePasswordCredentials( + properties.getUsername(), + properties.getPassword().toCharArray() + ) + ); + httpClientBuilder = httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider); + } + + if (properties.isInsecure()) { + try { + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init( + null, + new TrustManager[] { + new X509TrustManager() { + public java.security.cert.X509Certificate[] getAcceptedIssuers() { + return new java.security.cert.X509Certificate[0]; + } + + public void checkClientTrusted( + java.security.cert.X509Certificate[] chain, + String authType + ) {} + + public void checkServerTrusted( + java.security.cert.X509Certificate[] chain, + String authType + ) {} + }, + }, + new java.security.SecureRandom() + ); + httpClientBuilder = httpClientBuilder.setSSLContext(sslContext); + } catch (Exception e) { + throw new IllegalStateException("Failed to configure insecure SSL context", e); + } + } + return httpClientBuilder; + }); + + restClient = builder.build(); + return restClient; + } + + @Bean + public OpenSearchTransport openSearchTransport(RestClient restClient) { + JacksonJsonpMapper mapper = new JacksonJsonpMapper(); + return new RestClientTransport(restClient, mapper); + } + + @Bean + public OpenSearchClient openSearchClient(OpenSearchTransport transport) { + return new OpenSearchClient(transport); + } + + @PreDestroy + public void closeClient() throws IOException { + if (restClient != null) { + restClient.close(); + } + } +} diff --git a/backend/src/main/java/com/openisle/config/OpenSearchProperties.java b/backend/src/main/java/com/openisle/config/OpenSearchProperties.java new file mode 100644 index 000000000..33d813321 --- /dev/null +++ b/backend/src/main/java/com/openisle/config/OpenSearchProperties.java @@ -0,0 +1,63 @@ +package com.openisle.config; + +import java.time.Duration; +import java.util.List; +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Data +@ConfigurationProperties(prefix = "opensearch") +public class OpenSearchProperties { + + /** + * Flag to enable the OpenSearch integration. When disabled the application falls back to the + * legacy JPA based search implementation. + */ + private boolean enabled = false; + + /** + * Comma separated list of OpenSearch endpoints. Example: {@code https://localhost:9200}. + */ + private List hosts = List.of(); + + /** Username used when authenticating against the cluster. */ + private String username; + + /** Password used when authenticating against the cluster. */ + private String password; + + /** Optional toggle that allows disabling certificate validation in development environments. */ + private boolean insecure = false; + + /** Connection timeout when communicating with OpenSearch. */ + private Duration connectTimeout = Duration.ofSeconds(10); + + /** Socket timeout when communicating with OpenSearch. */ + private Duration socketTimeout = Duration.ofSeconds(30); + + /** Maximum number of search results returned for entity specific endpoints. */ + private int maxResults = 50; + + /** Highlight fragment size used when OpenSearch does not return highlighted text. */ + private int highlightFallbackLength = 200; + + public String getPostsIndex() { + return "posts"; + } + + public String getCommentsIndex() { + return "comments"; + } + + public String getUsersIndex() { + return "users"; + } + + public String getCategoriesIndex() { + return "categories"; + } + + public String getTagsIndex() { + return "tags"; + } +} diff --git a/backend/src/main/java/com/openisle/model/Category.java b/backend/src/main/java/com/openisle/model/Category.java index 73b34fb5a..aed56b870 100644 --- a/backend/src/main/java/com/openisle/model/Category.java +++ b/backend/src/main/java/com/openisle/model/Category.java @@ -1,5 +1,6 @@ package com.openisle.model; +import com.openisle.search.SearchEntityListener; import jakarta.persistence.*; import lombok.Getter; import lombok.NoArgsConstructor; @@ -10,6 +11,7 @@ import lombok.Setter; @Setter @NoArgsConstructor @Table(name = "categories") +@EntityListeners(SearchEntityListener.class) public class Category { @Id diff --git a/backend/src/main/java/com/openisle/model/Comment.java b/backend/src/main/java/com/openisle/model/Comment.java index 7e32694dd..fcb56bf8d 100644 --- a/backend/src/main/java/com/openisle/model/Comment.java +++ b/backend/src/main/java/com/openisle/model/Comment.java @@ -1,5 +1,6 @@ package com.openisle.model; +import com.openisle.search.SearchEntityListener; import jakarta.persistence.*; import java.time.LocalDateTime; import lombok.Getter; @@ -16,6 +17,7 @@ import org.hibernate.annotations.Where; @Table(name = "comments") @SQLDelete(sql = "UPDATE comments SET deleted_at = CURRENT_TIMESTAMP(6) WHERE id = ?") @Where(clause = "deleted_at IS NULL") +@EntityListeners(SearchEntityListener.class) public class Comment { @Id diff --git a/backend/src/main/java/com/openisle/model/Post.java b/backend/src/main/java/com/openisle/model/Post.java index b3ecb4a03..97484963e 100644 --- a/backend/src/main/java/com/openisle/model/Post.java +++ b/backend/src/main/java/com/openisle/model/Post.java @@ -1,6 +1,7 @@ package com.openisle.model; import com.openisle.model.Tag; +import com.openisle.search.SearchEntityListener; import jakarta.persistence.*; import java.time.LocalDateTime; import java.util.HashSet; @@ -19,6 +20,7 @@ import org.hibernate.annotations.CreationTimestamp; @NoArgsConstructor @Table(name = "posts") @Inheritance(strategy = InheritanceType.JOINED) +@EntityListeners(SearchEntityListener.class) public class Post { @Id diff --git a/backend/src/main/java/com/openisle/model/Tag.java b/backend/src/main/java/com/openisle/model/Tag.java index b1251248e..59623829d 100644 --- a/backend/src/main/java/com/openisle/model/Tag.java +++ b/backend/src/main/java/com/openisle/model/Tag.java @@ -1,5 +1,6 @@ package com.openisle.model; +import com.openisle.search.SearchEntityListener; import jakarta.persistence.*; import java.time.LocalDateTime; import lombok.Getter; @@ -12,6 +13,7 @@ import org.hibernate.annotations.CreationTimestamp; @Setter @NoArgsConstructor @Table(name = "tags") +@EntityListeners(SearchEntityListener.class) public class Tag { @Id diff --git a/backend/src/main/java/com/openisle/model/User.java b/backend/src/main/java/com/openisle/model/User.java index bf68d5507..e946e4786 100644 --- a/backend/src/main/java/com/openisle/model/User.java +++ b/backend/src/main/java/com/openisle/model/User.java @@ -1,5 +1,6 @@ package com.openisle.model; +import com.openisle.search.SearchEntityListener; import jakarta.persistence.*; import java.time.LocalDateTime; import java.util.EnumSet; @@ -19,6 +20,7 @@ import org.hibernate.annotations.CreationTimestamp; @Setter @NoArgsConstructor @Table(name = "users") +@EntityListeners(SearchEntityListener.class) public class User { @Id diff --git a/backend/src/main/java/com/openisle/search/OpenSearchGateway.java b/backend/src/main/java/com/openisle/search/OpenSearchGateway.java new file mode 100644 index 000000000..0a93550de --- /dev/null +++ b/backend/src/main/java/com/openisle/search/OpenSearchGateway.java @@ -0,0 +1,368 @@ +package com.openisle.search; + +import com.openisle.config.OpenSearchProperties; +import com.openisle.service.SearchService.SearchResult; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.opensearch.client.opensearch.OpenSearchClient; +import org.opensearch.client.opensearch._types.SortOrder; +import org.opensearch.client.opensearch._types.query_dsl.MultiMatchQueryType; +import org.opensearch.client.opensearch._types.query_dsl.Query; +import org.opensearch.client.opensearch.core.MsearchRequest; +import org.opensearch.client.opensearch.core.MsearchResponse; +import org.opensearch.client.opensearch.core.SearchRequest; +import org.opensearch.client.opensearch.core.SearchResponse; +import org.opensearch.client.opensearch.core.search.HighlightField; +import org.opensearch.client.opensearch.core.search.Hit; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(prefix = "opensearch", name = "enabled", havingValue = "true") +public class OpenSearchGateway { + + private final OpenSearchClient client; + private final OpenSearchProperties properties; + + public enum PostSearchMode { + TITLE_AND_CONTENT, + TITLE_ONLY, + CONTENT_ONLY, + } + + public List searchUserIds(String keyword) { + return searchForIds( + properties.getUsersIndex(), + keyword, + List.of("username^2", "displayName^1.5", "introduction"), + null + ); + } + + public List searchPostIds(String keyword, PostSearchMode mode) { + List fields; + switch (mode) { + case TITLE_ONLY: + fields = List.of("title^2"); + break; + case CONTENT_ONLY: + fields = List.of("content"); + break; + default: + fields = List.of("title^2", "content"); + } + return searchForIds( + properties.getPostsIndex(), + keyword, + fields, + Query.of(q -> q.match(m -> m.field("status").query("PUBLISHED"))) + ); + } + + public List searchCommentIds(String keyword) { + return searchForIds( + properties.getCommentsIndex(), + keyword, + List.of("content", "postTitle", "author"), + null + ); + } + + public List searchCategoryIds(String keyword) { + return searchForIds( + properties.getCategoriesIndex(), + keyword, + List.of("name^2", "description"), + null + ); + } + + public List searchTagIds(String keyword) { + return searchForIds( + properties.getTagsIndex(), + keyword, + List.of("name^2", "description"), + Query.of(q -> q.match(m -> m.field("approved").query(true))) + ); + } + + public List globalSearch(String keyword, int snippetLength) { + try { + MsearchRequest.Builder builder = new MsearchRequest.Builder(); + + builder.searches(s -> + s + .header(h -> h.index(properties.getUsersIndex())) + .body(searchBody(keyword, List.of("username^2", "displayName", "introduction"), null)) + ); + builder.searches(s -> + s + .header(h -> h.index(properties.getCategoriesIndex())) + .body(searchBody(keyword, List.of("name^2", "description"), null)) + ); + builder.searches(s -> + s + .header(h -> h.index(properties.getTagsIndex())) + .body( + searchBody( + keyword, + List.of("name^2", "description"), + Query.of(q -> q.match(m -> m.field("approved").query(true))) + ) + ) + ); + builder.searches(s -> + s + .header(h -> h.index(properties.getPostsIndex())) + .body( + searchBody( + keyword, + List.of("title^2", "content", "category", "tags"), + Query.of(q -> q.match(m -> m.field("status").query("PUBLISHED"))) + ) + ) + ); + builder.searches(s -> + s + .header(h -> h.index(properties.getCommentsIndex())) + .body(searchBody(keyword, List.of("content", "postTitle", "author"), null)) + ); + + MsearchResponse> response = client.msearch(builder.build(), Map.class); + + List results = new ArrayList<>(); + int snippetLimit = snippetLength >= 0 + ? snippetLength + : properties.getHighlightFallbackLength(); + + // Order corresponds to request order + List types = List.of("user", "category", "tag", "post", "comment"); + for (int i = 0; i < response.responses().size(); i++) { + var item = response.responses().get(i); + if (item.isFailure()) { + log.warn("OpenSearch multi search failed for {}: {}", types.get(i), item.error()); + continue; + } + for (Hit> hit : item.result().hits().hits()) { + String type = types.get(i); + Long id = hit.id() != null ? Long.valueOf(hit.id()) : null; + Map> highlight = hit.highlight() != null + ? hit.highlight() + : Map.of(); + Map source = hit.source() != null ? hit.source() : Map.of(); + String text = firstHighlight( + highlight, + List.of("title", "username", "name", "postTitle") + ); + if (text == null) { + text = optionalString( + source, + switch (type) { + case "user" -> "username"; + case "post" -> "title"; + case "comment" -> "postTitle"; + default -> "name"; + } + ); + } + String subText = null; + String extra = null; + Long postId = null; + + if ("user".equals(type)) { + subText = optionalString(source, "displayName"); + extra = snippetFromHighlight( + highlight, + List.of("introduction"), + optionalString(source, "introduction"), + snippetLimit + ); + } else if ("category".equals(type) || "tag".equals(type)) { + extra = snippetFromHighlight( + highlight, + List.of("description"), + optionalString(source, "description"), + snippetLimit + ); + } else if ("post".equals(type)) { + subText = optionalString(source, "category"); + extra = snippetFromHighlight( + highlight, + List.of("content"), + optionalString(source, "content"), + snippetLimit + ); + } else if ("comment".equals(type)) { + subText = optionalString(source, "author"); + postId = optionalLong(source, "postId"); + extra = snippetFromHighlight( + highlight, + List.of("content"), + optionalString(source, "content"), + snippetLimit + ); + } + + results.add(new SearchResult(type, id, text, subText, extra, postId)); + } + } + return results; + } catch (IOException e) { + throw new IllegalStateException("OpenSearch global search failed", e); + } + } + + private List searchForIds(String index, String keyword, List fields, Query filter) { + try { + SearchRequest request = SearchRequest.builder() + .index(index) + .size(properties.getMaxResults()) + .query(q -> + q.bool(b -> { + b.must( + Query.of(m -> + m.multiMatch(mm -> + mm.query(keyword).fields(fields).type(MultiMatchQueryType.BestFields) + ) + ) + ); + if (filter != null) { + b.filter(filter); + } + return b; + }) + ) + .sort(s -> s.score(o -> o.order(SortOrder.Desc))) + .build(); + SearchResponse> response = client.search(request, Map.class); + return response + .hits() + .hits() + .stream() + .map(Hit::id) + .filter(id -> id != null && !id.isBlank()) + .map(Long::valueOf) + .collect(Collectors.toList()); + } catch (IOException e) { + throw new IllegalStateException("OpenSearch search failed for index " + index, e); + } + } + + private SearchRequest searchBody(String keyword, List fields, Query filter) { + return SearchRequest.builder() + .size(10) + .query(q -> + q.bool(b -> { + b.must( + Query.of(m -> + m.multiMatch(mm -> + mm.query(keyword).fields(fields).type(MultiMatchQueryType.BestFields) + ) + ) + ); + if (filter != null) { + b.filter(filter); + } + return b; + }) + ) + .highlight(h -> + h + .preTags("") + .postTags("") + .fields( + "title", + HighlightField.of(f -> f.fragmentSize(properties.getHighlightFallbackLength())) + ) + .fields( + "username", + HighlightField.of(f -> f.fragmentSize(properties.getHighlightFallbackLength())) + ) + .fields( + "name", + HighlightField.of(f -> f.fragmentSize(properties.getHighlightFallbackLength())) + ) + .fields( + "postTitle", + HighlightField.of(f -> f.fragmentSize(properties.getHighlightFallbackLength())) + ) + .fields( + "content", + HighlightField.of(f -> f.fragmentSize(properties.getHighlightFallbackLength())) + ) + .fields( + "description", + HighlightField.of(f -> f.fragmentSize(properties.getHighlightFallbackLength())) + ) + .fields( + "introduction", + HighlightField.of(f -> f.fragmentSize(properties.getHighlightFallbackLength())) + ) + ) + .build(); + } + + private String firstHighlight(Map> highlight, List keys) { + for (String key : keys) { + List values = highlight.get(key); + if (values != null && !values.isEmpty()) { + return values.get(0); + } + } + return null; + } + + private String snippetFromHighlight( + Map> highlight, + List keys, + String fallback, + int snippetLength + ) { + for (String key : keys) { + List values = highlight.get(key); + if (values != null && !values.isEmpty()) { + return values.get(0); + } + } + if (fallback == null) { + return null; + } + if (snippetLength < 0) { + return fallback; + } + return fallback.length() > snippetLength ? fallback.substring(0, snippetLength) : fallback; + } + + private String optionalString(Map source, String key) { + if (source == null) { + return null; + } + Object value = source.get(key); + return value != null ? value.toString() : null; + } + + private Long optionalLong(Map source, String key) { + if (source == null) { + return null; + } + Object value = source.get(key); + if (value instanceof Number number) { + return number.longValue(); + } + if (value instanceof String str && !str.isBlank()) { + try { + return Long.valueOf(str); + } catch (NumberFormatException e) { + return null; + } + } + return null; + } +} diff --git a/backend/src/main/java/com/openisle/search/OpenSearchIndexManager.java b/backend/src/main/java/com/openisle/search/OpenSearchIndexManager.java new file mode 100644 index 000000000..eb9b35083 --- /dev/null +++ b/backend/src/main/java/com/openisle/search/OpenSearchIndexManager.java @@ -0,0 +1,115 @@ +package com.openisle.search; + +import com.openisle.config.OpenSearchProperties; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.opensearch.client.opensearch.OpenSearchClient; +import org.opensearch.client.opensearch._types.mapping.Property; +import org.opensearch.client.opensearch._types.mapping.TypeMapping; +import org.opensearch.client.opensearch.indices.CreateIndexRequest; +import org.opensearch.client.opensearch.indices.ExistsRequest; +import org.opensearch.client.opensearch.indices.IndexSettings; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.event.ContextRefreshedEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(prefix = "opensearch", name = "enabled", havingValue = "true") +public class OpenSearchIndexManager { + + private final OpenSearchClient client; + private final OpenSearchProperties properties; + + @EventListener(ContextRefreshedEvent.class) + public void initializeIndices() { + ensureIndex(properties.getPostsIndex(), this::postsMapping); + ensureIndex(properties.getCommentsIndex(), this::commentsMapping); + ensureIndex(properties.getUsersIndex(), this::usersMapping); + ensureIndex(properties.getCategoriesIndex(), this::categoriesMapping); + ensureIndex(properties.getTagsIndex(), this::tagsMapping); + } + + private void ensureIndex(String indexName, MappingSupplier supplier) { + try { + boolean exists = client.indices().exists(ExistsRequest.of(e -> e.index(indexName))).value(); + if (!exists) { + log.info("Creating OpenSearch index {}", indexName); + CreateIndexRequest request = CreateIndexRequest.builder() + .index(indexName) + .mappings(supplier.mapping()) + .settings(IndexSettings.of(s -> s.numberOfReplicas("1").numberOfShards("1"))) + .build(); + client.indices().create(request); + } + } catch (IOException e) { + throw new IllegalStateException("Failed to ensure index " + indexName, e); + } + } + + private TypeMapping postsMapping() { + return TypeMapping.builder() + .properties( + "title", + Property.of(p -> p.text(t -> t.analyzer("standard").fields("raw", f -> f.keyword(k -> k)))) + ) + .properties("content", Property.of(p -> p.text(t -> t.analyzer("standard")))) + .properties("author", Property.of(p -> p.keyword(k -> k))) + .properties("category", Property.of(p -> p.keyword(k -> k))) + .properties("tags", Property.of(p -> p.keyword(k -> k))) + .properties("status", Property.of(p -> p.keyword(k -> k))) + .properties("createdAt", Property.of(p -> p.date(d -> d))) + .build(); + } + + private TypeMapping commentsMapping() { + return TypeMapping.builder() + .properties("content", Property.of(p -> p.text(t -> t.analyzer("standard")))) + .properties("author", Property.of(p -> p.keyword(k -> k))) + .properties("postTitle", Property.of(p -> p.text(t -> t.analyzer("standard")))) + .properties("postId", Property.of(p -> p.long_(l -> l))) + .properties("createdAt", Property.of(p -> p.date(d -> d))) + .build(); + } + + private TypeMapping usersMapping() { + return TypeMapping.builder() + .properties( + "username", + Property.of(p -> p.text(t -> t.analyzer("standard").fields("raw", f -> f.keyword(k -> k)))) + ) + .properties("introduction", Property.of(p -> p.text(t -> t.analyzer("standard")))) + .properties("displayName", Property.of(p -> p.text(t -> t.analyzer("standard")))) + .properties("createdAt", Property.of(p -> p.date(d -> d))) + .build(); + } + + private TypeMapping categoriesMapping() { + return TypeMapping.builder() + .properties( + "name", + Property.of(p -> p.text(t -> t.analyzer("standard").fields("raw", f -> f.keyword(k -> k)))) + ) + .properties("description", Property.of(p -> p.text(t -> t.analyzer("standard")))) + .build(); + } + + private TypeMapping tagsMapping() { + return TypeMapping.builder() + .properties( + "name", + Property.of(p -> p.text(t -> t.analyzer("standard").fields("raw", f -> f.keyword(k -> k)))) + ) + .properties("description", Property.of(p -> p.text(t -> t.analyzer("standard")))) + .properties("approved", Property.of(p -> p.boolean_(b -> b))) + .build(); + } + + @FunctionalInterface + private interface MappingSupplier { + TypeMapping mapping(); + } +} diff --git a/backend/src/main/java/com/openisle/search/OpenSearchIndexer.java b/backend/src/main/java/com/openisle/search/OpenSearchIndexer.java new file mode 100644 index 000000000..84574d0bf --- /dev/null +++ b/backend/src/main/java/com/openisle/search/OpenSearchIndexer.java @@ -0,0 +1,166 @@ +package com.openisle.search; + +import com.openisle.config.OpenSearchProperties; +import com.openisle.model.Category; +import com.openisle.model.Comment; +import com.openisle.model.Post; +import com.openisle.model.Tag; +import com.openisle.model.User; +import java.io.IOException; +import java.time.ZoneOffset; +import java.util.HashMap; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.opensearch.client.opensearch.OpenSearchClient; +import org.opensearch.client.opensearch.core.DeleteRequest; +import org.opensearch.client.opensearch.core.IndexRequest; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(prefix = "opensearch", name = "enabled", havingValue = "true") +public class OpenSearchIndexer { + + private final OpenSearchClient client; + private final OpenSearchProperties properties; + + public void indexPost(Post post) { + runAfterCommit(() -> { + Map document = new HashMap<>(); + document.put("title", post.getTitle()); + document.put("content", post.getContent()); + document.put("author", post.getAuthor() != null ? post.getAuthor().getUsername() : null); + document.put("category", post.getCategory() != null ? post.getCategory().getName() : null); + document.put( + "tags", + post.getTags() != null + ? post.getTags().stream().map(Tag::getName).toList() + : java.util.List.of() + ); + document.put("status", post.getStatus() != null ? post.getStatus().name() : null); + if (post.getCreatedAt() != null) { + document.put("createdAt", post.getCreatedAt().atOffset(ZoneOffset.UTC)); + } + indexDocument(properties.getPostsIndex(), post.getId(), document); + }); + } + + public void deletePost(Long postId) { + runAfterCommit(() -> deleteDocument(properties.getPostsIndex(), postId)); + } + + public void indexComment(Comment comment) { + runAfterCommit(() -> { + Map document = new HashMap<>(); + document.put("content", comment.getContent()); + document.put( + "author", + comment.getAuthor() != null ? comment.getAuthor().getUsername() : null + ); + if (comment.getPost() != null) { + document.put("postId", comment.getPost().getId()); + document.put("postTitle", comment.getPost().getTitle()); + } + if (comment.getCreatedAt() != null) { + document.put("createdAt", comment.getCreatedAt().atOffset(ZoneOffset.UTC)); + } + indexDocument(properties.getCommentsIndex(), comment.getId(), document); + }); + } + + public void deleteComment(Long commentId) { + runAfterCommit(() -> deleteDocument(properties.getCommentsIndex(), commentId)); + } + + public void indexUser(User user) { + runAfterCommit(() -> { + Map document = new HashMap<>(); + document.put("username", user.getUsername()); + document.put("displayName", user.getDisplayName()); + document.put("introduction", user.getIntroduction()); + if (user.getCreatedAt() != null) { + document.put("createdAt", user.getCreatedAt().atOffset(ZoneOffset.UTC)); + } + indexDocument(properties.getUsersIndex(), user.getId(), document); + }); + } + + public void deleteUser(Long userId) { + runAfterCommit(() -> deleteDocument(properties.getUsersIndex(), userId)); + } + + public void indexCategory(Category category) { + runAfterCommit(() -> { + Map document = new HashMap<>(); + document.put("name", category.getName()); + document.put("description", category.getDescription()); + indexDocument(properties.getCategoriesIndex(), category.getId(), document); + }); + } + + public void deleteCategory(Long categoryId) { + runAfterCommit(() -> deleteDocument(properties.getCategoriesIndex(), categoryId)); + } + + public void indexTag(Tag tag) { + runAfterCommit(() -> { + Map document = new HashMap<>(); + document.put("name", tag.getName()); + document.put("description", tag.getDescription()); + document.put("approved", Boolean.TRUE.equals(tag.getApproved())); + indexDocument(properties.getTagsIndex(), tag.getId(), document); + }); + } + + public void deleteTag(Long tagId) { + runAfterCommit(() -> deleteDocument(properties.getTagsIndex(), tagId)); + } + + private void indexDocument(String index, Long id, Map document) { + if (id == null) { + return; + } + try { + IndexRequest> request = IndexRequest.>builder() + .index(index) + .id(id.toString()) + .document(document) + .build(); + client.index(request); + } catch (IOException e) { + log.error("Failed to index document {} in {}", id, index, e); + } + } + + private void deleteDocument(String index, Long id) { + if (id == null) { + return; + } + try { + DeleteRequest request = DeleteRequest.of(d -> d.index(index).id(id.toString())); + client.delete(request); + } catch (IOException e) { + log.error("Failed to delete document {} in {}", id, index, e); + } + } + + private void runAfterCommit(Runnable runnable) { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization( + new TransactionSynchronization() { + @Override + public void afterCommit() { + runnable.run(); + } + } + ); + } else { + runnable.run(); + } + } +} diff --git a/backend/src/main/java/com/openisle/search/SearchEntityListener.java b/backend/src/main/java/com/openisle/search/SearchEntityListener.java new file mode 100644 index 000000000..d06ca1af3 --- /dev/null +++ b/backend/src/main/java/com/openisle/search/SearchEntityListener.java @@ -0,0 +1,84 @@ +package com.openisle.search; + +import com.openisle.model.Category; +import com.openisle.model.Comment; +import com.openisle.model.Post; +import com.openisle.model.Tag; +import com.openisle.model.User; +import jakarta.persistence.PostPersist; +import jakarta.persistence.PostRemove; +import jakarta.persistence.PostUpdate; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +@Slf4j +public class SearchEntityListener { + + private static volatile OpenSearchIndexer indexer; + + public static void registerIndexer(OpenSearchIndexer openSearchIndexer) { + indexer = openSearchIndexer; + } + + @PostPersist + @PostUpdate + public void afterSave(Object entity) { + if (indexer == null) { + return; + } + if (entity instanceof Post post) { + indexer.indexPost(post); + } else if (entity instanceof Comment comment) { + indexer.indexComment(comment); + } else if (entity instanceof User user) { + indexer.indexUser(user); + } else if (entity instanceof Category category) { + indexer.indexCategory(category); + } else if (entity instanceof Tag tag) { + indexer.indexTag(tag); + } + } + + @PostRemove + public void afterDelete(Object entity) { + if (indexer == null) { + return; + } + if (entity instanceof Post post) { + Long id = post.getId(); + if (id != null) { + indexer.deletePost(id); + } + } else if (entity instanceof Comment comment) { + Long id = comment.getId(); + if (id != null) { + indexer.deleteComment(id); + } + } else if (entity instanceof User user) { + Long id = user.getId(); + if (id != null) { + indexer.deleteUser(id); + } + } else if (entity instanceof Category category) { + Long id = category.getId(); + if (id != null) { + indexer.deleteCategory(id); + } + } else if (entity instanceof Tag tag) { + Long id = tag.getId(); + if (id != null) { + indexer.deleteTag(id); + } + } + } + + @Component + @ConditionalOnProperty(prefix = "opensearch", name = "enabled", havingValue = "true") + public static class Registrar { + + public Registrar(OpenSearchIndexer openSearchIndexer) { + SearchEntityListener.registerIndexer(openSearchIndexer); + } + } +} diff --git a/backend/src/main/java/com/openisle/service/SearchService.java b/backend/src/main/java/com/openisle/service/SearchService.java index dee83fed9..b75199a1f 100644 --- a/backend/src/main/java/com/openisle/service/SearchService.java +++ b/backend/src/main/java/com/openisle/service/SearchService.java @@ -11,10 +11,17 @@ import com.openisle.repository.CommentRepository; import com.openisle.repository.PostRepository; import com.openisle.repository.TagRepository; import com.openisle.repository.UserRepository; +import com.openisle.search.OpenSearchGateway; +import com.openisle.search.OpenSearchGateway.PostSearchMode; import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.Stream; +import java.util.stream.StreamSupport; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -27,15 +34,26 @@ public class SearchService { private final CommentRepository commentRepository; private final CategoryRepository categoryRepository; private final TagRepository tagRepository; + private final Optional openSearchGateway; @org.springframework.beans.factory.annotation.Value("${app.snippet-length}") private int snippetLength; public List searchUsers(String keyword) { + if (openSearchGateway.isPresent()) { + List ids = openSearchGateway.get().searchUserIds(keyword); + return loadAndSort(ids, userRepository::findAllById, User::getId); + } return userRepository.findByUsernameContainingIgnoreCase(keyword); } public List searchPosts(String keyword) { + if (openSearchGateway.isPresent()) { + List ids = openSearchGateway + .get() + .searchPostIds(keyword, PostSearchMode.TITLE_AND_CONTENT); + return loadAndSort(ids, idList -> postRepository.findAllById(idList), Post::getId); + } return postRepository.findByTitleContainingIgnoreCaseOrContentContainingIgnoreCaseAndStatus( keyword, keyword, @@ -44,26 +62,49 @@ public class SearchService { } public List searchPostsByContent(String keyword) { + if (openSearchGateway.isPresent()) { + List ids = openSearchGateway.get().searchPostIds(keyword, PostSearchMode.CONTENT_ONLY); + return loadAndSort(ids, idList -> postRepository.findAllById(idList), Post::getId); + } return postRepository.findByContentContainingIgnoreCaseAndStatus(keyword, PostStatus.PUBLISHED); } public List searchPostsByTitle(String keyword) { + if (openSearchGateway.isPresent()) { + List ids = openSearchGateway.get().searchPostIds(keyword, PostSearchMode.TITLE_ONLY); + return loadAndSort(ids, idList -> postRepository.findAllById(idList), Post::getId); + } return postRepository.findByTitleContainingIgnoreCaseAndStatus(keyword, PostStatus.PUBLISHED); } public List searchComments(String keyword) { + if (openSearchGateway.isPresent()) { + List ids = openSearchGateway.get().searchCommentIds(keyword); + return loadAndSort(ids, idList -> commentRepository.findAllById(idList), Comment::getId); + } return commentRepository.findByContentContainingIgnoreCase(keyword); } public List searchCategories(String keyword) { + if (openSearchGateway.isPresent()) { + List ids = openSearchGateway.get().searchCategoryIds(keyword); + return loadAndSort(ids, idList -> categoryRepository.findAllById(idList), Category::getId); + } return categoryRepository.findByNameContainingIgnoreCase(keyword); } public List searchTags(String keyword) { + if (openSearchGateway.isPresent()) { + List ids = openSearchGateway.get().searchTagIds(keyword); + return loadAndSort(ids, idList -> tagRepository.findAllById(idList), Tag::getId); + } return tagRepository.findByNameContainingIgnoreCaseAndApprovedTrue(keyword); } public List globalSearch(String keyword) { + if (openSearchGateway.isPresent()) { + return openSearchGateway.get().globalSearch(keyword, snippetLength); + } Stream users = searchUsers(keyword) .stream() .map(u -> @@ -173,4 +214,18 @@ public class SearchService { String extra, Long postId ) {} + + private List loadAndSort( + List ids, + Function, Iterable> loader, + Function idExtractor + ) { + if (ids.isEmpty()) { + return List.of(); + } + Map entityMap = StreamSupport.stream(loader.apply(ids).spliterator(), false).collect( + Collectors.toMap(idExtractor, Function.identity()) + ); + return ids.stream().map(entityMap::get).filter(Objects::nonNull).toList(); + } } diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 00ccc0302..4aacb09fd 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -45,6 +45,17 @@ app.user.replies-limit=${USER_REPLIES_LIMIT:50} # Length of extracted snippets for posts and search (-1 to disable truncation) app.snippet-length=${SNIPPET_LENGTH:200} +# OpenSearch integration +opensearch.enabled=${OPENSEARCH_ENABLED:false} +opensearch.hosts=${OPENSEARCH_HOSTS:} +opensearch.username=${OPENSEARCH_USERNAME:} +opensearch.password=${OPENSEARCH_PASSWORD:} +opensearch.insecure=${OPENSEARCH_INSECURE:false} +opensearch.connect-timeout=${OPENSEARCH_CONNECT_TIMEOUT:10s} +opensearch.socket-timeout=${OPENSEARCH_SOCKET_TIMEOUT:30s} +opensearch.max-results=${OPENSEARCH_MAX_RESULTS:50} +opensearch.highlight-fallback-length=${OPENSEARCH_HIGHLIGHT_FALLBACK_LENGTH:${SNIPPET_LENGTH:200}} + # Captcha configuration app.captcha.enabled=${CAPTCHA_ENABLED:false} recaptcha.secret-key=${RECAPTCHA_SECRET_KEY:} diff --git a/backend/src/test/java/com/openisle/service/SearchServiceTest.java b/backend/src/test/java/com/openisle/service/SearchServiceTest.java index 59027b64d..452557a9e 100644 --- a/backend/src/test/java/com/openisle/service/SearchServiceTest.java +++ b/backend/src/test/java/com/openisle/service/SearchServiceTest.java @@ -10,6 +10,7 @@ import com.openisle.repository.PostRepository; import com.openisle.repository.TagRepository; import com.openisle.repository.UserRepository; import java.util.List; +import java.util.Optional; import org.junit.jupiter.api.Test; import org.mockito.Mockito; @@ -27,7 +28,8 @@ class SearchServiceTest { postRepo, commentRepo, categoryRepo, - tagRepo + tagRepo, + Optional.empty() ); Post post1 = new Post(); From 69869348f60beedce5c9b108eec04b02aff36703 Mon Sep 17 00:00:00 2001 From: tim Date: Fri, 26 Sep 2025 15:36:31 +0800 Subject: [PATCH 02/15] Revert "feat: add open search support" This reverts commit 4821b77c17acf6b97cf5521cf4500c5a11eaa3cb. --- backend/open-isle.env.example | 9 - backend/pom.xml | 5 - .../com/openisle/config/OpenSearchConfig.java | 119 ------ .../openisle/config/OpenSearchProperties.java | 63 --- .../java/com/openisle/model/Category.java | 2 - .../main/java/com/openisle/model/Comment.java | 2 - .../main/java/com/openisle/model/Post.java | 2 - .../src/main/java/com/openisle/model/Tag.java | 2 - .../main/java/com/openisle/model/User.java | 2 - .../openisle/search/OpenSearchGateway.java | 368 ------------------ .../search/OpenSearchIndexManager.java | 115 ------ .../openisle/search/OpenSearchIndexer.java | 166 -------- .../openisle/search/SearchEntityListener.java | 84 ---- .../com/openisle/service/SearchService.java | 55 --- .../src/main/resources/application.properties | 11 - .../openisle/service/SearchServiceTest.java | 4 +- 16 files changed, 1 insertion(+), 1008 deletions(-) delete mode 100644 backend/src/main/java/com/openisle/config/OpenSearchConfig.java delete mode 100644 backend/src/main/java/com/openisle/config/OpenSearchProperties.java delete mode 100644 backend/src/main/java/com/openisle/search/OpenSearchGateway.java delete mode 100644 backend/src/main/java/com/openisle/search/OpenSearchIndexManager.java delete mode 100644 backend/src/main/java/com/openisle/search/OpenSearchIndexer.java delete mode 100644 backend/src/main/java/com/openisle/search/SearchEntityListener.java diff --git a/backend/open-isle.env.example b/backend/open-isle.env.example index 66bd02d0b..a62ac877f 100644 --- a/backend/open-isle.env.example +++ b/backend/open-isle.env.example @@ -17,15 +17,6 @@ JWT_EXPIRATION=2592000000 REDIS_HOST= REDIS_PORT= -# === OpenSearch === -OPENSEARCH_ENABLED=false -OPENSEARCH_HOSTS=https://:9200 -# 可选:启用基本认证 -# OPENSEARCH_USERNAME= -# OPENSEARCH_PASSWORD= -# 开发调试时可关闭证书校验 -# OPENSEARCH_INSECURE=true - # === Resend === RESEND_API_KEY=<你的resend-api-key> RESEND_FROM_EMAIL=<你的 resend 发送邮箱> diff --git a/backend/pom.xml b/backend/pom.xml index af6170178..97d8c7f65 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -132,11 +132,6 @@ springdoc-openapi-starter-webmvc-api 2.2.0 - - org.opensearch.client - opensearch-java - 2.11.0 - diff --git a/backend/src/main/java/com/openisle/config/OpenSearchConfig.java b/backend/src/main/java/com/openisle/config/OpenSearchConfig.java deleted file mode 100644 index 7c42e385b..000000000 --- a/backend/src/main/java/com/openisle/config/OpenSearchConfig.java +++ /dev/null @@ -1,119 +0,0 @@ -package com.openisle.config; - -import jakarta.annotation.PreDestroy; -import java.io.IOException; -import java.util.List; -import javax.net.ssl.SSLContext; -import javax.net.ssl.TrustManager; -import javax.net.ssl.X509TrustManager; -import org.apache.hc.client5.http.auth.AuthScope; -import org.apache.hc.client5.http.auth.UsernamePasswordCredentials; -import org.apache.hc.client5.http.classic.HttpClientBuilder; -import org.apache.hc.client5.http.config.RequestConfig; -import org.apache.hc.client5.http.impl.auth.BasicCredentialsProvider; -import org.apache.hc.core5.http.HttpHost; -import org.opensearch.client.RestClient; -import org.opensearch.client.json.jackson.JacksonJsonpMapper; -import org.opensearch.client.opensearch.OpenSearchClient; -import org.opensearch.client.transport.OpenSearchTransport; -import org.opensearch.client.transport.rest_client.RestClientTransport; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@Configuration -@EnableConfigurationProperties(OpenSearchProperties.class) -@ConditionalOnProperty(prefix = "opensearch", name = "enabled", havingValue = "true") -public class OpenSearchConfig { - - private RestClient restClient; - - @Bean - public RestClient openSearchRestClient(OpenSearchProperties properties) { - List hosts = properties.getHosts(); - if (hosts == null || hosts.isEmpty()) { - throw new IllegalStateException( - "opensearch.hosts must be configured when OpenSearch is enabled" - ); - } - - HttpHost[] httpHosts = hosts.stream().map(HttpHost::create).toArray(HttpHost[]::new); - - RestClient.Builder builder = RestClient.builder(httpHosts); - - builder.setRequestConfigCallback(requestConfigBuilder -> { - RequestConfig.Builder config = RequestConfig.custom(); - config.setConnectTimeout(properties.getConnectTimeout()); - config.setResponseTimeout(properties.getSocketTimeout()); - return config; - }); - - builder.setHttpClientConfigCallback(clientBuilder -> { - HttpClientBuilder httpClientBuilder = clientBuilder; - if (properties.getUsername() != null && properties.getPassword() != null) { - BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider(); - credentialsProvider.setCredentials( - new AuthScope(null, -1), - new UsernamePasswordCredentials( - properties.getUsername(), - properties.getPassword().toCharArray() - ) - ); - httpClientBuilder = httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider); - } - - if (properties.isInsecure()) { - try { - SSLContext sslContext = SSLContext.getInstance("TLS"); - sslContext.init( - null, - new TrustManager[] { - new X509TrustManager() { - public java.security.cert.X509Certificate[] getAcceptedIssuers() { - return new java.security.cert.X509Certificate[0]; - } - - public void checkClientTrusted( - java.security.cert.X509Certificate[] chain, - String authType - ) {} - - public void checkServerTrusted( - java.security.cert.X509Certificate[] chain, - String authType - ) {} - }, - }, - new java.security.SecureRandom() - ); - httpClientBuilder = httpClientBuilder.setSSLContext(sslContext); - } catch (Exception e) { - throw new IllegalStateException("Failed to configure insecure SSL context", e); - } - } - return httpClientBuilder; - }); - - restClient = builder.build(); - return restClient; - } - - @Bean - public OpenSearchTransport openSearchTransport(RestClient restClient) { - JacksonJsonpMapper mapper = new JacksonJsonpMapper(); - return new RestClientTransport(restClient, mapper); - } - - @Bean - public OpenSearchClient openSearchClient(OpenSearchTransport transport) { - return new OpenSearchClient(transport); - } - - @PreDestroy - public void closeClient() throws IOException { - if (restClient != null) { - restClient.close(); - } - } -} diff --git a/backend/src/main/java/com/openisle/config/OpenSearchProperties.java b/backend/src/main/java/com/openisle/config/OpenSearchProperties.java deleted file mode 100644 index 33d813321..000000000 --- a/backend/src/main/java/com/openisle/config/OpenSearchProperties.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.openisle.config; - -import java.time.Duration; -import java.util.List; -import lombok.Data; -import org.springframework.boot.context.properties.ConfigurationProperties; - -@Data -@ConfigurationProperties(prefix = "opensearch") -public class OpenSearchProperties { - - /** - * Flag to enable the OpenSearch integration. When disabled the application falls back to the - * legacy JPA based search implementation. - */ - private boolean enabled = false; - - /** - * Comma separated list of OpenSearch endpoints. Example: {@code https://localhost:9200}. - */ - private List hosts = List.of(); - - /** Username used when authenticating against the cluster. */ - private String username; - - /** Password used when authenticating against the cluster. */ - private String password; - - /** Optional toggle that allows disabling certificate validation in development environments. */ - private boolean insecure = false; - - /** Connection timeout when communicating with OpenSearch. */ - private Duration connectTimeout = Duration.ofSeconds(10); - - /** Socket timeout when communicating with OpenSearch. */ - private Duration socketTimeout = Duration.ofSeconds(30); - - /** Maximum number of search results returned for entity specific endpoints. */ - private int maxResults = 50; - - /** Highlight fragment size used when OpenSearch does not return highlighted text. */ - private int highlightFallbackLength = 200; - - public String getPostsIndex() { - return "posts"; - } - - public String getCommentsIndex() { - return "comments"; - } - - public String getUsersIndex() { - return "users"; - } - - public String getCategoriesIndex() { - return "categories"; - } - - public String getTagsIndex() { - return "tags"; - } -} diff --git a/backend/src/main/java/com/openisle/model/Category.java b/backend/src/main/java/com/openisle/model/Category.java index aed56b870..73b34fb5a 100644 --- a/backend/src/main/java/com/openisle/model/Category.java +++ b/backend/src/main/java/com/openisle/model/Category.java @@ -1,6 +1,5 @@ package com.openisle.model; -import com.openisle.search.SearchEntityListener; import jakarta.persistence.*; import lombok.Getter; import lombok.NoArgsConstructor; @@ -11,7 +10,6 @@ import lombok.Setter; @Setter @NoArgsConstructor @Table(name = "categories") -@EntityListeners(SearchEntityListener.class) public class Category { @Id diff --git a/backend/src/main/java/com/openisle/model/Comment.java b/backend/src/main/java/com/openisle/model/Comment.java index fcb56bf8d..7e32694dd 100644 --- a/backend/src/main/java/com/openisle/model/Comment.java +++ b/backend/src/main/java/com/openisle/model/Comment.java @@ -1,6 +1,5 @@ package com.openisle.model; -import com.openisle.search.SearchEntityListener; import jakarta.persistence.*; import java.time.LocalDateTime; import lombok.Getter; @@ -17,7 +16,6 @@ import org.hibernate.annotations.Where; @Table(name = "comments") @SQLDelete(sql = "UPDATE comments SET deleted_at = CURRENT_TIMESTAMP(6) WHERE id = ?") @Where(clause = "deleted_at IS NULL") -@EntityListeners(SearchEntityListener.class) public class Comment { @Id diff --git a/backend/src/main/java/com/openisle/model/Post.java b/backend/src/main/java/com/openisle/model/Post.java index 97484963e..b3ecb4a03 100644 --- a/backend/src/main/java/com/openisle/model/Post.java +++ b/backend/src/main/java/com/openisle/model/Post.java @@ -1,7 +1,6 @@ package com.openisle.model; import com.openisle.model.Tag; -import com.openisle.search.SearchEntityListener; import jakarta.persistence.*; import java.time.LocalDateTime; import java.util.HashSet; @@ -20,7 +19,6 @@ import org.hibernate.annotations.CreationTimestamp; @NoArgsConstructor @Table(name = "posts") @Inheritance(strategy = InheritanceType.JOINED) -@EntityListeners(SearchEntityListener.class) public class Post { @Id diff --git a/backend/src/main/java/com/openisle/model/Tag.java b/backend/src/main/java/com/openisle/model/Tag.java index 59623829d..b1251248e 100644 --- a/backend/src/main/java/com/openisle/model/Tag.java +++ b/backend/src/main/java/com/openisle/model/Tag.java @@ -1,6 +1,5 @@ package com.openisle.model; -import com.openisle.search.SearchEntityListener; import jakarta.persistence.*; import java.time.LocalDateTime; import lombok.Getter; @@ -13,7 +12,6 @@ import org.hibernate.annotations.CreationTimestamp; @Setter @NoArgsConstructor @Table(name = "tags") -@EntityListeners(SearchEntityListener.class) public class Tag { @Id diff --git a/backend/src/main/java/com/openisle/model/User.java b/backend/src/main/java/com/openisle/model/User.java index e946e4786..bf68d5507 100644 --- a/backend/src/main/java/com/openisle/model/User.java +++ b/backend/src/main/java/com/openisle/model/User.java @@ -1,6 +1,5 @@ package com.openisle.model; -import com.openisle.search.SearchEntityListener; import jakarta.persistence.*; import java.time.LocalDateTime; import java.util.EnumSet; @@ -20,7 +19,6 @@ import org.hibernate.annotations.CreationTimestamp; @Setter @NoArgsConstructor @Table(name = "users") -@EntityListeners(SearchEntityListener.class) public class User { @Id diff --git a/backend/src/main/java/com/openisle/search/OpenSearchGateway.java b/backend/src/main/java/com/openisle/search/OpenSearchGateway.java deleted file mode 100644 index 0a93550de..000000000 --- a/backend/src/main/java/com/openisle/search/OpenSearchGateway.java +++ /dev/null @@ -1,368 +0,0 @@ -package com.openisle.search; - -import com.openisle.config.OpenSearchProperties; -import com.openisle.service.SearchService.SearchResult; -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.opensearch.client.opensearch.OpenSearchClient; -import org.opensearch.client.opensearch._types.SortOrder; -import org.opensearch.client.opensearch._types.query_dsl.MultiMatchQueryType; -import org.opensearch.client.opensearch._types.query_dsl.Query; -import org.opensearch.client.opensearch.core.MsearchRequest; -import org.opensearch.client.opensearch.core.MsearchResponse; -import org.opensearch.client.opensearch.core.SearchRequest; -import org.opensearch.client.opensearch.core.SearchResponse; -import org.opensearch.client.opensearch.core.search.HighlightField; -import org.opensearch.client.opensearch.core.search.Hit; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.stereotype.Component; - -@Slf4j -@Component -@RequiredArgsConstructor -@ConditionalOnProperty(prefix = "opensearch", name = "enabled", havingValue = "true") -public class OpenSearchGateway { - - private final OpenSearchClient client; - private final OpenSearchProperties properties; - - public enum PostSearchMode { - TITLE_AND_CONTENT, - TITLE_ONLY, - CONTENT_ONLY, - } - - public List searchUserIds(String keyword) { - return searchForIds( - properties.getUsersIndex(), - keyword, - List.of("username^2", "displayName^1.5", "introduction"), - null - ); - } - - public List searchPostIds(String keyword, PostSearchMode mode) { - List fields; - switch (mode) { - case TITLE_ONLY: - fields = List.of("title^2"); - break; - case CONTENT_ONLY: - fields = List.of("content"); - break; - default: - fields = List.of("title^2", "content"); - } - return searchForIds( - properties.getPostsIndex(), - keyword, - fields, - Query.of(q -> q.match(m -> m.field("status").query("PUBLISHED"))) - ); - } - - public List searchCommentIds(String keyword) { - return searchForIds( - properties.getCommentsIndex(), - keyword, - List.of("content", "postTitle", "author"), - null - ); - } - - public List searchCategoryIds(String keyword) { - return searchForIds( - properties.getCategoriesIndex(), - keyword, - List.of("name^2", "description"), - null - ); - } - - public List searchTagIds(String keyword) { - return searchForIds( - properties.getTagsIndex(), - keyword, - List.of("name^2", "description"), - Query.of(q -> q.match(m -> m.field("approved").query(true))) - ); - } - - public List globalSearch(String keyword, int snippetLength) { - try { - MsearchRequest.Builder builder = new MsearchRequest.Builder(); - - builder.searches(s -> - s - .header(h -> h.index(properties.getUsersIndex())) - .body(searchBody(keyword, List.of("username^2", "displayName", "introduction"), null)) - ); - builder.searches(s -> - s - .header(h -> h.index(properties.getCategoriesIndex())) - .body(searchBody(keyword, List.of("name^2", "description"), null)) - ); - builder.searches(s -> - s - .header(h -> h.index(properties.getTagsIndex())) - .body( - searchBody( - keyword, - List.of("name^2", "description"), - Query.of(q -> q.match(m -> m.field("approved").query(true))) - ) - ) - ); - builder.searches(s -> - s - .header(h -> h.index(properties.getPostsIndex())) - .body( - searchBody( - keyword, - List.of("title^2", "content", "category", "tags"), - Query.of(q -> q.match(m -> m.field("status").query("PUBLISHED"))) - ) - ) - ); - builder.searches(s -> - s - .header(h -> h.index(properties.getCommentsIndex())) - .body(searchBody(keyword, List.of("content", "postTitle", "author"), null)) - ); - - MsearchResponse> response = client.msearch(builder.build(), Map.class); - - List results = new ArrayList<>(); - int snippetLimit = snippetLength >= 0 - ? snippetLength - : properties.getHighlightFallbackLength(); - - // Order corresponds to request order - List types = List.of("user", "category", "tag", "post", "comment"); - for (int i = 0; i < response.responses().size(); i++) { - var item = response.responses().get(i); - if (item.isFailure()) { - log.warn("OpenSearch multi search failed for {}: {}", types.get(i), item.error()); - continue; - } - for (Hit> hit : item.result().hits().hits()) { - String type = types.get(i); - Long id = hit.id() != null ? Long.valueOf(hit.id()) : null; - Map> highlight = hit.highlight() != null - ? hit.highlight() - : Map.of(); - Map source = hit.source() != null ? hit.source() : Map.of(); - String text = firstHighlight( - highlight, - List.of("title", "username", "name", "postTitle") - ); - if (text == null) { - text = optionalString( - source, - switch (type) { - case "user" -> "username"; - case "post" -> "title"; - case "comment" -> "postTitle"; - default -> "name"; - } - ); - } - String subText = null; - String extra = null; - Long postId = null; - - if ("user".equals(type)) { - subText = optionalString(source, "displayName"); - extra = snippetFromHighlight( - highlight, - List.of("introduction"), - optionalString(source, "introduction"), - snippetLimit - ); - } else if ("category".equals(type) || "tag".equals(type)) { - extra = snippetFromHighlight( - highlight, - List.of("description"), - optionalString(source, "description"), - snippetLimit - ); - } else if ("post".equals(type)) { - subText = optionalString(source, "category"); - extra = snippetFromHighlight( - highlight, - List.of("content"), - optionalString(source, "content"), - snippetLimit - ); - } else if ("comment".equals(type)) { - subText = optionalString(source, "author"); - postId = optionalLong(source, "postId"); - extra = snippetFromHighlight( - highlight, - List.of("content"), - optionalString(source, "content"), - snippetLimit - ); - } - - results.add(new SearchResult(type, id, text, subText, extra, postId)); - } - } - return results; - } catch (IOException e) { - throw new IllegalStateException("OpenSearch global search failed", e); - } - } - - private List searchForIds(String index, String keyword, List fields, Query filter) { - try { - SearchRequest request = SearchRequest.builder() - .index(index) - .size(properties.getMaxResults()) - .query(q -> - q.bool(b -> { - b.must( - Query.of(m -> - m.multiMatch(mm -> - mm.query(keyword).fields(fields).type(MultiMatchQueryType.BestFields) - ) - ) - ); - if (filter != null) { - b.filter(filter); - } - return b; - }) - ) - .sort(s -> s.score(o -> o.order(SortOrder.Desc))) - .build(); - SearchResponse> response = client.search(request, Map.class); - return response - .hits() - .hits() - .stream() - .map(Hit::id) - .filter(id -> id != null && !id.isBlank()) - .map(Long::valueOf) - .collect(Collectors.toList()); - } catch (IOException e) { - throw new IllegalStateException("OpenSearch search failed for index " + index, e); - } - } - - private SearchRequest searchBody(String keyword, List fields, Query filter) { - return SearchRequest.builder() - .size(10) - .query(q -> - q.bool(b -> { - b.must( - Query.of(m -> - m.multiMatch(mm -> - mm.query(keyword).fields(fields).type(MultiMatchQueryType.BestFields) - ) - ) - ); - if (filter != null) { - b.filter(filter); - } - return b; - }) - ) - .highlight(h -> - h - .preTags("") - .postTags("") - .fields( - "title", - HighlightField.of(f -> f.fragmentSize(properties.getHighlightFallbackLength())) - ) - .fields( - "username", - HighlightField.of(f -> f.fragmentSize(properties.getHighlightFallbackLength())) - ) - .fields( - "name", - HighlightField.of(f -> f.fragmentSize(properties.getHighlightFallbackLength())) - ) - .fields( - "postTitle", - HighlightField.of(f -> f.fragmentSize(properties.getHighlightFallbackLength())) - ) - .fields( - "content", - HighlightField.of(f -> f.fragmentSize(properties.getHighlightFallbackLength())) - ) - .fields( - "description", - HighlightField.of(f -> f.fragmentSize(properties.getHighlightFallbackLength())) - ) - .fields( - "introduction", - HighlightField.of(f -> f.fragmentSize(properties.getHighlightFallbackLength())) - ) - ) - .build(); - } - - private String firstHighlight(Map> highlight, List keys) { - for (String key : keys) { - List values = highlight.get(key); - if (values != null && !values.isEmpty()) { - return values.get(0); - } - } - return null; - } - - private String snippetFromHighlight( - Map> highlight, - List keys, - String fallback, - int snippetLength - ) { - for (String key : keys) { - List values = highlight.get(key); - if (values != null && !values.isEmpty()) { - return values.get(0); - } - } - if (fallback == null) { - return null; - } - if (snippetLength < 0) { - return fallback; - } - return fallback.length() > snippetLength ? fallback.substring(0, snippetLength) : fallback; - } - - private String optionalString(Map source, String key) { - if (source == null) { - return null; - } - Object value = source.get(key); - return value != null ? value.toString() : null; - } - - private Long optionalLong(Map source, String key) { - if (source == null) { - return null; - } - Object value = source.get(key); - if (value instanceof Number number) { - return number.longValue(); - } - if (value instanceof String str && !str.isBlank()) { - try { - return Long.valueOf(str); - } catch (NumberFormatException e) { - return null; - } - } - return null; - } -} diff --git a/backend/src/main/java/com/openisle/search/OpenSearchIndexManager.java b/backend/src/main/java/com/openisle/search/OpenSearchIndexManager.java deleted file mode 100644 index eb9b35083..000000000 --- a/backend/src/main/java/com/openisle/search/OpenSearchIndexManager.java +++ /dev/null @@ -1,115 +0,0 @@ -package com.openisle.search; - -import com.openisle.config.OpenSearchProperties; -import java.io.IOException; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.opensearch.client.opensearch.OpenSearchClient; -import org.opensearch.client.opensearch._types.mapping.Property; -import org.opensearch.client.opensearch._types.mapping.TypeMapping; -import org.opensearch.client.opensearch.indices.CreateIndexRequest; -import org.opensearch.client.opensearch.indices.ExistsRequest; -import org.opensearch.client.opensearch.indices.IndexSettings; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.context.event.ContextRefreshedEvent; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Component; - -@Slf4j -@Component -@RequiredArgsConstructor -@ConditionalOnProperty(prefix = "opensearch", name = "enabled", havingValue = "true") -public class OpenSearchIndexManager { - - private final OpenSearchClient client; - private final OpenSearchProperties properties; - - @EventListener(ContextRefreshedEvent.class) - public void initializeIndices() { - ensureIndex(properties.getPostsIndex(), this::postsMapping); - ensureIndex(properties.getCommentsIndex(), this::commentsMapping); - ensureIndex(properties.getUsersIndex(), this::usersMapping); - ensureIndex(properties.getCategoriesIndex(), this::categoriesMapping); - ensureIndex(properties.getTagsIndex(), this::tagsMapping); - } - - private void ensureIndex(String indexName, MappingSupplier supplier) { - try { - boolean exists = client.indices().exists(ExistsRequest.of(e -> e.index(indexName))).value(); - if (!exists) { - log.info("Creating OpenSearch index {}", indexName); - CreateIndexRequest request = CreateIndexRequest.builder() - .index(indexName) - .mappings(supplier.mapping()) - .settings(IndexSettings.of(s -> s.numberOfReplicas("1").numberOfShards("1"))) - .build(); - client.indices().create(request); - } - } catch (IOException e) { - throw new IllegalStateException("Failed to ensure index " + indexName, e); - } - } - - private TypeMapping postsMapping() { - return TypeMapping.builder() - .properties( - "title", - Property.of(p -> p.text(t -> t.analyzer("standard").fields("raw", f -> f.keyword(k -> k)))) - ) - .properties("content", Property.of(p -> p.text(t -> t.analyzer("standard")))) - .properties("author", Property.of(p -> p.keyword(k -> k))) - .properties("category", Property.of(p -> p.keyword(k -> k))) - .properties("tags", Property.of(p -> p.keyword(k -> k))) - .properties("status", Property.of(p -> p.keyword(k -> k))) - .properties("createdAt", Property.of(p -> p.date(d -> d))) - .build(); - } - - private TypeMapping commentsMapping() { - return TypeMapping.builder() - .properties("content", Property.of(p -> p.text(t -> t.analyzer("standard")))) - .properties("author", Property.of(p -> p.keyword(k -> k))) - .properties("postTitle", Property.of(p -> p.text(t -> t.analyzer("standard")))) - .properties("postId", Property.of(p -> p.long_(l -> l))) - .properties("createdAt", Property.of(p -> p.date(d -> d))) - .build(); - } - - private TypeMapping usersMapping() { - return TypeMapping.builder() - .properties( - "username", - Property.of(p -> p.text(t -> t.analyzer("standard").fields("raw", f -> f.keyword(k -> k)))) - ) - .properties("introduction", Property.of(p -> p.text(t -> t.analyzer("standard")))) - .properties("displayName", Property.of(p -> p.text(t -> t.analyzer("standard")))) - .properties("createdAt", Property.of(p -> p.date(d -> d))) - .build(); - } - - private TypeMapping categoriesMapping() { - return TypeMapping.builder() - .properties( - "name", - Property.of(p -> p.text(t -> t.analyzer("standard").fields("raw", f -> f.keyword(k -> k)))) - ) - .properties("description", Property.of(p -> p.text(t -> t.analyzer("standard")))) - .build(); - } - - private TypeMapping tagsMapping() { - return TypeMapping.builder() - .properties( - "name", - Property.of(p -> p.text(t -> t.analyzer("standard").fields("raw", f -> f.keyword(k -> k)))) - ) - .properties("description", Property.of(p -> p.text(t -> t.analyzer("standard")))) - .properties("approved", Property.of(p -> p.boolean_(b -> b))) - .build(); - } - - @FunctionalInterface - private interface MappingSupplier { - TypeMapping mapping(); - } -} diff --git a/backend/src/main/java/com/openisle/search/OpenSearchIndexer.java b/backend/src/main/java/com/openisle/search/OpenSearchIndexer.java deleted file mode 100644 index 84574d0bf..000000000 --- a/backend/src/main/java/com/openisle/search/OpenSearchIndexer.java +++ /dev/null @@ -1,166 +0,0 @@ -package com.openisle.search; - -import com.openisle.config.OpenSearchProperties; -import com.openisle.model.Category; -import com.openisle.model.Comment; -import com.openisle.model.Post; -import com.openisle.model.Tag; -import com.openisle.model.User; -import java.io.IOException; -import java.time.ZoneOffset; -import java.util.HashMap; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.opensearch.client.opensearch.OpenSearchClient; -import org.opensearch.client.opensearch.core.DeleteRequest; -import org.opensearch.client.opensearch.core.IndexRequest; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.stereotype.Component; -import org.springframework.transaction.support.TransactionSynchronization; -import org.springframework.transaction.support.TransactionSynchronizationManager; - -@Slf4j -@Component -@RequiredArgsConstructor -@ConditionalOnProperty(prefix = "opensearch", name = "enabled", havingValue = "true") -public class OpenSearchIndexer { - - private final OpenSearchClient client; - private final OpenSearchProperties properties; - - public void indexPost(Post post) { - runAfterCommit(() -> { - Map document = new HashMap<>(); - document.put("title", post.getTitle()); - document.put("content", post.getContent()); - document.put("author", post.getAuthor() != null ? post.getAuthor().getUsername() : null); - document.put("category", post.getCategory() != null ? post.getCategory().getName() : null); - document.put( - "tags", - post.getTags() != null - ? post.getTags().stream().map(Tag::getName).toList() - : java.util.List.of() - ); - document.put("status", post.getStatus() != null ? post.getStatus().name() : null); - if (post.getCreatedAt() != null) { - document.put("createdAt", post.getCreatedAt().atOffset(ZoneOffset.UTC)); - } - indexDocument(properties.getPostsIndex(), post.getId(), document); - }); - } - - public void deletePost(Long postId) { - runAfterCommit(() -> deleteDocument(properties.getPostsIndex(), postId)); - } - - public void indexComment(Comment comment) { - runAfterCommit(() -> { - Map document = new HashMap<>(); - document.put("content", comment.getContent()); - document.put( - "author", - comment.getAuthor() != null ? comment.getAuthor().getUsername() : null - ); - if (comment.getPost() != null) { - document.put("postId", comment.getPost().getId()); - document.put("postTitle", comment.getPost().getTitle()); - } - if (comment.getCreatedAt() != null) { - document.put("createdAt", comment.getCreatedAt().atOffset(ZoneOffset.UTC)); - } - indexDocument(properties.getCommentsIndex(), comment.getId(), document); - }); - } - - public void deleteComment(Long commentId) { - runAfterCommit(() -> deleteDocument(properties.getCommentsIndex(), commentId)); - } - - public void indexUser(User user) { - runAfterCommit(() -> { - Map document = new HashMap<>(); - document.put("username", user.getUsername()); - document.put("displayName", user.getDisplayName()); - document.put("introduction", user.getIntroduction()); - if (user.getCreatedAt() != null) { - document.put("createdAt", user.getCreatedAt().atOffset(ZoneOffset.UTC)); - } - indexDocument(properties.getUsersIndex(), user.getId(), document); - }); - } - - public void deleteUser(Long userId) { - runAfterCommit(() -> deleteDocument(properties.getUsersIndex(), userId)); - } - - public void indexCategory(Category category) { - runAfterCommit(() -> { - Map document = new HashMap<>(); - document.put("name", category.getName()); - document.put("description", category.getDescription()); - indexDocument(properties.getCategoriesIndex(), category.getId(), document); - }); - } - - public void deleteCategory(Long categoryId) { - runAfterCommit(() -> deleteDocument(properties.getCategoriesIndex(), categoryId)); - } - - public void indexTag(Tag tag) { - runAfterCommit(() -> { - Map document = new HashMap<>(); - document.put("name", tag.getName()); - document.put("description", tag.getDescription()); - document.put("approved", Boolean.TRUE.equals(tag.getApproved())); - indexDocument(properties.getTagsIndex(), tag.getId(), document); - }); - } - - public void deleteTag(Long tagId) { - runAfterCommit(() -> deleteDocument(properties.getTagsIndex(), tagId)); - } - - private void indexDocument(String index, Long id, Map document) { - if (id == null) { - return; - } - try { - IndexRequest> request = IndexRequest.>builder() - .index(index) - .id(id.toString()) - .document(document) - .build(); - client.index(request); - } catch (IOException e) { - log.error("Failed to index document {} in {}", id, index, e); - } - } - - private void deleteDocument(String index, Long id) { - if (id == null) { - return; - } - try { - DeleteRequest request = DeleteRequest.of(d -> d.index(index).id(id.toString())); - client.delete(request); - } catch (IOException e) { - log.error("Failed to delete document {} in {}", id, index, e); - } - } - - private void runAfterCommit(Runnable runnable) { - if (TransactionSynchronizationManager.isSynchronizationActive()) { - TransactionSynchronizationManager.registerSynchronization( - new TransactionSynchronization() { - @Override - public void afterCommit() { - runnable.run(); - } - } - ); - } else { - runnable.run(); - } - } -} diff --git a/backend/src/main/java/com/openisle/search/SearchEntityListener.java b/backend/src/main/java/com/openisle/search/SearchEntityListener.java deleted file mode 100644 index d06ca1af3..000000000 --- a/backend/src/main/java/com/openisle/search/SearchEntityListener.java +++ /dev/null @@ -1,84 +0,0 @@ -package com.openisle.search; - -import com.openisle.model.Category; -import com.openisle.model.Comment; -import com.openisle.model.Post; -import com.openisle.model.Tag; -import com.openisle.model.User; -import jakarta.persistence.PostPersist; -import jakarta.persistence.PostRemove; -import jakarta.persistence.PostUpdate; -import lombok.extern.slf4j.Slf4j; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.stereotype.Component; - -@Slf4j -public class SearchEntityListener { - - private static volatile OpenSearchIndexer indexer; - - public static void registerIndexer(OpenSearchIndexer openSearchIndexer) { - indexer = openSearchIndexer; - } - - @PostPersist - @PostUpdate - public void afterSave(Object entity) { - if (indexer == null) { - return; - } - if (entity instanceof Post post) { - indexer.indexPost(post); - } else if (entity instanceof Comment comment) { - indexer.indexComment(comment); - } else if (entity instanceof User user) { - indexer.indexUser(user); - } else if (entity instanceof Category category) { - indexer.indexCategory(category); - } else if (entity instanceof Tag tag) { - indexer.indexTag(tag); - } - } - - @PostRemove - public void afterDelete(Object entity) { - if (indexer == null) { - return; - } - if (entity instanceof Post post) { - Long id = post.getId(); - if (id != null) { - indexer.deletePost(id); - } - } else if (entity instanceof Comment comment) { - Long id = comment.getId(); - if (id != null) { - indexer.deleteComment(id); - } - } else if (entity instanceof User user) { - Long id = user.getId(); - if (id != null) { - indexer.deleteUser(id); - } - } else if (entity instanceof Category category) { - Long id = category.getId(); - if (id != null) { - indexer.deleteCategory(id); - } - } else if (entity instanceof Tag tag) { - Long id = tag.getId(); - if (id != null) { - indexer.deleteTag(id); - } - } - } - - @Component - @ConditionalOnProperty(prefix = "opensearch", name = "enabled", havingValue = "true") - public static class Registrar { - - public Registrar(OpenSearchIndexer openSearchIndexer) { - SearchEntityListener.registerIndexer(openSearchIndexer); - } - } -} diff --git a/backend/src/main/java/com/openisle/service/SearchService.java b/backend/src/main/java/com/openisle/service/SearchService.java index b75199a1f..dee83fed9 100644 --- a/backend/src/main/java/com/openisle/service/SearchService.java +++ b/backend/src/main/java/com/openisle/service/SearchService.java @@ -11,17 +11,10 @@ import com.openisle.repository.CommentRepository; import com.openisle.repository.PostRepository; import com.openisle.repository.TagRepository; import com.openisle.repository.UserRepository; -import com.openisle.search.OpenSearchGateway; -import com.openisle.search.OpenSearchGateway.PostSearchMode; import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.Stream; -import java.util.stream.StreamSupport; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -34,26 +27,15 @@ public class SearchService { private final CommentRepository commentRepository; private final CategoryRepository categoryRepository; private final TagRepository tagRepository; - private final Optional openSearchGateway; @org.springframework.beans.factory.annotation.Value("${app.snippet-length}") private int snippetLength; public List searchUsers(String keyword) { - if (openSearchGateway.isPresent()) { - List ids = openSearchGateway.get().searchUserIds(keyword); - return loadAndSort(ids, userRepository::findAllById, User::getId); - } return userRepository.findByUsernameContainingIgnoreCase(keyword); } public List searchPosts(String keyword) { - if (openSearchGateway.isPresent()) { - List ids = openSearchGateway - .get() - .searchPostIds(keyword, PostSearchMode.TITLE_AND_CONTENT); - return loadAndSort(ids, idList -> postRepository.findAllById(idList), Post::getId); - } return postRepository.findByTitleContainingIgnoreCaseOrContentContainingIgnoreCaseAndStatus( keyword, keyword, @@ -62,49 +44,26 @@ public class SearchService { } public List searchPostsByContent(String keyword) { - if (openSearchGateway.isPresent()) { - List ids = openSearchGateway.get().searchPostIds(keyword, PostSearchMode.CONTENT_ONLY); - return loadAndSort(ids, idList -> postRepository.findAllById(idList), Post::getId); - } return postRepository.findByContentContainingIgnoreCaseAndStatus(keyword, PostStatus.PUBLISHED); } public List searchPostsByTitle(String keyword) { - if (openSearchGateway.isPresent()) { - List ids = openSearchGateway.get().searchPostIds(keyword, PostSearchMode.TITLE_ONLY); - return loadAndSort(ids, idList -> postRepository.findAllById(idList), Post::getId); - } return postRepository.findByTitleContainingIgnoreCaseAndStatus(keyword, PostStatus.PUBLISHED); } public List searchComments(String keyword) { - if (openSearchGateway.isPresent()) { - List ids = openSearchGateway.get().searchCommentIds(keyword); - return loadAndSort(ids, idList -> commentRepository.findAllById(idList), Comment::getId); - } return commentRepository.findByContentContainingIgnoreCase(keyword); } public List searchCategories(String keyword) { - if (openSearchGateway.isPresent()) { - List ids = openSearchGateway.get().searchCategoryIds(keyword); - return loadAndSort(ids, idList -> categoryRepository.findAllById(idList), Category::getId); - } return categoryRepository.findByNameContainingIgnoreCase(keyword); } public List searchTags(String keyword) { - if (openSearchGateway.isPresent()) { - List ids = openSearchGateway.get().searchTagIds(keyword); - return loadAndSort(ids, idList -> tagRepository.findAllById(idList), Tag::getId); - } return tagRepository.findByNameContainingIgnoreCaseAndApprovedTrue(keyword); } public List globalSearch(String keyword) { - if (openSearchGateway.isPresent()) { - return openSearchGateway.get().globalSearch(keyword, snippetLength); - } Stream users = searchUsers(keyword) .stream() .map(u -> @@ -214,18 +173,4 @@ public class SearchService { String extra, Long postId ) {} - - private List loadAndSort( - List ids, - Function, Iterable> loader, - Function idExtractor - ) { - if (ids.isEmpty()) { - return List.of(); - } - Map entityMap = StreamSupport.stream(loader.apply(ids).spliterator(), false).collect( - Collectors.toMap(idExtractor, Function.identity()) - ); - return ids.stream().map(entityMap::get).filter(Objects::nonNull).toList(); - } } diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 4aacb09fd..00ccc0302 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -45,17 +45,6 @@ app.user.replies-limit=${USER_REPLIES_LIMIT:50} # Length of extracted snippets for posts and search (-1 to disable truncation) app.snippet-length=${SNIPPET_LENGTH:200} -# OpenSearch integration -opensearch.enabled=${OPENSEARCH_ENABLED:false} -opensearch.hosts=${OPENSEARCH_HOSTS:} -opensearch.username=${OPENSEARCH_USERNAME:} -opensearch.password=${OPENSEARCH_PASSWORD:} -opensearch.insecure=${OPENSEARCH_INSECURE:false} -opensearch.connect-timeout=${OPENSEARCH_CONNECT_TIMEOUT:10s} -opensearch.socket-timeout=${OPENSEARCH_SOCKET_TIMEOUT:30s} -opensearch.max-results=${OPENSEARCH_MAX_RESULTS:50} -opensearch.highlight-fallback-length=${OPENSEARCH_HIGHLIGHT_FALLBACK_LENGTH:${SNIPPET_LENGTH:200}} - # Captcha configuration app.captcha.enabled=${CAPTCHA_ENABLED:false} recaptcha.secret-key=${RECAPTCHA_SECRET_KEY:} diff --git a/backend/src/test/java/com/openisle/service/SearchServiceTest.java b/backend/src/test/java/com/openisle/service/SearchServiceTest.java index 452557a9e..59027b64d 100644 --- a/backend/src/test/java/com/openisle/service/SearchServiceTest.java +++ b/backend/src/test/java/com/openisle/service/SearchServiceTest.java @@ -10,7 +10,6 @@ import com.openisle.repository.PostRepository; import com.openisle.repository.TagRepository; import com.openisle.repository.UserRepository; import java.util.List; -import java.util.Optional; import org.junit.jupiter.api.Test; import org.mockito.Mockito; @@ -28,8 +27,7 @@ class SearchServiceTest { postRepo, commentRepo, categoryRepo, - tagRepo, - Optional.empty() + tagRepo ); Post post1 = new Post(); From 0bc65077df2316fb24d9ffc24e6f95de9e4eb66f Mon Sep 17 00:00:00 2001 From: tim Date: Fri, 26 Sep 2025 16:37:13 +0800 Subject: [PATCH 03/15] feat: opensearch init --- backend/pom.xml | 13 ++ .../openisle/search/NoopSearchIndexer.java | 14 ++ .../com/openisle/search/OpenSearchConfig.java | 78 ++++++++ .../openisle/search/OpenSearchIndexer.java | 51 ++++++ .../openisle/search/OpenSearchProperties.java | 61 +++++++ .../com/openisle/search/SearchDocument.java | 16 ++ .../search/SearchDocumentFactory.java | 125 +++++++++++++ .../search/SearchIndexEventListener.java | 33 ++++ .../search/SearchIndexEventPublisher.java | 99 ++++++++++ .../search/SearchIndexInitializer.java | 102 +++++++++++ .../com/openisle/search/SearchIndexer.java | 6 + .../search/event/DeleteDocumentEvent.java | 3 + .../search/event/IndexDocumentEvent.java | 5 + .../com/openisle/service/CategoryService.java | 11 +- .../com/openisle/service/CommentService.java | 6 + .../com/openisle/service/PostService.java | 20 ++- .../com/openisle/service/SearchService.java | 170 ++++++++++++++++++ .../java/com/openisle/service/TagService.java | 15 +- .../com/openisle/service/UserService.java | 22 ++- .../src/main/resources/application.properties | 10 ++ .../openisle/service/CommentServiceTest.java | 5 +- .../com/openisle/service/PostServiceTest.java | 21 ++- .../openisle/service/SearchServiceTest.java | 6 +- 23 files changed, 874 insertions(+), 18 deletions(-) create mode 100644 backend/src/main/java/com/openisle/search/NoopSearchIndexer.java create mode 100644 backend/src/main/java/com/openisle/search/OpenSearchConfig.java create mode 100644 backend/src/main/java/com/openisle/search/OpenSearchIndexer.java create mode 100644 backend/src/main/java/com/openisle/search/OpenSearchProperties.java create mode 100644 backend/src/main/java/com/openisle/search/SearchDocument.java create mode 100644 backend/src/main/java/com/openisle/search/SearchDocumentFactory.java create mode 100644 backend/src/main/java/com/openisle/search/SearchIndexEventListener.java create mode 100644 backend/src/main/java/com/openisle/search/SearchIndexEventPublisher.java create mode 100644 backend/src/main/java/com/openisle/search/SearchIndexInitializer.java create mode 100644 backend/src/main/java/com/openisle/search/SearchIndexer.java create mode 100644 backend/src/main/java/com/openisle/search/event/DeleteDocumentEvent.java create mode 100644 backend/src/main/java/com/openisle/search/event/IndexDocumentEvent.java diff --git a/backend/pom.xml b/backend/pom.xml index 97d8c7f65..a0f1c9b7e 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -132,6 +132,19 @@ springdoc-openapi-starter-webmvc-api 2.2.0 + + + org.opensearch.client + opensearch-java + 3.2.0 + + + + + org.opensearch.client + opensearch-rest-client + 3.2.0 + diff --git a/backend/src/main/java/com/openisle/search/NoopSearchIndexer.java b/backend/src/main/java/com/openisle/search/NoopSearchIndexer.java new file mode 100644 index 000000000..afaca419c --- /dev/null +++ b/backend/src/main/java/com/openisle/search/NoopSearchIndexer.java @@ -0,0 +1,14 @@ +package com.openisle.search; + +public class NoopSearchIndexer implements SearchIndexer { + + @Override + public void indexDocument(String index, SearchDocument document) { + // no-op + } + + @Override + public void deleteDocument(String index, Long id) { + // no-op + } +} diff --git a/backend/src/main/java/com/openisle/search/OpenSearchConfig.java b/backend/src/main/java/com/openisle/search/OpenSearchConfig.java new file mode 100644 index 000000000..8ab484059 --- /dev/null +++ b/backend/src/main/java/com/openisle/search/OpenSearchConfig.java @@ -0,0 +1,78 @@ +package com.openisle.search; + +import org.apache.hc.client5.http.auth.AuthScope; +import org.apache.hc.client5.http.auth.UsernamePasswordCredentials; +import org.apache.hc.client5.http.impl.auth.BasicCredentialsProvider; +import org.apache.hc.core5.http.HttpHost; +import org.opensearch.client.RestClient; +import org.opensearch.client.RestClientBuilder; +import org.opensearch.client.json.jackson.JacksonJsonpMapper; +import org.opensearch.client.opensearch.OpenSearchClient; +import org.opensearch.client.transport.rest_client.RestClientTransport; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.util.StringUtils; + +@Configuration +@EnableConfigurationProperties(OpenSearchProperties.class) +public class OpenSearchConfig { + + @Bean(destroyMethod = "close") + @ConditionalOnProperty(prefix = "app.search", name = "enabled", havingValue = "true") + public RestClient openSearchRestClient(OpenSearchProperties properties) { + RestClientBuilder builder = RestClient.builder( + new HttpHost(properties.getScheme(), properties.getHost(), properties.getPort()) + ); + if (StringUtils.hasText(properties.getUsername())) { + BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider(); + credentialsProvider.setCredentials( + new AuthScope(properties.getHost(), properties.getPort()), + new UsernamePasswordCredentials( + properties.getUsername(), + properties.getPassword() != null ? properties.getPassword().toCharArray() : new char[0] + ) + ); + builder.setHttpClientConfigCallback(httpClientBuilder -> + httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider) + ); + } + return builder.build(); + } + + @Bean(destroyMethod = "close") + @ConditionalOnBean(RestClient.class) + public RestClientTransport openSearchTransport(RestClient restClient) { + return new RestClientTransport(restClient, new JacksonJsonpMapper()); + } + + @Bean + @ConditionalOnBean(RestClientTransport.class) + public OpenSearchClient openSearchClient(RestClientTransport transport) { + return new OpenSearchClient(transport); + } + + @Bean + @ConditionalOnBean(OpenSearchClient.class) + public SearchIndexInitializer searchIndexInitializer( + OpenSearchClient client, + OpenSearchProperties properties + ) { + return new SearchIndexInitializer(client, properties); + } + + @Bean + @ConditionalOnBean(OpenSearchClient.class) + public SearchIndexer openSearchIndexer(OpenSearchClient client, OpenSearchProperties properties) { + return new OpenSearchIndexer(client); + } + + @Bean + @ConditionalOnMissingBean(SearchIndexer.class) + public SearchIndexer noopSearchIndexer() { + return new NoopSearchIndexer(); + } +} diff --git a/backend/src/main/java/com/openisle/search/OpenSearchIndexer.java b/backend/src/main/java/com/openisle/search/OpenSearchIndexer.java new file mode 100644 index 000000000..5bffa0364 --- /dev/null +++ b/backend/src/main/java/com/openisle/search/OpenSearchIndexer.java @@ -0,0 +1,51 @@ +package com.openisle.search; + +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.opensearch.client.opensearch.OpenSearchClient; +import org.opensearch.client.opensearch.core.DeleteRequest; +import org.opensearch.client.opensearch.core.IndexRequest; +import org.opensearch.client.opensearch.core.IndexResponse; + +@Slf4j +@RequiredArgsConstructor +public class OpenSearchIndexer implements SearchIndexer { + + private final OpenSearchClient client; + + @Override + public void indexDocument(String index, SearchDocument document) { + if (document == null || document.entityId() == null) { + return; + } + try { + IndexRequest request = IndexRequest.of(builder -> + 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() + ); + } + } catch (IOException e) { + log.warn("Failed to index document {} into {}", document.entityId(), index, e); + } + } + + @Override + public void deleteDocument(String index, Long id) { + if (id == null) { + return; + } + try { + client.delete(DeleteRequest.of(builder -> builder.index(index).id(id.toString()))); + } catch (IOException e) { + log.warn("Failed to delete document {} from {}", id, index, e); + } + } +} diff --git a/backend/src/main/java/com/openisle/search/OpenSearchProperties.java b/backend/src/main/java/com/openisle/search/OpenSearchProperties.java new file mode 100644 index 000000000..f236abcb5 --- /dev/null +++ b/backend/src/main/java/com/openisle/search/OpenSearchProperties.java @@ -0,0 +1,61 @@ +package com.openisle.search; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@Setter +@ConfigurationProperties(prefix = "app.search") +public class OpenSearchProperties { + + private boolean enabled = false; + private String host = "localhost"; + private int port = 9200; + private String scheme = "http"; + private String username; + private String password; + private String indexPrefix = "openisle"; + private boolean initialize = true; + private int highlightFragmentSize = 200; + + private Indices indices = new Indices(); + + public String postsIndex() { + return indexName(indices.posts); + } + + public String commentsIndex() { + return indexName(indices.comments); + } + + public String usersIndex() { + return indexName(indices.users); + } + + public String categoriesIndex() { + return indexName(indices.categories); + } + + public String tagsIndex() { + return indexName(indices.tags); + } + + private String indexName(String suffix) { + if (indexPrefix == null || indexPrefix.isBlank()) { + return suffix; + } + return indexPrefix + "-" + suffix; + } + + @Getter + @Setter + public static class Indices { + + private String posts = "posts"; + private String comments = "comments"; + private String users = "users"; + private String categories = "categories"; + private String tags = "tags"; + } +} diff --git a/backend/src/main/java/com/openisle/search/SearchDocument.java b/backend/src/main/java/com/openisle/search/SearchDocument.java new file mode 100644 index 000000000..4dc699f34 --- /dev/null +++ b/backend/src/main/java/com/openisle/search/SearchDocument.java @@ -0,0 +1,16 @@ +package com.openisle.search; + +import java.time.Instant; +import java.util.List; + +public record SearchDocument( + String type, + Long entityId, + String title, + String content, + String author, + String category, + List tags, + Long postId, + Instant createdAt +) {} diff --git a/backend/src/main/java/com/openisle/search/SearchDocumentFactory.java b/backend/src/main/java/com/openisle/search/SearchDocumentFactory.java new file mode 100644 index 000000000..786a554e0 --- /dev/null +++ b/backend/src/main/java/com/openisle/search/SearchDocumentFactory.java @@ -0,0 +1,125 @@ +package com.openisle.search; + +import com.openisle.model.Category; +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; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +public final class SearchDocumentFactory { + + private SearchDocumentFactory() {} + + public static SearchDocument fromPost(Post post) { + if (post == null || post.getId() == null) { + return null; + } + List tags = post.getTags() == null + ? Collections.emptyList() + : post + .getTags() + .stream() + .map(Tag::getName) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + return new SearchDocument( + "post", + post.getId(), + post.getTitle(), + post.getContent(), + post.getAuthor() != null ? post.getAuthor().getUsername() : null, + post.getCategory() != null ? post.getCategory().getName() : null, + tags, + post.getId(), + toInstant(post.getCreatedAt()) + ); + } + + public static SearchDocument fromComment(Comment comment) { + if (comment == null || comment.getId() == null) { + return null; + } + Post post = comment.getPost(); + List tags = post == null || post.getTags() == null + ? Collections.emptyList() + : post + .getTags() + .stream() + .map(Tag::getName) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + return new SearchDocument( + "comment", + comment.getId(), + post != null ? post.getTitle() : null, + comment.getContent(), + comment.getAuthor() != null ? comment.getAuthor().getUsername() : null, + post != null && post.getCategory() != null ? post.getCategory().getName() : null, + tags, + post != null ? post.getId() : null, + toInstant(comment.getCreatedAt()) + ); + } + + public static SearchDocument fromUser(User user) { + if (user == null || user.getId() == null) { + return null; + } + return new SearchDocument( + "user", + user.getId(), + user.getUsername(), + user.getIntroduction(), + null, + null, + Collections.emptyList(), + null, + toInstant(user.getCreatedAt()) + ); + } + + public static SearchDocument fromCategory(Category category) { + if (category == null || category.getId() == null) { + return null; + } + return new SearchDocument( + "category", + category.getId(), + category.getName(), + category.getDescription(), + null, + null, + Collections.emptyList(), + null, + null + ); + } + + public static SearchDocument fromTag(Tag tag) { + if (tag == null || tag.getId() == null) { + return null; + } + return new SearchDocument( + "tag", + tag.getId(), + tag.getName(), + tag.getDescription(), + null, + null, + Collections.emptyList(), + null, + toInstant(tag.getCreatedAt()) + ); + } + + private static Instant toInstant(LocalDateTime time) { + return time == null ? null : time.atZone(ZoneId.systemDefault()).toInstant(); + } +} diff --git a/backend/src/main/java/com/openisle/search/SearchIndexEventListener.java b/backend/src/main/java/com/openisle/search/SearchIndexEventListener.java new file mode 100644 index 000000000..5565b039e --- /dev/null +++ b/backend/src/main/java/com/openisle/search/SearchIndexEventListener.java @@ -0,0 +1,33 @@ +package com.openisle.search; + +import com.openisle.search.event.DeleteDocumentEvent; +import com.openisle.search.event.IndexDocumentEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SearchIndexEventListener { + + private final SearchIndexer searchIndexer; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + public void handleIndex(IndexDocumentEvent event) { + if (event == null || event.document() == null) { + return; + } + searchIndexer.indexDocument(event.index(), event.document()); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + public void handleDelete(DeleteDocumentEvent event) { + if (event == null) { + return; + } + searchIndexer.deleteDocument(event.index(), event.id()); + } +} diff --git a/backend/src/main/java/com/openisle/search/SearchIndexEventPublisher.java b/backend/src/main/java/com/openisle/search/SearchIndexEventPublisher.java new file mode 100644 index 000000000..4f5fbbdd6 --- /dev/null +++ b/backend/src/main/java/com/openisle/search/SearchIndexEventPublisher.java @@ -0,0 +1,99 @@ +package com.openisle.search; + +import com.openisle.model.Category; +import com.openisle.model.Comment; +import com.openisle.model.Post; +import com.openisle.model.PostStatus; +import com.openisle.model.Tag; +import com.openisle.model.User; +import com.openisle.search.event.DeleteDocumentEvent; +import com.openisle.search.event.IndexDocumentEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class SearchIndexEventPublisher { + + private final ApplicationEventPublisher publisher; + private final OpenSearchProperties properties; + + public void publishPostSaved(Post post) { + if (!properties.isEnabled() || post == null || post.getStatus() != PostStatus.PUBLISHED) { + return; + } + SearchDocument document = SearchDocumentFactory.fromPost(post); + if (document != null) { + publisher.publishEvent(new IndexDocumentEvent(properties.postsIndex(), document)); + } + } + + public void publishPostDeleted(Long postId) { + if (!properties.isEnabled() || postId == null) { + return; + } + publisher.publishEvent(new DeleteDocumentEvent(properties.postsIndex(), postId)); + } + + public void publishCommentSaved(Comment comment) { + if (!properties.isEnabled() || comment == null) { + return; + } + SearchDocument document = SearchDocumentFactory.fromComment(comment); + if (document != null) { + publisher.publishEvent(new IndexDocumentEvent(properties.commentsIndex(), document)); + } + } + + public void publishCommentDeleted(Long commentId) { + if (!properties.isEnabled() || commentId == null) { + return; + } + publisher.publishEvent(new DeleteDocumentEvent(properties.commentsIndex(), commentId)); + } + + public void publishUserSaved(User user) { + if (!properties.isEnabled() || user == null) { + return; + } + SearchDocument document = SearchDocumentFactory.fromUser(user); + if (document != null) { + publisher.publishEvent(new IndexDocumentEvent(properties.usersIndex(), document)); + } + } + + public void publishCategorySaved(Category category) { + if (!properties.isEnabled() || category == null) { + return; + } + SearchDocument document = SearchDocumentFactory.fromCategory(category); + if (document != null) { + publisher.publishEvent(new IndexDocumentEvent(properties.categoriesIndex(), document)); + } + } + + public void publishCategoryDeleted(Long categoryId) { + if (!properties.isEnabled() || categoryId == null) { + return; + } + publisher.publishEvent(new DeleteDocumentEvent(properties.categoriesIndex(), categoryId)); + } + + public void publishTagSaved(Tag tag) { + if (!properties.isEnabled() || tag == null || !tag.isApproved()) { + return; + } + SearchDocument document = SearchDocumentFactory.fromTag(tag); + if (document != null) { + publisher.publishEvent(new IndexDocumentEvent(properties.tagsIndex(), document)); + } + } + + public void publishTagDeleted(Long tagId) { + if (!properties.isEnabled() || tagId == null) { + return; + } + publisher.publishEvent(new DeleteDocumentEvent(properties.tagsIndex(), tagId)); + } +} diff --git a/backend/src/main/java/com/openisle/search/SearchIndexInitializer.java b/backend/src/main/java/com/openisle/search/SearchIndexInitializer.java new file mode 100644 index 000000000..3f266d6a3 --- /dev/null +++ b/backend/src/main/java/com/openisle/search/SearchIndexInitializer.java @@ -0,0 +1,102 @@ +package com.openisle.search; + +import jakarta.annotation.PostConstruct; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.opensearch.client.opensearch.OpenSearchClient; +import org.opensearch.client.opensearch._types.mapping.Property; +import org.opensearch.client.opensearch._types.mapping.TypeMapping; + +@Slf4j +@RequiredArgsConstructor +public class SearchIndexInitializer { + + private final OpenSearchClient client; + private final OpenSearchProperties properties; + + @PostConstruct + public void initialize() { + if (!properties.isEnabled() || !properties.isInitialize()) { + return; + } + ensureIndex(properties.postsIndex(), this::postMapping); + ensureIndex(properties.commentsIndex(), this::commentMapping); + ensureIndex(properties.usersIndex(), this::userMapping); + ensureIndex(properties.categoriesIndex(), this::categoryMapping); + ensureIndex(properties.tagsIndex(), this::tagMapping); + } + + private void ensureIndex(String index, java.util.function.Supplier mappingSupplier) { + try { + boolean exists = client + .indices() + .exists(builder -> builder.index(index)) + .value(); + if (exists) { + return; + } + client.indices().create(builder -> builder.index(index).mappings(mappingSupplier.get())); + log.info("Created OpenSearch index {}", index); + } catch (IOException e) { + log.warn("Failed to initialize OpenSearch index {}", index, e); + } + } + + private TypeMapping postMapping() { + return TypeMapping.of(builder -> + builder + .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("author", Property.of(p -> p.keyword(k -> k))) + .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))) + ); + } + + private TypeMapping commentMapping() { + return TypeMapping.of(builder -> + builder + .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("author", Property.of(p -> p.keyword(k -> k))) + .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))) + ); + } + + private TypeMapping userMapping() { + return TypeMapping.of(builder -> + builder + .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))) + ); + } + + private TypeMapping categoryMapping() { + return TypeMapping.of(builder -> + builder + .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))) + ); + } + + private TypeMapping tagMapping() { + return TypeMapping.of(builder -> + builder + .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))) + ); + } +} diff --git a/backend/src/main/java/com/openisle/search/SearchIndexer.java b/backend/src/main/java/com/openisle/search/SearchIndexer.java new file mode 100644 index 000000000..c9da039ed --- /dev/null +++ b/backend/src/main/java/com/openisle/search/SearchIndexer.java @@ -0,0 +1,6 @@ +package com.openisle.search; + +public interface SearchIndexer { + void indexDocument(String index, SearchDocument document); + void deleteDocument(String index, Long id); +} diff --git a/backend/src/main/java/com/openisle/search/event/DeleteDocumentEvent.java b/backend/src/main/java/com/openisle/search/event/DeleteDocumentEvent.java new file mode 100644 index 000000000..42c934a34 --- /dev/null +++ b/backend/src/main/java/com/openisle/search/event/DeleteDocumentEvent.java @@ -0,0 +1,3 @@ +package com.openisle.search.event; + +public record DeleteDocumentEvent(String index, Long id) {} diff --git a/backend/src/main/java/com/openisle/search/event/IndexDocumentEvent.java b/backend/src/main/java/com/openisle/search/event/IndexDocumentEvent.java new file mode 100644 index 000000000..3447bbd9e --- /dev/null +++ b/backend/src/main/java/com/openisle/search/event/IndexDocumentEvent.java @@ -0,0 +1,5 @@ +package com.openisle.search.event; + +import com.openisle.search.SearchDocument; + +public record IndexDocumentEvent(String index, SearchDocument document) {} diff --git a/backend/src/main/java/com/openisle/service/CategoryService.java b/backend/src/main/java/com/openisle/service/CategoryService.java index 3d375e6e1..a9b7779af 100644 --- a/backend/src/main/java/com/openisle/service/CategoryService.java +++ b/backend/src/main/java/com/openisle/service/CategoryService.java @@ -3,6 +3,7 @@ package com.openisle.service; import com.openisle.config.CachingConfig; import com.openisle.model.Category; import com.openisle.repository.CategoryRepository; +import com.openisle.search.SearchIndexEventPublisher; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.cache.annotation.CacheEvict; @@ -14,6 +15,7 @@ import org.springframework.stereotype.Service; public class CategoryService { private final CategoryRepository categoryRepository; + private final SearchIndexEventPublisher searchIndexEventPublisher; @CacheEvict(value = CachingConfig.CATEGORY_CACHE_NAME, allEntries = true) public Category createCategory(String name, String description, String icon, String smallIcon) { @@ -22,7 +24,9 @@ public class CategoryService { category.setDescription(description); category.setIcon(icon); category.setSmallIcon(smallIcon); - return categoryRepository.save(category); + Category saved = categoryRepository.save(category); + searchIndexEventPublisher.publishCategorySaved(saved); + return saved; } @CacheEvict(value = CachingConfig.CATEGORY_CACHE_NAME, allEntries = true) @@ -48,12 +52,15 @@ public class CategoryService { if (smallIcon != null) { category.setSmallIcon(smallIcon); } - return categoryRepository.save(category); + Category saved = categoryRepository.save(category); + searchIndexEventPublisher.publishCategorySaved(saved); + return saved; } @CacheEvict(value = CachingConfig.CATEGORY_CACHE_NAME, allEntries = true) public void deleteCategory(Long id) { categoryRepository.deleteById(id); + searchIndexEventPublisher.publishCategoryDeleted(id); } public Category getCategory(Long id) { diff --git a/backend/src/main/java/com/openisle/service/CommentService.java b/backend/src/main/java/com/openisle/service/CommentService.java index fd8d1d1f7..9da1af2d7 100644 --- a/backend/src/main/java/com/openisle/service/CommentService.java +++ b/backend/src/main/java/com/openisle/service/CommentService.java @@ -16,6 +16,7 @@ import com.openisle.repository.PointHistoryRepository; import com.openisle.repository.PostRepository; import com.openisle.repository.ReactionRepository; import com.openisle.repository.UserRepository; +import com.openisle.search.SearchIndexEventPublisher; import com.openisle.service.NotificationService; import com.openisle.service.PointService; import com.openisle.service.SubscriptionService; @@ -49,6 +50,7 @@ public class CommentService { private final PointHistoryRepository pointHistoryRepository; private final PointService pointService; private final ImageUploader imageUploader; + private final SearchIndexEventPublisher searchIndexEventPublisher; @CacheEvict(value = CachingConfig.POST_CACHE_NAME, allEntries = true) @Transactional @@ -124,6 +126,7 @@ public class CommentService { } notificationService.notifyMentions(content, author, post, comment); log.debug("addComment finished for comment {}", comment.getId()); + searchIndexEventPublisher.publishCommentSaved(comment); return comment; } @@ -221,6 +224,7 @@ public class CommentService { } notificationService.notifyMentions(content, author, parent.getPost(), comment); log.debug("addReply finished for comment {}", comment.getId()); + searchIndexEventPublisher.publishCommentSaved(comment); return comment; } @@ -360,7 +364,9 @@ public class CommentService { // 逻辑删除评论 Post post = comment.getPost(); + Long commentId = comment.getId(); commentRepository.delete(comment); + searchIndexEventPublisher.publishCommentDeleted(commentId); // 删除积分历史 pointHistoryRepository.deleteAll(pointHistories); diff --git a/backend/src/main/java/com/openisle/service/PostService.java b/backend/src/main/java/com/openisle/service/PostService.java index 029c82882..c28566609 100644 --- a/backend/src/main/java/com/openisle/service/PostService.java +++ b/backend/src/main/java/com/openisle/service/PostService.java @@ -16,6 +16,7 @@ import com.openisle.repository.PostSubscriptionRepository; import com.openisle.repository.ReactionRepository; import com.openisle.repository.TagRepository; import com.openisle.repository.UserRepository; +import com.openisle.search.SearchIndexEventPublisher; import com.openisle.service.EmailSender; import java.time.Duration; import java.time.LocalDateTime; @@ -73,6 +74,8 @@ public class PostService { private final ConcurrentMap> scheduledFinalizations = new ConcurrentHashMap<>(); + private final SearchIndexEventPublisher searchIndexEventPublisher; + @Value("${app.website-url:https://www.open-isle.com}") private String websiteUrl; @@ -103,7 +106,8 @@ public class PostService { PostChangeLogService postChangeLogService, PointHistoryRepository pointHistoryRepository, @Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode, - RedisTemplate redisTemplate + RedisTemplate redisTemplate, + SearchIndexEventPublisher searchIndexEventPublisher ) { this.postRepository = postRepository; this.userRepository = userRepository; @@ -130,6 +134,7 @@ public class PostService { this.publishMode = publishMode; this.redisTemplate = redisTemplate; + this.searchIndexEventPublisher = searchIndexEventPublisher; } @EventListener(ApplicationReadyEvent.class) @@ -346,6 +351,9 @@ public class PostService { ); scheduledFinalizations.put(pp.getId(), future); } + if (post.getStatus() == PostStatus.PUBLISHED) { + searchIndexEventPublisher.publishPostSaved(post); + } return post; } @@ -868,10 +876,12 @@ public class PostService { if (!tag.isApproved()) { tag.setApproved(true); tagRepository.save(tag); + searchIndexEventPublisher.publishTagSaved(tag); } } post.setStatus(PostStatus.PUBLISHED); post = postRepository.save(post); + searchIndexEventPublisher.publishPostSaved(post); notificationService.createNotification( post.getAuthor(), NotificationType.POST_REVIEWED, @@ -895,13 +905,16 @@ public class PostService { if (!tag.isApproved()) { long count = postRepository.countDistinctByTags_Id(tag.getId()); if (count <= 1) { + Long tagId = tag.getId(); post.getTags().remove(tag); tagRepository.delete(tag); + searchIndexEventPublisher.publishTagDeleted(tagId); } } } post.setStatus(PostStatus.REJECTED); post = postRepository.save(post); + searchIndexEventPublisher.publishPostDeleted(post.getId()); notificationService.createNotification( post.getAuthor(), NotificationType.POST_REVIEWED, @@ -1042,6 +1055,9 @@ public class PostService { if (!oldTags.equals(newTags)) { postChangeLogService.recordTagChange(updated, user, oldTags, newTags); } + if (updated.getStatus() == PostStatus.PUBLISHED) { + searchIndexEventPublisher.publishPostSaved(updated); + } return updated; } @@ -1094,8 +1110,10 @@ public class PostService { } } String title = post.getTitle(); + Long postId = post.getId(); postChangeLogService.deleteLogsForPost(post); postRepository.delete(post); + searchIndexEventPublisher.publishPostDeleted(postId); if (adminDeleting) { notificationService.createNotification( author, diff --git a/backend/src/main/java/com/openisle/service/SearchService.java b/backend/src/main/java/com/openisle/service/SearchService.java index dee83fed9..287815210 100644 --- a/backend/src/main/java/com/openisle/service/SearchService.java +++ b/backend/src/main/java/com/openisle/service/SearchService.java @@ -11,14 +11,26 @@ import com.openisle.repository.CommentRepository; import com.openisle.repository.PostRepository; import com.openisle.repository.TagRepository; import com.openisle.repository.UserRepository; +import com.openisle.search.OpenSearchProperties; +import com.openisle.search.SearchDocument; +import java.io.IOException; +import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.Optional; import java.util.stream.Collectors; import java.util.stream.Stream; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.opensearch.client.opensearch.OpenSearchClient; +import org.opensearch.client.opensearch._types.query_dsl.TextQueryType; +import org.opensearch.client.opensearch.core.SearchResponse; +import org.opensearch.client.opensearch.core.search.Hit; import org.springframework.stereotype.Service; @Service +@Slf4j @RequiredArgsConstructor public class SearchService { @@ -27,10 +39,14 @@ public class SearchService { private final CommentRepository commentRepository; private final CategoryRepository categoryRepository; private final TagRepository tagRepository; + private final Optional openSearchClient; + private final OpenSearchProperties openSearchProperties; @org.springframework.beans.factory.annotation.Value("${app.snippet-length}") private int snippetLength; + private static final int DEFAULT_OPEN_SEARCH_LIMIT = 50; + public List searchUsers(String keyword) { return userRepository.findByUsernameContainingIgnoreCase(keyword); } @@ -64,6 +80,23 @@ public class SearchService { } public List globalSearch(String keyword) { + if (keyword == null || keyword.isBlank()) { + return List.of(); + } + if (isOpenSearchEnabled()) { + try { + List results = searchWithOpenSearch(keyword); + if (!results.isEmpty()) { + return results; + } + } catch (IOException e) { + log.warn("OpenSearch global search failed, falling back to database query", e); + } + } + return fallbackGlobalSearch(keyword); + } + + private List fallbackGlobalSearch(String keyword) { Stream users = searchUsers(keyword) .stream() .map(u -> @@ -138,6 +171,143 @@ public class SearchService { .toList(); } + private boolean isOpenSearchEnabled() { + return openSearchProperties.isEnabled() && openSearchClient.isPresent(); + } + + 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 + .index(searchIndices()) + .query(q -> + q.multiMatch(mm -> + mm + .query(trimmed) + .fields(List.of("title^3", "content^2", "author^2", "category", "tags")) + .type(TextQueryType.BestFields) + ) + ) + .highlight(h -> + h + .preTags("") + .postTags("") + .fields("content", f -> f.fragmentSize(highlightFragmentSize()).numberOfFragments(1)) + .fields("title", f -> f.fragmentSize(highlightFragmentSize()).numberOfFragments(1)) + ) + .size(DEFAULT_OPEN_SEARCH_LIMIT), + SearchDocument.class + ); + return mapHits(response.hits().hits(), trimmed); + } + + private int highlightFragmentSize() { + int configured = openSearchProperties.getHighlightFragmentSize(); + if (configured > 0) { + return configured; + } + if (snippetLength > 0) { + return snippetLength; + } + return 200; + } + + private List searchIndices() { + return List.of( + openSearchProperties.postsIndex(), + openSearchProperties.commentsIndex(), + openSearchProperties.usersIndex(), + openSearchProperties.categoriesIndex(), + openSearchProperties.tagsIndex() + ); + } + + private List mapHits(List> hits, String keyword) { + List results = new ArrayList<>(); + for (Hit hit : hits) { + SearchResult result = mapHit(hit, keyword); + if (result != null) { + results.add(result); + } + } + return results; + } + + private SearchResult mapHit(Hit hit, String keyword) { + SearchDocument document = hit.source(); + if (document == null || document.entityId() == null) { + return null; + } + Map> highlight = hit.highlight(); + String highlightedContent = firstHighlight(highlight, "content"); + String highlightedTitle = firstHighlight(highlight, "title"); + boolean highlightTitle = highlightedTitle != null && !highlightedTitle.isBlank(); + String documentType = document.type() != null ? document.type() : ""; + String effectiveType = documentType; + if ("post".equals(documentType) && highlightTitle) { + effectiveType = "post_title"; + } + String snippet = highlightedContent != null && !highlightedContent.isBlank() + ? cleanHighlight(highlightedContent) + : null; + if (snippet == null && highlightTitle) { + snippet = cleanHighlight(highlightedTitle); + } + boolean fromStart = "post_title".equals(effectiveType); + if (snippet == null || snippet.isBlank()) { + snippet = fallbackSnippet(document.content(), keyword, fromStart); + } + if (snippet == null) { + snippet = ""; + } + String subText = null; + Long postId = null; + if ("post".equals(documentType) || "post_title".equals(effectiveType)) { + subText = document.category(); + } else if ("comment".equals(documentType)) { + subText = document.author(); + postId = document.postId(); + } + return new SearchResult( + effectiveType, + document.entityId(), + document.title(), + subText, + snippet, + postId + ); + } + + private String firstHighlight(Map> highlight, String field) { + if (highlight == null || field == null) { + return null; + } + List values = highlight.get(field); + if (values == null || values.isEmpty()) { + return null; + } + return values.get(0); + } + + private String cleanHighlight(String value) { + if (value == null) { + return null; + } + return value.replaceAll("<[^>]+>", ""); + } + + private String fallbackSnippet(String content, String keyword, boolean fromStart) { + if (content == null) { + return ""; + } + return extractSnippet(content, keyword, fromStart); + } + private String extractSnippet(String content, String keyword, boolean fromStart) { if (content == null) return ""; int limit = snippetLength; diff --git a/backend/src/main/java/com/openisle/service/TagService.java b/backend/src/main/java/com/openisle/service/TagService.java index 0000f9d84..f7010a64b 100644 --- a/backend/src/main/java/com/openisle/service/TagService.java +++ b/backend/src/main/java/com/openisle/service/TagService.java @@ -5,6 +5,7 @@ import com.openisle.model.Tag; import com.openisle.model.User; import com.openisle.repository.TagRepository; import com.openisle.repository.UserRepository; +import com.openisle.search.SearchIndexEventPublisher; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.cache.annotation.CacheEvict; @@ -20,6 +21,7 @@ public class TagService { private final TagRepository tagRepository; private final TagValidator tagValidator; private final UserRepository userRepository; + private final SearchIndexEventPublisher searchIndexEventPublisher; @CacheEvict(value = CachingConfig.TAG_CACHE_NAME, allEntries = true) public Tag createTag( @@ -43,7 +45,9 @@ public class TagService { .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); tag.setCreator(creator); } - return tagRepository.save(tag); + Tag saved = tagRepository.save(tag); + searchIndexEventPublisher.publishTagSaved(saved); + return saved; } public Tag createTag( @@ -78,12 +82,15 @@ public class TagService { if (smallIcon != null) { tag.setSmallIcon(smallIcon); } - return tagRepository.save(tag); + Tag saved = tagRepository.save(tag); + searchIndexEventPublisher.publishTagSaved(saved); + return saved; } @CacheEvict(value = CachingConfig.TAG_CACHE_NAME, allEntries = true) public void deleteTag(Long id) { tagRepository.deleteById(id); + searchIndexEventPublisher.publishTagDeleted(id); } public Tag approveTag(Long id) { @@ -91,7 +98,9 @@ public class TagService { .findById(id) .orElseThrow(() -> new IllegalArgumentException("Tag not found")); tag.setApproved(true); - return tagRepository.save(tag); + Tag saved = tagRepository.save(tag); + searchIndexEventPublisher.publishTagSaved(saved); + return saved; } public List listPendingTags() { diff --git a/backend/src/main/java/com/openisle/service/UserService.java b/backend/src/main/java/com/openisle/service/UserService.java index d76d986dc..b41b8fd46 100644 --- a/backend/src/main/java/com/openisle/service/UserService.java +++ b/backend/src/main/java/com/openisle/service/UserService.java @@ -5,6 +5,7 @@ import com.openisle.exception.FieldException; import com.openisle.model.Role; import com.openisle.model.User; import com.openisle.repository.UserRepository; +import com.openisle.search.SearchIndexEventPublisher; import com.openisle.service.AvatarGenerator; import com.openisle.service.PasswordValidator; import com.openisle.service.UsernameValidator; @@ -34,6 +35,7 @@ public class UserService { private final RedisTemplate redisTemplate; private final EmailSender emailService; + private final SearchIndexEventPublisher searchIndexEventPublisher; public User register( String username, @@ -58,7 +60,9 @@ public class UserService { // u.setVerificationCode(genCode()); u.setRegisterReason(reason); u.setApproved(mode == com.openisle.model.RegisterMode.DIRECT); - return userRepository.save(u); + User saved = userRepository.save(u); + searchIndexEventPublisher.publishUserSaved(saved); + return saved; } // ── 再按邮箱查 ─────────────────────────────────────────── @@ -75,7 +79,9 @@ public class UserService { // u.setVerificationCode(genCode()); u.setRegisterReason(reason); u.setApproved(mode == com.openisle.model.RegisterMode.DIRECT); - return userRepository.save(u); + User saved = userRepository.save(u); + searchIndexEventPublisher.publishUserSaved(saved); + return saved; } // ── 完全新用户 ─────────────────────────────────────────── @@ -89,14 +95,18 @@ public class UserService { user.setAvatar(avatarGenerator.generate(username)); user.setRegisterReason(reason); user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT); - return userRepository.save(user); + User saved = userRepository.save(user); + searchIndexEventPublisher.publishUserSaved(saved); + return saved; } public User registerWithInvite(String username, String email, String password) { User user = register(username, email, password, "", com.openisle.model.RegisterMode.DIRECT); user.setVerified(true); // user.setVerificationCode(genCode()); - return userRepository.save(user); + User saved = userRepository.save(user); + searchIndexEventPublisher.publishUserSaved(saved); + return saved; } private String genCode() { @@ -209,7 +219,9 @@ public class UserService { .findByUsername(username) .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); user.setRegisterReason(reason); - return userRepository.save(user); + User saved = userRepository.save(user); + searchIndexEventPublisher.publishUserSaved(saved); + return saved; } public User updateProfile(String currentUsername, String newUsername, String introduction) { diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 00ccc0302..44ebbfbfe 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -45,6 +45,16 @@ app.user.replies-limit=${USER_REPLIES_LIMIT:50} # Length of extracted snippets for posts and search (-1 to disable truncation) app.snippet-length=${SNIPPET_LENGTH:200} +# OpenSearch integration +app.search.enabled=${SEARCH_ENABLED:true} +app.search.host=${SEARCH_HOST:localhost} +app.search.port=${SEARCH_PORT:9200} +app.search.scheme=${SEARCH_SCHEME:http} +app.search.username=${SEARCH_USERNAME:} +app.search.password=${SEARCH_PASSWORD:} +app.search.index-prefix=${SEARCH_INDEX_PREFIX:openisle} +app.search.highlight-fragment-size=${SEARCH_HIGHLIGHT_FRAGMENT_SIZE:${SNIPPET_LENGTH:200}} + # Captcha configuration app.captcha.enabled=${CAPTCHA_ENABLED:false} recaptcha.secret-key=${RECAPTCHA_SECRET_KEY:} diff --git a/backend/src/test/java/com/openisle/service/CommentServiceTest.java b/backend/src/test/java/com/openisle/service/CommentServiceTest.java index e52bdb7be..b40a96003 100644 --- a/backend/src/test/java/com/openisle/service/CommentServiceTest.java +++ b/backend/src/test/java/com/openisle/service/CommentServiceTest.java @@ -11,6 +11,7 @@ import com.openisle.repository.PointHistoryRepository; import com.openisle.repository.PostRepository; import com.openisle.repository.ReactionRepository; import com.openisle.repository.UserRepository; +import com.openisle.search.SearchIndexEventPublisher; import com.openisle.service.PointService; import org.junit.jupiter.api.Test; @@ -29,6 +30,7 @@ class CommentServiceTest { PointHistoryRepository pointHistoryRepo = mock(PointHistoryRepository.class); PointService pointService = mock(PointService.class); ImageUploader imageUploader = mock(ImageUploader.class); + SearchIndexEventPublisher searchIndexEventPublisher = mock(SearchIndexEventPublisher.class); CommentService service = new CommentService( commentRepo, @@ -41,7 +43,8 @@ class CommentServiceTest { nRepo, pointHistoryRepo, pointService, - imageUploader + imageUploader, + searchIndexEventPublisher ); when(commentRepo.countByAuthorAfter(eq("alice"), any())).thenReturn(3L); diff --git a/backend/src/test/java/com/openisle/service/PostServiceTest.java b/backend/src/test/java/com/openisle/service/PostServiceTest.java index 6d9a220b6..e57e8579b 100644 --- a/backend/src/test/java/com/openisle/service/PostServiceTest.java +++ b/backend/src/test/java/com/openisle/service/PostServiceTest.java @@ -6,6 +6,7 @@ import static org.mockito.Mockito.*; import com.openisle.exception.RateLimitException; import com.openisle.model.*; import com.openisle.repository.*; +import com.openisle.search.SearchIndexEventPublisher; import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -42,6 +43,7 @@ class PostServiceTest { PostChangeLogService postChangeLogService = mock(PostChangeLogService.class); PointHistoryRepository pointHistoryRepository = mock(PointHistoryRepository.class); RedisTemplate redisTemplate = mock(RedisTemplate.class); + SearchIndexEventPublisher searchIndexEventPublisher = mock(SearchIndexEventPublisher.class); PostService service = new PostService( postRepo, @@ -67,7 +69,8 @@ class PostServiceTest { postChangeLogService, pointHistoryRepository, PublishMode.DIRECT, - redisTemplate + redisTemplate, + searchIndexEventPublisher ); when(context.getBean(PostService.class)).thenReturn(service); @@ -118,6 +121,7 @@ class PostServiceTest { PostChangeLogService postChangeLogService = mock(PostChangeLogService.class); PointHistoryRepository pointHistoryRepository = mock(PointHistoryRepository.class); RedisTemplate redisTemplate = mock(RedisTemplate.class); + SearchIndexEventPublisher searchIndexEventPublisher = mock(SearchIndexEventPublisher.class); PostService service = new PostService( postRepo, @@ -143,7 +147,8 @@ class PostServiceTest { postChangeLogService, pointHistoryRepository, PublishMode.DIRECT, - redisTemplate + redisTemplate, + searchIndexEventPublisher ); when(context.getBean(PostService.class)).thenReturn(service); @@ -207,6 +212,7 @@ class PostServiceTest { PostChangeLogService postChangeLogService = mock(PostChangeLogService.class); PointHistoryRepository pointHistoryRepository = mock(PointHistoryRepository.class); RedisTemplate redisTemplate = mock(RedisTemplate.class); + SearchIndexEventPublisher searchIndexEventPublisher = mock(SearchIndexEventPublisher.class); PostService service = new PostService( postRepo, @@ -232,7 +238,8 @@ class PostServiceTest { postChangeLogService, pointHistoryRepository, PublishMode.DIRECT, - redisTemplate + redisTemplate, + searchIndexEventPublisher ); when(context.getBean(PostService.class)).thenReturn(service); @@ -283,6 +290,7 @@ class PostServiceTest { PostChangeLogService postChangeLogService = mock(PostChangeLogService.class); PointHistoryRepository pointHistoryRepository = mock(PointHistoryRepository.class); RedisTemplate redisTemplate = mock(RedisTemplate.class); + SearchIndexEventPublisher searchIndexEventPublisher = mock(SearchIndexEventPublisher.class); PostService service = new PostService( postRepo, @@ -308,7 +316,8 @@ class PostServiceTest { postChangeLogService, pointHistoryRepository, PublishMode.DIRECT, - redisTemplate + redisTemplate, + searchIndexEventPublisher ); when(context.getBean(PostService.class)).thenReturn(service); @@ -375,6 +384,7 @@ class PostServiceTest { PostChangeLogService postChangeLogService = mock(PostChangeLogService.class); PointHistoryRepository pointHistoryRepository = mock(PointHistoryRepository.class); RedisTemplate redisTemplate = mock(RedisTemplate.class); + SearchIndexEventPublisher searchIndexEventPublisher = mock(SearchIndexEventPublisher.class); PostService service = new PostService( postRepo, @@ -400,7 +410,8 @@ class PostServiceTest { postChangeLogService, pointHistoryRepository, PublishMode.DIRECT, - redisTemplate + redisTemplate, + searchIndexEventPublisher ); when(context.getBean(PostService.class)).thenReturn(service); diff --git a/backend/src/test/java/com/openisle/service/SearchServiceTest.java b/backend/src/test/java/com/openisle/service/SearchServiceTest.java index 59027b64d..3dbd5734a 100644 --- a/backend/src/test/java/com/openisle/service/SearchServiceTest.java +++ b/backend/src/test/java/com/openisle/service/SearchServiceTest.java @@ -9,7 +9,9 @@ import com.openisle.repository.CommentRepository; import com.openisle.repository.PostRepository; import com.openisle.repository.TagRepository; import com.openisle.repository.UserRepository; +import com.openisle.search.OpenSearchProperties; import java.util.List; +import java.util.Optional; import org.junit.jupiter.api.Test; import org.mockito.Mockito; @@ -27,7 +29,9 @@ class SearchServiceTest { postRepo, commentRepo, categoryRepo, - tagRepo + tagRepo, + Optional.empty(), + new OpenSearchProperties() ); Post post1 = new Post(); From 44addd2a7ba3408bd559fea11136e604c61a1f53 Mon Sep 17 00:00:00 2001 From: tim Date: Fri, 26 Sep 2025 18:03:25 +0800 Subject: [PATCH 04/15] =?UTF-8?q?fix:=20=E6=90=9C=E7=B4=A2main=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=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() { From 23cc2d1606620d7c65399bc28a61cf3fcc56e71f Mon Sep 17 00:00:00 2001 From: tim Date: Fri, 26 Sep 2025 18:19:29 +0800 Subject: [PATCH 05/15] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E8=B4=B4?= =?UTF-8?q?=E6=96=87reindex?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openisle/search/OpenSearchProperties.java | 2 + .../search/SearchReindexInitializer.java | 30 ++++++ .../openisle/search/SearchReindexService.java | 94 +++++++++++++++++++ .../src/main/resources/application.properties | 2 + 4 files changed, 128 insertions(+) create mode 100644 backend/src/main/java/com/openisle/search/SearchReindexInitializer.java create mode 100644 backend/src/main/java/com/openisle/search/SearchReindexService.java diff --git a/backend/src/main/java/com/openisle/search/OpenSearchProperties.java b/backend/src/main/java/com/openisle/search/OpenSearchProperties.java index f236abcb5..a334ad8d9 100644 --- a/backend/src/main/java/com/openisle/search/OpenSearchProperties.java +++ b/backend/src/main/java/com/openisle/search/OpenSearchProperties.java @@ -18,6 +18,8 @@ public class OpenSearchProperties { private String indexPrefix = "openisle"; private boolean initialize = true; private int highlightFragmentSize = 200; + private boolean reindexOnStartup = false; + private int reindexBatchSize = 500; private Indices indices = new Indices(); diff --git a/backend/src/main/java/com/openisle/search/SearchReindexInitializer.java b/backend/src/main/java/com/openisle/search/SearchReindexInitializer.java new file mode 100644 index 000000000..5eddf68f5 --- /dev/null +++ b/backend/src/main/java/com/openisle/search/SearchReindexInitializer.java @@ -0,0 +1,30 @@ +package com.openisle.search; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SearchReindexInitializer implements CommandLineRunner { + + private final OpenSearchProperties properties; + private final SearchReindexService searchReindexService; + + @Override + public void run(String... args) { + if (!properties.isEnabled()) { + log.info("Search indexing disabled, skipping startup reindex."); + return; + } + + if (!properties.isReindexOnStartup()) { + log.debug("Startup reindex disabled by configuration."); + return; + } + + searchReindexService.reindexAll(); + } +} diff --git a/backend/src/main/java/com/openisle/search/SearchReindexService.java b/backend/src/main/java/com/openisle/search/SearchReindexService.java new file mode 100644 index 000000000..1179eadd6 --- /dev/null +++ b/backend/src/main/java/com/openisle/search/SearchReindexService.java @@ -0,0 +1,94 @@ +package com.openisle.search; + +import com.openisle.model.Post; +import com.openisle.model.PostStatus; +import com.openisle.model.Tag; +import com.openisle.repository.CategoryRepository; +import com.openisle.repository.CommentRepository; +import com.openisle.repository.PostRepository; +import com.openisle.repository.TagRepository; +import com.openisle.repository.UserRepository; +import java.util.Objects; +import java.util.function.Function; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SearchReindexService { + + private final SearchIndexer searchIndexer; + private final OpenSearchProperties properties; + private final PostRepository postRepository; + private final CommentRepository commentRepository; + private final UserRepository userRepository; + private final CategoryRepository categoryRepository; + private final TagRepository tagRepository; + + @Transactional(readOnly = true) + public void reindexAll() { + if (!properties.isEnabled()) { + log.info("Search indexing is disabled, skipping reindex operation."); + return; + } + + log.info("Starting full search reindex operation."); + + reindex(properties.postsIndex(), postRepository::findAll, (Post post) -> + post.getStatus() == PostStatus.PUBLISHED ? SearchDocumentFactory.fromPost(post) : null + ); + + reindex( + properties.commentsIndex(), + commentRepository::findAll, + SearchDocumentFactory::fromComment + ); + + reindex(properties.usersIndex(), userRepository::findAll, SearchDocumentFactory::fromUser); + + reindex( + properties.categoriesIndex(), + categoryRepository::findAll, + SearchDocumentFactory::fromCategory + ); + + reindex(properties.tagsIndex(), tagRepository::findAll, (Tag tag) -> + tag.isApproved() ? SearchDocumentFactory.fromTag(tag) : null + ); + + log.info("Completed full search reindex operation."); + } + + private void reindex( + String index, + Function> pageSupplier, + Function mapper + ) { + int batchSize = Math.max(1, properties.getReindexBatchSize()); + int pageNumber = 0; + + Page page; + do { + Pageable pageable = PageRequest.of(pageNumber, batchSize); + page = pageSupplier.apply(pageable); + if (page.isEmpty() && pageNumber == 0) { + log.info("No entities found for index {}.", index); + } + + log.info("Reindexing {} entities for index {}.", page.getTotalElements(), index); + for (T entity : page) { + SearchDocument document = mapper.apply(entity); + if (Objects.nonNull(document)) { + searchIndexer.indexDocument(index, document); + } + } + pageNumber++; + } while (page.hasNext()); + } +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 44ebbfbfe..479fc683c 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -54,6 +54,8 @@ app.search.username=${SEARCH_USERNAME:} app.search.password=${SEARCH_PASSWORD:} app.search.index-prefix=${SEARCH_INDEX_PREFIX:openisle} app.search.highlight-fragment-size=${SEARCH_HIGHLIGHT_FRAGMENT_SIZE:${SNIPPET_LENGTH:200}} +app.search.reindex-on-startup=${SEARCH_REINDEX_ON_STARTUP:true} +app.search.reindex-batch-size=${SEARCH_REINDEX_BATCH_SIZE:500} # Captcha configuration app.captcha.enabled=${CAPTCHA_ENABLED:false} From 892aa6a7c6435fd4b79b9d9b1e08fe1888622106 Mon Sep 17 00:00:00 2001 From: smallclover <18363998103@163.com> Date: Sat, 27 Sep 2025 08:59:11 +0900 Subject: [PATCH 06/15] =?UTF-8?q?UI=E8=B0=83=E6=95=B4=20https://github.com?= =?UTF-8?q?/nagisa77/OpenIsle/issues/855?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend_nuxt/components/Dropdown.vue | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend_nuxt/components/Dropdown.vue b/frontend_nuxt/components/Dropdown.vue index 147a0d5fe..0486041b8 100644 --- a/frontend_nuxt/components/Dropdown.vue +++ b/frontend_nuxt/components/Dropdown.vue @@ -297,6 +297,7 @@ export default { border: 1px solid var(--normal-border-color); border-radius: 5px; padding: 5px 10px; + margin-bottom: 4px; cursor: pointer; display: flex; justify-content: space-between; @@ -315,8 +316,9 @@ export default { right: 0; background: var(--background-color); border: 1px solid var(--normal-border-color); + border-radius: 5px; z-index: 10000; - max-height: 200px; + max-height: 300px; min-width: 350px; overflow-y: auto; } From 8869121bcb0d1a9ea686b724e0d810e609b2e4cf Mon Sep 17 00:00:00 2001 From: Tim Date: Sun, 28 Sep 2025 14:28:45 +0800 Subject: [PATCH 07/15] fix: add pinyin --- .../search/SearchIndexInitializer.java | 86 +++++++++++++++---- .../com/openisle/service/SearchService.java | 38 +++++++- 2 files changed, 105 insertions(+), 19 deletions(-) diff --git a/backend/src/main/java/com/openisle/search/SearchIndexInitializer.java b/backend/src/main/java/com/openisle/search/SearchIndexInitializer.java index 23f30e408..4a069a9b0 100644 --- a/backend/src/main/java/com/openisle/search/SearchIndexInitializer.java +++ b/backend/src/main/java/com/openisle/search/SearchIndexInitializer.java @@ -2,11 +2,16 @@ package com.openisle.search; import jakarta.annotation.PostConstruct; import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.opensearch.client.json.JsonData; import org.opensearch.client.opensearch.OpenSearchClient; import org.opensearch.client.opensearch._types.mapping.Property; import org.opensearch.client.opensearch._types.mapping.TypeMapping; +import org.opensearch.client.opensearch.indices.IndexSettings; @Slf4j @RequiredArgsConstructor @@ -36,7 +41,11 @@ public class SearchIndexInitializer { if (exists) { return; } - client.indices().create(builder -> builder.index(index).mappings(mappingSupplier.get())); + client + .indices() + .create(builder -> + builder.index(index).settings(this::applyPinyinAnalysis).mappings(mappingSupplier.get()) + ); log.info("Created OpenSearch index {}", index); } catch (IOException e) { log.warn("Failed to initialize OpenSearch index {}", index, e); @@ -47,11 +56,11 @@ public class SearchIndexInitializer { return TypeMapping.of(builder -> builder .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("author", Property.of(p -> p.keyword(k -> k))) - .properties("category", Property.of(p -> p.keyword(k -> k))) - .properties("tags", Property.of(p -> p.keyword(k -> k))) + .properties("title", textWithPinyin()) + .properties("content", textWithPinyin()) + .properties("author", keywordWithPinyin()) + .properties("category", keywordWithPinyin()) + .properties("tags", keywordWithPinyin()) .properties("postId", Property.of(p -> p.long_(l -> l))) .properties( "createdAt", @@ -64,11 +73,11 @@ public class SearchIndexInitializer { return TypeMapping.of(builder -> builder .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("author", Property.of(p -> p.keyword(k -> k))) - .properties("category", Property.of(p -> p.keyword(k -> k))) - .properties("tags", Property.of(p -> p.keyword(k -> k))) + .properties("title", textWithPinyin()) + .properties("content", textWithPinyin()) + .properties("author", keywordWithPinyin()) + .properties("category", keywordWithPinyin()) + .properties("tags", keywordWithPinyin()) .properties("postId", Property.of(p -> p.long_(l -> l))) .properties( "createdAt", @@ -81,8 +90,8 @@ public class SearchIndexInitializer { return TypeMapping.of(builder -> builder .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("title", textWithPinyin()) + .properties("content", textWithPinyin()) .properties( "createdAt", Property.of(p -> p.date(d -> d.format("strict_date_optional_time||epoch_millis"))) @@ -94,8 +103,8 @@ public class SearchIndexInitializer { return TypeMapping.of(builder -> builder .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("title", textWithPinyin()) + .properties("content", textWithPinyin()) ); } @@ -103,12 +112,55 @@ public class SearchIndexInitializer { return TypeMapping.of(builder -> builder .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("title", textWithPinyin()) + .properties("content", textWithPinyin()) .properties( "createdAt", Property.of(p -> p.date(d -> d.format("strict_date_optional_time||epoch_millis"))) ) ); } + + private Property textWithPinyin() { + return Property.of(p -> + p.text(t -> + t.fields("py", field -> + field.text(sub -> sub.analyzer("py_index").searchAnalyzer("py_search")) + ) + ) + ); + } + + private Property keywordWithPinyin() { + return Property.of(p -> + p.keyword(k -> + k.fields("py", field -> + field.text(sub -> sub.analyzer("py_index").searchAnalyzer("py_search")) + ) + ) + ); + } + + private IndexSettings.Builder applyPinyinAnalysis(IndexSettings.Builder builder) { + Map settings = new LinkedHashMap<>(); + settings.put("analysis.filter.py_filter.type", JsonData.of("pinyin")); + settings.put("analysis.filter.py_filter.keep_full_pinyin", JsonData.of(true)); + settings.put("analysis.filter.py_filter.keep_joined_full_pinyin", JsonData.of(true)); + settings.put("analysis.filter.py_filter.keep_first_letter", JsonData.of(true)); + settings.put("analysis.filter.py_filter.remove_duplicated_term", JsonData.of(true)); + settings.put("analysis.analyzer.py_index.type", JsonData.of("custom")); + settings.put("analysis.analyzer.py_index.tokenizer", JsonData.of("standard")); + settings.put( + "analysis.analyzer.py_index.filter", + JsonData.of(List.of("lowercase", "py_filter")) + ); + settings.put("analysis.analyzer.py_search.type", JsonData.of("custom")); + settings.put("analysis.analyzer.py_search.tokenizer", JsonData.of("standard")); + settings.put( + "analysis.analyzer.py_search.filter", + JsonData.of(List.of("lowercase", "py_filter")) + ); + settings.forEach(builder::customSettings); + return builder; + } } diff --git a/backend/src/main/java/com/openisle/service/SearchService.java b/backend/src/main/java/com/openisle/service/SearchService.java index 417100895..8e1ead2c5 100644 --- a/backend/src/main/java/com/openisle/service/SearchService.java +++ b/backend/src/main/java/com/openisle/service/SearchService.java @@ -197,7 +197,7 @@ public class SearchService { s.multiMatch(mm -> mm .query(qRaw) - .fields("title^3", "content^2") + .fields("title^3", "title.py^3", "content^2", "content.py^2") .type(TextQueryType.BestFields) .fuzziness("AUTO") .minimumShouldMatch("70%") @@ -210,7 +210,17 @@ public class SearchService { bool.should(s -> s.queryString(qs -> qs - .query("(title:" + qsEscaped + "* OR content:" + qsEscaped + "*)") + .query( + "(title:" + + qsEscaped + + "* OR title.py:" + + qsEscaped + + "* OR content:" + + qsEscaped + + "* OR content.py:" + + qsEscaped + + "*)" + ) .analyzeWildcard(true) ) ); @@ -226,6 +236,30 @@ public class SearchService { .boost(2.0f) ) ); + bool.should(s -> + s.match(m -> + m + .field("author.py") + .query(v -> v.stringValue(qRaw)) + .boost(2.0f) + ) + ); + bool.should(s -> + s.match(m -> + m + .field("category.py") + .query(v -> v.stringValue(qRaw)) + .boost(1.2f) + ) + ); + bool.should(s -> + s.match(m -> + m + .field("tags.py") + .query(v -> v.stringValue(qRaw)) + .boost(1.2f) + ) + ); if (enableWildcard) { // prefix/wildcard 这里的 value 是 String,直接传即可 From 2977d2898f16f09ce01ef459fbb19c842e42bca0 Mon Sep 17 00:00:00 2001 From: Tim Date: Sun, 28 Sep 2025 14:55:56 +0800 Subject: [PATCH 08/15] =?UTF-8?q?fix:=20=E5=90=8E=E7=AB=AFhighlight?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openisle/controller/SearchController.java | 3 + .../com/openisle/dto/SearchResultDto.java | 3 + .../com/openisle/service/SearchService.java | 157 +++++++++++++++--- .../controller/SearchControllerTest.java | 6 +- frontend_nuxt/components/SearchDropdown.vue | 45 +++-- .../components/SearchPersonDropdown.vue | 33 +++- 6 files changed, 199 insertions(+), 48 deletions(-) diff --git a/backend/src/main/java/com/openisle/controller/SearchController.java b/backend/src/main/java/com/openisle/controller/SearchController.java index 1757e0fe7..5e1bac0bc 100644 --- a/backend/src/main/java/com/openisle/controller/SearchController.java +++ b/backend/src/main/java/com/openisle/controller/SearchController.java @@ -115,6 +115,9 @@ public class SearchController { dto.setSubText(r.subText()); dto.setExtra(r.extra()); dto.setPostId(r.postId()); + dto.setHighlightedText(r.highlightedText()); + dto.setHighlightedSubText(r.highlightedSubText()); + dto.setHighlightedExtra(r.highlightedExtra()); return dto; }) .collect(Collectors.toList()); diff --git a/backend/src/main/java/com/openisle/dto/SearchResultDto.java b/backend/src/main/java/com/openisle/dto/SearchResultDto.java index 59b7b0c72..faf09437e 100644 --- a/backend/src/main/java/com/openisle/dto/SearchResultDto.java +++ b/backend/src/main/java/com/openisle/dto/SearchResultDto.java @@ -12,4 +12,7 @@ public class SearchResultDto { private String subText; private String extra; private Long postId; + private String highlightedText; + private String highlightedSubText; + private String highlightedExtra; } diff --git a/backend/src/main/java/com/openisle/service/SearchService.java b/backend/src/main/java/com/openisle/service/SearchService.java index 8e1ead2c5..cc3372c64 100644 --- a/backend/src/main/java/com/openisle/service/SearchService.java +++ b/backend/src/main/java/com/openisle/service/SearchService.java @@ -19,6 +19,8 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.stream.Stream; import lombok.RequiredArgsConstructor; @@ -28,6 +30,7 @@ import org.opensearch.client.opensearch._types.query_dsl.TextQueryType; import org.opensearch.client.opensearch.core.SearchResponse; import org.opensearch.client.opensearch.core.search.Hit; import org.springframework.stereotype.Service; +import org.springframework.web.util.HtmlUtils; @Service @Slf4j @@ -97,49 +100,96 @@ public class SearchService { } private List fallbackGlobalSearch(String keyword) { + final String effectiveKeyword = keyword == null ? "" : keyword.trim(); Stream users = searchUsers(keyword) .stream() .map(u -> - new SearchResult("user", u.getId(), u.getUsername(), u.getIntroduction(), null, null) + new SearchResult( + "user", + u.getId(), + u.getUsername(), + u.getIntroduction(), + null, + null, + highlightHtml(u.getUsername(), effectiveKeyword), + highlightHtml(u.getIntroduction(), effectiveKeyword), + null + ) ); Stream categories = searchCategories(keyword) .stream() .map(c -> - new SearchResult("category", c.getId(), c.getName(), null, c.getDescription(), null) + new SearchResult( + "category", + c.getId(), + c.getName(), + null, + c.getDescription(), + null, + highlightHtml(c.getName(), effectiveKeyword), + null, + highlightHtml(c.getDescription(), effectiveKeyword) + ) ); Stream tags = searchTags(keyword) .stream() - .map(t -> new SearchResult("tag", t.getId(), t.getName(), null, t.getDescription(), null)); + .map(t -> + new SearchResult( + "tag", + t.getId(), + t.getName(), + null, + t.getDescription(), + null, + highlightHtml(t.getName(), effectiveKeyword), + null, + highlightHtml(t.getDescription(), effectiveKeyword) + ) + ); // Merge post results while removing duplicates between search by content // and search by title List mergedPosts = Stream.concat( searchPosts(keyword) .stream() - .map(p -> - new SearchResult( + .map(p -> { + String snippet = extractSnippet(p.getContent(), keyword, false); + return new SearchResult( "post", p.getId(), p.getTitle(), p.getCategory() != null ? p.getCategory().getName() : null, - extractSnippet(p.getContent(), keyword, false), - null - ) - ), + snippet, + null, + highlightHtml(p.getTitle(), effectiveKeyword), + highlightHtml( + p.getCategory() != null ? p.getCategory().getName() : null, + effectiveKeyword + ), + highlightHtml(snippet, effectiveKeyword) + ); + }), searchPostsByTitle(keyword) .stream() - .map(p -> - new SearchResult( + .map(p -> { + String snippet = extractSnippet(p.getContent(), keyword, true); + return new SearchResult( "post_title", p.getId(), p.getTitle(), p.getCategory() != null ? p.getCategory().getName() : null, - extractSnippet(p.getContent(), keyword, true), - null - ) - ) + snippet, + null, + highlightHtml(p.getTitle(), effectiveKeyword), + highlightHtml( + p.getCategory() != null ? p.getCategory().getName() : null, + effectiveKeyword + ), + highlightHtml(snippet, effectiveKeyword) + ); + }) ) .collect( java.util.stream.Collectors.toMap( @@ -155,16 +205,20 @@ public class SearchService { Stream comments = searchComments(keyword) .stream() - .map(c -> - new SearchResult( + .map(c -> { + String snippet = extractSnippet(c.getContent(), keyword, false); + return new SearchResult( "comment", c.getId(), c.getPost().getTitle(), c.getAuthor().getUsername(), - extractSnippet(c.getContent(), keyword, false), - c.getPost().getId() - ) - ); + snippet, + c.getPost().getId(), + highlightHtml(c.getPost().getTitle(), effectiveKeyword), + highlightHtml(c.getAuthor().getUsername(), effectiveKeyword), + highlightHtml(snippet, effectiveKeyword) + ); + }); return Stream.of(users, categories, tags, mergedPosts.stream(), comments) .flatMap(s -> s) @@ -379,15 +433,23 @@ public class SearchService { if ("post".equals(documentType) && highlightTitle) { effectiveType = "post_title"; } - String snippet = highlightedContent != null && !highlightedContent.isBlank() - ? cleanHighlight(highlightedContent) + String snippetHtml = highlightedContent != null && !highlightedContent.isBlank() + ? highlightedContent : null; - if (snippet == null && highlightTitle) { - snippet = cleanHighlight(highlightedTitle); + if (snippetHtml == null && highlightTitle) { + snippetHtml = highlightedTitle; } + String snippet = snippetHtml != null && !snippetHtml.isBlank() + ? cleanHighlight(snippetHtml) + : null; boolean fromStart = "post_title".equals(effectiveType); if (snippet == null || snippet.isBlank()) { snippet = fallbackSnippet(document.content(), keyword, fromStart); + if (snippetHtml == null) { + snippetHtml = highlightHtml(snippet, keyword); + } + } else if (snippetHtml == null) { + snippetHtml = highlightHtml(snippet, keyword); } if (snippet == null) { snippet = ""; @@ -400,13 +462,21 @@ public class SearchService { subText = document.author(); postId = document.postId(); } + String highlightedText = highlightTitle + ? highlightedTitle + : highlightHtml(document.title(), keyword); + String highlightedSubText = highlightHtml(subText, keyword); + String highlightedExtra = snippetHtml != null ? snippetHtml : highlightHtml(snippet, keyword); return new SearchResult( effectiveType, document.entityId(), document.title(), subText, snippet, - postId + postId, + highlightedText, + highlightedSubText, + highlightedExtra ); } @@ -462,12 +532,45 @@ public class SearchService { return snippet; } + private String highlightHtml(String text, String keyword) { + if (text == null) { + return null; + } + String normalizedKeyword = keyword == null ? "" : keyword.trim(); + if (normalizedKeyword.isEmpty()) { + return HtmlUtils.htmlEscape(text); + } + Pattern pattern = Pattern.compile( + Pattern.quote(normalizedKeyword), + Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE + ); + Matcher matcher = pattern.matcher(text); + if (!matcher.find()) { + return HtmlUtils.htmlEscape(text); + } + matcher.reset(); + StringBuilder sb = new StringBuilder(); + int lastEnd = 0; + while (matcher.find()) { + sb.append(HtmlUtils.htmlEscape(text.substring(lastEnd, matcher.start()))); + sb.append(""); + sb.append(HtmlUtils.htmlEscape(matcher.group())); + sb.append(""); + lastEnd = matcher.end(); + } + sb.append(HtmlUtils.htmlEscape(text.substring(lastEnd))); + return sb.toString(); + } + public record SearchResult( String type, Long id, String text, String subText, String extra, - Long postId + Long postId, + String highlightedText, + String highlightedSubText, + String highlightedExtra ) {} } diff --git a/backend/src/test/java/com/openisle/controller/SearchControllerTest.java b/backend/src/test/java/com/openisle/controller/SearchControllerTest.java index 58313baa4..add7e5400 100644 --- a/backend/src/test/java/com/openisle/controller/SearchControllerTest.java +++ b/backend/src/test/java/com/openisle/controller/SearchControllerTest.java @@ -68,9 +68,9 @@ class SearchControllerTest { c.setContent("nice"); Mockito.when(searchService.globalSearch("n")).thenReturn( List.of( - new SearchService.SearchResult("user", 1L, "bob", null, null, null), - new SearchService.SearchResult("post", 2L, "hello", null, null, null), - new SearchService.SearchResult("comment", 3L, "nice", null, null, null) + new SearchService.SearchResult("user", 1L, "bob", null, null, null, null, null, null), + new SearchService.SearchResult("post", 2L, "hello", null, null, null, null, null, null), + new SearchService.SearchResult("comment", 3L, "nice", null, null, null, null, null, null) ) ); diff --git a/frontend_nuxt/components/SearchDropdown.vue b/frontend_nuxt/components/SearchDropdown.vue index b5ea366e9..e8fde21d6 100644 --- a/frontend_nuxt/components/SearchDropdown.vue +++ b/frontend_nuxt/components/SearchDropdown.vue @@ -26,9 +26,20 @@
-
-
-
+
+
+
@@ -70,16 +81,30 @@ const fetchResults = async (kw) => { subText: r.subText, extra: r.extra, postId: r.postId, + highlightedText: r.highlightedText, + highlightedSubText: r.highlightedSubText, + highlightedExtra: r.highlightedExtra, })) return results.value } -const highlight = (text) => { - text = stripMarkdown(text) - if (!keyword.value) return text - const reg = new RegExp(keyword.value, 'gi') - const res = text.replace(reg, (m) => `${m}`) - return res +const escapeHtml = (value = '') => + String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + +const renderHighlight = (highlighted, fallback) => { + if (highlighted) { + return highlighted + } + const plain = stripMarkdown(fallback || '') + if (!plain) { + return '' + } + return escapeHtml(plain) } const iconMap = { @@ -168,7 +193,7 @@ defineExpose({ padding: 10px 20px; } -:deep(.highlight) { +:deep(mark) { color: var(--primary-color); } diff --git a/frontend_nuxt/components/SearchPersonDropdown.vue b/frontend_nuxt/components/SearchPersonDropdown.vue index 78b3acfd2..1bba727d4 100644 --- a/frontend_nuxt/components/SearchPersonDropdown.vue +++ b/frontend_nuxt/components/SearchPersonDropdown.vue @@ -32,11 +32,14 @@ :disable-link="true" />
-
+
@@ -79,15 +82,29 @@ const fetchResults = async (kw) => { username: u.username, avatar: u.avatar, introduction: u.introduction, + highlightedUsername: u.highlightedText, + highlightedIntroduction: u.highlightedSubText || u.highlightedExtra, })) return results.value } -const highlight = (text) => { - text = stripMarkdown(text || '') - if (!keyword.value) return text - const reg = new RegExp(keyword.value, 'gi') - return text.replace(reg, (m) => `${m}`) +const escapeHtml = (value = '') => + String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + +const renderHighlight = (highlighted, fallback) => { + if (highlighted) { + return highlighted + } + const plain = stripMarkdown(fallback || '') + if (!plain) { + return '' + } + return escapeHtml(plain) } watch(selected, async (val) => { @@ -170,7 +187,7 @@ defineExpose({ padding: 10px 20px; } -:deep(.highlight) { +:deep(mark) { color: var(--primary-color); } From ffebeb46b7ed1e1f1760de6904c27c98942a3317 Mon Sep 17 00:00:00 2001 From: Tim Date: Sun, 28 Sep 2025 15:08:20 +0800 Subject: [PATCH 09/15] =?UTF-8?q?fix:=20=E6=96=B0=E5=A2=9E=E6=8B=BC?= =?UTF-8?q?=E9=9F=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/openisle/service/SearchService.java | 51 +++++++++++++++---- 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/backend/src/main/java/com/openisle/service/SearchService.java b/backend/src/main/java/com/openisle/service/SearchService.java index cc3372c64..61e845a32 100644 --- a/backend/src/main/java/com/openisle/service/SearchService.java +++ b/backend/src/main/java/com/openisle/service/SearchService.java @@ -343,7 +343,17 @@ public class SearchService { .preTags("") .postTags("") .fields("title", f -> f.fragmentSize(highlightFragmentSize()).numberOfFragments(1)) + .fields("title.py", f -> f.fragmentSize(highlightFragmentSize()).numberOfFragments(1)) .fields("content", f -> f.fragmentSize(highlightFragmentSize()).numberOfFragments(1)) + .fields("content.py", f -> + f.fragmentSize(highlightFragmentSize()).numberOfFragments(1) + ) + .fields("author", f -> f.numberOfFragments(0)) + .fields("author.py", f -> f.numberOfFragments(0)) + .fields("category", f -> f.numberOfFragments(0)) + .fields("category.py", f -> f.numberOfFragments(0)) + .fields("tags", f -> f.numberOfFragments(0)) + .fields("tags.py", f -> f.numberOfFragments(0)) ) .size(DEFAULT_OPEN_SEARCH_LIMIT > 0 ? DEFAULT_OPEN_SEARCH_LIMIT : 10), SearchDocument.class @@ -425,8 +435,10 @@ public class SearchService { return null; } Map> highlight = hit.highlight(); - String highlightedContent = firstHighlight(highlight, "content"); - String highlightedTitle = firstHighlight(highlight, "title"); + String highlightedContent = firstHighlight(highlight, "content", "content.py"); + String highlightedTitle = firstHighlight(highlight, "title", "title.py"); + String highlightedAuthor = firstHighlight(highlight, "author", "author.py"); + String highlightedCategory = firstHighlight(highlight, "category", "category.py"); boolean highlightTitle = highlightedTitle != null && !highlightedTitle.isBlank(); String documentType = document.type() != null ? document.type() : ""; String effectiveType = documentType; @@ -465,7 +477,18 @@ public class SearchService { String highlightedText = highlightTitle ? highlightedTitle : highlightHtml(document.title(), keyword); - String highlightedSubText = highlightHtml(subText, keyword); + String highlightedSubText; + if ("comment".equals(documentType)) { + highlightedSubText = highlightedAuthor != null && !highlightedAuthor.isBlank() + ? highlightedAuthor + : highlightHtml(subText, keyword); + } else if ("post".equals(documentType) || "post_title".equals(effectiveType)) { + highlightedSubText = highlightedCategory != null && !highlightedCategory.isBlank() + ? highlightedCategory + : highlightHtml(subText, keyword); + } else { + highlightedSubText = highlightHtml(subText, keyword); + } String highlightedExtra = snippetHtml != null ? snippetHtml : highlightHtml(snippet, keyword); return new SearchResult( effectiveType, @@ -480,15 +503,25 @@ public class SearchService { ); } - private String firstHighlight(Map> highlight, String field) { - if (highlight == null || field == null) { + private String firstHighlight(Map> highlight, String... fields) { + if (highlight == null || fields == null) { return null; } - List values = highlight.get(field); - if (values == null || values.isEmpty()) { - return null; + for (String field : fields) { + if (field == null) { + continue; + } + List values = highlight.get(field); + if (values == null || values.isEmpty()) { + continue; + } + for (String value : values) { + if (value != null && !value.isBlank()) { + return value; + } + } } - return values.get(0); + return null; } private String cleanHighlight(String value) { From c8a1e6d8c8adb3d3c75aaa5248259c9dc1ede90a Mon Sep 17 00:00:00 2001 From: Tim Date: Sun, 28 Sep 2025 15:21:02 +0800 Subject: [PATCH 10/15] =?UTF-8?q?fix:=20=E7=A6=81=E7=94=A8=E9=A6=96?= =?UTF-8?q?=E5=AD=97=E6=AF=8D=E5=8C=B9=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/openisle/search/SearchIndexInitializer.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/main/java/com/openisle/search/SearchIndexInitializer.java b/backend/src/main/java/com/openisle/search/SearchIndexInitializer.java index 4a069a9b0..b61dad4df 100644 --- a/backend/src/main/java/com/openisle/search/SearchIndexInitializer.java +++ b/backend/src/main/java/com/openisle/search/SearchIndexInitializer.java @@ -146,7 +146,7 @@ public class SearchIndexInitializer { settings.put("analysis.filter.py_filter.type", JsonData.of("pinyin")); settings.put("analysis.filter.py_filter.keep_full_pinyin", JsonData.of(true)); settings.put("analysis.filter.py_filter.keep_joined_full_pinyin", JsonData.of(true)); - settings.put("analysis.filter.py_filter.keep_first_letter", JsonData.of(true)); + settings.put("analysis.filter.py_filter.keep_first_letter", JsonData.of(false)); settings.put("analysis.filter.py_filter.remove_duplicated_term", JsonData.of(true)); settings.put("analysis.analyzer.py_index.type", JsonData.of("custom")); settings.put("analysis.analyzer.py_index.tokenizer", JsonData.of("standard")); From e3f680ad0f7e5c94cb11048ede0405f69c35915f Mon Sep 17 00:00:00 2001 From: Tim Date: Sun, 28 Sep 2025 17:58:10 +0800 Subject: [PATCH 11/15] =?UTF-8?q?fix:=20=E7=B4=A2=E5=BC=95/=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E8=A7=84=E5=88=99=E5=BE=AE=E8=B0=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../search/SearchIndexInitializer.java | 105 ++++++++--- .../com/openisle/service/SearchService.java | 178 ++++++++++-------- 2 files changed, 184 insertions(+), 99 deletions(-) diff --git a/backend/src/main/java/com/openisle/search/SearchIndexInitializer.java b/backend/src/main/java/com/openisle/search/SearchIndexInitializer.java index b61dad4df..1deb90e2f 100644 --- a/backend/src/main/java/com/openisle/search/SearchIndexInitializer.java +++ b/backend/src/main/java/com/openisle/search/SearchIndexInitializer.java @@ -52,15 +52,17 @@ public class SearchIndexInitializer { } } + // SearchIndexInitializer.java —— 只贴需要替换/新增的方法 + private TypeMapping postMapping() { return TypeMapping.of(builder -> builder .properties("type", Property.of(p -> p.keyword(k -> k))) - .properties("title", textWithPinyin()) - .properties("content", textWithPinyin()) - .properties("author", keywordWithPinyin()) - .properties("category", keywordWithPinyin()) - .properties("tags", keywordWithPinyin()) + .properties("title", textWithRawAndPinyin()) + .properties("content", textWithPinyinOnly()) // content 不做 .raw,避免超长 keyword + .properties("author", keywordWithRawAndPinyin()) + .properties("category", keywordWithRawAndPinyin()) + .properties("tags", keywordWithRawAndPinyin()) .properties("postId", Property.of(p -> p.long_(l -> l))) .properties( "createdAt", @@ -73,11 +75,11 @@ public class SearchIndexInitializer { return TypeMapping.of(builder -> builder .properties("type", Property.of(p -> p.keyword(k -> k))) - .properties("title", textWithPinyin()) - .properties("content", textWithPinyin()) - .properties("author", keywordWithPinyin()) - .properties("category", keywordWithPinyin()) - .properties("tags", keywordWithPinyin()) + .properties("title", textWithRawAndPinyin()) + .properties("content", textWithPinyinOnly()) + .properties("author", keywordWithRawAndPinyin()) + .properties("category", keywordWithRawAndPinyin()) + .properties("tags", keywordWithRawAndPinyin()) .properties("postId", Property.of(p -> p.long_(l -> l))) .properties( "createdAt", @@ -90,8 +92,8 @@ public class SearchIndexInitializer { return TypeMapping.of(builder -> builder .properties("type", Property.of(p -> p.keyword(k -> k))) - .properties("title", textWithPinyin()) - .properties("content", textWithPinyin()) + .properties("title", textWithRawAndPinyin()) + .properties("content", textWithPinyinOnly()) .properties( "createdAt", Property.of(p -> p.date(d -> d.format("strict_date_optional_time||epoch_millis"))) @@ -103,8 +105,8 @@ public class SearchIndexInitializer { return TypeMapping.of(builder -> builder .properties("type", Property.of(p -> p.keyword(k -> k))) - .properties("title", textWithPinyin()) - .properties("content", textWithPinyin()) + .properties("title", textWithRawAndPinyin()) + .properties("content", textWithPinyinOnly()) ); } @@ -112,8 +114,8 @@ public class SearchIndexInitializer { return TypeMapping.of(builder -> builder .properties("type", Property.of(p -> p.keyword(k -> k))) - .properties("title", textWithPinyin()) - .properties("content", textWithPinyin()) + .properties("title", textWithRawAndPinyin()) + .properties("content", textWithPinyinOnly()) .properties( "createdAt", Property.of(p -> p.date(d -> d.format("strict_date_optional_time||epoch_millis"))) @@ -121,45 +123,100 @@ public class SearchIndexInitializer { ); } - private Property textWithPinyin() { + // SearchIndexInitializer.java —— 只贴需要替换/新增的方法 + + /** 文本字段:.raw(keyword 精确) + .py(拼音短语精确) + .zh(ICU+2~3gram 召回) */ + private Property textWithRawAndPinyin() { return Property.of(p -> p.text(t -> - t.fields("py", field -> - field.text(sub -> sub.analyzer("py_index").searchAnalyzer("py_search")) - ) + t + .fields("raw", f -> f.keyword(k -> k.normalizer("lowercase_normalizer"))) + .fields("py", f -> f.text(sub -> sub.analyzer("py_index").searchAnalyzer("py_search"))) + .fields("zh", f -> + f.text(sub -> sub.analyzer("zh_ngram_index").searchAnalyzer("zh_search")) + ) ) ); } - private Property keywordWithPinyin() { + /** 长文本 content:保留拼音 + 新增 zh 子字段(不加 .raw,避免过长 keyword) */ + private Property textWithPinyinOnly() { + return Property.of(p -> + p.text(t -> + t + .fields("py", f -> f.text(sub -> sub.analyzer("py_index").searchAnalyzer("py_search"))) + .fields("zh", f -> + f.text(sub -> sub.analyzer("zh_ngram_index").searchAnalyzer("zh_search")) + ) + ) + ); + } + + /** 关键词字段(author/category/tags):keyword 等值 + .py + .zh(尽量对齐标题策略) */ + private Property keywordWithRawAndPinyin() { return Property.of(p -> p.keyword(k -> - k.fields("py", field -> - field.text(sub -> sub.analyzer("py_index").searchAnalyzer("py_search")) - ) + k + .normalizer("lowercase_normalizer") + .fields("raw", f -> f.keyword(kk -> kk.normalizer("lowercase_normalizer"))) + .fields("py", f -> f.text(sub -> sub.analyzer("py_index").searchAnalyzer("py_search"))) + .fields("zh", f -> + f.text(sub -> sub.analyzer("zh_ngram_index").searchAnalyzer("zh_search")) + ) ) ); } + /** 新增 zh 分析器(ICU + 2~3gram),并保留你已有的 pinyin/normalizer 设置 */ private IndexSettings.Builder applyPinyinAnalysis(IndexSettings.Builder builder) { Map settings = new LinkedHashMap<>(); + + // --- 已有:keyword normalizer(用于 .raw) + settings.put("analysis.normalizer.lowercase_normalizer.type", JsonData.of("custom")); + settings.put( + "analysis.normalizer.lowercase_normalizer.filter", + JsonData.of(List.of("lowercase")) + ); + + // --- 已有:pinyin filter + analyzers settings.put("analysis.filter.py_filter.type", JsonData.of("pinyin")); settings.put("analysis.filter.py_filter.keep_full_pinyin", JsonData.of(true)); settings.put("analysis.filter.py_filter.keep_joined_full_pinyin", JsonData.of(true)); settings.put("analysis.filter.py_filter.keep_first_letter", JsonData.of(false)); settings.put("analysis.filter.py_filter.remove_duplicated_term", JsonData.of(true)); + settings.put("analysis.analyzer.py_index.type", JsonData.of("custom")); settings.put("analysis.analyzer.py_index.tokenizer", JsonData.of("standard")); settings.put( "analysis.analyzer.py_index.filter", JsonData.of(List.of("lowercase", "py_filter")) ); + settings.put("analysis.analyzer.py_search.type", JsonData.of("custom")); settings.put("analysis.analyzer.py_search.tokenizer", JsonData.of("standard")); settings.put( "analysis.analyzer.py_search.filter", JsonData.of(List.of("lowercase", "py_filter")) ); + + settings.put("analysis.filter.zh_ngram_2_3.type", JsonData.of("ngram")); + settings.put("analysis.filter.zh_ngram_2_3.min_gram", JsonData.of(2)); + settings.put("analysis.filter.zh_ngram_2_3.max_gram", JsonData.of(3)); + + settings.put("analysis.analyzer.zh_ngram_index.type", JsonData.of("custom")); + settings.put("analysis.analyzer.zh_ngram_index.tokenizer", JsonData.of("icu_tokenizer")); + settings.put( + "analysis.analyzer.zh_ngram_index.filter", + JsonData.of(List.of("lowercase", "zh_ngram_2_3")) + ); + + settings.put("analysis.analyzer.zh_search.type", JsonData.of("custom")); + settings.put("analysis.analyzer.zh_search.tokenizer", JsonData.of("icu_tokenizer")); + settings.put( + "analysis.analyzer.zh_search.filter", + JsonData.of(List.of("lowercase", "zh_ngram_2_3")) + ); + settings.forEach(builder::customSettings); return builder; } diff --git a/backend/src/main/java/com/openisle/service/SearchService.java b/backend/src/main/java/com/openisle/service/SearchService.java index 61e845a32..ec9ad2770 100644 --- a/backend/src/main/java/com/openisle/service/SearchService.java +++ b/backend/src/main/java/com/openisle/service/SearchService.java @@ -26,6 +26,7 @@ import java.util.stream.Stream; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.opensearch.client.opensearch.OpenSearchClient; +import org.opensearch.client.opensearch._types.FieldValue; import org.opensearch.client.opensearch._types.query_dsl.TextQueryType; import org.opensearch.client.opensearch.core.SearchResponse; import org.opensearch.client.opensearch.core.search.Hit; @@ -229,6 +230,15 @@ public class SearchService { return openSearchProperties.isEnabled() && openSearchClient.isPresent(); } + // 在类里加上(字段或静态常量都可) + private static final java.util.regex.Pattern HANS_PATTERN = java.util.regex.Pattern.compile( + "\\p{IsHan}" + ); + + private static boolean containsHan(String s) { + return s != null && HANS_PATTERN.matcher(s).find(); + } + private List searchWithOpenSearch(String keyword) throws IOException { var client = openSearchClient.orElse(null); if (client == null) return List.of(); @@ -236,8 +246,7 @@ public class SearchService { final String qRaw = keyword == null ? "" : keyword.trim(); if (qRaw.isEmpty()) return List.of(); - final boolean enableWildcard = qRaw.length() >= 2; - final String qsEscaped = escapeForQueryString(qRaw); + final boolean hasHan = containsHan(qRaw); SearchResponse resp = client.search( b -> @@ -246,105 +255,110 @@ public class SearchService { .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", "title.py^3", "content^2", "content.py^2") - .type(TextQueryType.BestFields) - .fuzziness("AUTO") - .minimumShouldMatch("70%") - .lenient(true) - ) + s.matchPhrase(mp -> mp.field("title").query(qRaw).slop(2).boost(6.0f)) + ); + bool.should(s -> + s.matchPhrase(mp -> mp.field("content").query(qRaw).slop(2).boost(2.5f)) ); - // 2) 兜底:open* 前缀命中 - if (enableWildcard) { - bool.should(s -> - s.queryString(qs -> - qs - .query( - "(title:" + - qsEscaped + - "* OR title.py:" + - qsEscaped + - "* OR content:" + - qsEscaped + - "* OR content.py:" + - qsEscaped + - "*)" - ) - .analyzeWildcard(true) - ) - ); - } - - // 3) 结构化字段(keyword) - // term 需要 FieldValue(用 lambda 设置 stringValue) + // 结构化等值(.raw) bool.should(s -> s.term(t -> t - .field("author") + .field("author.raw") .value(v -> v.stringValue(qRaw)) - .boost(2.0f) + .boost(4.0f) ) ); bool.should(s -> - s.match(m -> - m - .field("author.py") - .query(v -> v.stringValue(qRaw)) - .boost(2.0f) + s.term(t -> + t + .field("category.raw") + .value(v -> v.stringValue(qRaw)) + .boost(3.0f) ) ); bool.should(s -> - s.match(m -> - m - .field("category.py") - .query(v -> v.stringValue(qRaw)) - .boost(1.2f) - ) - ); - bool.should(s -> - s.match(m -> - m - .field("tags.py") - .query(v -> v.stringValue(qRaw)) - .boost(1.2f) + s.term(t -> + t + .field("tags.raw") + .value(v -> v.stringValue(qRaw)) + .boost(3.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.matchPhrase(mp -> mp.field("title.py").query(qRaw).slop(1).boost(4.0f)) + ); + bool.should(s -> + s.matchPhrase(mp -> mp.field("content.py").query(qRaw).slop(1).boost(1.8f)) + ); + bool.should(s -> + s.matchPhrase(mp -> mp.field("author.py").query(qRaw).slop(1).boost(2.2f)) + ); + bool.should(s -> + s.matchPhrase(mp -> mp.field("category.py").query(qRaw).slop(1).boost(2.0f)) + ); + bool.should(s -> + s.matchPhrase(mp -> mp.field("tags.py").query(qRaw).slop(1).boost(2.0f)) + ); + // ---------- 放宽层(仅当包含中文时启用) ---------- + if (hasHan) { + // title.zh bool.should(s -> - s.term(t -> - t - .field("tags") - .value(v -> v.stringValue(qRaw)) - .boost(1.2f) + s.match(m -> + m + .field("title.zh") + .query(org.opensearch.client.opensearch._types.FieldValue.of(qRaw)) + .operator(org.opensearch.client.opensearch._types.query_dsl.Operator.Or) + .minimumShouldMatch("2<-1 3<-1 4<-1 5<-2 6<-2 7<-3") + .boost(3.0f) ) ); + // content.zh bool.should(s -> - s.wildcard(w -> w.field("tags").value("*" + qRaw + "*").boost(1.0f)) + s.match(m -> + m + .field("content.zh") + .query(org.opensearch.client.opensearch._types.FieldValue.of(qRaw)) + .operator(org.opensearch.client.opensearch._types.query_dsl.Operator.Or) + .minimumShouldMatch("2<-1 3<-1 4<-1 5<-2 6<-2 7<-3") + .boost(1.6f) + ) ); } return bool.minimumShouldMatch("1"); }) ) - .highlight(h -> - h + // ---------- 高亮:允许跨子字段回填 + 匹配字段组 ---------- + .highlight(h -> { + var hb = h .preTags("") .postTags("") - .fields("title", f -> f.fragmentSize(highlightFragmentSize()).numberOfFragments(1)) + .requireFieldMatch(false) + .fields("title", f -> + f + .fragmentSize(highlightFragmentSize()) + .numberOfFragments(1) + .matchedFields(List.of("title", "title.zh", "title.py")) + ) + .fields("content", f -> + f + .fragmentSize(highlightFragmentSize()) + .numberOfFragments(1) + .matchedFields(List.of("content", "content.zh", "content.py")) + ) + .fields("title.zh", f -> f.fragmentSize(highlightFragmentSize()).numberOfFragments(1)) + .fields("content.zh", f -> + f.fragmentSize(highlightFragmentSize()).numberOfFragments(1) + ) .fields("title.py", f -> f.fragmentSize(highlightFragmentSize()).numberOfFragments(1)) - .fields("content", f -> f.fragmentSize(highlightFragmentSize()).numberOfFragments(1)) .fields("content.py", f -> f.fragmentSize(highlightFragmentSize()).numberOfFragments(1) ) @@ -353,8 +367,9 @@ public class SearchService { .fields("category", f -> f.numberOfFragments(0)) .fields("category.py", f -> f.numberOfFragments(0)) .fields("tags", f -> f.numberOfFragments(0)) - .fields("tags.py", f -> f.numberOfFragments(0)) - ) + .fields("tags.py", f -> f.numberOfFragments(0)); + return hb; + }) .size(DEFAULT_OPEN_SEARCH_LIMIT > 0 ? DEFAULT_OPEN_SEARCH_LIMIT : 10), SearchDocument.class ); @@ -435,8 +450,20 @@ public class SearchService { return null; } Map> highlight = hit.highlight(); - String highlightedContent = firstHighlight(highlight, "content", "content.py"); - String highlightedTitle = firstHighlight(highlight, "title", "title.py"); + String highlightedContent = firstHighlight( + highlight, + "content", + "content.py", + "content.zh", + "content.raw" + ); + String highlightedTitle = firstHighlight( + highlight, + "title", + "title.py", + "title.zh", + "title.raw" + ); String highlightedAuthor = firstHighlight(highlight, "author", "author.py"); String highlightedCategory = firstHighlight(highlight, "category", "category.py"); boolean highlightTitle = highlightedTitle != null && !highlightedTitle.isBlank(); @@ -451,6 +478,7 @@ public class SearchService { if (snippetHtml == null && highlightTitle) { snippetHtml = highlightedTitle; } + String snippet = snippetHtml != null && !snippetHtml.isBlank() ? cleanHighlight(snippetHtml) : null; From b0597d34b67d13b6051294afec91bf830df84d70 Mon Sep 17 00:00:00 2001 From: Tim Date: Sun, 28 Sep 2025 17:58:58 +0800 Subject: [PATCH 12/15] =?UTF-8?q?fix:=20=E5=8E=BB=E9=99=A4=E6=97=A0?= =?UTF-8?q?=E7=94=A8=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/openisle/search/SearchIndexInitializer.java | 4 ---- 1 file changed, 4 deletions(-) diff --git a/backend/src/main/java/com/openisle/search/SearchIndexInitializer.java b/backend/src/main/java/com/openisle/search/SearchIndexInitializer.java index 1deb90e2f..0e2e20760 100644 --- a/backend/src/main/java/com/openisle/search/SearchIndexInitializer.java +++ b/backend/src/main/java/com/openisle/search/SearchIndexInitializer.java @@ -52,8 +52,6 @@ public class SearchIndexInitializer { } } - // SearchIndexInitializer.java —— 只贴需要替换/新增的方法 - private TypeMapping postMapping() { return TypeMapping.of(builder -> builder @@ -123,8 +121,6 @@ public class SearchIndexInitializer { ); } - // SearchIndexInitializer.java —— 只贴需要替换/新增的方法 - /** 文本字段:.raw(keyword 精确) + .py(拼音短语精确) + .zh(ICU+2~3gram 召回) */ private Property textWithRawAndPinyin() { return Property.of(p -> From c0ca6154394fa79839428ba4ee7f1bab8e1ecb3b Mon Sep 17 00:00:00 2001 From: Tim Date: Sun, 28 Sep 2025 18:05:49 +0800 Subject: [PATCH 13/15] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9Edocker=E9=83=A8?= =?UTF-8?q?=E7=BD=B2=E7=9B=B8=E5=85=B3=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker/DockerFile | 9 +++++++++ docker/docker-compose.yaml | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 docker/DockerFile diff --git a/docker/DockerFile b/docker/DockerFile new file mode 100644 index 000000000..cd25a38d4 --- /dev/null +++ b/docker/DockerFile @@ -0,0 +1,9 @@ +# opensearch +FROM opensearchproject/opensearch:3.0.0 +RUN /usr/share/opensearch/bin/opensearch-plugin install -b analysis-icu +RUN /usr/share/opensearch/bin/opensearch-plugin install -b \ + https://github.com/aparo/opensearch-analysis-pinyin/releases/download/3.0.0/opensearch-analysis-pinyin.zip + +# ... + + diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 79ba8e3c8..607b0a5fa 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -14,6 +14,44 @@ services: - ../backend/src/main/resources/db/init:/docker-entrypoint-initdb.d networks: - openisle-network + + # OpenSearch Service + opensearch: + build: + context: . + dockerfile: Dockerfile + container_name: opensearch + environment: + - cluster.name=os-single + - node.name=os-node-1 + - discovery.type=single-node + - bootstrap.memory_lock=true + - OPENSEARCH_JAVA_OPTS=-Xms1g -Xmx1g + - DISABLE_SECURITY_PLUGIN=true + - cluster.blocks.create_index=false + ulimits: + memlock: { soft: -1, hard: -1 } + nofile: { soft: 65536, hard: 65536 } + volumes: + - ./data:/usr/share/opensearch/data + - ./snapshots:/snapshots + ports: + - "9200:9200" + - "9600:9600" + restart: unless-stopped + + dashboards: + image: opensearchproject/opensearch-dashboards:3.0.0 + container_name: os-dashboards + environment: + - OPENSEARCH_HOSTS=["http://opensearch:9200"] + - DISABLE_SECURITY_DASHBOARDS_PLUGIN=true + ports: + - "5601:5601" + depends_on: + - opensearch + restart: unless-stopped + # Java spring boot service springboot: From 04616a30f3e1fb30d4a2acdd9bd530365b11dac3 Mon Sep 17 00:00:00 2001 From: Tim Date: Sun, 28 Sep 2025 19:13:17 +0800 Subject: [PATCH 14/15] =?UTF-8?q?fix:=20=E6=96=B0=E5=A2=9E=E7=AB=AF?= =?UTF-8?q?=E5=8F=A3=E6=8C=87=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker/.env.example | 5 +++++ docker/docker-compose.yaml | 6 +++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/docker/.env.example b/docker/.env.example index d96c83f28..0ad80a93c 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -1,6 +1,11 @@ # 前端访问端口 SERVER_PORT=8080 +# OpenSearch 配置 +OPENSEARCH_PORT=9200 +OPENSEARCH_METRICS_PORT=9600 +OPENSEARCH_DASHBOARDS_PORT=5601 + # MySQL 配置 MYSQL_ROOT_PASSWORD=toor diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 607b0a5fa..50a60557d 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -36,8 +36,8 @@ services: - ./data:/usr/share/opensearch/data - ./snapshots:/snapshots ports: - - "9200:9200" - - "9600:9600" + - "${OPENSEARCH_PORT:-9200}:9200" + - "${OPENSEARCH_METRICS_PORT:-9600}:9600" restart: unless-stopped dashboards: @@ -47,7 +47,7 @@ services: - OPENSEARCH_HOSTS=["http://opensearch:9200"] - DISABLE_SECURITY_DASHBOARDS_PLUGIN=true ports: - - "5601:5601" + - "${OPENSEARCH_DASHBOARDS_PORT:-5601}:5601" depends_on: - opensearch restart: unless-stopped From 0119605649962cd430b8b73dad9eef9c789f0709 Mon Sep 17 00:00:00 2001 From: tim Date: Mon, 29 Sep 2025 01:14:50 +0800 Subject: [PATCH 15/15] =?UTF-8?q?feat:=20=E5=85=88=E6=8A=8A=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E5=AE=9A=E6=97=B6=E6=9E=84=E4=BB=B6=E7=BB=99=E6=B3=A8?= =?UTF-8?q?=E9=87=8A=E6=8E=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5bfb41eb7..28c013027 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -2,8 +2,8 @@ name: CI & CD on: workflow_dispatch: - schedule: - - cron: "0 19 * * *" # 每天 UTC 19:00,相当于北京时间凌晨3点 + # schedule: + # - cron: "0 19 * * *" # 每天 UTC 19:00,相当于北京时间凌晨3点 jobs: build-and-deploy: