feat: add open search support

This commit is contained in:
Tim
2025-09-26 15:34:06 +08:00
parent 4fc7c861ee
commit 4821b77c17
16 changed files with 1008 additions and 1 deletions

View File

@@ -0,0 +1,119 @@
package com.openisle.config;
import jakarta.annotation.PreDestroy;
import java.io.IOException;
import java.util.List;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import org.apache.hc.client5.http.auth.AuthScope;
import org.apache.hc.client5.http.auth.UsernamePasswordCredentials;
import org.apache.hc.client5.http.classic.HttpClientBuilder;
import org.apache.hc.client5.http.config.RequestConfig;
import org.apache.hc.client5.http.impl.auth.BasicCredentialsProvider;
import org.apache.hc.core5.http.HttpHost;
import org.opensearch.client.RestClient;
import org.opensearch.client.json.jackson.JacksonJsonpMapper;
import org.opensearch.client.opensearch.OpenSearchClient;
import org.opensearch.client.transport.OpenSearchTransport;
import org.opensearch.client.transport.rest_client.RestClientTransport;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableConfigurationProperties(OpenSearchProperties.class)
@ConditionalOnProperty(prefix = "opensearch", name = "enabled", havingValue = "true")
public class OpenSearchConfig {
private RestClient restClient;
@Bean
public RestClient openSearchRestClient(OpenSearchProperties properties) {
List<String> hosts = properties.getHosts();
if (hosts == null || hosts.isEmpty()) {
throw new IllegalStateException(
"opensearch.hosts must be configured when OpenSearch is enabled"
);
}
HttpHost[] httpHosts = hosts.stream().map(HttpHost::create).toArray(HttpHost[]::new);
RestClient.Builder builder = RestClient.builder(httpHosts);
builder.setRequestConfigCallback(requestConfigBuilder -> {
RequestConfig.Builder config = RequestConfig.custom();
config.setConnectTimeout(properties.getConnectTimeout());
config.setResponseTimeout(properties.getSocketTimeout());
return config;
});
builder.setHttpClientConfigCallback(clientBuilder -> {
HttpClientBuilder httpClientBuilder = clientBuilder;
if (properties.getUsername() != null && properties.getPassword() != null) {
BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider();
credentialsProvider.setCredentials(
new AuthScope(null, -1),
new UsernamePasswordCredentials(
properties.getUsername(),
properties.getPassword().toCharArray()
)
);
httpClientBuilder = httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider);
}
if (properties.isInsecure()) {
try {
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(
null,
new TrustManager[] {
new X509TrustManager() {
public java.security.cert.X509Certificate[] getAcceptedIssuers() {
return new java.security.cert.X509Certificate[0];
}
public void checkClientTrusted(
java.security.cert.X509Certificate[] chain,
String authType
) {}
public void checkServerTrusted(
java.security.cert.X509Certificate[] chain,
String authType
) {}
},
},
new java.security.SecureRandom()
);
httpClientBuilder = httpClientBuilder.setSSLContext(sslContext);
} catch (Exception e) {
throw new IllegalStateException("Failed to configure insecure SSL context", e);
}
}
return httpClientBuilder;
});
restClient = builder.build();
return restClient;
}
@Bean
public OpenSearchTransport openSearchTransport(RestClient restClient) {
JacksonJsonpMapper mapper = new JacksonJsonpMapper();
return new RestClientTransport(restClient, mapper);
}
@Bean
public OpenSearchClient openSearchClient(OpenSearchTransport transport) {
return new OpenSearchClient(transport);
}
@PreDestroy
public void closeClient() throws IOException {
if (restClient != null) {
restClient.close();
}
}
}

View File

