mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-22 18:51:14 +08:00
Compare commits
19 Commits
codex/fix-
...
feature/op
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04616a30f3 | ||
|
|
c0ca615439 | ||
|
|
b0597d34b6 | ||
|
|
e3f680ad0f | ||
|
|
c8a1e6d8c8 | ||
|
|
ffebeb46b7 | ||
|
|
2977d2898f | ||
|
|
8869121bcb | ||
|
|
23cc2d1606 | ||
|
|
44addd2a7b | ||
|
|
0bc65077df | ||
|
|
69869348f6 | ||
|
|
4821b77c17 | ||
|
|
4fc7c861ee | ||
|
|
81dfddf6e1 | ||
|
|
8b93aa95cf | ||
|
|
425fc7d2b1 | ||
|
|
0fff73b682 | ||
|
|
e1171212d7 |
@@ -132,6 +132,19 @@
|
|||||||
<artifactId>springdoc-openapi-starter-webmvc-api</artifactId>
|
<artifactId>springdoc-openapi-starter-webmvc-api</artifactId>
|
||||||
<version>2.2.0</version>
|
<version>2.2.0</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<!-- 高阶 Java 客户端 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.opensearch.client</groupId>
|
||||||
|
<artifactId>opensearch-java</artifactId>
|
||||||
|
<version>3.2.0</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- 低阶 RestClient,提供 org.opensearch.client.RestClient 给你的 RestClientTransport 用 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.opensearch.client</groupId>
|
||||||
|
<artifactId>opensearch-rest-client</artifactId>
|
||||||
|
<version>3.2.0</version>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|||||||
@@ -115,6 +115,9 @@ public class SearchController {
|
|||||||
dto.setSubText(r.subText());
|
dto.setSubText(r.subText());
|
||||||
dto.setExtra(r.extra());
|
dto.setExtra(r.extra());
|
||||||
dto.setPostId(r.postId());
|
dto.setPostId(r.postId());
|
||||||
|
dto.setHighlightedText(r.highlightedText());
|
||||||
|
dto.setHighlightedSubText(r.highlightedSubText());
|
||||||
|
dto.setHighlightedExtra(r.highlightedExtra());
|
||||||
return dto;
|
return dto;
|
||||||
})
|
})
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
|
|||||||
@@ -12,4 +12,7 @@ public class SearchResultDto {
|
|||||||
private String subText;
|
private String subText;
|
||||||
private String extra;
|
private String extra;
|
||||||
private Long postId;
|
private Long postId;
|
||||||
|
private String highlightedText;
|
||||||
|
private String highlightedSubText;
|
||||||
|
private String highlightedExtra;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SearchDocument> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> tags,
|
||||||
|
Long postId,
|
||||||
|
Long createdAt
|
||||||
|
) {}
|
||||||
@@ -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<String> 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<String> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<TypeMapping> 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<String, JsonData> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.openisle.search;
|
||||||
|
|
||||||
|
public interface SearchIndexer {
|
||||||
|
void indexDocument(String index, SearchDocument document);
|
||||||
|
void deleteDocument(String index, Long id);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <T> void reindex(
|
||||||
|
String index,
|
||||||
|
Function<Pageable, Page<T>> pageSupplier,
|
||||||
|
Function<T, SearchDocument> mapper
|
||||||
|
) {
|
||||||
|
int batchSize = Math.max(1, properties.getReindexBatchSize());
|
||||||
|
int pageNumber = 0;
|
||||||
|
|
||||||
|
Page<T> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package com.openisle.search.event;
|
||||||
|
|
||||||
|
public record DeleteDocumentEvent(String index, Long id) {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.openisle.search.event;
|
||||||
|
|
||||||
|
import com.openisle.search.SearchDocument;
|
||||||
|
|
||||||
|
public record IndexDocumentEvent(String index, SearchDocument document) {}
|
||||||
@@ -3,6 +3,7 @@ package com.openisle.service;
|
|||||||
import com.openisle.config.CachingConfig;
|
import com.openisle.config.CachingConfig;
|
||||||
import com.openisle.model.Category;
|
import com.openisle.model.Category;
|
||||||
import com.openisle.repository.CategoryRepository;
|
import com.openisle.repository.CategoryRepository;
|
||||||
|
import com.openisle.search.SearchIndexEventPublisher;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.cache.annotation.CacheEvict;
|
import org.springframework.cache.annotation.CacheEvict;
|
||||||
@@ -14,6 +15,7 @@ import org.springframework.stereotype.Service;
|
|||||||
public class CategoryService {
|
public class CategoryService {
|
||||||
|
|
||||||
private final CategoryRepository categoryRepository;
|
private final CategoryRepository categoryRepository;
|
||||||
|
private final SearchIndexEventPublisher searchIndexEventPublisher;
|
||||||
|
|
||||||
@CacheEvict(value = CachingConfig.CATEGORY_CACHE_NAME, allEntries = true)
|
@CacheEvict(value = CachingConfig.CATEGORY_CACHE_NAME, allEntries = true)
|
||||||
public Category createCategory(String name, String description, String icon, String smallIcon) {
|
public Category createCategory(String name, String description, String icon, String smallIcon) {
|
||||||
@@ -22,7 +24,9 @@ public class CategoryService {
|
|||||||
category.setDescription(description);
|
category.setDescription(description);
|
||||||
category.setIcon(icon);
|
category.setIcon(icon);
|
||||||
category.setSmallIcon(smallIcon);
|
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)
|
@CacheEvict(value = CachingConfig.CATEGORY_CACHE_NAME, allEntries = true)
|
||||||
@@ -48,12 +52,15 @@ public class CategoryService {
|
|||||||
if (smallIcon != null) {
|
if (smallIcon != null) {
|
||||||
category.setSmallIcon(smallIcon);
|
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)
|
@CacheEvict(value = CachingConfig.CATEGORY_CACHE_NAME, allEntries = true)
|
||||||
public void deleteCategory(Long id) {
|
public void deleteCategory(Long id) {
|
||||||
categoryRepository.deleteById(id);
|
categoryRepository.deleteById(id);
|
||||||
|
searchIndexEventPublisher.publishCategoryDeleted(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Category getCategory(Long id) {
|
public Category getCategory(Long id) {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import com.openisle.repository.PointHistoryRepository;
|
|||||||
import com.openisle.repository.PostRepository;
|
import com.openisle.repository.PostRepository;
|
||||||
import com.openisle.repository.ReactionRepository;
|
import com.openisle.repository.ReactionRepository;
|
||||||
import com.openisle.repository.UserRepository;
|
import com.openisle.repository.UserRepository;
|
||||||
|
import com.openisle.search.SearchIndexEventPublisher;
|
||||||
import com.openisle.service.NotificationService;
|
import com.openisle.service.NotificationService;
|
||||||
import com.openisle.service.PointService;
|
import com.openisle.service.PointService;
|
||||||
import com.openisle.service.SubscriptionService;
|
import com.openisle.service.SubscriptionService;
|
||||||
@@ -49,6 +50,7 @@ public class CommentService {
|
|||||||
private final PointHistoryRepository pointHistoryRepository;
|
private final PointHistoryRepository pointHistoryRepository;
|
||||||
private final PointService pointService;
|
private final PointService pointService;
|
||||||
private final ImageUploader imageUploader;
|
private final ImageUploader imageUploader;
|
||||||
|
private final SearchIndexEventPublisher searchIndexEventPublisher;
|
||||||
|
|
||||||
@CacheEvict(value = CachingConfig.POST_CACHE_NAME, allEntries = true)
|
@CacheEvict(value = CachingConfig.POST_CACHE_NAME, allEntries = true)
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -124,6 +126,7 @@ public class CommentService {
|
|||||||
}
|
}
|
||||||
notificationService.notifyMentions(content, author, post, comment);
|
notificationService.notifyMentions(content, author, post, comment);
|
||||||
log.debug("addComment finished for comment {}", comment.getId());
|
log.debug("addComment finished for comment {}", comment.getId());
|
||||||
|
searchIndexEventPublisher.publishCommentSaved(comment);
|
||||||
return comment;
|
return comment;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,6 +224,7 @@ public class CommentService {
|
|||||||
}
|
}
|
||||||
notificationService.notifyMentions(content, author, parent.getPost(), comment);
|
notificationService.notifyMentions(content, author, parent.getPost(), comment);
|
||||||
log.debug("addReply finished for comment {}", comment.getId());
|
log.debug("addReply finished for comment {}", comment.getId());
|
||||||
|
searchIndexEventPublisher.publishCommentSaved(comment);
|
||||||
return comment;
|
return comment;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,7 +364,9 @@ public class CommentService {
|
|||||||
|
|
||||||
// 逻辑删除评论
|
// 逻辑删除评论
|
||||||
Post post = comment.getPost();
|
Post post = comment.getPost();
|
||||||
|
Long commentId = comment.getId();
|
||||||
commentRepository.delete(comment);
|
commentRepository.delete(comment);
|
||||||
|
searchIndexEventPublisher.publishCommentDeleted(commentId);
|
||||||
// 删除积分历史
|
// 删除积分历史
|
||||||
pointHistoryRepository.deleteAll(pointHistories);
|
pointHistoryRepository.deleteAll(pointHistories);
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import com.openisle.repository.PostSubscriptionRepository;
|
|||||||
import com.openisle.repository.ReactionRepository;
|
import com.openisle.repository.ReactionRepository;
|
||||||
import com.openisle.repository.TagRepository;
|
import com.openisle.repository.TagRepository;
|
||||||
import com.openisle.repository.UserRepository;
|
import com.openisle.repository.UserRepository;
|
||||||
|
import com.openisle.search.SearchIndexEventPublisher;
|
||||||
import com.openisle.service.EmailSender;
|
import com.openisle.service.EmailSender;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
@@ -73,6 +74,8 @@ public class PostService {
|
|||||||
private final ConcurrentMap<Long, ScheduledFuture<?>> scheduledFinalizations =
|
private final ConcurrentMap<Long, ScheduledFuture<?>> scheduledFinalizations =
|
||||||
new ConcurrentHashMap<>();
|
new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
private final SearchIndexEventPublisher searchIndexEventPublisher;
|
||||||
|
|
||||||
@Value("${app.website-url:https://www.open-isle.com}")
|
@Value("${app.website-url:https://www.open-isle.com}")
|
||||||
private String websiteUrl;
|
private String websiteUrl;
|
||||||
|
|
||||||
@@ -103,7 +106,8 @@ public class PostService {
|
|||||||
PostChangeLogService postChangeLogService,
|
PostChangeLogService postChangeLogService,
|
||||||
PointHistoryRepository pointHistoryRepository,
|
PointHistoryRepository pointHistoryRepository,
|
||||||
@Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode,
|
@Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode,
|
||||||
RedisTemplate redisTemplate
|
RedisTemplate redisTemplate,
|
||||||
|
SearchIndexEventPublisher searchIndexEventPublisher
|
||||||
) {
|
) {
|
||||||
this.postRepository = postRepository;
|
this.postRepository = postRepository;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
@@ -130,6 +134,7 @@ public class PostService {
|
|||||||
this.publishMode = publishMode;
|
this.publishMode = publishMode;
|
||||||
|
|
||||||
this.redisTemplate = redisTemplate;
|
this.redisTemplate = redisTemplate;
|
||||||
|
this.searchIndexEventPublisher = searchIndexEventPublisher;
|
||||||
}
|
}
|
||||||
|
|
||||||
@EventListener(ApplicationReadyEvent.class)
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
@@ -346,6 +351,9 @@ public class PostService {
|
|||||||
);
|
);
|
||||||
scheduledFinalizations.put(pp.getId(), future);
|
scheduledFinalizations.put(pp.getId(), future);
|
||||||
}
|
}
|
||||||
|
if (post.getStatus() == PostStatus.PUBLISHED) {
|
||||||
|
searchIndexEventPublisher.publishPostSaved(post);
|
||||||
|
}
|
||||||
return post;
|
return post;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -868,10 +876,12 @@ public class PostService {
|
|||||||
if (!tag.isApproved()) {
|
if (!tag.isApproved()) {
|
||||||
tag.setApproved(true);
|
tag.setApproved(true);
|
||||||
tagRepository.save(tag);
|
tagRepository.save(tag);
|
||||||
|
searchIndexEventPublisher.publishTagSaved(tag);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
post.setStatus(PostStatus.PUBLISHED);
|
post.setStatus(PostStatus.PUBLISHED);
|
||||||
post = postRepository.save(post);
|
post = postRepository.save(post);
|
||||||
|
searchIndexEventPublisher.publishPostSaved(post);
|
||||||
notificationService.createNotification(
|
notificationService.createNotification(
|
||||||
post.getAuthor(),
|
post.getAuthor(),
|
||||||
NotificationType.POST_REVIEWED,
|
NotificationType.POST_REVIEWED,
|
||||||
@@ -895,13 +905,16 @@ public class PostService {
|
|||||||
if (!tag.isApproved()) {
|
if (!tag.isApproved()) {
|
||||||
long count = postRepository.countDistinctByTags_Id(tag.getId());
|
long count = postRepository.countDistinctByTags_Id(tag.getId());
|
||||||
if (count <= 1) {
|
if (count <= 1) {
|
||||||
|
Long tagId = tag.getId();
|
||||||
post.getTags().remove(tag);
|
post.getTags().remove(tag);
|
||||||
tagRepository.delete(tag);
|
tagRepository.delete(tag);
|
||||||
|
searchIndexEventPublisher.publishTagDeleted(tagId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
post.setStatus(PostStatus.REJECTED);
|
post.setStatus(PostStatus.REJECTED);
|
||||||
post = postRepository.save(post);
|
post = postRepository.save(post);
|
||||||
|
searchIndexEventPublisher.publishPostDeleted(post.getId());
|
||||||
notificationService.createNotification(
|
notificationService.createNotification(
|
||||||
post.getAuthor(),
|
post.getAuthor(),
|
||||||
NotificationType.POST_REVIEWED,
|
NotificationType.POST_REVIEWED,
|
||||||
@@ -1042,6 +1055,9 @@ public class PostService {
|
|||||||
if (!oldTags.equals(newTags)) {
|
if (!oldTags.equals(newTags)) {
|
||||||
postChangeLogService.recordTagChange(updated, user, oldTags, newTags);
|
postChangeLogService.recordTagChange(updated, user, oldTags, newTags);
|
||||||
}
|
}
|
||||||
|
if (updated.getStatus() == PostStatus.PUBLISHED) {
|
||||||
|
searchIndexEventPublisher.publishPostSaved(updated);
|
||||||
|
}
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1094,8 +1110,10 @@ public class PostService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
String title = post.getTitle();
|
String title = post.getTitle();
|
||||||
|
Long postId = post.getId();
|
||||||
postChangeLogService.deleteLogsForPost(post);
|
postChangeLogService.deleteLogsForPost(post);
|
||||||
postRepository.delete(post);
|
postRepository.delete(post);
|
||||||
|
searchIndexEventPublisher.publishPostDeleted(postId);
|
||||||
if (adminDeleting) {
|
if (adminDeleting) {
|
||||||
notificationService.createNotification(
|
notificationService.createNotification(
|
||||||
author,
|
author,
|
||||||
|
|||||||
@@ -11,14 +11,30 @@ import com.openisle.repository.CommentRepository;
|
|||||||
import com.openisle.repository.PostRepository;
|
import com.openisle.repository.PostRepository;
|
||||||
import com.openisle.repository.TagRepository;
|
import com.openisle.repository.TagRepository;
|
||||||
import com.openisle.repository.UserRepository;
|
import com.openisle.repository.UserRepository;
|
||||||
|
import com.openisle.search.OpenSearchProperties;
|
||||||
|
import com.openisle.search.SearchDocument;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
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.Collectors;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
import lombok.RequiredArgsConstructor;
|
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.stereotype.Service;
|
||||||
|
import org.springframework.web.util.HtmlUtils;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
|
@Slf4j
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class SearchService {
|
public class SearchService {
|
||||||
|
|
||||||
@@ -27,10 +43,14 @@ public class SearchService {
|
|||||||
private final CommentRepository commentRepository;
|
private final CommentRepository commentRepository;
|
||||||
private final CategoryRepository categoryRepository;
|
private final CategoryRepository categoryRepository;
|
||||||
private final TagRepository tagRepository;
|
private final TagRepository tagRepository;
|
||||||
|
private final Optional<OpenSearchClient> openSearchClient;
|
||||||
|
private final OpenSearchProperties openSearchProperties;
|
||||||
|
|
||||||
@org.springframework.beans.factory.annotation.Value("${app.snippet-length}")
|
@org.springframework.beans.factory.annotation.Value("${app.snippet-length}")
|
||||||
private int snippetLength;
|
private int snippetLength;
|
||||||
|
|
||||||
|
private static final int DEFAULT_OPEN_SEARCH_LIMIT = 50;
|
||||||
|
|
||||||
public List<User> searchUsers(String keyword) {
|
public List<User> searchUsers(String keyword) {
|
||||||
return userRepository.findByUsernameContainingIgnoreCase(keyword);
|
return userRepository.findByUsernameContainingIgnoreCase(keyword);
|
||||||
}
|
}
|
||||||
@@ -64,49 +84,113 @@ public class SearchService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public List<SearchResult> globalSearch(String keyword) {
|
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) {
|
||||||
|
final String effectiveKeyword = keyword == null ? "" : keyword.trim();
|
||||||
Stream<SearchResult> users = searchUsers(keyword)
|
Stream<SearchResult> users = searchUsers(keyword)
|
||||||
.stream()
|
.stream()
|
||||||
.map(u ->
|
.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<SearchResult> categories = searchCategories(keyword)
|
Stream<SearchResult> categories = searchCategories(keyword)
|
||||||
.stream()
|
.stream()
|
||||||
.map(c ->
|
.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<SearchResult> tags = searchTags(keyword)
|
Stream<SearchResult> tags = searchTags(keyword)
|
||||||
.stream()
|
.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
|
// Merge post results while removing duplicates between search by content
|
||||||
// and search by title
|
// and search by title
|
||||||
List<SearchResult> mergedPosts = Stream.concat(
|
List<SearchResult> mergedPosts = Stream.concat(
|
||||||
searchPosts(keyword)
|
searchPosts(keyword)
|
||||||
.stream()
|
.stream()
|
||||||
.map(p ->
|
.map(p -> {
|
||||||
new SearchResult(
|
String snippet = extractSnippet(p.getContent(), keyword, false);
|
||||||
|
return new SearchResult(
|
||||||
"post",
|
"post",
|
||||||
p.getId(),
|
p.getId(),
|
||||||
p.getTitle(),
|
p.getTitle(),
|
||||||
p.getCategory() != null ? p.getCategory().getName() : null,
|
p.getCategory() != null ? p.getCategory().getName() : null,
|
||||||
extractSnippet(p.getContent(), keyword, false),
|
snippet,
|
||||||
null
|
null,
|
||||||
)
|
highlightHtml(p.getTitle(), effectiveKeyword),
|
||||||
),
|
highlightHtml(
|
||||||
|
p.getCategory() != null ? p.getCategory().getName() : null,
|
||||||
|
effectiveKeyword
|
||||||
|
),
|
||||||
|
highlightHtml(snippet, effectiveKeyword)
|
||||||
|
);
|
||||||
|
}),
|
||||||
searchPostsByTitle(keyword)
|
searchPostsByTitle(keyword)
|
||||||
.stream()
|
.stream()
|
||||||
.map(p ->
|
.map(p -> {
|
||||||
new SearchResult(
|
String snippet = extractSnippet(p.getContent(), keyword, true);
|
||||||
|
return new SearchResult(
|
||||||
"post_title",
|
"post_title",
|
||||||
p.getId(),
|
p.getId(),
|
||||||
p.getTitle(),
|
p.getTitle(),
|
||||||
p.getCategory() != null ? p.getCategory().getName() : null,
|
p.getCategory() != null ? p.getCategory().getName() : null,
|
||||||
extractSnippet(p.getContent(), keyword, true),
|
snippet,
|
||||||
null
|
null,
|
||||||
)
|
highlightHtml(p.getTitle(), effectiveKeyword),
|
||||||
)
|
highlightHtml(
|
||||||
|
p.getCategory() != null ? p.getCategory().getName() : null,
|
||||||
|
effectiveKeyword
|
||||||
|
),
|
||||||
|
highlightHtml(snippet, effectiveKeyword)
|
||||||
|
);
|
||||||
|
})
|
||||||
)
|
)
|
||||||
.collect(
|
.collect(
|
||||||
java.util.stream.Collectors.toMap(
|
java.util.stream.Collectors.toMap(
|
||||||
@@ -122,22 +206,366 @@ public class SearchService {
|
|||||||
|
|
||||||
Stream<SearchResult> comments = searchComments(keyword)
|
Stream<SearchResult> comments = searchComments(keyword)
|
||||||
.stream()
|
.stream()
|
||||||
.map(c ->
|
.map(c -> {
|
||||||
new SearchResult(
|
String snippet = extractSnippet(c.getContent(), keyword, false);
|
||||||
|
return new SearchResult(
|
||||||
"comment",
|
"comment",
|
||||||
c.getId(),
|
c.getId(),
|
||||||
c.getPost().getTitle(),
|
c.getPost().getTitle(),
|
||||||
c.getAuthor().getUsername(),
|
c.getAuthor().getUsername(),
|
||||||
extractSnippet(c.getContent(), keyword, false),
|
snippet,
|
||||||
c.getPost().getId()
|
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)
|
return Stream.of(users, categories, tags, mergedPosts.stream(), comments)
|
||||||
.flatMap(s -> s)
|
.flatMap(s -> s)
|
||||||
.toList();
|
.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<SearchResult> 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<SearchDocument> 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("<mark>")
|
||||||
|
.postTags("</mark>")
|
||||||
|
.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<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",
|
||||||
|
"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<String, List<String>> highlight, String... fields) {
|
||||||
|
if (highlight == null || fields == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (String field : fields) {
|
||||||
|
if (field == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
List<String> 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) {
|
private String extractSnippet(String content, String keyword, boolean fromStart) {
|
||||||
if (content == null) return "";
|
if (content == null) return "";
|
||||||
int limit = snippetLength;
|
int limit = snippetLength;
|
||||||
@@ -165,12 +593,45 @@ public class SearchService {
|
|||||||
return snippet;
|
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("<mark>");
|
||||||
|
sb.append(HtmlUtils.htmlEscape(matcher.group()));
|
||||||
|
sb.append("</mark>");
|
||||||
|
lastEnd = matcher.end();
|
||||||
|
}
|
||||||
|
sb.append(HtmlUtils.htmlEscape(text.substring(lastEnd)));
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
public record SearchResult(
|
public record SearchResult(
|
||||||
String type,
|
String type,
|
||||||
Long id,
|
Long id,
|
||||||
String text,
|
String text,
|
||||||
String subText,
|
String subText,
|
||||||
String extra,
|
String extra,
|
||||||
Long postId
|
Long postId,
|
||||||
|
String highlightedText,
|
||||||
|
String highlightedSubText,
|
||||||
|
String highlightedExtra
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.openisle.model.Tag;
|
|||||||
import com.openisle.model.User;
|
import com.openisle.model.User;
|
||||||
import com.openisle.repository.TagRepository;
|
import com.openisle.repository.TagRepository;
|
||||||
import com.openisle.repository.UserRepository;
|
import com.openisle.repository.UserRepository;
|
||||||
|
import com.openisle.search.SearchIndexEventPublisher;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.cache.annotation.CacheEvict;
|
import org.springframework.cache.annotation.CacheEvict;
|
||||||
@@ -20,6 +21,7 @@ public class TagService {
|
|||||||
private final TagRepository tagRepository;
|
private final TagRepository tagRepository;
|
||||||
private final TagValidator tagValidator;
|
private final TagValidator tagValidator;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
|
private final SearchIndexEventPublisher searchIndexEventPublisher;
|
||||||
|
|
||||||
@CacheEvict(value = CachingConfig.TAG_CACHE_NAME, allEntries = true)
|
@CacheEvict(value = CachingConfig.TAG_CACHE_NAME, allEntries = true)
|
||||||
public Tag createTag(
|
public Tag createTag(
|
||||||
@@ -43,7 +45,9 @@ public class TagService {
|
|||||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||||
tag.setCreator(creator);
|
tag.setCreator(creator);
|
||||||
}
|
}
|
||||||
return tagRepository.save(tag);
|
Tag saved = tagRepository.save(tag);
|
||||||
|
searchIndexEventPublisher.publishTagSaved(saved);
|
||||||
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Tag createTag(
|
public Tag createTag(
|
||||||
@@ -78,12 +82,15 @@ public class TagService {
|
|||||||
if (smallIcon != null) {
|
if (smallIcon != null) {
|
||||||
tag.setSmallIcon(smallIcon);
|
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)
|
@CacheEvict(value = CachingConfig.TAG_CACHE_NAME, allEntries = true)
|
||||||
public void deleteTag(Long id) {
|
public void deleteTag(Long id) {
|
||||||
tagRepository.deleteById(id);
|
tagRepository.deleteById(id);
|
||||||
|
searchIndexEventPublisher.publishTagDeleted(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Tag approveTag(Long id) {
|
public Tag approveTag(Long id) {
|
||||||
@@ -91,7 +98,9 @@ public class TagService {
|
|||||||
.findById(id)
|
.findById(id)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Tag not found"));
|
.orElseThrow(() -> new IllegalArgumentException("Tag not found"));
|
||||||
tag.setApproved(true);
|
tag.setApproved(true);
|
||||||
return tagRepository.save(tag);
|
Tag saved = tagRepository.save(tag);
|
||||||
|
searchIndexEventPublisher.publishTagSaved(saved);
|
||||||
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Tag> listPendingTags() {
|
public List<Tag> listPendingTags() {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.openisle.exception.FieldException;
|
|||||||
import com.openisle.model.Role;
|
import com.openisle.model.Role;
|
||||||
import com.openisle.model.User;
|
import com.openisle.model.User;
|
||||||
import com.openisle.repository.UserRepository;
|
import com.openisle.repository.UserRepository;
|
||||||
|
import com.openisle.search.SearchIndexEventPublisher;
|
||||||
import com.openisle.service.AvatarGenerator;
|
import com.openisle.service.AvatarGenerator;
|
||||||
import com.openisle.service.PasswordValidator;
|
import com.openisle.service.PasswordValidator;
|
||||||
import com.openisle.service.UsernameValidator;
|
import com.openisle.service.UsernameValidator;
|
||||||
@@ -34,6 +35,7 @@ public class UserService {
|
|||||||
private final RedisTemplate redisTemplate;
|
private final RedisTemplate redisTemplate;
|
||||||
|
|
||||||
private final EmailSender emailService;
|
private final EmailSender emailService;
|
||||||
|
private final SearchIndexEventPublisher searchIndexEventPublisher;
|
||||||
|
|
||||||
public User register(
|
public User register(
|
||||||
String username,
|
String username,
|
||||||
@@ -58,7 +60,9 @@ public class UserService {
|
|||||||
// u.setVerificationCode(genCode());
|
// u.setVerificationCode(genCode());
|
||||||
u.setRegisterReason(reason);
|
u.setRegisterReason(reason);
|
||||||
u.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
|
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.setVerificationCode(genCode());
|
||||||
u.setRegisterReason(reason);
|
u.setRegisterReason(reason);
|
||||||
u.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
|
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.setAvatar(avatarGenerator.generate(username));
|
||||||
user.setRegisterReason(reason);
|
user.setRegisterReason(reason);
|
||||||
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
|
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) {
|
public User registerWithInvite(String username, String email, String password) {
|
||||||
User user = register(username, email, password, "", com.openisle.model.RegisterMode.DIRECT);
|
User user = register(username, email, password, "", com.openisle.model.RegisterMode.DIRECT);
|
||||||
user.setVerified(true);
|
user.setVerified(true);
|
||||||
// user.setVerificationCode(genCode());
|
// user.setVerificationCode(genCode());
|
||||||
return userRepository.save(user);
|
User saved = userRepository.save(user);
|
||||||
|
searchIndexEventPublisher.publishUserSaved(saved);
|
||||||
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String genCode() {
|
private String genCode() {
|
||||||
@@ -209,7 +219,9 @@ public class UserService {
|
|||||||
.findByUsername(username)
|
.findByUsername(username)
|
||||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||||
user.setRegisterReason(reason);
|
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) {
|
public User updateProfile(String currentUsername, String newUsername, String introduction) {
|
||||||
|
|||||||
@@ -45,6 +45,18 @@ app.user.replies-limit=${USER_REPLIES_LIMIT:50}
|
|||||||
# Length of extracted snippets for posts and search (-1 to disable truncation)
|
# Length of extracted snippets for posts and search (-1 to disable truncation)
|
||||||
app.snippet-length=${SNIPPET_LENGTH:200}
|
app.snippet-length=${SNIPPET_LENGTH:200}
|
||||||
|
|
||||||
|
# OpenSearch integration
|
||||||
|
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
|
# Captcha configuration
|
||||||
app.captcha.enabled=${CAPTCHA_ENABLED:false}
|
app.captcha.enabled=${CAPTCHA_ENABLED:false}
|
||||||
recaptcha.secret-key=${RECAPTCHA_SECRET_KEY:}
|
recaptcha.secret-key=${RECAPTCHA_SECRET_KEY:}
|
||||||
|
|||||||
@@ -68,9 +68,9 @@ class SearchControllerTest {
|
|||||||
c.setContent("nice");
|
c.setContent("nice");
|
||||||
Mockito.when(searchService.globalSearch("n")).thenReturn(
|
Mockito.when(searchService.globalSearch("n")).thenReturn(
|
||||||
List.of(
|
List.of(
|
||||||
new SearchService.SearchResult("user", 1L, "bob", null, null, null),
|
new SearchService.SearchResult("user", 1L, "bob", null, null, null, null, null, null),
|
||||||
new SearchService.SearchResult("post", 2L, "hello", null, null, null),
|
new SearchService.SearchResult("post", 2L, "hello", null, null, null, null, null, null),
|
||||||
new SearchService.SearchResult("comment", 3L, "nice", null, null, null)
|
new SearchService.SearchResult("comment", 3L, "nice", null, null, null, null, null, null)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import com.openisle.repository.PointHistoryRepository;
|
|||||||
import com.openisle.repository.PostRepository;
|
import com.openisle.repository.PostRepository;
|
||||||
import com.openisle.repository.ReactionRepository;
|
import com.openisle.repository.ReactionRepository;
|
||||||
import com.openisle.repository.UserRepository;
|
import com.openisle.repository.UserRepository;
|
||||||
|
import com.openisle.search.SearchIndexEventPublisher;
|
||||||
import com.openisle.service.PointService;
|
import com.openisle.service.PointService;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ class CommentServiceTest {
|
|||||||
PointHistoryRepository pointHistoryRepo = mock(PointHistoryRepository.class);
|
PointHistoryRepository pointHistoryRepo = mock(PointHistoryRepository.class);
|
||||||
PointService pointService = mock(PointService.class);
|
PointService pointService = mock(PointService.class);
|
||||||
ImageUploader imageUploader = mock(ImageUploader.class);
|
ImageUploader imageUploader = mock(ImageUploader.class);
|
||||||
|
SearchIndexEventPublisher searchIndexEventPublisher = mock(SearchIndexEventPublisher.class);
|
||||||
|
|
||||||
CommentService service = new CommentService(
|
CommentService service = new CommentService(
|
||||||
commentRepo,
|
commentRepo,
|
||||||
@@ -41,7 +43,8 @@ class CommentServiceTest {
|
|||||||
nRepo,
|
nRepo,
|
||||||
pointHistoryRepo,
|
pointHistoryRepo,
|
||||||
pointService,
|
pointService,
|
||||||
imageUploader
|
imageUploader,
|
||||||
|
searchIndexEventPublisher
|
||||||
);
|
);
|
||||||
|
|
||||||
when(commentRepo.countByAuthorAfter(eq("alice"), any())).thenReturn(3L);
|
when(commentRepo.countByAuthorAfter(eq("alice"), any())).thenReturn(3L);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import static org.mockito.Mockito.*;
|
|||||||
import com.openisle.exception.RateLimitException;
|
import com.openisle.exception.RateLimitException;
|
||||||
import com.openisle.model.*;
|
import com.openisle.model.*;
|
||||||
import com.openisle.repository.*;
|
import com.openisle.repository.*;
|
||||||
|
import com.openisle.search.SearchIndexEventPublisher;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -42,6 +43,7 @@ class PostServiceTest {
|
|||||||
PostChangeLogService postChangeLogService = mock(PostChangeLogService.class);
|
PostChangeLogService postChangeLogService = mock(PostChangeLogService.class);
|
||||||
PointHistoryRepository pointHistoryRepository = mock(PointHistoryRepository.class);
|
PointHistoryRepository pointHistoryRepository = mock(PointHistoryRepository.class);
|
||||||
RedisTemplate redisTemplate = mock(RedisTemplate.class);
|
RedisTemplate redisTemplate = mock(RedisTemplate.class);
|
||||||
|
SearchIndexEventPublisher searchIndexEventPublisher = mock(SearchIndexEventPublisher.class);
|
||||||
|
|
||||||
PostService service = new PostService(
|
PostService service = new PostService(
|
||||||
postRepo,
|
postRepo,
|
||||||
@@ -67,7 +69,8 @@ class PostServiceTest {
|
|||||||
postChangeLogService,
|
postChangeLogService,
|
||||||
pointHistoryRepository,
|
pointHistoryRepository,
|
||||||
PublishMode.DIRECT,
|
PublishMode.DIRECT,
|
||||||
redisTemplate
|
redisTemplate,
|
||||||
|
searchIndexEventPublisher
|
||||||
);
|
);
|
||||||
when(context.getBean(PostService.class)).thenReturn(service);
|
when(context.getBean(PostService.class)).thenReturn(service);
|
||||||
|
|
||||||
@@ -118,6 +121,7 @@ class PostServiceTest {
|
|||||||
PostChangeLogService postChangeLogService = mock(PostChangeLogService.class);
|
PostChangeLogService postChangeLogService = mock(PostChangeLogService.class);
|
||||||
PointHistoryRepository pointHistoryRepository = mock(PointHistoryRepository.class);
|
PointHistoryRepository pointHistoryRepository = mock(PointHistoryRepository.class);
|
||||||
RedisTemplate redisTemplate = mock(RedisTemplate.class);
|
RedisTemplate redisTemplate = mock(RedisTemplate.class);
|
||||||
|
SearchIndexEventPublisher searchIndexEventPublisher = mock(SearchIndexEventPublisher.class);
|
||||||
|
|
||||||
PostService service = new PostService(
|
PostService service = new PostService(
|
||||||
postRepo,
|
postRepo,
|
||||||
@@ -143,7 +147,8 @@ class PostServiceTest {
|
|||||||
postChangeLogService,
|
postChangeLogService,
|
||||||
pointHistoryRepository,
|
pointHistoryRepository,
|
||||||
PublishMode.DIRECT,
|
PublishMode.DIRECT,
|
||||||
redisTemplate
|
redisTemplate,
|
||||||
|
searchIndexEventPublisher
|
||||||
);
|
);
|
||||||
when(context.getBean(PostService.class)).thenReturn(service);
|
when(context.getBean(PostService.class)).thenReturn(service);
|
||||||
|
|
||||||
@@ -207,6 +212,7 @@ class PostServiceTest {
|
|||||||
PostChangeLogService postChangeLogService = mock(PostChangeLogService.class);
|
PostChangeLogService postChangeLogService = mock(PostChangeLogService.class);
|
||||||
PointHistoryRepository pointHistoryRepository = mock(PointHistoryRepository.class);
|
PointHistoryRepository pointHistoryRepository = mock(PointHistoryRepository.class);
|
||||||
RedisTemplate redisTemplate = mock(RedisTemplate.class);
|
RedisTemplate redisTemplate = mock(RedisTemplate.class);
|
||||||
|
SearchIndexEventPublisher searchIndexEventPublisher = mock(SearchIndexEventPublisher.class);
|
||||||
|
|
||||||
PostService service = new PostService(
|
PostService service = new PostService(
|
||||||
postRepo,
|
postRepo,
|
||||||
@@ -232,7 +238,8 @@ class PostServiceTest {
|
|||||||
postChangeLogService,
|
postChangeLogService,
|
||||||
pointHistoryRepository,
|
pointHistoryRepository,
|
||||||
PublishMode.DIRECT,
|
PublishMode.DIRECT,
|
||||||
redisTemplate
|
redisTemplate,
|
||||||
|
searchIndexEventPublisher
|
||||||
);
|
);
|
||||||
when(context.getBean(PostService.class)).thenReturn(service);
|
when(context.getBean(PostService.class)).thenReturn(service);
|
||||||
|
|
||||||
@@ -283,6 +290,7 @@ class PostServiceTest {
|
|||||||
PostChangeLogService postChangeLogService = mock(PostChangeLogService.class);
|
PostChangeLogService postChangeLogService = mock(PostChangeLogService.class);
|
||||||
PointHistoryRepository pointHistoryRepository = mock(PointHistoryRepository.class);
|
PointHistoryRepository pointHistoryRepository = mock(PointHistoryRepository.class);
|
||||||
RedisTemplate redisTemplate = mock(RedisTemplate.class);
|
RedisTemplate redisTemplate = mock(RedisTemplate.class);
|
||||||
|
SearchIndexEventPublisher searchIndexEventPublisher = mock(SearchIndexEventPublisher.class);
|
||||||
|
|
||||||
PostService service = new PostService(
|
PostService service = new PostService(
|
||||||
postRepo,
|
postRepo,
|
||||||
@@ -308,7 +316,8 @@ class PostServiceTest {
|
|||||||
postChangeLogService,
|
postChangeLogService,
|
||||||
pointHistoryRepository,
|
pointHistoryRepository,
|
||||||
PublishMode.DIRECT,
|
PublishMode.DIRECT,
|
||||||
redisTemplate
|
redisTemplate,
|
||||||
|
searchIndexEventPublisher
|
||||||
);
|
);
|
||||||
when(context.getBean(PostService.class)).thenReturn(service);
|
when(context.getBean(PostService.class)).thenReturn(service);
|
||||||
|
|
||||||
@@ -375,6 +384,7 @@ class PostServiceTest {
|
|||||||
PostChangeLogService postChangeLogService = mock(PostChangeLogService.class);
|
PostChangeLogService postChangeLogService = mock(PostChangeLogService.class);
|
||||||
PointHistoryRepository pointHistoryRepository = mock(PointHistoryRepository.class);
|
PointHistoryRepository pointHistoryRepository = mock(PointHistoryRepository.class);
|
||||||
RedisTemplate redisTemplate = mock(RedisTemplate.class);
|
RedisTemplate redisTemplate = mock(RedisTemplate.class);
|
||||||
|
SearchIndexEventPublisher searchIndexEventPublisher = mock(SearchIndexEventPublisher.class);
|
||||||
|
|
||||||
PostService service = new PostService(
|
PostService service = new PostService(
|
||||||
postRepo,
|
postRepo,
|
||||||
@@ -400,7 +410,8 @@ class PostServiceTest {
|
|||||||
postChangeLogService,
|
postChangeLogService,
|
||||||
pointHistoryRepository,
|
pointHistoryRepository,
|
||||||
PublishMode.DIRECT,
|
PublishMode.DIRECT,
|
||||||
redisTemplate
|
redisTemplate,
|
||||||
|
searchIndexEventPublisher
|
||||||
);
|
);
|
||||||
when(context.getBean(PostService.class)).thenReturn(service);
|
when(context.getBean(PostService.class)).thenReturn(service);
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import com.openisle.repository.CommentRepository;
|
|||||||
import com.openisle.repository.PostRepository;
|
import com.openisle.repository.PostRepository;
|
||||||
import com.openisle.repository.TagRepository;
|
import com.openisle.repository.TagRepository;
|
||||||
import com.openisle.repository.UserRepository;
|
import com.openisle.repository.UserRepository;
|
||||||
|
import com.openisle.search.OpenSearchProperties;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.mockito.Mockito;
|
import org.mockito.Mockito;
|
||||||
|
|
||||||
@@ -27,7 +29,9 @@ class SearchServiceTest {
|
|||||||
postRepo,
|
postRepo,
|
||||||
commentRepo,
|
commentRepo,
|
||||||
categoryRepo,
|
categoryRepo,
|
||||||
tagRepo
|
tagRepo,
|
||||||
|
Optional.empty(),
|
||||||
|
new OpenSearchProperties()
|
||||||
);
|
);
|
||||||
|
|
||||||
Post post1 = new Post();
|
Post post1 = new Post();
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
# 前端访问端口
|
# 前端访问端口
|
||||||
SERVER_PORT=8080
|
SERVER_PORT=8080
|
||||||
|
|
||||||
|
# OpenSearch 配置
|
||||||
|
OPENSEARCH_PORT=9200
|
||||||
|
OPENSEARCH_METRICS_PORT=9600
|
||||||
|
OPENSEARCH_DASHBOARDS_PORT=5601
|
||||||
|
|
||||||
# MySQL 配置
|
# MySQL 配置
|
||||||
MYSQL_ROOT_PASSWORD=toor
|
MYSQL_ROOT_PASSWORD=toor
|
||||||
|
|
||||||
|
|||||||
9
docker/DockerFile
Normal file
9
docker/DockerFile
Normal file
@@ -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
|
||||||
|
|
||||||
|
# ...
|
||||||
|
|
||||||
|
|
||||||
@@ -14,6 +14,44 @@ services:
|
|||||||
- ../backend/src/main/resources/db/init:/docker-entrypoint-initdb.d
|
- ../backend/src/main/resources/db/init:/docker-entrypoint-initdb.d
|
||||||
networks:
|
networks:
|
||||||
- openisle-network
|
- 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
|
# Java spring boot service
|
||||||
springboot:
|
springboot:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
<div class="common-info-content-header">
|
<div class="common-info-content-header">
|
||||||
<div class="info-content-header-left">
|
<div class="info-content-header-left">
|
||||||
<span class="user-name">{{ comment.userName }}</span>
|
<span class="user-name">{{ comment.userName }}</span>
|
||||||
|
<span v-if="isCommentFromPostAuthor" class="op-badge" title="楼主">OP</span>
|
||||||
<medal-one class="medal-icon" />
|
<medal-one class="medal-icon" />
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-if="comment.medal"
|
v-if="comment.medal"
|
||||||
@@ -157,6 +158,12 @@ const lightboxImgs = ref([])
|
|||||||
const loggedIn = computed(() => authState.loggedIn)
|
const loggedIn = computed(() => authState.loggedIn)
|
||||||
const countReplies = (list) => list.reduce((sum, r) => sum + 1 + countReplies(r.reply || []), 0)
|
const countReplies = (list) => list.reduce((sum, r) => sum + 1 + countReplies(r.reply || []), 0)
|
||||||
const replyCount = computed(() => countReplies(props.comment.reply || []))
|
const replyCount = computed(() => countReplies(props.comment.reply || []))
|
||||||
|
const isCommentFromPostAuthor = computed(() => {
|
||||||
|
if (props.comment.userId == null || props.postAuthorId == null) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return String(props.comment.userId) === String(props.postAuthorId)
|
||||||
|
})
|
||||||
|
|
||||||
const toggleReplies = () => {
|
const toggleReplies = () => {
|
||||||
showReplies.value = !showReplies.value
|
showReplies.value = !showReplies.value
|
||||||
@@ -426,6 +433,21 @@ const handleContentClick = (e) => {
|
|||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.op-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-left: 6px;
|
||||||
|
padding: 0 6px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background-color: rgba(242, 100, 25, 0.12);
|
||||||
|
color: #f26419;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.medal-icon {
|
.medal-icon {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
|
|||||||
@@ -26,9 +26,20 @@
|
|||||||
<div class="search-option-item">
|
<div class="search-option-item">
|
||||||
<component :is="iconMap[option.type]" class="result-icon" />
|
<component :is="iconMap[option.type]" class="result-icon" />
|
||||||
<div class="result-body">
|
<div class="result-body">
|
||||||
<div class="result-main" v-html="highlight(option.text)"></div>
|
<div
|
||||||
<div v-if="option.subText" class="result-sub" v-html="highlight(option.subText)"></div>
|
class="result-main"
|
||||||
<div v-if="option.extra" class="result-extra" v-html="highlight(option.extra)"></div>
|
v-html="renderHighlight(option.highlightedText, option.text)"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
v-if="option.subText"
|
||||||
|
class="result-sub"
|
||||||
|
v-html="renderHighlight(option.highlightedSubText, option.subText)"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
v-if="option.extra"
|
||||||
|
class="result-extra"
|
||||||
|
v-html="renderHighlight(option.highlightedExtra, option.extra)"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -70,16 +81,30 @@ const fetchResults = async (kw) => {
|
|||||||
subText: r.subText,
|
subText: r.subText,
|
||||||
extra: r.extra,
|
extra: r.extra,
|
||||||
postId: r.postId,
|
postId: r.postId,
|
||||||
|
highlightedText: r.highlightedText,
|
||||||
|
highlightedSubText: r.highlightedSubText,
|
||||||
|
highlightedExtra: r.highlightedExtra,
|
||||||
}))
|
}))
|
||||||
return results.value
|
return results.value
|
||||||
}
|
}
|
||||||
|
|
||||||
const highlight = (text) => {
|
const escapeHtml = (value = '') =>
|
||||||
text = stripMarkdown(text)
|
String(value)
|
||||||
if (!keyword.value) return text
|
.replace(/&/g, '&')
|
||||||
const reg = new RegExp(keyword.value, 'gi')
|
.replace(/</g, '<')
|
||||||
const res = text.replace(reg, (m) => `<span class="highlight">${m}</span>`)
|
.replace(/>/g, '>')
|
||||||
return res
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
|
||||||
|
const renderHighlight = (highlighted, fallback) => {
|
||||||
|
if (highlighted) {
|
||||||
|
return highlighted
|
||||||
|
}
|
||||||
|
const plain = stripMarkdown(fallback || '')
|
||||||
|
if (!plain) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return escapeHtml(plain)
|
||||||
}
|
}
|
||||||
|
|
||||||
const iconMap = {
|
const iconMap = {
|
||||||
@@ -168,7 +193,7 @@ defineExpose({
|
|||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.highlight) {
|
:deep(mark) {
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,11 +32,14 @@
|
|||||||
:disable-link="true"
|
:disable-link="true"
|
||||||
/>
|
/>
|
||||||
<div class="result-body">
|
<div class="result-body">
|
||||||
<div class="result-main" v-html="highlight(option.username)"></div>
|
<div
|
||||||
|
class="result-main"
|
||||||
|
v-html="renderHighlight(option.highlightedUsername, option.username)"
|
||||||
|
></div>
|
||||||
<div
|
<div
|
||||||
v-if="option.introduction"
|
v-if="option.introduction"
|
||||||
class="result-sub"
|
class="result-sub"
|
||||||
v-html="highlight(option.introduction)"
|
v-html="renderHighlight(option.highlightedIntroduction, option.introduction)"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -79,15 +82,29 @@ const fetchResults = async (kw) => {
|
|||||||
username: u.username,
|
username: u.username,
|
||||||
avatar: u.avatar,
|
avatar: u.avatar,
|
||||||
introduction: u.introduction,
|
introduction: u.introduction,
|
||||||
|
highlightedUsername: u.highlightedText,
|
||||||
|
highlightedIntroduction: u.highlightedSubText || u.highlightedExtra,
|
||||||
}))
|
}))
|
||||||
return results.value
|
return results.value
|
||||||
}
|
}
|
||||||
|
|
||||||
const highlight = (text) => {
|
const escapeHtml = (value = '') =>
|
||||||
text = stripMarkdown(text || '')
|
String(value)
|
||||||
if (!keyword.value) return text
|
.replace(/&/g, '&')
|
||||||
const reg = new RegExp(keyword.value, 'gi')
|
.replace(/</g, '<')
|
||||||
return text.replace(reg, (m) => `<span class="highlight">${m}</span>`)
|
.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) => {
|
watch(selected, async (val) => {
|
||||||
@@ -170,7 +187,7 @@ defineExpose({
|
|||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.highlight) {
|
:deep(mark) {
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="article-member-avatars-container">
|
<div class="article-member-avatars-container">
|
||||||
<div v-for="member in article.members">
|
<div v-for="member in article.members" class="article-member-avatar-item">
|
||||||
<BaseUserAvatar
|
<BaseUserAvatar
|
||||||
class="article-member-avatar-item-img"
|
class="article-member-avatar-item-img"
|
||||||
:src="member.avatar"
|
:src="member.avatar"
|
||||||
|
|||||||
Reference in New Issue
Block a user