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