@@ -0,0 +1,63 @@
package com.openisle.config;
import java.time.Duration;
import java.util.List;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
@Data
@ConfigurationProperties(prefix = "opensearch")
public class OpenSearchProperties {
/**
* Flag to enable the OpenSearch integration. When disabled the application falls back to the
* legacy JPA based search implementation.
*/
private boolean enabled = false;
/**
* Comma separated list of OpenSearch endpoints. Example: {@code https://localhost:9200}.
*/
private List<String> hosts = List.of();
/** Username used when authenticating against the cluster. */
private String username;
/** Password used when authenticating against the cluster. */
private String password;
/** Optional toggle that allows disabling certificate validation in development environments. */
private boolean insecure = false;
/** Connection timeout when communicating with OpenSearch. */
private Duration connectTimeout = Duration.ofSeconds(10);
/** Socket timeout when communicating with OpenSearch. */
private Duration socketTimeout = Duration.ofSeconds(30);
/** Maximum number of search results returned for entity specific endpoints. */
private int maxResults = 50;
/** Highlight fragment size used when OpenSearch does not return highlighted text. */
private int highlightFallbackLength = 200;
public String getPostsIndex() {
return "posts";
}
public String getCommentsIndex() {
return "comments";
}
public String getUsersIndex() {
return "users";
}
public String getCategoriesIndex() {
return "categories";
}
public String getTagsIndex() {
return "tags";
}
}

View File

