From 3d33a734628fe48228a32c2803f6f0e8e59ffa5a Mon Sep 17 00:00:00 2001 From: Hanserwei <2628273921@qq.com> Date: Sat, 1 Nov 2025 14:53:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(search):=20=E5=AE=9E=E7=8E=B0=E7=AC=94?= =?UTF-8?q?=E8=AE=B0=E6=90=9C=E7=B4=A2=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 Elasticsearch 配置,支持通过 ObjectMapper 注入 - 创建笔记索引字段常量类 NoteIndex - 新增搜索控制器 NoteController,提供搜索接口 - 实现 NoteService 接口及具体业务逻辑 NoteServiceImpl - 构建 function_score 查询,结合多字段匹配与评分函数 - 支持搜索结果高亮显示及分页处理 - 添加请求参数校验和响应数据格式化逻辑 - 更新 SearchUserRspVO 使用 JsonAlias 替代 JsonProperty --- .../search/config/ElasticsearchConfig.java | 11 +- .../search/controller/NoteController.java | 30 ++++ .../hannote/search/index/NoteIndex.java | 70 ++++++++ .../search/model/vo/SearchNoteReqVO.java | 22 +++ .../search/model/vo/SearchNoteRspVO.java | 62 +++++++ .../search/model/vo/SearchUserRspVO.java | 7 +- .../hannote/search/service/NoteService.java | 16 ++ .../search/service/impl/NoteServiceImpl.java | 157 ++++++++++++++++++ 8 files changed, 366 insertions(+), 9 deletions(-) create mode 100644 han-note-search/src/main/java/com/hanserwei/hannote/search/controller/NoteController.java create mode 100644 han-note-search/src/main/java/com/hanserwei/hannote/search/index/NoteIndex.java create mode 100644 han-note-search/src/main/java/com/hanserwei/hannote/search/model/vo/SearchNoteReqVO.java create mode 100644 han-note-search/src/main/java/com/hanserwei/hannote/search/model/vo/SearchNoteRspVO.java create mode 100644 han-note-search/src/main/java/com/hanserwei/hannote/search/service/NoteService.java create mode 100644 han-note-search/src/main/java/com/hanserwei/hannote/search/service/impl/NoteServiceImpl.java diff --git a/han-note-search/src/main/java/com/hanserwei/hannote/search/config/ElasticsearchConfig.java b/han-note-search/src/main/java/com/hanserwei/hannote/search/config/ElasticsearchConfig.java index 2b09948..0e84779 100644 --- a/han-note-search/src/main/java/com/hanserwei/hannote/search/config/ElasticsearchConfig.java +++ b/han-note-search/src/main/java/com/hanserwei/hannote/search/config/ElasticsearchConfig.java @@ -5,7 +5,7 @@ import co.elastic.clients.json.jackson.JacksonJsonpMapper; import co.elastic.clients.transport.ElasticsearchTransport; import co.elastic.clients.transport.rest_client.RestClientTransport; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.json.JsonMapper; +import jakarta.annotation.Resource; import org.apache.http.HttpHost; import org.elasticsearch.client.RestClient; import org.springframework.beans.factory.annotation.Value; @@ -19,6 +19,9 @@ public class ElasticsearchConfig { @Value("${elasticsearch.host}") private String host; + @Resource + private ObjectMapper objectMapper; + @Bean public ElasticsearchClient elasticsearchClient() { // 1. 创建底层 RestClient(低级客户端) @@ -26,12 +29,8 @@ public class ElasticsearchConfig { .builder(HttpHost.create(host)) .build(); - // 2. 创建 JSON 映射器 - ObjectMapper mapper = JsonMapper.builder().build(); - JacksonJsonpMapper jsonpMapper = new JacksonJsonpMapper(mapper); - // 3. 构建传输层 - ElasticsearchTransport transport = new RestClientTransport(restClient, jsonpMapper); + ElasticsearchTransport transport = new RestClientTransport(restClient, new JacksonJsonpMapper(objectMapper)); // 4. 创建高层次的 Elasticsearch 客户端 return new ElasticsearchClient(transport); diff --git a/han-note-search/src/main/java/com/hanserwei/hannote/search/controller/NoteController.java b/han-note-search/src/main/java/com/hanserwei/hannote/search/controller/NoteController.java new file mode 100644 index 0000000..d8e94bb --- /dev/null +++ b/han-note-search/src/main/java/com/hanserwei/hannote/search/controller/NoteController.java @@ -0,0 +1,30 @@ +package com.hanserwei.hannote.search.controller; + +import com.hanserwei.framework.biz.operationlog.aspect.ApiOperationLog; +import com.hanserwei.framework.common.response.PageResponse; +import com.hanserwei.hannote.search.model.vo.SearchNoteReqVO; +import com.hanserwei.hannote.search.model.vo.SearchNoteRspVO; +import com.hanserwei.hannote.search.service.NoteService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/search") +@Slf4j +public class NoteController { + + @Resource + private NoteService noteService; + + @PostMapping("/note") + @ApiOperationLog(description = "搜索笔记") + public PageResponse searchNote(@RequestBody @Validated SearchNoteReqVO searchNoteReqVO) { + return noteService.searchNote(searchNoteReqVO); + } + +} \ No newline at end of file diff --git a/han-note-search/src/main/java/com/hanserwei/hannote/search/index/NoteIndex.java b/han-note-search/src/main/java/com/hanserwei/hannote/search/index/NoteIndex.java new file mode 100644 index 0000000..7f3d7b9 --- /dev/null +++ b/han-note-search/src/main/java/com/hanserwei/hannote/search/index/NoteIndex.java @@ -0,0 +1,70 @@ +package com.hanserwei.hannote.search.index; + +public class NoteIndex { + + /** + * 索引名称 + */ + public static final String NAME = "note"; + + /** + * 笔记ID + */ + public static final String FIELD_NOTE_ID = "id"; + + /** + * 封面 + */ + public static final String FIELD_NOTE_COVER = "cover"; + + /** + * 头像 + */ + public static final String FIELD_NOTE_TITLE = "title"; + + /** + * 话题名称 + */ + public static final String FIELD_NOTE_TOPIC = "topic"; + + /** + * 发布者昵称 + */ + public static final String FIELD_NOTE_NICKNAME = "nickname"; + + /** + * 发布者头像 + */ + public static final String FIELD_NOTE_AVATAR = "avatar"; + + /** + * 笔记类型 + */ + public static final String FIELD_NOTE_TYPE = "type"; + + /** + * 发布时间 + */ + public static final String FIELD_NOTE_CREATE_TIME = "create_time"; + + /** + * 更新时间 + */ + public static final String FIELD_NOTE_UPDATE_TIME = "update_time"; + + /** + * 笔记被点赞数 + */ + public static final String FIELD_NOTE_LIKE_TOTAL = "like_total"; + + /** + * 笔记被收藏数 + */ + public static final String FIELD_NOTE_COLLECT_TOTAL = "collect_total"; + + /** + * 笔记被评论数 + */ + public static final String FIELD_NOTE_COMMENT_TOTAL = "comment_total"; + +} \ No newline at end of file diff --git a/han-note-search/src/main/java/com/hanserwei/hannote/search/model/vo/SearchNoteReqVO.java b/han-note-search/src/main/java/com/hanserwei/hannote/search/model/vo/SearchNoteReqVO.java new file mode 100644 index 0000000..4407ad7 --- /dev/null +++ b/han-note-search/src/main/java/com/hanserwei/hannote/search/model/vo/SearchNoteReqVO.java @@ -0,0 +1,22 @@ +package com.hanserwei.hannote.search.model.vo; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class SearchNoteReqVO { + + @NotBlank(message = "搜索关键词不能为空") + private String keyword; + + @Min(value = 1, message = "页码不能小于 1") + private Integer pageNo = 1; // 默认值为第一页 + +} \ No newline at end of file diff --git a/han-note-search/src/main/java/com/hanserwei/hannote/search/model/vo/SearchNoteRspVO.java b/han-note-search/src/main/java/com/hanserwei/hannote/search/model/vo/SearchNoteRspVO.java new file mode 100644 index 0000000..5c50845 --- /dev/null +++ b/han-note-search/src/main/java/com/hanserwei/hannote/search/model/vo/SearchNoteRspVO.java @@ -0,0 +1,62 @@ +package com.hanserwei.hannote.search.model.vo; + +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder +@JsonIgnoreProperties(ignoreUnknown = true) +public class SearchNoteRspVO { + + /** + * 笔记ID + */ + @JsonAlias("id") + private Long noteId; + + /** + * 封面 + */ + private String cover; + + /** + * 标题 + */ + private String title; + + /** + * 标题:关键词高亮 + */ + private String highlightTitle; + + /** + * 发布者头像 + */ + private String avatar; + + /** + * 发布者昵称 + */ + private String nickname; + + /** + * 最后一次编辑时间 + */ + @JsonAlias("update_time") + private LocalDateTime updateTime; + + /** + * 被点赞总数 + */ + @JsonAlias("like_total") + private String likeTotal; + +} \ No newline at end of file diff --git a/han-note-search/src/main/java/com/hanserwei/hannote/search/model/vo/SearchUserRspVO.java b/han-note-search/src/main/java/com/hanserwei/hannote/search/model/vo/SearchUserRspVO.java index 25179be..841ad4e 100644 --- a/han-note-search/src/main/java/com/hanserwei/hannote/search/model/vo/SearchUserRspVO.java +++ b/han-note-search/src/main/java/com/hanserwei/hannote/search/model/vo/SearchUserRspVO.java @@ -1,5 +1,6 @@ package com.hanserwei.hannote.search.model.vo; +import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; @@ -38,19 +39,19 @@ public class SearchUserRspVO { /** * 小憨书ID */ - @JsonProperty("han_note_id") + @JsonAlias("han_note_id") private String hanNoteId; /** * 笔记发布总数 */ - @JsonProperty("note_total") + @JsonAlias("note_total") private Integer noteTotal; /** * 粉丝总数 */ - @JsonProperty("fans_total") + @JsonAlias("fans_total") private String fansTotal; } \ No newline at end of file diff --git a/han-note-search/src/main/java/com/hanserwei/hannote/search/service/NoteService.java b/han-note-search/src/main/java/com/hanserwei/hannote/search/service/NoteService.java new file mode 100644 index 0000000..8a3511d --- /dev/null +++ b/han-note-search/src/main/java/com/hanserwei/hannote/search/service/NoteService.java @@ -0,0 +1,16 @@ +package com.hanserwei.hannote.search.service; + +import com.hanserwei.framework.common.response.PageResponse; +import com.hanserwei.hannote.search.model.vo.SearchNoteReqVO; +import com.hanserwei.hannote.search.model.vo.SearchNoteRspVO; + +public interface NoteService { + + /** + * 搜索笔记 + * + * @param searchNoteReqVO 搜索笔记请求 + * @return 搜索笔记响应 + */ + PageResponse searchNote(SearchNoteReqVO searchNoteReqVO); +} diff --git a/han-note-search/src/main/java/com/hanserwei/hannote/search/service/impl/NoteServiceImpl.java b/han-note-search/src/main/java/com/hanserwei/hannote/search/service/impl/NoteServiceImpl.java new file mode 100644 index 0000000..a6c288a --- /dev/null +++ b/han-note-search/src/main/java/com/hanserwei/hannote/search/service/impl/NoteServiceImpl.java @@ -0,0 +1,157 @@ +package com.hanserwei.hannote.search.service.impl; + +import cn.hutool.core.collection.CollUtil; +import co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.elasticsearch._types.SortOrder; +import co.elastic.clients.elasticsearch._types.query_dsl.*; +import co.elastic.clients.elasticsearch.core.SearchRequest; +import co.elastic.clients.elasticsearch.core.SearchResponse; +import co.elastic.clients.elasticsearch.core.search.HighlightField; +import co.elastic.clients.elasticsearch.core.search.Hit; +import co.elastic.clients.util.NamedValue; +import com.hanserwei.framework.common.response.PageResponse; +import com.hanserwei.framework.common.utils.NumberUtils; +import com.hanserwei.hannote.search.index.NoteIndex; +import com.hanserwei.hannote.search.model.vo.SearchNoteReqVO; +import com.hanserwei.hannote.search.model.vo.SearchNoteRspVO; +import com.hanserwei.hannote.search.service.NoteService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +@Service +@Slf4j +public class NoteServiceImpl implements NoteService { + + @Resource + private ElasticsearchClient client; + + @Override + public PageResponse searchNote(SearchNoteReqVO searchNoteReqVO) { + // 查询关键词 + String keyword = searchNoteReqVO.getKeyword(); + // 当前页码 + Integer pageNo = searchNoteReqVO.getPageNo(); + + int pageSize = 10; // 每页展示数据量 + int from = (pageNo - 1) * pageSize; // 偏移量 + + // 1. 构建基础 Multi-Match Query (原始查询条件) + MultiMatchQuery multiMatchQuery = MultiMatchQuery.of(m -> m + .query(keyword) + // 字段权重设置,与 RHL 的 .field(field, boost) 对应 + .fields(NoteIndex.FIELD_NOTE_TITLE + "^2.0", NoteIndex.FIELD_NOTE_TOPIC) + ); + Query functionScoreQuery = Query.of(q -> q + .functionScore(fs -> fs + // 设置初始查询条件 + .query(innerQuery -> innerQuery.multiMatch(multiMatchQuery)) + .scoreMode(FunctionScoreMode.Sum) + .boostMode(FunctionBoostMode.Sum) + // 设置function数组 + .functions(List.of( + // 评分函数1 + FunctionScore.of(f -> f.fieldValueFactor(fvf -> fvf + .field(NoteIndex.FIELD_NOTE_LIKE_TOTAL) + .factor(0.5) + .modifier(FieldValueFactorModifier.Sqrt) + .missing(0.0))), + // 评分函数2 + FunctionScore.of(f -> f.fieldValueFactor(fvf -> fvf + .field(NoteIndex.FIELD_NOTE_COLLECT_TOTAL) + .factor(0.3) + .modifier(FieldValueFactorModifier.Sqrt) + .missing(0.0))), + // 评分函数3 + FunctionScore.of(f -> f.fieldValueFactor(fvf -> fvf + .field(NoteIndex.FIELD_NOTE_COMMENT_TOTAL) + .factor(0.2) + .modifier(FieldValueFactorModifier.Sqrt) + .missing(0.0))) + )) + ) + ); + // 3. 构建 Highlight 配置 + HighlightField titleHighlight = HighlightField.of(hf -> hf + .preTags("") + .postTags("") + ); + // 4. 构建最终的 SearchRequest + SearchRequest searchRequest = SearchRequest.of(r -> r + .index(NoteIndex.NAME) + .query(functionScoreQuery) // 设置 function_score 查询 + + // 排序:按 _score 降序 + .sort(s -> s.score(d -> d.order(SortOrder.Desc))) + + // 分页 + .from(from) + .size(pageSize) + // 高亮 + .highlight(h -> h + .fields(NamedValue.of(NoteIndex.FIELD_NOTE_TITLE, titleHighlight)) + ) + ); + // 返参 VO 集合 + List searchNoteRspVOS = null; + long total = 0; + + try { + log.info("==> NoteSearchRequest: {}", searchRequest.toString()); + + // ⭐️ 执行查询请求,并自动反序列化文档源到 SearchNoteRspVO + SearchResponse searchResponse = + client.search(searchRequest, SearchNoteRspVO.class); + + total = searchResponse.hits().total() != null ? searchResponse.hits().total().value() : 0; + log.info("==> 命中文档总数, hits: {}", total); + + // ⭐️ 处理搜索结果:合并原始文档和高亮数据 + searchNoteRspVOS = searchResponse.hits().hits().stream() + .map(this::processNoteHit) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + } catch (Exception e) { + log.error("==> 查询 Elasticsearch 异常: ", e); + } + + return PageResponse.success(searchNoteRspVOS, pageNo, total); + } + + /** + * 辅助方法:处理 Hit,合并 Source 和 Highlight 数据 + */ + private SearchNoteRspVO processNoteHit(Hit hit) { + SearchNoteRspVO rspVO = hit.source(); + + if (rspVO == null) { + return null; + } + + // 2. ⭐️ 处理高亮字段 + Map> highlights = hit.highlight(); + + if (CollUtil.isNotEmpty(highlights)) { + List titleHighlights = highlights.get(NoteIndex.FIELD_NOTE_TITLE); + if (CollUtil.isNotEmpty(titleHighlights)) { + // 设置高亮标题 + rspVO.setHighlightTitle(titleHighlights.getFirst()); + } + } + // 3. 确保 highlightTitle 有值 (如果没有高亮结果,使用原始 title) + if (rspVO.getHighlightTitle() == null) { + rspVO.setHighlightTitle(rspVO.getTitle()); + } + // 4. 处理特殊格式化(如 RHL 代码中的点赞数格式化) + if (rspVO.getLikeTotal() != null) { + rspVO.setLikeTotal(NumberUtils.formatNumberString(Long.parseLong(rspVO.getLikeTotal()))); + } + return rspVO; + } +}