mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-07-22 12:36:08 +00:00
@@ -17,15 +17,6 @@ JWT_EXPIRATION=2592000000
|
|||||||
REDIS_HOST=<Redis 地址>
|
REDIS_HOST=<Redis 地址>
|
||||||
REDIS_PORT=<Redis 端口>
|
REDIS_PORT=<Redis 端口>
|
||||||
|
|
||||||
# === OpenSearch ===
|
|
||||||
OPENSEARCH_ENABLED=false
|
|
||||||
OPENSEARCH_HOSTS=https://<opensearch-host>:9200
|
|
||||||
# 可选:启用基本认证
|
|
||||||
# OPENSEARCH_USERNAME=<opensearch-username>
|
|
||||||
# OPENSEARCH_PASSWORD=<opensearch-password>
|
|
||||||
# 开发调试时可关闭证书校验
|
|
||||||
# OPENSEARCH_INSECURE=true
|
|
||||||
|
|
||||||
# === Resend ===
|
# === Resend ===
|
||||||
RESEND_API_KEY=<你的resend-api-key>
|
RESEND_API_KEY=<你的resend-api-key>
|
||||||
RESEND_FROM_EMAIL=<你的 resend 发送邮箱>
|
RESEND_FROM_EMAIL=<你的 resend 发送邮箱>
|
||||||
|
|||||||
@@ -132,11 +132,6 @@
|
|||||||
<artifactId>springdoc-openapi-starter-webmvc-api</artifactId>
|
<artifactId>springdoc-openapi-starter-webmvc-api</artifactId>
|
||||||
<version>2.2.0</version>
|
<version>2.2.0</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
|
||||||
<groupId>org.opensearch.client</groupId>
|
|
||||||
<artifactId>opensearch-java</artifactId>
|
|
||||||
<version>2.11.0</version>
|
|
||||||
</dependency>
|
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
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,6 +1,5 @@
|
|||||||
package com.openisle.model;
|
package com.openisle.model;
|
||||||
|
|
||||||
import com.openisle.search.SearchEntityListener;
|
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
@@ -11,7 +10,6 @@ import lombok.Setter;
|
|||||||
@Setter
|
@Setter
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@Table(name = "categories")
|
@Table(name = "categories")
|
||||||
@EntityListeners(SearchEntityListener.class)
|
|
||||||
public class Category {
|
public class Category {
|
||||||
|
|
||||||
@Id
|
@Id
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.openisle.model;
|
package com.openisle.model;
|
||||||
|
|
||||||
import com.openisle.search.SearchEntityListener;
|
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
@@ -17,7 +16,6 @@ import org.hibernate.annotations.Where;
|
|||||||
@Table(name = "comments")
|
@Table(name = "comments")
|
||||||
@SQLDelete(sql = "UPDATE comments SET deleted_at = CURRENT_TIMESTAMP(6) WHERE id = ?")
|
@SQLDelete(sql = "UPDATE comments SET deleted_at = CURRENT_TIMESTAMP(6) WHERE id = ?")
|
||||||
@Where(clause = "deleted_at IS NULL")
|
@Where(clause = "deleted_at IS NULL")
|
||||||
@EntityListeners(SearchEntityListener.class)
|
|
||||||
public class Comment {
|
public class Comment {
|
||||||
|
|
||||||
@Id
|
@Id
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package com.openisle.model;
|
package com.openisle.model;
|
||||||
|
|
||||||
import com.openisle.model.Tag;
|
import com.openisle.model.Tag;
|
||||||
import com.openisle.search.SearchEntityListener;
|
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
@@ -20,7 +19,6 @@ import org.hibernate.annotations.CreationTimestamp;
|
|||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@Table(name = "posts")
|
@Table(name = "posts")
|
||||||
@Inheritance(strategy = InheritanceType.JOINED)
|
@Inheritance(strategy = InheritanceType.JOINED)
|
||||||
@EntityListeners(SearchEntityListener.class)
|
|
||||||
public class Post {
|
public class Post {
|
||||||
|
|
||||||
@Id
|
@Id
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.openisle.model;
|
package com.openisle.model;
|
||||||
|
|
||||||
import com.openisle.search.SearchEntityListener;
|
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
@@ -13,7 +12,6 @@ import org.hibernate.annotations.CreationTimestamp;
|
|||||||
@Setter
|
@Setter
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@Table(name = "tags")
|
@Table(name = "tags")
|
||||||
@EntityListeners(SearchEntityListener.class)
|
|
||||||
public class Tag {
|
public class Tag {
|
||||||
|
|
||||||
@Id
|
@Id
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.openisle.model;
|
package com.openisle.model;
|
||||||
|
|
||||||
import com.openisle.search.SearchEntityListener;
|
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.EnumSet;
|
import java.util.EnumSet;
|
||||||
@@ -20,7 +19,6 @@ import org.hibernate.annotations.CreationTimestamp;
|
|||||||
@Setter
|
@Setter
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@Table(name = "users")
|
@Table(name = "users")
|
||||||
@EntityListeners(SearchEntityListener.class)
|
|
||||||
public class User {
|
public class User {
|
||||||
|
|
||||||
@Id
|
@Id
|
||||||
|
|||||||
@@ -1,368 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
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,17 +11,10 @@ import com.openisle.repository.CommentRepository;
|
|||||||
import com.openisle.repository.PostRepository;
|
import com.openisle.repository.PostRepository;
|
||||||
import com.openisle.repository.TagRepository;
|
import com.openisle.repository.TagRepository;
|
||||||
import com.openisle.repository.UserRepository;
|
import com.openisle.repository.UserRepository;
|
||||||
import com.openisle.search.OpenSearchGateway;
|
|
||||||
import com.openisle.search.OpenSearchGateway.PostSearchMode;
|
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
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.Collectors;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
import java.util.stream.StreamSupport;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@@ -34,26 +27,15 @@ public class SearchService {
|
|||||||
private final CommentRepository commentRepository;
|
private final CommentRepository commentRepository;
|
||||||
private final CategoryRepository categoryRepository;
|
private final CategoryRepository categoryRepository;
|
||||||
private final TagRepository tagRepository;
|
private final TagRepository tagRepository;
|
||||||
private final Optional<OpenSearchGateway> openSearchGateway;
|
|
||||||
|
|
||||||
@org.springframework.beans.factory.annotation.Value("${app.snippet-length}")
|
@org.springframework.beans.factory.annotation.Value("${app.snippet-length}")
|
||||||
private int snippetLength;
|
private int snippetLength;
|
||||||
|
|
||||||
public List<User> searchUsers(String keyword) {
|
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);
|
return userRepository.findByUsernameContainingIgnoreCase(keyword);
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Post> searchPosts(String 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(
|
return postRepository.findByTitleContainingIgnoreCaseOrContentContainingIgnoreCaseAndStatus(
|
||||||
keyword,
|
keyword,
|
||||||
keyword,
|
keyword,
|
||||||
@@ -62,49 +44,26 @@ public class SearchService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public List<Post> searchPostsByContent(String keyword) {
|
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);
|
return postRepository.findByContentContainingIgnoreCaseAndStatus(keyword, PostStatus.PUBLISHED);
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Post> searchPostsByTitle(String keyword) {
|
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);
|
return postRepository.findByTitleContainingIgnoreCaseAndStatus(keyword, PostStatus.PUBLISHED);
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Comment> searchComments(String keyword) {
|
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);
|
return commentRepository.findByContentContainingIgnoreCase(keyword);
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Category> searchCategories(String 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);
|
return categoryRepository.findByNameContainingIgnoreCase(keyword);
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Tag> searchTags(String 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);
|
return tagRepository.findByNameContainingIgnoreCaseAndApprovedTrue(keyword);
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<SearchResult> globalSearch(String keyword) {
|
public List<SearchResult> globalSearch(String keyword) {
|
||||||
if (openSearchGateway.isPresent()) {
|
|
||||||
return openSearchGateway.get().globalSearch(keyword, snippetLength);
|
|
||||||
}
|
|
||||||
Stream<SearchResult> users = searchUsers(keyword)
|
Stream<SearchResult> users = searchUsers(keyword)
|
||||||
.stream()
|
.stream()
|
||||||
.map(u ->
|
.map(u ->
|
||||||
@@ -214,18 +173,4 @@ public class SearchService {
|
|||||||
String extra,
|
String extra,
|
||||||
Long postId
|
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,17 +45,6 @@ app.user.replies-limit=${USER_REPLIES_LIMIT:50}
|
|||||||
# Length of extracted snippets for posts and search (-1 to disable truncation)
|
# Length of extracted snippets for posts and search (-1 to disable truncation)
|
||||||
app.snippet-length=${SNIPPET_LENGTH:200}
|
app.snippet-length=${SNIPPET_LENGTH:200}
|
||||||
|
|
||||||
# OpenSearch integration
|
|
||||||
opensearch.enabled=${OPENSEARCH_ENABLED:false}
|
|
||||||
opensearch.hosts=${OPENSEARCH_HOSTS:}
|
|
||||||
opensearch.username=${OPENSEARCH_USERNAME:}
|
|
||||||
opensearch.password=${OPENSEARCH_PASSWORD:}
|
|
||||||
opensearch.insecure=${OPENSEARCH_INSECURE:false}
|
|
||||||
opensearch.connect-timeout=${OPENSEARCH_CONNECT_TIMEOUT:10s}
|
|
||||||
opensearch.socket-timeout=${OPENSEARCH_SOCKET_TIMEOUT:30s}
|
|
||||||
opensearch.max-results=${OPENSEARCH_MAX_RESULTS:50}
|
|
||||||
opensearch.highlight-fallback-length=${OPENSEARCH_HIGHLIGHT_FALLBACK_LENGTH:${SNIPPET_LENGTH:200}}
|
|
||||||
|
|
||||||
# Captcha configuration
|
# Captcha configuration
|
||||||
app.captcha.enabled=${CAPTCHA_ENABLED:false}
|
app.captcha.enabled=${CAPTCHA_ENABLED:false}
|
||||||
recaptcha.secret-key=${RECAPTCHA_SECRET_KEY:}
|
recaptcha.secret-key=${RECAPTCHA_SECRET_KEY:}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import com.openisle.repository.PostRepository;
|
|||||||
import com.openisle.repository.TagRepository;
|
import com.openisle.repository.TagRepository;
|
||||||
import com.openisle.repository.UserRepository;
|
import com.openisle.repository.UserRepository;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.mockito.Mockito;
|
import org.mockito.Mockito;
|
||||||
|
|
||||||
@@ -28,8 +27,7 @@ class SearchServiceTest {
|
|||||||
postRepo,
|
postRepo,
|
||||||
commentRepo,
|
commentRepo,
|
||||||
categoryRepo,
|
categoryRepo,
|
||||||
tagRepo,
|
tagRepo
|
||||||
Optional.empty()
|
|
||||||
);
|
);
|
||||||
|
|
||||||
Post post1 = new Post();
|
Post post1 = new Post();
|
||||||
|
|||||||
Reference in New Issue
Block a user