Revert "feat: add open search support"

This reverts commit 4821b77c17.
This commit is contained in:
tim
2025-09-26 15:36:31 +08:00
parent 4821b77c17
commit 69869348f6
16 changed files with 1 additions and 1008 deletions

View File

@@ -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;
}
}

View File

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

View File

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

View File

@@ -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);
}
}
}