mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-05-11 13:17:29 +08:00
feat: opensearch init
This commit is contained in:
@@ -3,6 +3,7 @@ package com.openisle.service;
|
||||
import com.openisle.config.CachingConfig;
|
||||
import com.openisle.model.Category;
|
||||
import com.openisle.repository.CategoryRepository;
|
||||
import com.openisle.search.SearchIndexEventPublisher;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
@@ -14,6 +15,7 @@ import org.springframework.stereotype.Service;
|
||||
public class CategoryService {
|
||||
|
||||
private final CategoryRepository categoryRepository;
|
||||
private final SearchIndexEventPublisher searchIndexEventPublisher;
|
||||
|
||||
@CacheEvict(value = CachingConfig.CATEGORY_CACHE_NAME, allEntries = true)
|
||||
public Category createCategory(String name, String description, String icon, String smallIcon) {
|
||||
@@ -22,7 +24,9 @@ public class CategoryService {
|
||||
category.setDescription(description);
|
||||
category.setIcon(icon);
|
||||
category.setSmallIcon(smallIcon);
|
||||
return categoryRepository.save(category);
|
||||
Category saved = categoryRepository.save(category);
|
||||
searchIndexEventPublisher.publishCategorySaved(saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
@CacheEvict(value = CachingConfig.CATEGORY_CACHE_NAME, allEntries = true)
|
||||
@@ -48,12 +52,15 @@ public class CategoryService {
|
||||
if (smallIcon != null) {
|
||||
category.setSmallIcon(smallIcon);
|
||||
}
|
||||
return categoryRepository.save(category);
|
||||
Category saved = categoryRepository.save(category);
|
||||
searchIndexEventPublisher.publishCategorySaved(saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
@CacheEvict(value = CachingConfig.CATEGORY_CACHE_NAME, allEntries = true)
|
||||
public void deleteCategory(Long id) {
|
||||
categoryRepository.deleteById(id);
|
||||
searchIndexEventPublisher.publishCategoryDeleted(id);
|
||||
}
|
||||
|
||||
public Category getCategory(Long id) {
|
||||
|
||||
@@ -16,6 +16,7 @@ import com.openisle.repository.PointHistoryRepository;
|
||||
import com.openisle.repository.PostRepository;
|
||||
import com.openisle.repository.ReactionRepository;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import com.openisle.search.SearchIndexEventPublisher;
|
||||
import com.openisle.service.NotificationService;
|
||||
import com.openisle.service.PointService;
|
||||
import com.openisle.service.SubscriptionService;
|
||||
@@ -49,6 +50,7 @@ public class CommentService {
|
||||
private final PointHistoryRepository pointHistoryRepository;
|
||||
private final PointService pointService;
|
||||
private final ImageUploader imageUploader;
|
||||
private final SearchIndexEventPublisher searchIndexEventPublisher;
|
||||
|
||||
@CacheEvict(value = CachingConfig.POST_CACHE_NAME, allEntries = true)
|
||||
@Transactional
|
||||
@@ -124,6 +126,7 @@ public class CommentService {
|
||||
}
|
||||
notificationService.notifyMentions(content, author, post, comment);
|
||||
log.debug("addComment finished for comment {}", comment.getId());
|
||||
searchIndexEventPublisher.publishCommentSaved(comment);
|
||||
return comment;
|
||||
}
|
||||
|
||||
@@ -221,6 +224,7 @@ public class CommentService {
|
||||
}
|
||||
notificationService.notifyMentions(content, author, parent.getPost(), comment);
|
||||
log.debug("addReply finished for comment {}", comment.getId());
|
||||
searchIndexEventPublisher.publishCommentSaved(comment);
|
||||
return comment;
|
||||
}
|
||||
|
||||
@@ -360,7 +364,9 @@ public class CommentService {
|
||||
|
||||
// 逻辑删除评论
|
||||
Post post = comment.getPost();
|
||||
Long commentId = comment.getId();
|
||||
commentRepository.delete(comment);
|
||||
searchIndexEventPublisher.publishCommentDeleted(commentId);
|
||||
// 删除积分历史
|
||||
pointHistoryRepository.deleteAll(pointHistories);
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import com.openisle.repository.PostSubscriptionRepository;
|
||||
import com.openisle.repository.ReactionRepository;
|
||||
import com.openisle.repository.TagRepository;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import com.openisle.search.SearchIndexEventPublisher;
|
||||
import com.openisle.service.EmailSender;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
@@ -73,6 +74,8 @@ public class PostService {
|
||||
private final ConcurrentMap<Long, ScheduledFuture<?>> scheduledFinalizations =
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
private final SearchIndexEventPublisher searchIndexEventPublisher;
|
||||
|
||||
@Value("${app.website-url:https://www.open-isle.com}")
|
||||
private String websiteUrl;
|
||||
|
||||
@@ -103,7 +106,8 @@ public class PostService {
|
||||
PostChangeLogService postChangeLogService,
|
||||
PointHistoryRepository pointHistoryRepository,
|
||||
@Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode,
|
||||
RedisTemplate redisTemplate
|
||||
RedisTemplate redisTemplate,
|
||||
SearchIndexEventPublisher searchIndexEventPublisher
|
||||
) {
|
||||
this.postRepository = postRepository;
|
||||
this.userRepository = userRepository;
|
||||
@@ -130,6 +134,7 @@ public class PostService {
|
||||
this.publishMode = publishMode;
|
||||
|
||||
this.redisTemplate = redisTemplate;
|
||||
this.searchIndexEventPublisher = searchIndexEventPublisher;
|
||||
}
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
@@ -346,6 +351,9 @@ public class PostService {
|
||||
);
|
||||
scheduledFinalizations.put(pp.getId(), future);
|
||||
}
|
||||
if (post.getStatus() == PostStatus.PUBLISHED) {
|
||||
searchIndexEventPublisher.publishPostSaved(post);
|
||||
}
|
||||
return post;
|
||||
}
|
||||
|
||||
@@ -868,10 +876,12 @@ public class PostService {
|
||||
if (!tag.isApproved()) {
|
||||
tag.setApproved(true);
|
||||
tagRepository.save(tag);
|
||||
searchIndexEventPublisher.publishTagSaved(tag);
|
||||
}
|
||||
}
|
||||
post.setStatus(PostStatus.PUBLISHED);
|
||||
post = postRepository.save(post);
|
||||
searchIndexEventPublisher.publishPostSaved(post);
|
||||
notificationService.createNotification(
|
||||
post.getAuthor(),
|
||||
NotificationType.POST_REVIEWED,
|
||||
@@ -895,13 +905,16 @@ public class PostService {
|
||||
if (!tag.isApproved()) {
|
||||
long count = postRepository.countDistinctByTags_Id(tag.getId());
|
||||
if (count <= 1) {
|
||||
Long tagId = tag.getId();
|
||||
post.getTags().remove(tag);
|
||||
tagRepository.delete(tag);
|
||||
searchIndexEventPublisher.publishTagDeleted(tagId);
|
||||
}
|
||||
}
|
||||
}
|
||||
post.setStatus(PostStatus.REJECTED);
|
||||
post = postRepository.save(post);
|
||||
searchIndexEventPublisher.publishPostDeleted(post.getId());
|
||||
notificationService.createNotification(
|
||||
post.getAuthor(),
|
||||
NotificationType.POST_REVIEWED,
|
||||
@@ -1042,6 +1055,9 @@ public class PostService {
|
||||
if (!oldTags.equals(newTags)) {
|
||||
postChangeLogService.recordTagChange(updated, user, oldTags, newTags);
|
||||
}
|
||||
if (updated.getStatus() == PostStatus.PUBLISHED) {
|
||||
searchIndexEventPublisher.publishPostSaved(updated);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -1094,8 +1110,10 @@ public class PostService {
|
||||
}
|
||||
}
|
||||
String title = post.getTitle();
|
||||
Long postId = post.getId();
|
||||
postChangeLogService.deleteLogsForPost(post);
|
||||
postRepository.delete(post);
|
||||
searchIndexEventPublisher.publishPostDeleted(postId);
|
||||
if (adminDeleting) {
|
||||
notificationService.createNotification(
|
||||
author,
|
||||
|
||||
@@ -11,14 +11,26 @@ import com.openisle.repository.CommentRepository;
|
||||
import com.openisle.repository.PostRepository;
|
||||
import com.openisle.repository.TagRepository;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import com.openisle.search.OpenSearchProperties;
|
||||
import com.openisle.search.SearchDocument;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.opensearch.client.opensearch.OpenSearchClient;
|
||||
import org.opensearch.client.opensearch._types.query_dsl.TextQueryType;
|
||||
import org.opensearch.client.opensearch.core.SearchResponse;
|
||||
import org.opensearch.client.opensearch.core.search.Hit;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class SearchService {
|
||||
|
||||
@@ -27,10 +39,14 @@ public class SearchService {
|
||||
private final CommentRepository commentRepository;
|
||||
private final CategoryRepository categoryRepository;
|
||||
private final TagRepository tagRepository;
|
||||
private final Optional<OpenSearchClient> openSearchClient;
|
||||
private final OpenSearchProperties openSearchProperties;
|
||||
|
||||
@org.springframework.beans.factory.annotation.Value("${app.snippet-length}")
|
||||
private int snippetLength;
|
||||
|
||||
private static final int DEFAULT_OPEN_SEARCH_LIMIT = 50;
|
||||
|
||||
public List<User> searchUsers(String keyword) {
|
||||
return userRepository.findByUsernameContainingIgnoreCase(keyword);
|
||||
}
|
||||
@@ -64,6 +80,23 @@ public class SearchService {
|
||||
}
|
||||
|
||||
public List<SearchResult> globalSearch(String keyword) {
|
||||
if (keyword == null || keyword.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
if (isOpenSearchEnabled()) {
|
||||
try {
|
||||
List<SearchResult> results = searchWithOpenSearch(keyword);
|
||||
if (!results.isEmpty()) {
|
||||
return results;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.warn("OpenSearch global search failed, falling back to database query", e);
|
||||
}
|
||||
}
|
||||
return fallbackGlobalSearch(keyword);
|
||||
}
|
||||
|
||||
private List<SearchResult> fallbackGlobalSearch(String keyword) {
|
||||
Stream<SearchResult> users = searchUsers(keyword)
|
||||
.stream()
|
||||
.map(u ->
|
||||
@@ -138,6 +171,143 @@ public class SearchService {
|
||||
.toList();
|
||||
}
|
||||
|
||||
private boolean isOpenSearchEnabled() {
|
||||
return openSearchProperties.isEnabled() && openSearchClient.isPresent();
|
||||
}
|
||||
|
||||
private List<SearchResult> searchWithOpenSearch(String keyword) throws IOException {
|
||||
OpenSearchClient client = openSearchClient.orElse(null);
|
||||
if (client == null) {
|
||||
return List.of();
|
||||
}
|
||||
String trimmed = keyword.trim();
|
||||
SearchResponse<SearchDocument> response = client.search(
|
||||
builder ->
|
||||
builder
|
||||
.index(searchIndices())
|
||||
.query(q ->
|
||||
q.multiMatch(mm ->
|
||||
mm
|
||||
.query(trimmed)
|
||||
.fields(List.of("title^3", "content^2", "author^2", "category", "tags"))
|
||||
.type(TextQueryType.BestFields)
|
||||
)
|
||||
)
|
||||
.highlight(h ->
|
||||
h
|
||||
.preTags("<mark>")
|
||||
.postTags("</mark>")
|
||||
.fields("content", f -> f.fragmentSize(highlightFragmentSize()).numberOfFragments(1))
|
||||
.fields("title", f -> f.fragmentSize(highlightFragmentSize()).numberOfFragments(1))
|
||||
)
|
||||
.size(DEFAULT_OPEN_SEARCH_LIMIT),
|
||||
SearchDocument.class
|
||||
);
|
||||
return mapHits(response.hits().hits(), trimmed);
|
||||
}
|
||||
|
||||
private int highlightFragmentSize() {
|
||||
int configured = openSearchProperties.getHighlightFragmentSize();
|
||||
if (configured > 0) {
|
||||
return configured;
|
||||
}
|
||||
if (snippetLength > 0) {
|
||||
return snippetLength;
|
||||
}
|
||||
return 200;
|
||||
}
|
||||
|
||||
private List<String> searchIndices() {
|
||||
return List.of(
|
||||
openSearchProperties.postsIndex(),
|
||||
openSearchProperties.commentsIndex(),
|
||||
openSearchProperties.usersIndex(),
|
||||
openSearchProperties.categoriesIndex(),
|
||||
openSearchProperties.tagsIndex()
|
||||
);
|
||||
}
|
||||
|
||||
private List<SearchResult> mapHits(List<Hit<SearchDocument>> hits, String keyword) {
|
||||
List<SearchResult> results = new ArrayList<>();
|
||||
for (Hit<SearchDocument> hit : hits) {
|
||||
SearchResult result = mapHit(hit, keyword);
|
||||
if (result != null) {
|
||||
results.add(result);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private SearchResult mapHit(Hit<SearchDocument> hit, String keyword) {
|
||||
SearchDocument document = hit.source();
|
||||
if (document == null || document.entityId() == null) {
|
||||
return null;
|
||||
}
|
||||
Map<String, List<String>> highlight = hit.highlight();
|
||||
String highlightedContent = firstHighlight(highlight, "content");
|
||||
String highlightedTitle = firstHighlight(highlight, "title");
|
||||
boolean highlightTitle = highlightedTitle != null && !highlightedTitle.isBlank();
|
||||
String documentType = document.type() != null ? document.type() : "";
|
||||
String effectiveType = documentType;
|
||||
if ("post".equals(documentType) && highlightTitle) {
|
||||
effectiveType = "post_title";
|
||||
}
|
||||
String snippet = highlightedContent != null && !highlightedContent.isBlank()
|
||||
? cleanHighlight(highlightedContent)
|
||||
: null;
|
||||
if (snippet == null && highlightTitle) {
|
||||
snippet = cleanHighlight(highlightedTitle);
|
||||
}
|
||||
boolean fromStart = "post_title".equals(effectiveType);
|
||||
if (snippet == null || snippet.isBlank()) {
|
||||
snippet = fallbackSnippet(document.content(), keyword, fromStart);
|
||||
}
|
||||
if (snippet == null) {
|
||||
snippet = "";
|
||||
}
|
||||
String subText = null;
|
||||
Long postId = null;
|
||||
if ("post".equals(documentType) || "post_title".equals(effectiveType)) {
|
||||
subText = document.category();
|
||||
} else if ("comment".equals(documentType)) {
|
||||
subText = document.author();
|
||||
postId = document.postId();
|
||||
}
|
||||
return new SearchResult(
|
||||
effectiveType,
|
||||
document.entityId(),
|
||||
document.title(),
|
||||
subText,
|
||||
snippet,
|
||||
postId
|
||||
);
|
||||
}
|
||||
|
||||
private String firstHighlight(Map<String, List<String>> highlight, String field) {
|
||||
if (highlight == null || field == null) {
|
||||
return null;
|
||||
}
|
||||
List<String> values = highlight.get(field);
|
||||
if (values == null || values.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return values.get(0);
|
||||
}
|
||||
|
||||
private String cleanHighlight(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
return value.replaceAll("<[^>]+>", "");
|
||||
}
|
||||
|
||||
private String fallbackSnippet(String content, String keyword, boolean fromStart) {
|
||||
if (content == null) {
|
||||
return "";
|
||||
}
|
||||
return extractSnippet(content, keyword, fromStart);
|
||||
}
|
||||
|
||||
private String extractSnippet(String content, String keyword, boolean fromStart) {
|
||||
if (content == null) return "";
|
||||
int limit = snippetLength;
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.openisle.model.Tag;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.repository.TagRepository;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import com.openisle.search.SearchIndexEventPublisher;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
@@ -20,6 +21,7 @@ public class TagService {
|
||||
private final TagRepository tagRepository;
|
||||
private final TagValidator tagValidator;
|
||||
private final UserRepository userRepository;
|
||||
private final SearchIndexEventPublisher searchIndexEventPublisher;
|
||||
|
||||
@CacheEvict(value = CachingConfig.TAG_CACHE_NAME, allEntries = true)
|
||||
public Tag createTag(
|
||||
@@ -43,7 +45,9 @@ public class TagService {
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
tag.setCreator(creator);
|
||||
}
|
||||
return tagRepository.save(tag);
|
||||
Tag saved = tagRepository.save(tag);
|
||||
searchIndexEventPublisher.publishTagSaved(saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
public Tag createTag(
|
||||
@@ -78,12 +82,15 @@ public class TagService {
|
||||
if (smallIcon != null) {
|
||||
tag.setSmallIcon(smallIcon);
|
||||
}
|
||||
return tagRepository.save(tag);
|
||||
Tag saved = tagRepository.save(tag);
|
||||
searchIndexEventPublisher.publishTagSaved(saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
@CacheEvict(value = CachingConfig.TAG_CACHE_NAME, allEntries = true)
|
||||
public void deleteTag(Long id) {
|
||||
tagRepository.deleteById(id);
|
||||
searchIndexEventPublisher.publishTagDeleted(id);
|
||||
}
|
||||
|
||||
public Tag approveTag(Long id) {
|
||||
@@ -91,7 +98,9 @@ public class TagService {
|
||||
.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Tag not found"));
|
||||
tag.setApproved(true);
|
||||
return tagRepository.save(tag);
|
||||
Tag saved = tagRepository.save(tag);
|
||||
searchIndexEventPublisher.publishTagSaved(saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
public List<Tag> listPendingTags() {
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.openisle.exception.FieldException;
|
||||
import com.openisle.model.Role;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import com.openisle.search.SearchIndexEventPublisher;
|
||||
import com.openisle.service.AvatarGenerator;
|
||||
import com.openisle.service.PasswordValidator;
|
||||
import com.openisle.service.UsernameValidator;
|
||||
@@ -34,6 +35,7 @@ public class UserService {
|
||||
private final RedisTemplate redisTemplate;
|
||||
|
||||
private final EmailSender emailService;
|
||||
private final SearchIndexEventPublisher searchIndexEventPublisher;
|
||||
|
||||
public User register(
|
||||
String username,
|
||||
@@ -58,7 +60,9 @@ public class UserService {
|
||||
// u.setVerificationCode(genCode());
|
||||
u.setRegisterReason(reason);
|
||||
u.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
|
||||
return userRepository.save(u);
|
||||
User saved = userRepository.save(u);
|
||||
searchIndexEventPublisher.publishUserSaved(saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
// ── 再按邮箱查 ───────────────────────────────────────────
|
||||
@@ -75,7 +79,9 @@ public class UserService {
|
||||
// u.setVerificationCode(genCode());
|
||||
u.setRegisterReason(reason);
|
||||
u.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
|
||||
return userRepository.save(u);
|
||||
User saved = userRepository.save(u);
|
||||
searchIndexEventPublisher.publishUserSaved(saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
// ── 完全新用户 ───────────────────────────────────────────
|
||||
@@ -89,14 +95,18 @@ public class UserService {
|
||||
user.setAvatar(avatarGenerator.generate(username));
|
||||
user.setRegisterReason(reason);
|
||||
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
|
||||
return userRepository.save(user);
|
||||
User saved = userRepository.save(user);
|
||||
searchIndexEventPublisher.publishUserSaved(saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
public User registerWithInvite(String username, String email, String password) {
|
||||
User user = register(username, email, password, "", com.openisle.model.RegisterMode.DIRECT);
|
||||
user.setVerified(true);
|
||||
// user.setVerificationCode(genCode());
|
||||
return userRepository.save(user);
|
||||
User saved = userRepository.save(user);
|
||||
searchIndexEventPublisher.publishUserSaved(saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
private String genCode() {
|
||||
@@ -209,7 +219,9 @@ public class UserService {
|
||||
.findByUsername(username)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
user.setRegisterReason(reason);
|
||||
return userRepository.save(user);
|
||||
User saved = userRepository.save(user);
|
||||
searchIndexEventPublisher.publishUserSaved(saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
public User updateProfile(String currentUsername, String newUsername, String introduction) {
|
||||
|
||||
Reference in New Issue
Block a user