diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5bfb41eb7..28c013027 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -2,8 +2,8 @@ name: CI & CD on: workflow_dispatch: - schedule: - - cron: "0 19 * * *" # 每天 UTC 19:00,相当于北京时间凌晨3点 + # schedule: + # - cron: "0 19 * * *" # 每天 UTC 19:00,相当于北京时间凌晨3点 jobs: build-and-deploy: diff --git a/backend/pom.xml b/backend/pom.xml index 97d8c7f65..a0f1c9b7e 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -132,6 +132,19 @@ springdoc-openapi-starter-webmvc-api 2.2.0 + + + org.opensearch.client + opensearch-java + 3.2.0 + + + + + org.opensearch.client + opensearch-rest-client + 3.2.0 + diff --git a/backend/src/main/java/com/openisle/controller/SearchController.java b/backend/src/main/java/com/openisle/controller/SearchController.java index 1757e0fe7..5e1bac0bc 100644 --- a/backend/src/main/java/com/openisle/controller/SearchController.java +++ b/backend/src/main/java/com/openisle/controller/SearchController.java @@ -115,6 +115,9 @@ public class SearchController { dto.setSubText(r.subText()); dto.setExtra(r.extra()); dto.setPostId(r.postId()); + dto.setHighlightedText(r.highlightedText()); + dto.setHighlightedSubText(r.highlightedSubText()); + dto.setHighlightedExtra(r.highlightedExtra()); return dto; }) .collect(Collectors.toList()); diff --git a/backend/src/main/java/com/openisle/dto/SearchResultDto.java b/backend/src/main/java/com/openisle/dto/SearchResultDto.java index 59b7b0c72..faf09437e 100644 --- a/backend/src/main/java/com/openisle/dto/SearchResultDto.java +++ b/backend/src/main/java/com/openisle/dto/SearchResultDto.java @@ -12,4 +12,7 @@ public class SearchResultDto { private String subText; private String extra; private Long postId; + private String highlightedText; + private String highlightedSubText; + private String highlightedExtra; } diff --git a/backend/src/main/java/com/openisle/search/NoopSearchIndexer.java b/backend/src/main/java/com/openisle/search/NoopSearchIndexer.java new file mode 100644 index 000000000..afaca419c --- /dev/null +++ b/backend/src/main/java/com/openisle/search/NoopSearchIndexer.java @@ -0,0 +1,14 @@ +package com.openisle.search; + +public class NoopSearchIndexer implements SearchIndexer { + + @Override + public void indexDocument(String index, SearchDocument document) { + // no-op + } + + @Override + public void deleteDocument(String index, Long id) { + // no-op + } +} diff --git a/backend/src/main/java/com/openisle/search/OpenSearchConfig.java b/backend/src/main/java/com/openisle/search/OpenSearchConfig.java new file mode 100644 index 000000000..8ab484059 --- /dev/null +++ b/backend/src/main/java/com/openisle/search/OpenSearchConfig.java @@ -0,0 +1,78 @@ +package com.openisle.search; + +import org.apache.hc.client5.http.auth.AuthScope; +import org.apache.hc.client5.http.auth.UsernamePasswordCredentials; +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.RestClientBuilder; +import org.opensearch.client.json.jackson.JacksonJsonpMapper; +import org.opensearch.client.opensearch.OpenSearchClient; +import org.opensearch.client.transport.rest_client.RestClientTransport; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +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; +import org.springframework.util.StringUtils; + +@Configuration +@EnableConfigurationProperties(OpenSearchProperties.class) +public class OpenSearchConfig { + + @Bean(destroyMethod = "close") + @ConditionalOnProperty(prefix = "app.search", name = "enabled", havingValue = "true") + public RestClient openSearchRestClient(OpenSearchProperties properties) { + RestClientBuilder builder = RestClient.builder( + new HttpHost(properties.getScheme(), properties.getHost(), properties.getPort()) + ); + if (StringUtils.hasText(properties.getUsername())) { + BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider(); + credentialsProvider.setCredentials( + new AuthScope(properties.getHost(), properties.getPort()), + new UsernamePasswordCredentials( + properties.getUsername(), + properties.getPassword() != null ? properties.getPassword().toCharArray() : new char[0] + ) + ); + builder.setHttpClientConfigCallback(httpClientBuilder -> + httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider) + ); + } + return builder.build(); + } + + @Bean(destroyMethod = "close") + @ConditionalOnBean(RestClient.class) + public RestClientTransport openSearchTransport(RestClient restClient) { + return new RestClientTransport(restClient, new JacksonJsonpMapper()); + } + + @Bean + @ConditionalOnBean(RestClientTransport.class) + public OpenSearchClient openSearchClient(RestClientTransport transport) { + return new OpenSearchClient(transport); + } + + @Bean + @ConditionalOnBean(OpenSearchClient.class) + public SearchIndexInitializer searchIndexInitializer( + OpenSearchClient client, + OpenSearchProperties properties + ) { + return new SearchIndexInitializer(client, properties); + } + + @Bean + @ConditionalOnBean(OpenSearchClient.class) + public SearchIndexer openSearchIndexer(OpenSearchClient client, OpenSearchProperties properties) { + return new OpenSearchIndexer(client); + } + + @Bean + @ConditionalOnMissingBean(SearchIndexer.class) + public SearchIndexer noopSearchIndexer() { + return new NoopSearchIndexer(); + } +} diff --git a/backend/src/main/java/com/openisle/search/OpenSearchIndexer.java b/backend/src/main/java/com/openisle/search/OpenSearchIndexer.java new file mode 100644 index 000000000..a677d79cc --- /dev/null +++ b/backend/src/main/java/com/openisle/search/OpenSearchIndexer.java @@ -0,0 +1,49 @@ +package com.openisle.search; + +import java.io.IOException; +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.opensearch.client.opensearch.core.IndexResponse; + +@Slf4j +@RequiredArgsConstructor +public class OpenSearchIndexer implements SearchIndexer { + + private final OpenSearchClient client; + + @Override + public void indexDocument(String index, SearchDocument document) { + if (document == null || document.entityId() == null) { + return; + } + try { + IndexRequest request = IndexRequest.of(builder -> + builder.index(index).id(document.entityId().toString()).document(document) + ); + IndexResponse response = client.index(request); + log.info( + "Indexed document {} into {} with result {}", + document.entityId(), + index, + response.result() + ); + } catch (IOException e) { + log.warn("Failed to index document {} into {}", document.entityId(), index, e); + } + } + + @Override + public void deleteDocument(String index, Long id) { + if (id == null) { + return; + } + try { + client.delete(DeleteRequest.of(builder -> builder.index(index).id(id.toString()))); + } catch (IOException e) { + log.warn("Failed to delete document {} from {}", id, index, e); + } + } +} diff --git a/backend/src/main/java/com/openisle/search/OpenSearchProperties.java b/backend/src/main/java/com/openisle/search/OpenSearchProperties.java new file mode 100644 index 000000000..a334ad8d9 --- /dev/null +++ b/backend/src/main/java/com/openisle/search/OpenSearchProperties.java @@ -0,0 +1,63 @@ +package com.openisle.search; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@Setter +@ConfigurationProperties(prefix = "app.search") +public class OpenSearchProperties { + + private boolean enabled = false; + private String host = "localhost"; + private int port = 9200; + private String scheme = "http"; + private String username; + private String password; + private String indexPrefix = "openisle"; + private boolean initialize = true; + private int highlightFragmentSize = 200; + private boolean reindexOnStartup = false; + private int reindexBatchSize = 500; + + private Indices indices = new Indices(); + + public String postsIndex() { + return indexName(indices.posts); + } + + public String commentsIndex() { + return indexName(indices.comments); + } + + public String usersIndex() { + return indexName(indices.users); + } + + public String categoriesIndex() { + return indexName(indices.categories); + } + + public String tagsIndex() { + return indexName(indices.tags); + } + + private String indexName(String suffix) { + if (indexPrefix == null || indexPrefix.isBlank()) { + return suffix; + } + return indexPrefix + "-" + suffix; + } + + @Getter + @Setter + public static class Indices { + + private String posts = "posts"; + private String comments = "comments"; + private String users = "users"; + private String categories = "categories"; + private String tags = "tags"; + } +} diff --git a/backend/src/main/java/com/openisle/search/SearchDocument.java b/backend/src/main/java/com/openisle/search/SearchDocument.java new file mode 100644 index 000000000..08ef0e6e6 --- /dev/null +++ b/backend/src/main/java/com/openisle/search/SearchDocument.java @@ -0,0 +1,15 @@ +package com.openisle.search; + +import java.util.List; + +public record SearchDocument( + String type, + Long entityId, + String title, + String content, + String author, + String category, + List tags, + Long postId, + Long createdAt +) {} diff --git a/backend/src/main/java/com/openisle/search/SearchDocumentFactory.java b/backend/src/main/java/com/openisle/search/SearchDocumentFactory.java new file mode 100644 index 000000000..26174ac57 --- /dev/null +++ b/backend/src/main/java/com/openisle/search/SearchDocumentFactory.java @@ -0,0 +1,127 @@ +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 java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +public final class SearchDocumentFactory { + + private SearchDocumentFactory() {} + + public static SearchDocument fromPost(Post post) { + if (post == null || post.getId() == null) { + return null; + } + List tags = post.getTags() == null + ? Collections.emptyList() + : post + .getTags() + .stream() + .map(Tag::getName) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + return new SearchDocument( + "post", + post.getId(), + post.getTitle(), + post.getContent(), + post.getAuthor() != null ? post.getAuthor().getUsername() : null, + post.getCategory() != null ? post.getCategory().getName() : null, + tags, + post.getId(), + toEpochMillis(post.getCreatedAt()) + ); + } + + public static SearchDocument fromComment(Comment comment) { + if (comment == null || comment.getId() == null) { + return null; + } + Post post = comment.getPost(); + List tags = post == null || post.getTags() == null + ? Collections.emptyList() + : post + .getTags() + .stream() + .map(Tag::getName) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + return new SearchDocument( + "comment", + comment.getId(), + post != null ? post.getTitle() : null, + comment.getContent(), + comment.getAuthor() != null ? comment.getAuthor().getUsername() : null, + post != null && post.getCategory() != null ? post.getCategory().getName() : null, + tags, + post != null ? post.getId() : null, + toEpochMillis(comment.getCreatedAt()) + ); + } + + public static SearchDocument fromUser(User user) { + if (user == null || user.getId() == null) { + return null; + } + return new SearchDocument( + "user", + user.getId(), + user.getUsername(), + user.getIntroduction(), + null, + null, + Collections.emptyList(), + null, + toEpochMillis(user.getCreatedAt()) + ); + } + + public static SearchDocument fromCategory(Category category) { + if (category == null || category.getId() == null) { + return null; + } + return new SearchDocument( + "category", + category.getId(), + category.getName(), + category.getDescription(), + null, + null, + Collections.emptyList(), + null, + null + ); + } + + public static SearchDocument fromTag(Tag tag) { + if (tag == null || tag.getId() == null) { + return null; + } + return new SearchDocument( + "tag", + tag.getId(), + tag.getName(), + tag.getDescription(), + null, + null, + Collections.emptyList(), + null, + toEpochMillis(tag.getCreatedAt()) + ); + } + + private static Long toEpochMillis(LocalDateTime time) { + if (time == null) { + return null; + } + return time.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); + } +} diff --git a/backend/src/main/java/com/openisle/search/SearchIndexEventListener.java b/backend/src/main/java/com/openisle/search/SearchIndexEventListener.java new file mode 100644 index 000000000..5565b039e --- /dev/null +++ b/backend/src/main/java/com/openisle/search/SearchIndexEventListener.java @@ -0,0 +1,33 @@ +package com.openisle.search; + +import com.openisle.search.event.DeleteDocumentEvent; +import com.openisle.search.event.IndexDocumentEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SearchIndexEventListener { + + private final SearchIndexer searchIndexer; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + public void handleIndex(IndexDocumentEvent event) { + if (event == null || event.document() == null) { + return; + } + searchIndexer.indexDocument(event.index(), event.document()); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + public void handleDelete(DeleteDocumentEvent event) { + if (event == null) { + return; + } + searchIndexer.deleteDocument(event.index(), event.id()); + } +} diff --git a/backend/src/main/java/com/openisle/search/SearchIndexEventPublisher.java b/backend/src/main/java/com/openisle/search/SearchIndexEventPublisher.java new file mode 100644 index 000000000..4f5fbbdd6 --- /dev/null +++ b/backend/src/main/java/com/openisle/search/SearchIndexEventPublisher.java @@ -0,0 +1,99 @@ +package com.openisle.search; + +import com.openisle.model.Category; +import com.openisle.model.Comment; +import com.openisle.model.Post; +import com.openisle.model.PostStatus; +import com.openisle.model.Tag; +import com.openisle.model.User; +import com.openisle.search.event.DeleteDocumentEvent; +import com.openisle.search.event.IndexDocumentEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class SearchIndexEventPublisher { + + private final ApplicationEventPublisher publisher; + private final OpenSearchProperties properties; + + public void publishPostSaved(Post post) { + if (!properties.isEnabled() || post == null || post.getStatus() != PostStatus.PUBLISHED) { + return; + } + SearchDocument document = SearchDocumentFactory.fromPost(post); + if (document != null) { + publisher.publishEvent(new IndexDocumentEvent(properties.postsIndex(), document)); + } + } + + public void publishPostDeleted(Long postId) { + if (!properties.isEnabled() || postId == null) { + return; + } + publisher.publishEvent(new DeleteDocumentEvent(properties.postsIndex(), postId)); + } + + public void publishCommentSaved(Comment comment) { + if (!properties.isEnabled() || comment == null) { + return; + } + SearchDocument document = SearchDocumentFactory.fromComment(comment); + if (document != null) { + publisher.publishEvent(new IndexDocumentEvent(properties.commentsIndex(), document)); + } + } + + public void publishCommentDeleted(Long commentId) { + if (!properties.isEnabled() || commentId == null) { + return; + } + publisher.publishEvent(new DeleteDocumentEvent(properties.commentsIndex(), commentId)); + } + + public void publishUserSaved(User user) { + if (!properties.isEnabled() || user == null) { + return; + } + SearchDocument document = SearchDocumentFactory.fromUser(user); + if (document != null) { + publisher.publishEvent(new IndexDocumentEvent(properties.usersIndex(), document)); + } + } + + public void publishCategorySaved(Category category) { + if (!properties.isEnabled() || category == null) { + return; + } + SearchDocument document = SearchDocumentFactory.fromCategory(category); + if (document != null) { + publisher.publishEvent(new IndexDocumentEvent(properties.categoriesIndex(), document)); + } + } + + public void publishCategoryDeleted(Long categoryId) { + if (!properties.isEnabled() || categoryId == null) { + return; + } + publisher.publishEvent(new DeleteDocumentEvent(properties.categoriesIndex(), categoryId)); + } + + public void publishTagSaved(Tag tag) { + if (!properties.isEnabled() || tag == null || !tag.isApproved()) { + return; + } + SearchDocument document = SearchDocumentFactory.fromTag(tag); + if (document != null) { + publisher.publishEvent(new IndexDocumentEvent(properties.tagsIndex(), document)); + } + } + + public void publishTagDeleted(Long tagId) { + if (!properties.isEnabled() || tagId == null) { + return; + } + publisher.publishEvent(new DeleteDocumentEvent(properties.tagsIndex(), tagId)); + } +} diff --git a/backend/src/main/java/com/openisle/search/SearchIndexInitializer.java b/backend/src/main/java/com/openisle/search/SearchIndexInitializer.java new file mode 100644 index 000000000..0e2e20760 --- /dev/null +++ b/backend/src/main/java/com/openisle/search/SearchIndexInitializer.java @@ -0,0 +1,219 @@ +package com.openisle.search; + +import jakarta.annotation.PostConstruct; +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.opensearch.client.json.JsonData; +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.IndexSettings; + +@Slf4j +@RequiredArgsConstructor +public class SearchIndexInitializer { + + private final OpenSearchClient client; + private final OpenSearchProperties properties; + + @PostConstruct + public void initialize() { + if (!properties.isEnabled() || !properties.isInitialize()) { + return; + } + ensureIndex(properties.postsIndex(), this::postMapping); + ensureIndex(properties.commentsIndex(), this::commentMapping); + ensureIndex(properties.usersIndex(), this::userMapping); + ensureIndex(properties.categoriesIndex(), this::categoryMapping); + ensureIndex(properties.tagsIndex(), this::tagMapping); + } + + private void ensureIndex(String index, java.util.function.Supplier mappingSupplier) { + try { + boolean exists = client + .indices() + .exists(builder -> builder.index(index)) + .value(); + if (exists) { + return; + } + client + .indices() + .create(builder -> + builder.index(index).settings(this::applyPinyinAnalysis).mappings(mappingSupplier.get()) + ); + log.info("Created OpenSearch index {}", index); + } catch (IOException e) { + log.warn("Failed to initialize OpenSearch index {}", index, e); + } + } + + private TypeMapping postMapping() { + return TypeMapping.of(builder -> + builder + .properties("type", Property.of(p -> p.keyword(k -> k))) + .properties("title", textWithRawAndPinyin()) + .properties("content", textWithPinyinOnly()) // content 不做 .raw,避免超长 keyword + .properties("author", keywordWithRawAndPinyin()) + .properties("category", keywordWithRawAndPinyin()) + .properties("tags", keywordWithRawAndPinyin()) + .properties("postId", Property.of(p -> p.long_(l -> l))) + .properties( + "createdAt", + Property.of(p -> p.date(d -> d.format("strict_date_optional_time||epoch_millis"))) + ) + ); + } + + private TypeMapping commentMapping() { + return TypeMapping.of(builder -> + builder + .properties("type", Property.of(p -> p.keyword(k -> k))) + .properties("title", textWithRawAndPinyin()) + .properties("content", textWithPinyinOnly()) + .properties("author", keywordWithRawAndPinyin()) + .properties("category", keywordWithRawAndPinyin()) + .properties("tags", keywordWithRawAndPinyin()) + .properties("postId", Property.of(p -> p.long_(l -> l))) + .properties( + "createdAt", + Property.of(p -> p.date(d -> d.format("strict_date_optional_time||epoch_millis"))) + ) + ); + } + + private TypeMapping userMapping() { + return TypeMapping.of(builder -> + builder + .properties("type", Property.of(p -> p.keyword(k -> k))) + .properties("title", textWithRawAndPinyin()) + .properties("content", textWithPinyinOnly()) + .properties( + "createdAt", + Property.of(p -> p.date(d -> d.format("strict_date_optional_time||epoch_millis"))) + ) + ); + } + + private TypeMapping categoryMapping() { + return TypeMapping.of(builder -> + builder + .properties("type", Property.of(p -> p.keyword(k -> k))) + .properties("title", textWithRawAndPinyin()) + .properties("content", textWithPinyinOnly()) + ); + } + + private TypeMapping tagMapping() { + return TypeMapping.of(builder -> + builder + .properties("type", Property.of(p -> p.keyword(k -> k))) + .properties("title", textWithRawAndPinyin()) + .properties("content", textWithPinyinOnly()) + .properties( + "createdAt", + Property.of(p -> p.date(d -> d.format("strict_date_optional_time||epoch_millis"))) + ) + ); + } + + /** 文本字段:.raw(keyword 精确) + .py(拼音短语精确) + .zh(ICU+2~3gram 召回) */ + private Property textWithRawAndPinyin() { + return Property.of(p -> + p.text(t -> + t + .fields("raw", f -> f.keyword(k -> k.normalizer("lowercase_normalizer"))) + .fields("py", f -> f.text(sub -> sub.analyzer("py_index").searchAnalyzer("py_search"))) + .fields("zh", f -> + f.text(sub -> sub.analyzer("zh_ngram_index").searchAnalyzer("zh_search")) + ) + ) + ); + } + + /** 长文本 content:保留拼音 + 新增 zh 子字段(不加 .raw,避免过长 keyword) */ + private Property textWithPinyinOnly() { + return Property.of(p -> + p.text(t -> + t + .fields("py", f -> f.text(sub -> sub.analyzer("py_index").searchAnalyzer("py_search"))) + .fields("zh", f -> + f.text(sub -> sub.analyzer("zh_ngram_index").searchAnalyzer("zh_search")) + ) + ) + ); + } + + /** 关键词字段(author/category/tags):keyword 等值 + .py + .zh(尽量对齐标题策略) */ + private Property keywordWithRawAndPinyin() { + return Property.of(p -> + p.keyword(k -> + k + .normalizer("lowercase_normalizer") + .fields("raw", f -> f.keyword(kk -> kk.normalizer("lowercase_normalizer"))) + .fields("py", f -> f.text(sub -> sub.analyzer("py_index").searchAnalyzer("py_search"))) + .fields("zh", f -> + f.text(sub -> sub.analyzer("zh_ngram_index").searchAnalyzer("zh_search")) + ) + ) + ); + } + + /** 新增 zh 分析器(ICU + 2~3gram),并保留你已有的 pinyin/normalizer 设置 */ + private IndexSettings.Builder applyPinyinAnalysis(IndexSettings.Builder builder) { + Map settings = new LinkedHashMap<>(); + + // --- 已有:keyword normalizer(用于 .raw) + settings.put("analysis.normalizer.lowercase_normalizer.type", JsonData.of("custom")); + settings.put( + "analysis.normalizer.lowercase_normalizer.filter", + JsonData.of(List.of("lowercase")) + ); + + // --- 已有:pinyin filter + analyzers + settings.put("analysis.filter.py_filter.type", JsonData.of("pinyin")); + settings.put("analysis.filter.py_filter.keep_full_pinyin", JsonData.of(true)); + settings.put("analysis.filter.py_filter.keep_joined_full_pinyin", JsonData.of(true)); + settings.put("analysis.filter.py_filter.keep_first_letter", JsonData.of(false)); + settings.put("analysis.filter.py_filter.remove_duplicated_term", JsonData.of(true)); + + settings.put("analysis.analyzer.py_index.type", JsonData.of("custom")); + settings.put("analysis.analyzer.py_index.tokenizer", JsonData.of("standard")); + settings.put( + "analysis.analyzer.py_index.filter", + JsonData.of(List.of("lowercase", "py_filter")) + ); + + settings.put("analysis.analyzer.py_search.type", JsonData.of("custom")); + settings.put("analysis.analyzer.py_search.tokenizer", JsonData.of("standard")); + settings.put( + "analysis.analyzer.py_search.filter", + JsonData.of(List.of("lowercase", "py_filter")) + ); + + settings.put("analysis.filter.zh_ngram_2_3.type", JsonData.of("ngram")); + settings.put("analysis.filter.zh_ngram_2_3.min_gram", JsonData.of(2)); + settings.put("analysis.filter.zh_ngram_2_3.max_gram", JsonData.of(3)); + + settings.put("analysis.analyzer.zh_ngram_index.type", JsonData.of("custom")); + settings.put("analysis.analyzer.zh_ngram_index.tokenizer", JsonData.of("icu_tokenizer")); + settings.put( + "analysis.analyzer.zh_ngram_index.filter", + JsonData.of(List.of("lowercase", "zh_ngram_2_3")) + ); + + settings.put("analysis.analyzer.zh_search.type", JsonData.of("custom")); + settings.put("analysis.analyzer.zh_search.tokenizer", JsonData.of("icu_tokenizer")); + settings.put( + "analysis.analyzer.zh_search.filter", + JsonData.of(List.of("lowercase", "zh_ngram_2_3")) + ); + + settings.forEach(builder::customSettings); + return builder; + } +} diff --git a/backend/src/main/java/com/openisle/search/SearchIndexer.java b/backend/src/main/java/com/openisle/search/SearchIndexer.java new file mode 100644 index 000000000..c9da039ed --- /dev/null +++ b/backend/src/main/java/com/openisle/search/SearchIndexer.java @@ -0,0 +1,6 @@ +package com.openisle.search; + +public interface SearchIndexer { + void indexDocument(String index, SearchDocument document); + void deleteDocument(String index, Long id); +} diff --git a/backend/src/main/java/com/openisle/search/SearchReindexInitializer.java b/backend/src/main/java/com/openisle/search/SearchReindexInitializer.java new file mode 100644 index 000000000..5eddf68f5 --- /dev/null +++ b/backend/src/main/java/com/openisle/search/SearchReindexInitializer.java @@ -0,0 +1,30 @@ +package com.openisle.search; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SearchReindexInitializer implements CommandLineRunner { + + private final OpenSearchProperties properties; + private final SearchReindexService searchReindexService; + + @Override + public void run(String... args) { + if (!properties.isEnabled()) { + log.info("Search indexing disabled, skipping startup reindex."); + return; + } + + if (!properties.isReindexOnStartup()) { + log.debug("Startup reindex disabled by configuration."); + return; + } + + searchReindexService.reindexAll(); + } +} diff --git a/backend/src/main/java/com/openisle/search/SearchReindexService.java b/backend/src/main/java/com/openisle/search/SearchReindexService.java new file mode 100644 index 000000000..1179eadd6 --- /dev/null +++ b/backend/src/main/java/com/openisle/search/SearchReindexService.java @@ -0,0 +1,94 @@ +package com.openisle.search; + +import com.openisle.model.Post; +import com.openisle.model.PostStatus; +import com.openisle.model.Tag; +import com.openisle.repository.CategoryRepository; +import com.openisle.repository.CommentRepository; +import com.openisle.repository.PostRepository; +import com.openisle.repository.TagRepository; +import com.openisle.repository.UserRepository; +import java.util.Objects; +import java.util.function.Function; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SearchReindexService { + + private final SearchIndexer searchIndexer; + private final OpenSearchProperties properties; + private final PostRepository postRepository; + private final CommentRepository commentRepository; + private final UserRepository userRepository; + private final CategoryRepository categoryRepository; + private final TagRepository tagRepository; + + @Transactional(readOnly = true) + public void reindexAll() { + if (!properties.isEnabled()) { + log.info("Search indexing is disabled, skipping reindex operation."); + return; + } + + log.info("Starting full search reindex operation."); + + reindex(properties.postsIndex(), postRepository::findAll, (Post post) -> + post.getStatus() == PostStatus.PUBLISHED ? SearchDocumentFactory.fromPost(post) : null + ); + + reindex( + properties.commentsIndex(), + commentRepository::findAll, + SearchDocumentFactory::fromComment + ); + + reindex(properties.usersIndex(), userRepository::findAll, SearchDocumentFactory::fromUser); + + reindex( + properties.categoriesIndex(), + categoryRepository::findAll, + SearchDocumentFactory::fromCategory + ); + + reindex(properties.tagsIndex(), tagRepository::findAll, (Tag tag) -> + tag.isApproved() ? SearchDocumentFactory.fromTag(tag) : null + ); + + log.info("Completed full search reindex operation."); + } + + private void reindex( + String index, + Function> pageSupplier, + Function mapper + ) { + int batchSize = Math.max(1, properties.getReindexBatchSize()); + int pageNumber = 0; + + Page page; + do { + Pageable pageable = PageRequest.of(pageNumber, batchSize); + page = pageSupplier.apply(pageable); + if (page.isEmpty() && pageNumber == 0) { + log.info("No entities found for index {}.", index); + } + + log.info("Reindexing {} entities for index {}.", page.getTotalElements(), index); + for (T entity : page) { + SearchDocument document = mapper.apply(entity); + if (Objects.nonNull(document)) { + searchIndexer.indexDocument(index, document); + } + } + pageNumber++; + } while (page.hasNext()); + } +} diff --git a/backend/src/main/java/com/openisle/search/event/DeleteDocumentEvent.java b/backend/src/main/java/com/openisle/search/event/DeleteDocumentEvent.java new file mode 100644 index 000000000..42c934a34 --- /dev/null +++ b/backend/src/main/java/com/openisle/search/event/DeleteDocumentEvent.java @@ -0,0 +1,3 @@ +package com.openisle.search.event; + +public record DeleteDocumentEvent(String index, Long id) {} diff --git a/backend/src/main/java/com/openisle/search/event/IndexDocumentEvent.java b/backend/src/main/java/com/openisle/search/event/IndexDocumentEvent.java new file mode 100644 index 000000000..3447bbd9e --- /dev/null +++ b/backend/src/main/java/com/openisle/search/event/IndexDocumentEvent.java @@ -0,0 +1,5 @@ +package com.openisle.search.event; + +import com.openisle.search.SearchDocument; + +public record IndexDocumentEvent(String index, SearchDocument document) {} diff --git a/backend/src/main/java/com/openisle/service/CategoryService.java b/backend/src/main/java/com/openisle/service/CategoryService.java index 3d375e6e1..a9b7779af 100644 --- a/backend/src/main/java/com/openisle/service/CategoryService.java +++ b/backend/src/main/java/com/openisle/service/CategoryService.java @@ -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) { diff --git a/backend/src/main/java/com/openisle/service/CommentService.java b/backend/src/main/java/com/openisle/service/CommentService.java index fd8d1d1f7..9da1af2d7 100644 --- a/backend/src/main/java/com/openisle/service/CommentService.java +++ b/backend/src/main/java/com/openisle/service/CommentService.java @@ -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); diff --git a/backend/src/main/java/com/openisle/service/PostService.java b/backend/src/main/java/com/openisle/service/PostService.java index 9c4be50f1..16c15d8de 100644 --- a/backend/src/main/java/com/openisle/service/PostService.java +++ b/backend/src/main/java/com/openisle/service/PostService.java @@ -3,7 +3,21 @@ package com.openisle.service; import com.openisle.config.CachingConfig; import com.openisle.exception.RateLimitException; import com.openisle.model.*; -import com.openisle.repository.*; +import com.openisle.repository.CategoryProposalPostRepository; +import com.openisle.repository.CategoryRepository; +import com.openisle.repository.CommentRepository; +import com.openisle.repository.LotteryPostRepository; +import com.openisle.repository.NotificationRepository; +import com.openisle.repository.PointHistoryRepository; +import com.openisle.repository.PollPostRepository; +import com.openisle.repository.PollVoteRepository; +import com.openisle.repository.PostRepository; +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; import java.time.ZoneId; @@ -59,6 +73,8 @@ public class PostService { private final ConcurrentMap> scheduledFinalizations = new ConcurrentHashMap<>(); + private final SearchIndexEventPublisher searchIndexEventPublisher; + @Value("${app.website-url:https://www.open-isle.com}") private String websiteUrl; @@ -90,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; @@ -118,6 +135,7 @@ public class PostService { this.publishMode = publishMode; this.redisTemplate = redisTemplate; + this.searchIndexEventPublisher = searchIndexEventPublisher; } @EventListener(ApplicationReadyEvent.class) @@ -401,6 +419,9 @@ public class PostService { ); scheduledFinalizations.put(pp.getId(), future); } + if (post.getStatus() == PostStatus.PUBLISHED) { + searchIndexEventPublisher.publishPostSaved(post); + } return post; } @@ -992,10 +1013,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, @@ -1019,13 +1042,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, @@ -1166,6 +1192,9 @@ public class PostService { if (!oldTags.equals(newTags)) { postChangeLogService.recordTagChange(updated, user, oldTags, newTags); } + if (updated.getStatus() == PostStatus.PUBLISHED) { + searchIndexEventPublisher.publishPostSaved(updated); + } return updated; } @@ -1218,8 +1247,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, diff --git a/backend/src/main/java/com/openisle/service/SearchService.java b/backend/src/main/java/com/openisle/service/SearchService.java index dee83fed9..ec9ad2770 100644 --- a/backend/src/main/java/com/openisle/service/SearchService.java +++ b/backend/src/main/java/com/openisle/service/SearchService.java @@ -11,14 +11,30 @@ 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.regex.Matcher; +import java.util.regex.Pattern; 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.FieldValue; +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; +import org.springframework.web.util.HtmlUtils; @Service +@Slf4j @RequiredArgsConstructor public class SearchService { @@ -27,10 +43,14 @@ public class SearchService { private final CommentRepository commentRepository; private final CategoryRepository categoryRepository; private final TagRepository tagRepository; + private final Optional 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 searchUsers(String keyword) { return userRepository.findByUsernameContainingIgnoreCase(keyword); } @@ -64,49 +84,113 @@ public class SearchService { } public List globalSearch(String keyword) { + if (keyword == null || keyword.isBlank()) { + return List.of(); + } + if (isOpenSearchEnabled()) { + try { + List 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 fallbackGlobalSearch(String keyword) { + final String effectiveKeyword = keyword == null ? "" : keyword.trim(); Stream users = searchUsers(keyword) .stream() .map(u -> - new SearchResult("user", u.getId(), u.getUsername(), u.getIntroduction(), null, null) + new SearchResult( + "user", + u.getId(), + u.getUsername(), + u.getIntroduction(), + null, + null, + highlightHtml(u.getUsername(), effectiveKeyword), + highlightHtml(u.getIntroduction(), effectiveKeyword), + null + ) ); Stream categories = searchCategories(keyword) .stream() .map(c -> - new SearchResult("category", c.getId(), c.getName(), null, c.getDescription(), null) + new SearchResult( + "category", + c.getId(), + c.getName(), + null, + c.getDescription(), + null, + highlightHtml(c.getName(), effectiveKeyword), + null, + highlightHtml(c.getDescription(), effectiveKeyword) + ) ); Stream tags = searchTags(keyword) .stream() - .map(t -> new SearchResult("tag", t.getId(), t.getName(), null, t.getDescription(), null)); + .map(t -> + new SearchResult( + "tag", + t.getId(), + t.getName(), + null, + t.getDescription(), + null, + highlightHtml(t.getName(), effectiveKeyword), + null, + highlightHtml(t.getDescription(), effectiveKeyword) + ) + ); // Merge post results while removing duplicates between search by content // and search by title List mergedPosts = Stream.concat( searchPosts(keyword) .stream() - .map(p -> - new SearchResult( + .map(p -> { + String snippet = extractSnippet(p.getContent(), keyword, false); + return new SearchResult( "post", p.getId(), p.getTitle(), p.getCategory() != null ? p.getCategory().getName() : null, - extractSnippet(p.getContent(), keyword, false), - null - ) - ), + snippet, + null, + highlightHtml(p.getTitle(), effectiveKeyword), + highlightHtml( + p.getCategory() != null ? p.getCategory().getName() : null, + effectiveKeyword + ), + highlightHtml(snippet, effectiveKeyword) + ); + }), searchPostsByTitle(keyword) .stream() - .map(p -> - new SearchResult( + .map(p -> { + String snippet = extractSnippet(p.getContent(), keyword, true); + return new SearchResult( "post_title", p.getId(), p.getTitle(), p.getCategory() != null ? p.getCategory().getName() : null, - extractSnippet(p.getContent(), keyword, true), - null - ) - ) + snippet, + null, + highlightHtml(p.getTitle(), effectiveKeyword), + highlightHtml( + p.getCategory() != null ? p.getCategory().getName() : null, + effectiveKeyword + ), + highlightHtml(snippet, effectiveKeyword) + ); + }) ) .collect( java.util.stream.Collectors.toMap( @@ -122,22 +206,366 @@ public class SearchService { Stream comments = searchComments(keyword) .stream() - .map(c -> - new SearchResult( + .map(c -> { + String snippet = extractSnippet(c.getContent(), keyword, false); + return new SearchResult( "comment", c.getId(), c.getPost().getTitle(), c.getAuthor().getUsername(), - extractSnippet(c.getContent(), keyword, false), - c.getPost().getId() - ) - ); + snippet, + c.getPost().getId(), + highlightHtml(c.getPost().getTitle(), effectiveKeyword), + highlightHtml(c.getAuthor().getUsername(), effectiveKeyword), + highlightHtml(snippet, effectiveKeyword) + ); + }); return Stream.of(users, categories, tags, mergedPosts.stream(), comments) .flatMap(s -> s) .toList(); } + private boolean isOpenSearchEnabled() { + return openSearchProperties.isEnabled() && openSearchClient.isPresent(); + } + + // 在类里加上(字段或静态常量都可) + private static final java.util.regex.Pattern HANS_PATTERN = java.util.regex.Pattern.compile( + "\\p{IsHan}" + ); + + private static boolean containsHan(String s) { + return s != null && HANS_PATTERN.matcher(s).find(); + } + + private List searchWithOpenSearch(String keyword) throws IOException { + var client = openSearchClient.orElse(null); + if (client == null) return List.of(); + + final String qRaw = keyword == null ? "" : keyword.trim(); + if (qRaw.isEmpty()) return List.of(); + + final boolean hasHan = containsHan(qRaw); + + SearchResponse resp = client.search( + b -> + b + .index(searchIndices()) + .trackTotalHits(t -> t.enabled(true)) + .query(qb -> + qb.bool(bool -> { + // ---------- 严格层 ---------- + // 中文/任意短语(轻微符号/空白扰动) + bool.should(s -> + s.matchPhrase(mp -> mp.field("title").query(qRaw).slop(2).boost(6.0f)) + ); + bool.should(s -> + s.matchPhrase(mp -> mp.field("content").query(qRaw).slop(2).boost(2.5f)) + ); + + // 结构化等值(.raw) + bool.should(s -> + s.term(t -> + t + .field("author.raw") + .value(v -> v.stringValue(qRaw)) + .boost(4.0f) + ) + ); + bool.should(s -> + s.term(t -> + t + .field("category.raw") + .value(v -> v.stringValue(qRaw)) + .boost(3.0f) + ) + ); + bool.should(s -> + s.term(t -> + t + .field("tags.raw") + .value(v -> v.stringValue(qRaw)) + .boost(3.0f) + ) + ); + + // 拼音短语(严格) + bool.should(s -> + s.matchPhrase(mp -> mp.field("title.py").query(qRaw).slop(1).boost(4.0f)) + ); + bool.should(s -> + s.matchPhrase(mp -> mp.field("content.py").query(qRaw).slop(1).boost(1.8f)) + ); + bool.should(s -> + s.matchPhrase(mp -> mp.field("author.py").query(qRaw).slop(1).boost(2.2f)) + ); + bool.should(s -> + s.matchPhrase(mp -> mp.field("category.py").query(qRaw).slop(1).boost(2.0f)) + ); + bool.should(s -> + s.matchPhrase(mp -> mp.field("tags.py").query(qRaw).slop(1).boost(2.0f)) + ); + + // ---------- 放宽层(仅当包含中文时启用) ---------- + if (hasHan) { + // title.zh + bool.should(s -> + s.match(m -> + m + .field("title.zh") + .query(org.opensearch.client.opensearch._types.FieldValue.of(qRaw)) + .operator(org.opensearch.client.opensearch._types.query_dsl.Operator.Or) + .minimumShouldMatch("2<-1 3<-1 4<-1 5<-2 6<-2 7<-3") + .boost(3.0f) + ) + ); + // content.zh + bool.should(s -> + s.match(m -> + m + .field("content.zh") + .query(org.opensearch.client.opensearch._types.FieldValue.of(qRaw)) + .operator(org.opensearch.client.opensearch._types.query_dsl.Operator.Or) + .minimumShouldMatch("2<-1 3<-1 4<-1 5<-2 6<-2 7<-3") + .boost(1.6f) + ) + ); + } + + return bool.minimumShouldMatch("1"); + }) + ) + // ---------- 高亮:允许跨子字段回填 + 匹配字段组 ---------- + .highlight(h -> { + var hb = h + .preTags("") + .postTags("") + .requireFieldMatch(false) + .fields("title", f -> + f + .fragmentSize(highlightFragmentSize()) + .numberOfFragments(1) + .matchedFields(List.of("title", "title.zh", "title.py")) + ) + .fields("content", f -> + f + .fragmentSize(highlightFragmentSize()) + .numberOfFragments(1) + .matchedFields(List.of("content", "content.zh", "content.py")) + ) + .fields("title.zh", f -> f.fragmentSize(highlightFragmentSize()).numberOfFragments(1)) + .fields("content.zh", f -> + f.fragmentSize(highlightFragmentSize()).numberOfFragments(1) + ) + .fields("title.py", f -> f.fragmentSize(highlightFragmentSize()).numberOfFragments(1)) + .fields("content.py", f -> + f.fragmentSize(highlightFragmentSize()).numberOfFragments(1) + ) + .fields("author", f -> f.numberOfFragments(0)) + .fields("author.py", f -> f.numberOfFragments(0)) + .fields("category", f -> f.numberOfFragments(0)) + .fields("category.py", f -> f.numberOfFragments(0)) + .fields("tags", f -> f.numberOfFragments(0)) + .fields("tags.py", f -> f.numberOfFragments(0)); + return hb; + }) + .size(DEFAULT_OPEN_SEARCH_LIMIT > 0 ? DEFAULT_OPEN_SEARCH_LIMIT : 10), + SearchDocument.class + ); + + return mapHits(resp.hits().hits(), qRaw); + } + + /** Lucene query_string 安全转义(保留 * 由我们自己追加) */ + private static String escapeForQueryString(String s) { + if (s == null || s.isEmpty()) return ""; + StringBuilder sb = new StringBuilder(s.length() * 2); + for (char c : s.toCharArray()) { + switch (c) { + case '+': + case '-': + case '=': + case '&': + case '|': + case '>': + case '<': + case '!': + case '(': + case ')': + case '{': + case '}': + case '[': + case ']': + case '^': + case '"': + case '~': /* case '*': */ /* case '?': */ + case ':': + case '\\': + case '/': + sb.append('\\').append(c); + break; + default: + sb.append(c); + } + } + return sb.toString(); + } + + private int highlightFragmentSize() { + int configured = openSearchProperties.getHighlightFragmentSize(); + if (configured > 0) { + return configured; + } + if (snippetLength > 0) { + return snippetLength; + } + return 200; + } + + private List searchIndices() { + return List.of( + openSearchProperties.postsIndex(), + openSearchProperties.commentsIndex(), + openSearchProperties.usersIndex(), + openSearchProperties.categoriesIndex(), + openSearchProperties.tagsIndex() + ); + } + + private List mapHits(List> hits, String keyword) { + List results = new ArrayList<>(); + for (Hit hit : hits) { + SearchResult result = mapHit(hit, keyword); + if (result != null) { + results.add(result); + } + } + return results; + } + + private SearchResult mapHit(Hit hit, String keyword) { + SearchDocument document = hit.source(); + if (document == null || document.entityId() == null) { + return null; + } + Map> highlight = hit.highlight(); + String highlightedContent = firstHighlight( + highlight, + "content", + "content.py", + "content.zh", + "content.raw" + ); + String highlightedTitle = firstHighlight( + highlight, + "title", + "title.py", + "title.zh", + "title.raw" + ); + String highlightedAuthor = firstHighlight(highlight, "author", "author.py"); + String highlightedCategory = firstHighlight(highlight, "category", "category.py"); + 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 snippetHtml = highlightedContent != null && !highlightedContent.isBlank() + ? highlightedContent + : null; + if (snippetHtml == null && highlightTitle) { + snippetHtml = highlightedTitle; + } + + String snippet = snippetHtml != null && !snippetHtml.isBlank() + ? cleanHighlight(snippetHtml) + : null; + boolean fromStart = "post_title".equals(effectiveType); + if (snippet == null || snippet.isBlank()) { + snippet = fallbackSnippet(document.content(), keyword, fromStart); + if (snippetHtml == null) { + snippetHtml = highlightHtml(snippet, keyword); + } + } else if (snippetHtml == null) { + snippetHtml = highlightHtml(snippet, keyword); + } + 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(); + } + String highlightedText = highlightTitle + ? highlightedTitle + : highlightHtml(document.title(), keyword); + String highlightedSubText; + if ("comment".equals(documentType)) { + highlightedSubText = highlightedAuthor != null && !highlightedAuthor.isBlank() + ? highlightedAuthor + : highlightHtml(subText, keyword); + } else if ("post".equals(documentType) || "post_title".equals(effectiveType)) { + highlightedSubText = highlightedCategory != null && !highlightedCategory.isBlank() + ? highlightedCategory + : highlightHtml(subText, keyword); + } else { + highlightedSubText = highlightHtml(subText, keyword); + } + String highlightedExtra = snippetHtml != null ? snippetHtml : highlightHtml(snippet, keyword); + return new SearchResult( + effectiveType, + document.entityId(), + document.title(), + subText, + snippet, + postId, + highlightedText, + highlightedSubText, + highlightedExtra + ); + } + + private String firstHighlight(Map> highlight, String... fields) { + if (highlight == null || fields == null) { + return null; + } + for (String field : fields) { + if (field == null) { + continue; + } + List values = highlight.get(field); + if (values == null || values.isEmpty()) { + continue; + } + for (String value : values) { + if (value != null && !value.isBlank()) { + return value; + } + } + } + return null; + } + + 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; @@ -165,12 +593,45 @@ public class SearchService { return snippet; } + private String highlightHtml(String text, String keyword) { + if (text == null) { + return null; + } + String normalizedKeyword = keyword == null ? "" : keyword.trim(); + if (normalizedKeyword.isEmpty()) { + return HtmlUtils.htmlEscape(text); + } + Pattern pattern = Pattern.compile( + Pattern.quote(normalizedKeyword), + Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE + ); + Matcher matcher = pattern.matcher(text); + if (!matcher.find()) { + return HtmlUtils.htmlEscape(text); + } + matcher.reset(); + StringBuilder sb = new StringBuilder(); + int lastEnd = 0; + while (matcher.find()) { + sb.append(HtmlUtils.htmlEscape(text.substring(lastEnd, matcher.start()))); + sb.append(""); + sb.append(HtmlUtils.htmlEscape(matcher.group())); + sb.append(""); + lastEnd = matcher.end(); + } + sb.append(HtmlUtils.htmlEscape(text.substring(lastEnd))); + return sb.toString(); + } + public record SearchResult( String type, Long id, String text, String subText, String extra, - Long postId + Long postId, + String highlightedText, + String highlightedSubText, + String highlightedExtra ) {} } diff --git a/backend/src/main/java/com/openisle/service/TagService.java b/backend/src/main/java/com/openisle/service/TagService.java index 0000f9d84..f7010a64b 100644 --- a/backend/src/main/java/com/openisle/service/TagService.java +++ b/backend/src/main/java/com/openisle/service/TagService.java @@ -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 listPendingTags() { diff --git a/backend/src/main/java/com/openisle/service/UserService.java b/backend/src/main/java/com/openisle/service/UserService.java index d76d986dc..b41b8fd46 100644 --- a/backend/src/main/java/com/openisle/service/UserService.java +++ b/backend/src/main/java/com/openisle/service/UserService.java @@ -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) { diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 1fef7f09b..8a68ccf90 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -46,6 +46,18 @@ app.user.replies-limit=${USER_REPLIES_LIMIT:50} # Length of extracted snippets for posts and search (-1 to disable truncation) app.snippet-length=${SNIPPET_LENGTH:200} +# OpenSearch integration +app.search.enabled=${SEARCH_ENABLED:true} +app.search.host=${SEARCH_HOST:localhost} +app.search.port=${SEARCH_PORT:9200} +app.search.scheme=${SEARCH_SCHEME:http} +app.search.username=${SEARCH_USERNAME:} +app.search.password=${SEARCH_PASSWORD:} +app.search.index-prefix=${SEARCH_INDEX_PREFIX:openisle} +app.search.highlight-fragment-size=${SEARCH_HIGHLIGHT_FRAGMENT_SIZE:${SNIPPET_LENGTH:200}} +app.search.reindex-on-startup=${SEARCH_REINDEX_ON_STARTUP:true} +app.search.reindex-batch-size=${SEARCH_REINDEX_BATCH_SIZE:500} + # Captcha configuration app.captcha.enabled=${CAPTCHA_ENABLED:false} recaptcha.secret-key=${RECAPTCHA_SECRET_KEY:} diff --git a/backend/src/test/java/com/openisle/controller/SearchControllerTest.java b/backend/src/test/java/com/openisle/controller/SearchControllerTest.java index 58313baa4..add7e5400 100644 --- a/backend/src/test/java/com/openisle/controller/SearchControllerTest.java +++ b/backend/src/test/java/com/openisle/controller/SearchControllerTest.java @@ -68,9 +68,9 @@ class SearchControllerTest { c.setContent("nice"); Mockito.when(searchService.globalSearch("n")).thenReturn( List.of( - new SearchService.SearchResult("user", 1L, "bob", null, null, null), - new SearchService.SearchResult("post", 2L, "hello", null, null, null), - new SearchService.SearchResult("comment", 3L, "nice", null, null, null) + new SearchService.SearchResult("user", 1L, "bob", null, null, null, null, null, null), + new SearchService.SearchResult("post", 2L, "hello", null, null, null, null, null, null), + new SearchService.SearchResult("comment", 3L, "nice", null, null, null, null, null, null) ) ); diff --git a/backend/src/test/java/com/openisle/service/CommentServiceTest.java b/backend/src/test/java/com/openisle/service/CommentServiceTest.java index e52bdb7be..b40a96003 100644 --- a/backend/src/test/java/com/openisle/service/CommentServiceTest.java +++ b/backend/src/test/java/com/openisle/service/CommentServiceTest.java @@ -11,6 +11,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.PointService; import org.junit.jupiter.api.Test; @@ -29,6 +30,7 @@ class CommentServiceTest { PointHistoryRepository pointHistoryRepo = mock(PointHistoryRepository.class); PointService pointService = mock(PointService.class); ImageUploader imageUploader = mock(ImageUploader.class); + SearchIndexEventPublisher searchIndexEventPublisher = mock(SearchIndexEventPublisher.class); CommentService service = new CommentService( commentRepo, @@ -41,7 +43,8 @@ class CommentServiceTest { nRepo, pointHistoryRepo, pointService, - imageUploader + imageUploader, + searchIndexEventPublisher ); when(commentRepo.countByAuthorAfter(eq("alice"), any())).thenReturn(3L); diff --git a/backend/src/test/java/com/openisle/service/PostServiceTest.java b/backend/src/test/java/com/openisle/service/PostServiceTest.java index 71f3ebc45..357fc4871 100644 --- a/backend/src/test/java/com/openisle/service/PostServiceTest.java +++ b/backend/src/test/java/com/openisle/service/PostServiceTest.java @@ -6,6 +6,7 @@ import static org.mockito.Mockito.*; import com.openisle.exception.RateLimitException; import com.openisle.model.*; import com.openisle.repository.*; +import com.openisle.search.SearchIndexEventPublisher; import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -43,6 +44,7 @@ class PostServiceTest { PostChangeLogService postChangeLogService = mock(PostChangeLogService.class); PointHistoryRepository pointHistoryRepository = mock(PointHistoryRepository.class); RedisTemplate redisTemplate = mock(RedisTemplate.class); + SearchIndexEventPublisher searchIndexEventPublisher = mock(SearchIndexEventPublisher.class); PostService service = new PostService( postRepo, @@ -69,7 +71,8 @@ class PostServiceTest { postChangeLogService, pointHistoryRepository, PublishMode.DIRECT, - redisTemplate + redisTemplate, + searchIndexEventPublisher ); when(context.getBean(PostService.class)).thenReturn(service); @@ -121,6 +124,7 @@ class PostServiceTest { PostChangeLogService postChangeLogService = mock(PostChangeLogService.class); PointHistoryRepository pointHistoryRepository = mock(PointHistoryRepository.class); RedisTemplate redisTemplate = mock(RedisTemplate.class); + SearchIndexEventPublisher searchIndexEventPublisher = mock(SearchIndexEventPublisher.class); PostService service = new PostService( postRepo, @@ -147,7 +151,8 @@ class PostServiceTest { postChangeLogService, pointHistoryRepository, PublishMode.DIRECT, - redisTemplate + redisTemplate, + searchIndexEventPublisher ); when(context.getBean(PostService.class)).thenReturn(service); @@ -212,6 +217,7 @@ class PostServiceTest { PostChangeLogService postChangeLogService = mock(PostChangeLogService.class); PointHistoryRepository pointHistoryRepository = mock(PointHistoryRepository.class); RedisTemplate redisTemplate = mock(RedisTemplate.class); + SearchIndexEventPublisher searchIndexEventPublisher = mock(SearchIndexEventPublisher.class); PostService service = new PostService( postRepo, @@ -238,7 +244,8 @@ class PostServiceTest { postChangeLogService, pointHistoryRepository, PublishMode.DIRECT, - redisTemplate + redisTemplate, + searchIndexEventPublisher ); when(context.getBean(PostService.class)).thenReturn(service); @@ -295,6 +302,7 @@ class PostServiceTest { PostChangeLogService postChangeLogService = mock(PostChangeLogService.class); PointHistoryRepository pointHistoryRepository = mock(PointHistoryRepository.class); RedisTemplate redisTemplate = mock(RedisTemplate.class); + SearchIndexEventPublisher searchIndexEventPublisher = mock(SearchIndexEventPublisher.class); PostService service = new PostService( postRepo, @@ -321,7 +329,8 @@ class PostServiceTest { postChangeLogService, pointHistoryRepository, PublishMode.DIRECT, - redisTemplate + redisTemplate, + searchIndexEventPublisher ); when(context.getBean(PostService.class)).thenReturn(service); @@ -389,6 +398,7 @@ class PostServiceTest { PostChangeLogService postChangeLogService = mock(PostChangeLogService.class); PointHistoryRepository pointHistoryRepository = mock(PointHistoryRepository.class); RedisTemplate redisTemplate = mock(RedisTemplate.class); + SearchIndexEventPublisher searchIndexEventPublisher = mock(SearchIndexEventPublisher.class); PostService service = new PostService( postRepo, @@ -415,7 +425,8 @@ class PostServiceTest { postChangeLogService, pointHistoryRepository, PublishMode.DIRECT, - redisTemplate + redisTemplate, + searchIndexEventPublisher ); when(context.getBean(PostService.class)).thenReturn(service); diff --git a/backend/src/test/java/com/openisle/service/SearchServiceTest.java b/backend/src/test/java/com/openisle/service/SearchServiceTest.java index 59027b64d..3dbd5734a 100644 --- a/backend/src/test/java/com/openisle/service/SearchServiceTest.java +++ b/backend/src/test/java/com/openisle/service/SearchServiceTest.java @@ -9,7 +9,9 @@ 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 java.util.List; +import java.util.Optional; import org.junit.jupiter.api.Test; import org.mockito.Mockito; @@ -27,7 +29,9 @@ class SearchServiceTest { postRepo, commentRepo, categoryRepo, - tagRepo + tagRepo, + Optional.empty(), + new OpenSearchProperties() ); Post post1 = new Post(); diff --git a/docker/.env.example b/docker/.env.example index d96c83f28..0ad80a93c 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -1,6 +1,11 @@ # 前端访问端口 SERVER_PORT=8080 +# OpenSearch 配置 +OPENSEARCH_PORT=9200 +OPENSEARCH_METRICS_PORT=9600 +OPENSEARCH_DASHBOARDS_PORT=5601 + # MySQL 配置 MYSQL_ROOT_PASSWORD=toor diff --git a/docker/DockerFile b/docker/DockerFile new file mode 100644 index 000000000..cd25a38d4 --- /dev/null +++ b/docker/DockerFile @@ -0,0 +1,9 @@ +# opensearch +FROM opensearchproject/opensearch:3.0.0 +RUN /usr/share/opensearch/bin/opensearch-plugin install -b analysis-icu +RUN /usr/share/opensearch/bin/opensearch-plugin install -b \ + https://github.com/aparo/opensearch-analysis-pinyin/releases/download/3.0.0/opensearch-analysis-pinyin.zip + +# ... + + diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 79ba8e3c8..50a60557d 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -14,6 +14,44 @@ services: - ../backend/src/main/resources/db/init:/docker-entrypoint-initdb.d networks: - openisle-network + + # OpenSearch Service + opensearch: + build: + context: . + dockerfile: Dockerfile + container_name: opensearch + environment: + - cluster.name=os-single + - node.name=os-node-1 + - discovery.type=single-node + - bootstrap.memory_lock=true + - OPENSEARCH_JAVA_OPTS=-Xms1g -Xmx1g + - DISABLE_SECURITY_PLUGIN=true + - cluster.blocks.create_index=false + ulimits: + memlock: { soft: -1, hard: -1 } + nofile: { soft: 65536, hard: 65536 } + volumes: + - ./data:/usr/share/opensearch/data + - ./snapshots:/snapshots + ports: + - "${OPENSEARCH_PORT:-9200}:9200" + - "${OPENSEARCH_METRICS_PORT:-9600}:9600" + restart: unless-stopped + + dashboards: + image: opensearchproject/opensearch-dashboards:3.0.0 + container_name: os-dashboards + environment: + - OPENSEARCH_HOSTS=["http://opensearch:9200"] + - DISABLE_SECURITY_DASHBOARDS_PLUGIN=true + ports: + - "${OPENSEARCH_DASHBOARDS_PORT:-5601}:5601" + depends_on: + - opensearch + restart: unless-stopped + # Java spring boot service springboot: diff --git a/frontend_nuxt/components/Dropdown.vue b/frontend_nuxt/components/Dropdown.vue index 147a0d5fe..0486041b8 100644 --- a/frontend_nuxt/components/Dropdown.vue +++ b/frontend_nuxt/components/Dropdown.vue @@ -297,6 +297,7 @@ export default { border: 1px solid var(--normal-border-color); border-radius: 5px; padding: 5px 10px; + margin-bottom: 4px; cursor: pointer; display: flex; justify-content: space-between; @@ -315,8 +316,9 @@ export default { right: 0; background: var(--background-color); border: 1px solid var(--normal-border-color); + border-radius: 5px; z-index: 10000; - max-height: 200px; + max-height: 300px; min-width: 350px; overflow-y: auto; } diff --git a/frontend_nuxt/components/SearchDropdown.vue b/frontend_nuxt/components/SearchDropdown.vue index b5ea366e9..e8fde21d6 100644 --- a/frontend_nuxt/components/SearchDropdown.vue +++ b/frontend_nuxt/components/SearchDropdown.vue @@ -26,9 +26,20 @@
-
-
-
+
+
+
@@ -70,16 +81,30 @@ const fetchResults = async (kw) => { subText: r.subText, extra: r.extra, postId: r.postId, + highlightedText: r.highlightedText, + highlightedSubText: r.highlightedSubText, + highlightedExtra: r.highlightedExtra, })) return results.value } -const highlight = (text) => { - text = stripMarkdown(text) - if (!keyword.value) return text - const reg = new RegExp(keyword.value, 'gi') - const res = text.replace(reg, (m) => `${m}`) - return res +const escapeHtml = (value = '') => + String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + +const renderHighlight = (highlighted, fallback) => { + if (highlighted) { + return highlighted + } + const plain = stripMarkdown(fallback || '') + if (!plain) { + return '' + } + return escapeHtml(plain) } const iconMap = { @@ -168,7 +193,7 @@ defineExpose({ padding: 10px 20px; } -:deep(.highlight) { +:deep(mark) { color: var(--primary-color); } diff --git a/frontend_nuxt/components/SearchPersonDropdown.vue b/frontend_nuxt/components/SearchPersonDropdown.vue index 78b3acfd2..1bba727d4 100644 --- a/frontend_nuxt/components/SearchPersonDropdown.vue +++ b/frontend_nuxt/components/SearchPersonDropdown.vue @@ -32,11 +32,14 @@ :disable-link="true" />
-
+
@@ -79,15 +82,29 @@ const fetchResults = async (kw) => { username: u.username, avatar: u.avatar, introduction: u.introduction, + highlightedUsername: u.highlightedText, + highlightedIntroduction: u.highlightedSubText || u.highlightedExtra, })) return results.value } -const highlight = (text) => { - text = stripMarkdown(text || '') - if (!keyword.value) return text - const reg = new RegExp(keyword.value, 'gi') - return text.replace(reg, (m) => `${m}`) +const escapeHtml = (value = '') => + String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + +const renderHighlight = (highlighted, fallback) => { + if (highlighted) { + return highlighted + } + const plain = stripMarkdown(fallback || '') + if (!plain) { + return '' + } + return escapeHtml(plain) } watch(selected, async (val) => { @@ -170,7 +187,7 @@ defineExpose({ padding: 10px 20px; } -:deep(.highlight) { +:deep(mark) { color: var(--primary-color); }