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