mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-19 13:30:55 +08:00
feat: add open search support
This commit is contained in:
119
backend/src/main/java/com/openisle/config/OpenSearchConfig.java
Normal file
119
backend/src/main/java/com/openisle/config/OpenSearchConfig.java
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
368
backend/src/main/java/com/openisle/search/OpenSearchGateway.java
Normal file
368
backend/src/main/java/com/openisle/search/OpenSearchGateway.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
166
backend/src/main/java/com/openisle/search/OpenSearchIndexer.java
Normal file
166
backend/src/main/java/com/openisle/search/OpenSearchIndexer.java
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user