feat(search): 实现笔记搜索功能

- 添加 Elasticsearch 配置,支持通过 ObjectMapper 注入
- 创建笔记索引字段常量类 NoteIndex
- 新增搜索控制器 NoteController,提供搜索接口
- 实现 NoteService 接口及具体业务逻辑 NoteServiceImpl
- 构建 function_score 查询,结合多字段匹配与评分函数
- 支持搜索结果高亮显示及分页处理
- 添加请求参数校验和响应数据格式化逻辑
- 更新 SearchUserRspVO 使用 JsonAlias 替代 JsonProperty
This commit is contained in:
2025-11-01 14:53:28 +08:00
parent 4b13e52a29
commit 3d33a73462
8 changed files with 366 additions and 9 deletions

View File

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

View File

@@ -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<SearchNoteRspVO> searchNote(@RequestBody @Validated SearchNoteReqVO searchNoteReqVO) {
return noteService.searchNote(searchNoteReqVO);
}
}

View File

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

View File

@@ -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; // 默认值为第一页
}

View File

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

View File

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

View File

@@ -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<SearchNoteRspVO> searchNote(SearchNoteReqVO searchNoteReqVO);
}

View File

@@ -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<SearchNoteRspVO> 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("<strong>")
.postTags("</strong>")
);
// 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<SearchNoteRspVO> searchNoteRspVOS = null;
long total = 0;
try {
log.info("==> NoteSearchRequest: {}", searchRequest.toString());
// ⭐️ 执行查询请求,并自动反序列化文档源到 SearchNoteRspVO
SearchResponse<SearchNoteRspVO> 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<SearchNoteRspVO> hit) {
SearchNoteRspVO rspVO = hit.source();
if (rspVO == null) {
return null;
}
// 2. ⭐️ 处理高亮字段
Map<String, List<String>> highlights = hit.highlight();
if (CollUtil.isNotEmpty(highlights)) {
List<String> 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;
}
}