@@ -1,5 +1,6 @@
package com.openisle.model;
import com.openisle.search.SearchEntityListener;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
@@ -10,6 +11,7 @@ import lombok.Setter;
@Setter
@NoArgsConstructor
@Table(name = "categories")
@EntityListeners(SearchEntityListener.class)
public class Category {
@Id

View File

@@ -1,5 +1,6 @@
package com.openisle.model;
import com.openisle.search.SearchEntityListener;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import lombok.Getter;
@@ -16,6 +17,7 @@ import org.hibernate.annotations.Where;
@Table(name = "comments")
@SQLDelete(sql = "UPDATE comments SET deleted_at = CURRENT_TIMESTAMP(6) WHERE id = ?")
@Where(clause = "deleted_at IS NULL")
@EntityListeners(SearchEntityListener.class)
public class Comment {
@Id

View File

@@ -1,6 +1,7 @@
package com.openisle.model;
import com.openisle.model.Tag;
import com.openisle.search.SearchEntityListener;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.util.HashSet;
@@ -19,6 +20,7 @@ import org.hibernate.annotations.CreationTimestamp;
@NoArgsConstructor
@Table(name = "posts")
@Inheritance(strategy = InheritanceType.JOINED)
@EntityListeners(SearchEntityListener.class)
public class Post {
@Id

View File

@@ -1,5 +1,6 @@
package com.openisle.model;
import com.openisle.search.SearchEntityListener;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import lombok.Getter;
@@ -12,6 +13,7 @@ import org.hibernate.annotations.CreationTimestamp;
@Setter
@NoArgsConstructor
@Table(name = "tags")
@EntityListeners(SearchEntityListener.class)
public class Tag {
@Id

View File

@@ -1,5 +1,6 @@
package com.openisle.model;
import com.openisle.search.SearchEntityListener;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.util.EnumSet;
@@ -19,6 +20,7 @@ import org.hibernate.annotations.CreationTimestamp;
@Setter
@NoArgsConstructor
@Table(name = "users")
@EntityListeners(SearchEntityListener.class)
public class User {
@Id

View File

@@ -0,0 +1,368 @@
package com.openisle.search;
import com.openisle.config.OpenSearchProperties;
import com.openisle.service.SearchService.SearchResult;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.opensearch.client.opensearch.OpenSearchClient;
import org.opensearch.client.opensearch._types.SortOrder;
import org.opensearch.client.opensearch._types.query_dsl.MultiMatchQueryType;
import org.opensearch.client.opensearch._types.query_dsl.Query;
import org.opensearch.client.opensearch.core.MsearchRequest;
import org.opensearch.client.opensearch.core.MsearchResponse;
import org.opensearch.client.opensearch.core.SearchRequest;
import org.opensearch.client.opensearch.core.SearchResponse;
import org.opensearch.client.opensearch.core.search.HighlightField;
import org.opensearch.client.opensearch.core.search.Hit;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnProperty(prefix = "opensearch", name = "enabled", havingValue = "true")
public class OpenSearchGateway {
private final OpenSearchClient client;
private final OpenSearchProperties properties;
public enum PostSearchMode {
TITLE_AND_CONTENT,
TITLE_ONLY,
CONTENT_ONLY,
}
public List<Long> searchUserIds(String keyword) {
return searchForIds(
properties.getUsersIndex(),
keyword,
List.of("username^2", "displayName^1.5", "introduction"),
null
);
}
public List<Long> searchPostIds(String keyword, PostSearchMode mode) {
List<String> fields;
switch (mode) {
case TITLE_ONLY:
fields = List.of("title^2");
break;
case CONTENT_ONLY:
fields = List.of("content");
break;
default:
fields = List.of("title^2", "content");
}
return searchForIds(
properties.getPostsIndex(),
keyword,
fields,
Query.of(q -> q.match(m -> m.field("status").query("PUBLISHED")))
);
}
public List<Long> searchCommentIds(String keyword) {
return searchForIds(
properties.getCommentsIndex(),
keyword,
List.of("content", "postTitle", "author"),
null
);
}
public List<Long> searchCategoryIds(String keyword) {
return searchForIds(
properties.getCategoriesIndex(),
keyword,
List.of("name^2", "description"),
null
);
}
public List<Long> searchTagIds(String keyword) {
return searchForIds(
properties.getTagsIndex(),
keyword,
List.of("name^2", "description"),
Query.of(q -> q.match(m -> m.field("approved").query(true)))
);
}
public List<SearchResult> globalSearch(String keyword, int snippetLength) {
try {
MsearchRequest.Builder builder = new MsearchRequest.Builder();
builder.searches(s ->
s
.header(h -> h.index(properties.getUsersIndex()))
.body(searchBody(keyword, List.of("username^2", "displayName", "introduction"), null))
);
builder.searches(s ->
s
.header(h -> h.index(properties.getCategoriesIndex()))
.body(searchBody(keyword, List.of("name^2", "description"), null))
);
builder.searches(s ->
s
.header(h -> h.index(properties.getTagsIndex()))
.body(
searchBody(
keyword,
List.of("name^2", "description"),
Query.of(q -> q.match(m -> m.field("approved").query(true)))
)
)
);
builder.searches(s ->
s
.header(h -> h.index(properties.getPostsIndex()))
.body(
searchBody(
keyword,
List.of("title^2", "content", "category", "tags"),
Query.of(q -> q.match(m -> m.field("status").query("PUBLISHED")))
)
)
);
builder.searches(s ->
s
.header(h -> h.index(properties.getCommentsIndex()))
.body(searchBody(keyword, List.of("content", "postTitle", "author"), null))
);
MsearchResponse<Map<String, Object>> response = client.msearch(builder.build(), Map.class);
List<SearchResult> results = new ArrayList<>();
int snippetLimit = snippetLength >= 0
? snippetLength
: properties.getHighlightFallbackLength();
// Order corresponds to request order
List<String> types = List.of("user", "category", "tag", "post", "comment");
for (int i = 0; i < response.responses().size(); i++) {
var item = response.responses().get(i);
if (item.isFailure()) {
log.warn("OpenSearch multi search failed for {}: {}", types.get(i), item.error());
continue;
}
for (Hit<Map<String, Object>> hit : item.result().hits().hits()) {
String type = types.get(i);
Long id = hit.id() != null ? Long.valueOf(hit.id()) : null;
Map<String, List<String>> highlight = hit.highlight() != null
? hit.highlight()
: Map.of();
Map<String, Object> source = hit.source() != null ? hit.source() : Map.of();
String text = firstHighlight(
highlight,
List.of("title", "username", "name", "postTitle")
);
if (text == null) {
text = optionalString(
source,
switch (type) {
case "user" -> "username";
case "post" -> "title";
case "comment" -> "postTitle";
default -> "name";
}
);
}
String subText = null;
String extra = null;
Long postId = null;
if ("user".equals(type)) {
subText = optionalString(source, "displayName");
extra = snippetFromHighlight(
highlight,
List.of("introduction"),
optionalString(source, "introduction"),
snippetLimit
);
} else if ("category".equals(type) || "tag".equals(type)) {
extra = snippetFromHighlight(
highlight,
List.of("description"),
optionalString(source, "description"),
snippetLimit
);
} else if ("post".equals(type)) {
subText = optionalString(source, "category");
extra = snippetFromHighlight(
highlight,
List.of("content"),
optionalString(source, "content"),
snippetLimit
);
} else if ("comment".equals(type)) {
subText = optionalString(source, "author");
postId = optionalLong(source, "postId");
extra = snippetFromHighlight(
highlight,
List.of("content"),
optionalString(source, "content"),
snippetLimit
);
}
results.add(new SearchResult(type, id, text, subText, extra, postId));
}
}
return results;
} catch (IOException e) {
throw new IllegalStateException("OpenSearch global search failed", e);
}
}
private List<Long> searchForIds(String index, String keyword, List<String> fields, Query filter) {
try {
SearchRequest request = SearchRequest.builder()
.index(index)
.size(properties.getMaxResults())
.query(q ->
q.bool(b -> {
b.must(
Query.of(m ->
m.multiMatch(mm ->
mm.query(keyword).fields(fields).type(MultiMatchQueryType.BestFields)
)
)
);
if (filter != null) {
b.filter(filter);
}
return b;
})
)
.sort(s -> s.score(o -> o.order(SortOrder.Desc)))
.build();
SearchResponse<Map<String, Object>> response = client.search(request, Map.class);
return response
.hits()
.hits()
.stream()
.map(Hit::id)
.filter(id -> id != null && !id.isBlank())
.map(Long::valueOf)
.collect(Collectors.toList());
} catch (IOException e) {
throw new IllegalStateException("OpenSearch search failed for index " + index, e);
}
}
private SearchRequest searchBody(String keyword, List<String> fields, Query filter) {
return SearchRequest.builder()
.size(10)
.query(q ->
q.bool(b -> {
b.must(
Query.of(m ->
m.multiMatch(mm ->
mm.query(keyword).fields(fields).type(MultiMatchQueryType.BestFields)
)
)
);
if (filter != null) {
b.filter(filter);
}
return b;
})
)
.highlight(h ->
h
.preTags("<em>")
.postTags("</em>")
.fields(
"title",
HighlightField.of(f -> f.fragmentSize(properties.getHighlightFallbackLength()))
)
.fields(
"username",
HighlightField.of(f -> f.fragmentSize(properties.getHighlightFallbackLength()))
)
.fields(
"name",
HighlightField.of(f -> f.fragmentSize(properties.getHighlightFallbackLength()))
)
.fields(
"postTitle",
HighlightField.of(f -> f.fragmentSize(properties.getHighlightFallbackLength()))
)
.fields(
"content",
HighlightField.of(f -> f.fragmentSize(properties.getHighlightFallbackLength()))
)
.fields(
"description",
HighlightField.of(f -> f.fragmentSize(properties.getHighlightFallbackLength()))
)
.fields(
"introduction",
HighlightField.of(f -> f.fragmentSize(properties.getHighlightFallbackLength()))
)
)
.build();
}
private String firstHighlight(Map<String, List<String>> highlight, List<String> keys) {
for (String key : keys) {
List<String> values = highlight.get(key);
if (values != null && !values.isEmpty()) {
return values.get(0);
}
}
return null;
}
private String snippetFromHighlight(
Map<String, List<String>> highlight,
List<String> keys,
String fallback,
int snippetLength
) {
for (String key : keys) {
List<String> values = highlight.get(key);
if (values != null && !values.isEmpty()) {
return values.get(0);
}
}
if (fallback == null) {
return null;
}
if (snippetLength < 0) {
return fallback;
}
return fallback.length() > snippetLength ? fallback.substring(0, snippetLength) : fallback;
}
private String optionalString(Map<String, Object> source, String key) {
if (source == null) {
return null;
}
Object value = source.get(key);
return value != null ? value.toString() : null;
}
private Long optionalLong(Map<String, Object> source, String key) {
if (source == null) {
return null;
}
Object value = source.get(key);
if (value instanceof Number number) {
return number.longValue();
}
if (value instanceof String str && !str.isBlank()) {
try {
return Long.valueOf(str);
} catch (NumberFormatException e) {
return null;
}
}
return null;
}
}

View File

@@ -0,0 +1,115 @@
package com.openisle.search;
import com.openisle.config.OpenSearchProperties;
import java.io.IOException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.opensearch.client.opensearch.OpenSearchClient;
import org.opensearch.client.opensearch._types.mapping.Property;
import org.opensearch.client.opensearch._types.mapping.TypeMapping;
import org.opensearch.client.opensearch.indices.CreateIndexRequest;
import org.opensearch.client.opensearch.indices.ExistsRequest;
import org.opensearch.client.opensearch.indices.IndexSettings;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnProperty(prefix = "opensearch", name = "enabled", havingValue = "true")
public class OpenSearchIndexManager {
private final OpenSearchClient client;
private final OpenSearchProperties properties;
@EventListener(ContextRefreshedEvent.class)
public void initializeIndices() {
ensureIndex(properties.getPostsIndex(), this::postsMapping);
ensureIndex(properties.getCommentsIndex(), this::commentsMapping);
ensureIndex(properties.getUsersIndex(), this::usersMapping);
ensureIndex(properties.getCategoriesIndex(), this::categoriesMapping);
ensureIndex(properties.getTagsIndex(), this::tagsMapping);
}
private void ensureIndex(String indexName, MappingSupplier supplier) {
try {
boolean exists = client.indices().exists(ExistsRequest.of(e -> e.index(indexName))).value();
if (!exists) {
log.info("Creating OpenSearch index {}", indexName);
CreateIndexRequest request = CreateIndexRequest.builder()
.index(indexName)
.mappings(supplier.mapping())
.settings(IndexSettings.of(s -> s.numberOfReplicas("1").numberOfShards("1")))
.build();
client.indices().create(request);
}
} catch (IOException e) {
throw new IllegalStateException("Failed to ensure index " + indexName, e);
}
}
private TypeMapping postsMapping() {
return TypeMapping.builder()
.properties(
"title",
Property.of(p -> p.text(t -> t.analyzer("standard").fields("raw", f -> f.keyword(k -> k))))
)
.properties("content", Property.of(p -> p.text(t -> t.analyzer("standard"))))
.properties("author", Property.of(p -> p.keyword(k -> k)))
.properties("category", Property.of(p -> p.keyword(k -> k)))
.properties("tags", Property.of(p -> p.keyword(k -> k)))
.properties("status", Property.of(p -> p.keyword(k -> k)))
.properties("createdAt", Property.of(p -> p.date(d -> d)))
.build();
}
private TypeMapping commentsMapping() {
return TypeMapping.builder()
.properties("content", Property.of(p -> p.text(t -> t.analyzer("standard"))))
.properties("author", Property.of(p -> p.keyword(k -> k)))
.properties("postTitle", Property.of(p -> p.text(t -> t.analyzer("standard"))))
.properties("postId", Property.of(p -> p.long_(l -> l)))
.properties("createdAt", Property.of(p -> p.date(d -> d)))
.build();
}
private TypeMapping usersMapping() {
return TypeMapping.builder()
.properties(
"username",
Property.of(p -> p.text(t -> t.analyzer("standard").fields("raw", f -> f.keyword(k -> k))))
)
.properties("introduction", Property.of(p -> p.text(t -> t.analyzer("standard"))))
.properties("displayName", Property.of(p -> p.text(t -> t.analyzer("standard"))))
.properties("createdAt", Property.of(p -> p.date(d -> d)))
.build();
}
private TypeMapping categoriesMapping() {
return TypeMapping.builder()
.properties(
"name",
Property.of(p -> p.text(t -> t.analyzer("standard").fields("raw", f -> f.keyword(k -> k))))
)
.properties("description", Property.of(p -> p.text(t -> t.analyzer("standard"))))
.build();
}
private TypeMapping tagsMapping() {
return TypeMapping.builder()
.properties(
"name",
Property.of(p -> p.text(t -> t.analyzer("standard").fields("raw", f -> f.keyword(k -> k))))
)
.properties("description", Property.of(p -> p.text(t -> t.analyzer("standard"))))
.properties("approved", Property.of(p -> p.boolean_(b -> b)))
.build();
}
@FunctionalInterface
private interface MappingSupplier {
TypeMapping mapping();
}
}

View File

@@ -0,0 +1,166 @@
package com.openisle.search;
import com.openisle.config.OpenSearchProperties;
import com.openisle.model.Category;
import com.openisle.model.Comment;
import com.openisle.model.Post;
import com.openisle.model.Tag;
import com.openisle.model.User;
import java.io.IOException;
import java.time.ZoneOffset;
import java.util.HashMap;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.opensearch.client.opensearch.OpenSearchClient;
import org.opensearch.client.opensearch.core.DeleteRequest;
import org.opensearch.client.opensearch.core.IndexRequest;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnProperty(prefix = "opensearch", name = "enabled", havingValue = "true")
public class OpenSearchIndexer {
private final OpenSearchClient client;
private final OpenSearchProperties properties;
public void indexPost(Post post) {
runAfterCommit(() -> {
Map<String, Object> document = new HashMap<>();
document.put("title", post.getTitle());
document.put("content", post.getContent());
document.put("author", post.getAuthor() != null ? post.getAuthor().getUsername() : null);
document.put("category", post.getCategory() != null ? post.getCategory().getName() : null);
document.put(
"tags",
post.getTags() != null
? post.getTags().stream().map(Tag::getName).toList()
: java.util.List.of()
);
document.put("status", post.getStatus() != null ? post.getStatus().name() : null);
if (post.getCreatedAt() != null) {
document.put("createdAt", post.getCreatedAt().atOffset(ZoneOffset.UTC));
}
indexDocument(properties.getPostsIndex(), post.getId(), document);
});
}
public void deletePost(Long postId) {
runAfterCommit(() -> deleteDocument(properties.getPostsIndex(), postId));
}
public void indexComment(Comment comment) {
runAfterCommit(() -> {
Map<String, Object> document = new HashMap<>();
document.put("content", comment.getContent());
document.put(
"author",
comment.getAuthor() != null ? comment.getAuthor().getUsername() : null
);
if (comment.getPost() != null) {
document.put("postId", comment.getPost().getId());
document.put("postTitle", comment.getPost().getTitle());
}
if (comment.getCreatedAt() != null) {
document.put("createdAt", comment.getCreatedAt().atOffset(ZoneOffset.UTC));
}
indexDocument(properties.getCommentsIndex(), comment.getId(), document);
});
}
public void deleteComment(Long commentId) {
runAfterCommit(() -> deleteDocument(properties.getCommentsIndex(), commentId));
}
public void indexUser(User user) {
runAfterCommit(() -> {
Map<String, Object> document = new HashMap<>();
document.put("username", user.getUsername());
document.put("displayName", user.getDisplayName());
document.put("introduction", user.getIntroduction());
if (user.getCreatedAt() != null) {
document.put("createdAt", user.getCreatedAt().atOffset(ZoneOffset.UTC));
}
indexDocument(properties.getUsersIndex(), user.getId(), document);
});
}
public void deleteUser(Long userId) {
runAfterCommit(() -> deleteDocument(properties.getUsersIndex(), userId));
}
public void indexCategory(Category category) {
runAfterCommit(() -> {
Map<String, Object> document = new HashMap<>();
document.put("name", category.getName());
document.put("description", category.getDescription());
indexDocument(properties.getCategoriesIndex(), category.getId(), document);
});
}
public void deleteCategory(Long categoryId) {
runAfterCommit(() -> deleteDocument(properties.getCategoriesIndex(), categoryId));
}
public void indexTag(Tag tag) {
runAfterCommit(() -> {
Map<String, Object> document = new HashMap<>();
document.put("name", tag.getName());
document.put("description", tag.getDescription());
document.put("approved", Boolean.TRUE.equals(tag.getApproved()));
indexDocument(properties.getTagsIndex(), tag.getId(), document);
});
}
public void deleteTag(Long tagId) {
runAfterCommit(() -> deleteDocument(properties.getTagsIndex(), tagId));
}
private void indexDocument(String index, Long id, Map<String, Object> document) {
if (id == null) {
return;
}
try {
IndexRequest<Map<String, Object>> request = IndexRequest.<Map<String, Object>>builder()
.index(index)
.id(id.toString())
.document(document)
.build();
client.index(request);
} catch (IOException e) {
log.error("Failed to index document {} in {}", id, index, e);
}
}
private void deleteDocument(String index, Long id) {
if (id == null) {
return;
}
try {
DeleteRequest request = DeleteRequest.of(d -> d.index(index).id(id.toString()));
client.delete(request);
} catch (IOException e) {
log.error("Failed to delete document {} in {}", id, index, e);
}
}
private void runAfterCommit(Runnable runnable) {
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
runnable.run();
}
}
);
} else {
runnable.run();
}
}
}

View File

@@ -0,0 +1,84 @@
package com.openisle.search;
import com.openisle.model.Category;
import com.openisle.model.Comment;
import com.openisle.model.Post;
import com.openisle.model.Tag;
import com.openisle.model.User;
import jakarta.persistence.PostPersist;
import jakarta.persistence.PostRemove;
import jakarta.persistence.PostUpdate;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
@Slf4j
public class SearchEntityListener {
private static volatile OpenSearchIndexer indexer;
public static void registerIndexer(OpenSearchIndexer openSearchIndexer) {
indexer = openSearchIndexer;
}
@PostPersist
@PostUpdate
public void afterSave(Object entity) {
if (indexer == null) {
return;
}
if (entity instanceof Post post) {
indexer.indexPost(post);
} else if (entity instanceof Comment comment) {
indexer.indexComment(comment);
} else if (entity instanceof User user) {
indexer.indexUser(user);
} else if (entity instanceof Category category) {
indexer.indexCategory(category);
} else if (entity instanceof Tag tag) {
indexer.indexTag(tag);
}
}
@PostRemove
public void afterDelete(Object entity) {
if (indexer == null) {
return;
}
if (entity instanceof Post post) {
Long id = post.getId();
if (id != null) {
indexer.deletePost(id);
}
} else if (entity instanceof Comment comment) {
Long id = comment.getId();
if (id != null) {
indexer.deleteComment(id);
}
} else if (entity instanceof User user) {
Long id = user.getId();
if (id != null) {
indexer.deleteUser(id);
}
} else if (entity instanceof Category category) {
Long id = category.getId();
if (id != null) {
indexer.deleteCategory(id);
}
} else if (entity instanceof Tag tag) {
Long id = tag.getId();
if (id != null) {
indexer.deleteTag(id);
}
}
}
@Component
@ConditionalOnProperty(prefix = "opensearch", name = "enabled", havingValue = "true")
public static class Registrar {
public Registrar(OpenSearchIndexer openSearchIndexer) {
SearchEntityListener.registerIndexer(openSearchIndexer);
}
}
}

View File

@@ -11,10 +11,17 @@ import com.openisle.repository.CommentRepository;
import com.openisle.repository.PostRepository;
import com.openisle.repository.TagRepository;
import com.openisle.repository.UserRepository;
import com.openisle.search.OpenSearchGateway;
import com.openisle.search.OpenSearchGateway.PostSearchMode;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@@ -27,15 +34,26 @@ public class SearchService {
private final CommentRepository commentRepository;
private final CategoryRepository categoryRepository;
private final TagRepository tagRepository;
private final Optional<OpenSearchGateway> openSearchGateway;
@org.springframework.beans.factory.annotation.Value("${app.snippet-length}")
private int snippetLength;
public List<User> searchUsers(String keyword) {
if (openSearchGateway.isPresent()) {
List<Long> ids = openSearchGateway.get().searchUserIds(keyword);
return loadAndSort(ids, userRepository::findAllById, User::getId);
}
return userRepository.findByUsernameContainingIgnoreCase(keyword);
}
public List<Post> searchPosts(String keyword) {
if (openSearchGateway.isPresent()) {
List<Long> ids = openSearchGateway
.get()
.searchPostIds(keyword, PostSearchMode.TITLE_AND_CONTENT);
return loadAndSort(ids, idList -> postRepository.findAllById(idList), Post::getId);
}
return postRepository.findByTitleContainingIgnoreCaseOrContentContainingIgnoreCaseAndStatus(
keyword,
keyword,
@@ -44,26 +62,49 @@ public class SearchService {
}
public List<Post> searchPostsByContent(String keyword) {
if (openSearchGateway.isPresent()) {
List<Long> ids = openSearchGateway.get().searchPostIds(keyword, PostSearchMode.CONTENT_ONLY);
return loadAndSort(ids, idList -> postRepository.findAllById(idList), Post::getId);
}
return postRepository.findByContentContainingIgnoreCaseAndStatus(keyword, PostStatus.PUBLISHED);
}
public List<Post> searchPostsByTitle(String keyword) {
if (openSearchGateway.isPresent()) {
List<Long> ids = openSearchGateway.get().searchPostIds(keyword, PostSearchMode.TITLE_ONLY);
return loadAndSort(ids, idList -> postRepository.findAllById(idList), Post::getId);
}
return postRepository.findByTitleContainingIgnoreCaseAndStatus(keyword, PostStatus.PUBLISHED);
}
public List<Comment> searchComments(String keyword) {
if (openSearchGateway.isPresent()) {
List<Long> ids = openSearchGateway.get().searchCommentIds(keyword);
return loadAndSort(ids, idList -> commentRepository.findAllById(idList), Comment::getId);
}
return commentRepository.findByContentContainingIgnoreCase(keyword);
}
public List<Category> searchCategories(String keyword) {
if (openSearchGateway.isPresent()) {
List<Long> ids = openSearchGateway.get().searchCategoryIds(keyword);
return loadAndSort(ids, idList -> categoryRepository.findAllById(idList), Category::getId);
}
return categoryRepository.findByNameContainingIgnoreCase(keyword);
}
public List<Tag> searchTags(String keyword) {
if (openSearchGateway.isPresent()) {
List<Long> ids = openSearchGateway.get().searchTagIds(keyword);
return loadAndSort(ids, idList -> tagRepository.findAllById(idList), Tag::getId);
}
return tagRepository.findByNameContainingIgnoreCaseAndApprovedTrue(keyword);
}
public List<SearchResult> globalSearch(String keyword) {
if (openSearchGateway.isPresent()) {
return openSearchGateway.get().globalSearch(keyword, snippetLength);
}
Stream<SearchResult> users = searchUsers(keyword)
.stream()
.map(u ->
@@ -173,4 +214,18 @@ public class SearchService {
String extra,
Long postId
) {}
private <T> List<T> loadAndSort(
List<Long> ids,
Function<Iterable<Long>, Iterable<T>> loader,
Function<T, Long> idExtractor
) {
if (ids.isEmpty()) {
return List.of();
}
Map<Long, T> entityMap = StreamSupport.stream(loader.apply(ids).spliterator(), false).collect(
Collectors.toMap(idExtractor, Function.identity())
);
return ids.stream().map(entityMap::get).filter(Objects::nonNull).toList();
}
}