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