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();