refactor(search):重构搜索服务模块结构

- 将 han-note-search 模块拆分为 han-note-search-api 和 han-note-search-biz
- 调整包路径,统一添加 biz 子包以区分业务实现
- 更新相关类的导入路径以适配新的包结构
- 修改 Maven 模块配置,设置父模块打包方式为 pom- 添加新的 API 模块用于 RPC 接口定义
- 更新依赖配置,确保模块间正确引用
- 调整 IDEA 编译器配置以识别新模块
- 更新 HTTP 客户端测试数据和请求示例
- 添加 Feign 客户端支持以实现服务间通信
- 实现笔记文档重建功能并提供对外接口
- 增加数据对齐服务中远程调用搜索服务的能力
- 更新全局异常处理器和枚举类的包路径
- 调整应用启动类的 Mapper 扫描路径
- 更新 Elasticsearch 配置类和索引相关类路径
- 修改控制器和服务接口以支持新架构
- 更新测试类路径以匹配新的项目结构
This commit is contained in:
2025-11-03 16:00:22 +08:00
parent 218f4c6974
commit 2b2cd2be70
52 changed files with 512 additions and 181 deletions

View File

@@ -0,0 +1,108 @@
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<!-- 指定父项目 -->
<parent>
<groupId>com.hanserwei</groupId>
<artifactId>han-note-search</artifactId>
<version>${revision}</version>
</parent>
<!-- 指定打包方式 -->
<packaging>jar</packaging>
<artifactId>han-note-search-biz</artifactId>
<name>${project.artifactId}</name>
<description>搜索服务</description>
<dependencies>
<dependency>
<groupId>com.hanserwei</groupId>
<artifactId>hanserwei-common</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- 业务接口日志组件 -->
<dependency>
<groupId>com.hanserwei</groupId>
<artifactId>hanserwei-spring-boot-starter-biz-operationlog</artifactId>
</dependency>
<!-- Jackson 组件 -->
<dependency>
<groupId>com.hanserwei</groupId>
<artifactId>hanserwei-spring-boot-starter-jackson</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId>
</dependency>
<!-- 服务注册发现 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<!-- Elasticsearch 分布式搜索引擎 -->
<dependency>
<groupId>co.elastic.clients</groupId>
<artifactId>elasticsearch-java</artifactId>
</dependency>
<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-client</artifactId>
</dependency>
<!-- Canal -->
<dependency>
<groupId>com.alibaba.otter</groupId>
<artifactId>canal.client</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.otter</groupId>
<artifactId>canal.common</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.otter</groupId>
<artifactId>canal.protocol</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>
<!-- Druid 数据库连接池 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-3-starter</artifactId>
</dependency>
<dependency>
<groupId>com.hanserwei</groupId>
<artifactId>han-note-search-api</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,15 @@
package com.hanserwei.hannote.search.biz;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
@MapperScan("com.hanserwei.hannote.search.biz.domain.mapper")
public class HannoteSearchApplication {
public static void main(String[] args) {
SpringApplication.run(HannoteSearchApplication.class, args);
}
}

View File

@@ -0,0 +1,64 @@
package com.hanserwei.hannote.search.biz.canal;
import com.alibaba.otter.canal.client.CanalConnector;
import com.alibaba.otter.canal.client.CanalConnectors;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Component;
import java.net.InetSocketAddress;
import java.util.Objects;
@Component
@Slf4j
public class CanalClient implements DisposableBean {
@Resource
private CanalProperties canalProperties;
private CanalConnector canalConnector;
/**
* 实例化 Canal 链接对象
*
* @return CanalConnector
*/
@Bean
public CanalConnector getCanalConnector() {
// Canal 链接地址
String address = canalProperties.getAddress();
String[] addressArr = address.split(":");
// IP 地址
String host = addressArr[0];
// 端口
int port = Integer.parseInt(addressArr[1]);
// 创建一个 CanalConnector 实例,连接到指定的 Canal 服务端
canalConnector = CanalConnectors.newSingleConnector(
new InetSocketAddress(host, port),
canalProperties.getDestination(),
canalProperties.getUsername(),
canalProperties.getPassword());
// 连接到 Canal 服务端
canalConnector.connect();
// 订阅 Canal 中的数据变化,指定要监听的数据库和表(可以使用表名、数据库名的通配符)
canalConnector.subscribe(canalProperties.getSubscribe());
// 回滚 Canal 消费者的位点,回滚到上次提交的消费位置
canalConnector.rollback();
return canalConnector;
}
/**
* 在 Spring 容器销毁时释放资源
*/
@Override
public void destroy() {
if (Objects.nonNull(canalConnector)) {
// 断开 canalConnector 与 Canal 服务的连接
canalConnector.disconnect();
}
}
}

View File

@@ -0,0 +1,43 @@
package com.hanserwei.hannote.search.biz.canal;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@ConfigurationProperties(prefix = CanalProperties.PREFIX)
@Component
@Data
public class CanalProperties {
public static final String PREFIX = "canal";
/**
* Canal 链接地址
*/
private String address;
/**
* 数据目标
*/
private String destination;
/**
* 用户名
*/
private String username;
/**
* 密码
*/
private String password;
/**
* 订阅规则
*/
private String subscribe;
/**
* 一批次拉取数据量,默认 1000 条
*/
private int batchSize = 1000;
}

View File

@@ -0,0 +1,329 @@
package com.hanserwei.hannote.search.biz.canal;
import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.elasticsearch.core.BulkRequest;
import co.elastic.clients.elasticsearch.core.IndexRequest;
import co.elastic.clients.elasticsearch.core.bulk.BulkOperation;
import com.alibaba.otter.canal.client.CanalConnector;
import com.alibaba.otter.canal.protocol.CanalEntry;
import com.alibaba.otter.canal.protocol.Message;
import com.google.common.collect.Maps;
import com.hanserwei.framework.common.enums.StatusEnum;
import com.hanserwei.hannote.search.biz.domain.mapper.SelectMapper;
import com.hanserwei.hannote.search.biz.enums.NoteStatusEnum;
import com.hanserwei.hannote.search.biz.enums.NoteVisibleEnum;
import com.hanserwei.hannote.search.biz.index.NoteIndex;
import com.hanserwei.hannote.search.biz.index.UserIndex;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
@Component
@Slf4j
public class CanalSchedule implements Runnable {
@Resource
private CanalProperties canalProperties;
@Resource
private CanalConnector canalConnector;
@Resource
private SelectMapper selectMapper;
@Resource
private ElasticsearchClient client;
@Override
@Scheduled(fixedDelay = 100) // 每隔 100ms 被执行一次
public void run() {
// 初始化批次 ID-1 表示未开始或未获取到数据
long batchId = -1;
try {
// 从 canalConnector 获取批量消息,返回的数据量由 batchSize 控制,若不足,则拉取已有的
Message message = canalConnector.getWithoutAck(canalProperties.getBatchSize());
// 获取当前拉取消息的批次 ID
batchId = message.getId();
// 获取当前批次中的数据条数
long size = message.getEntries().size();
if (batchId == -1 || size == 0) {
try {
// 拉取数据为空,休眠 1s, 防止频繁拉取
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
log.error("休眠异常", e);
}
} else {
// 如果当前批次有数据,处理这批次数据
processEntry(message.getEntries());
}
// 对当前批次的消息进行 ack 确认,表示该批次的数据已经被成功消费
canalConnector.ack(batchId);
} catch (Exception e) {
log.error("消费 Canal 批次数据异常", e);
// 如果出现异常,需要进行数据回滚,以便重新消费这批次的数据
canalConnector.rollback(batchId);
}
}
/**
* 处理这一批次数据
* @param entrys 批次数据
*/
private void processEntry(List<CanalEntry.Entry> entrys) throws Exception {
// 循环处理批次数据
for (CanalEntry.Entry entry : entrys) {
// 只处理 ROWDATA 行数据类型的 Entry忽略事务等其他类型
if (entry.getEntryType() == CanalEntry.EntryType.ROWDATA) {
// 获取事件类型INSERT、UPDATE、DELETE 等等)
CanalEntry.EventType eventType = entry.getHeader().getEventType();
// 获取数据库名称
String database = entry.getHeader().getSchemaName();
// 获取表名称
String table = entry.getHeader().getTableName();
// 解析出 RowChange 对象,包含 RowData 和事件相关信息
CanalEntry.RowChange rowChange = CanalEntry.RowChange.parseFrom(entry.getStoreValue());
// 遍历所有行数据RowData
for (CanalEntry.RowData rowData : rowChange.getRowDatasList()) {
// 获取行中所有列的最新值AfterColumns
List<CanalEntry.Column> columns = rowData.getAfterColumnsList();
// 将列数据解析为 Map方便后续处理
Map<String, Object> columnMap = parseColumns2Map(columns);
// 自定义处理
log.info("EventType: {}, Database: {}, Table: {}, Columns: {}", eventType, database, table, columnMap);
// 处理事件
processEvent(columnMap, table, eventType);
}
}
}
}
private void processEvent(Map<String, Object> columnMap, String table, CanalEntry.EventType eventType) {
switch (table) {
case "t_note" -> handleNoteEvent(columnMap, eventType); // 笔记表
case "t_user" -> handleUserEvent(columnMap, eventType); // 用户表
default -> log.warn("Table: {} not support", table);
}
}
/**
* 处理用户表事件
*
* @param columnMap 列数据
* @param eventType 事件类型
*/
private void handleUserEvent(Map<String, Object> columnMap, CanalEntry.EventType eventType) {
// 获取用户 ID
Long userId = Long.parseLong(columnMap.get("id").toString());
// 不同的事件,处理逻辑不同
switch (eventType) {
case INSERT -> syncUserIndex(userId); // 记录新增事件
case UPDATE -> { // 记录更新事件
// 用户变更后的状态
Integer status = Integer.parseInt(columnMap.get("status").toString());
// 逻辑删除
Integer isDeleted = Integer.parseInt(columnMap.get("is_deleted").toString());
if (Objects.equals(status, StatusEnum.ENABLE.getValue())
&& Objects.equals(isDeleted, 0)) { // 用户状态为已启用,并且未被逻辑删除
// 更新用户索引、笔记索引
syncNotesIndexAndUserIndex(userId);
} else if (Objects.equals(status, StatusEnum.DISABLED.getValue()) // 用户状态为禁用
|| Objects.equals(isDeleted, 1)) { // 被逻辑删除
// 删除用户文档
deleteUserDocument(String.valueOf(userId));
}
}
default -> log.warn("Unhandled event type for t_user: {}", eventType);
}
}
/**
* 删除用户文档
*
* @param documentId 文档 ID
*/
private void deleteUserDocument(String documentId) {
try {
client.delete(d -> d.index(UserIndex.NAME).id(documentId));
} catch (IOException e) {
log.error("删除用户文档异常", e);
}
}
/**
* 同步用户索引、笔记索引(可能是多条)
*
* @param userId 用户 ID
*/
private void syncNotesIndexAndUserIndex(Long userId) {
BulkRequest.Builder bulkRequestBuilder = new BulkRequest.Builder();
// 1. 用户索引
List<Map<String, Object>> userResult = selectMapper.selectEsUserIndexData(userId);
// 遍历查询结果,将每条记录同步到 Elasticsearch
for (Map<String, Object> recordMap : userResult) {
// 构建 BulkOperation
BulkOperation op = BulkOperation.of(b -> b
.index(i -> i
.index(UserIndex.NAME)
.id(String.valueOf(recordMap.get(UserIndex.FIELD_USER_ID)))
.document(recordMap)
)
);
// 通过 operations() 方法添加到 Builder
bulkRequestBuilder.operations(op);
}
List<Map<String, Object>> noteResult = selectMapper.selectEsNoteIndexData(null, userId);
for (Map<String, Object> recordMap : noteResult) {
BulkOperation op = BulkOperation.of(b -> b
.index(i -> i
.index(NoteIndex.NAME)
.id(String.valueOf(recordMap.get(NoteIndex.FIELD_NOTE_ID)))
.document(recordMap)
)
);
bulkRequestBuilder.operations(op);
}
// 构建 BulkRequest
BulkRequest bulkRequest = bulkRequestBuilder.build();
try {
client.bulk(bulkRequest);
} catch (IOException e) {
log.error("执行批量请求出错:", e);
}
}
/**
* 同步用户索引数据
*
* @param userId 用户 ID
*/
private void syncUserIndex(Long userId) {
// 1. 同步用户索引
List<Map<String, Object>> userResult = selectMapper.selectEsUserIndexData(userId);
// 遍历查询结果,将每条记录同步到 Elasticsearch
for (Map<String, Object> recordMap : userResult) {
// 创建索引请求对象,指定索引名称
IndexRequest<Object> request = IndexRequest.of(indexRequest -> indexRequest
// 指定索引名称
.index(UserIndex.NAME)
// 设置文档的 ID使用记录中的主键 “id” 字段值
.id(String.valueOf(recordMap.get(UserIndex.FIELD_USER_ID)))
// 设置文档的内容,使用查询结果的记录数据
.document(recordMap));
// 将数据写入 Elasticsearch 索引
try {
client.index(request);
} catch (IOException e) {
log.error("写入索引异常", e);
}
}
}
/**
* 处理笔记表事件
*
* @param columnMap 列数据
* @param eventType 事件类型
*/
private void handleNoteEvent(Map<String, Object> columnMap, CanalEntry.EventType eventType) {
// 获取笔记 ID
Long noteId = Long.parseLong(columnMap.get("id").toString());
// 不同的事件,处理逻辑不同
switch (eventType) {
case INSERT -> syncNoteIndex(noteId); // 记录新增事件
case UPDATE -> {
// 记录更新事件
// 笔记变更后的状态
Integer status = Integer.parseInt(columnMap.get("status").toString());
// 笔记可见范围
Integer visible = Integer.parseInt(columnMap.get("visible").toString());
if (Objects.equals(status, NoteStatusEnum.NORMAL.getCode())
&& Objects.equals(visible, NoteVisibleEnum.PUBLIC.getCode())) { // 正常展示,并且可见性为公开
// 对索引进行覆盖更新
syncNoteIndex(noteId);
} else if (Objects.equals(visible, NoteVisibleEnum.PRIVATE.getCode()) // 仅对自己可见
|| Objects.equals(status, NoteStatusEnum.DELETED.getCode())
|| Objects.equals(status, NoteStatusEnum.DOWNED.getCode())) { // 被逻辑删除、被下架
// 删除笔记文档
deleteNoteDocument(String.valueOf(noteId));
}
}
default -> log.warn("Unhandled event type for t_note: {}", eventType);
}
}
/**
* 删除笔记文档
*
* @param documentId 文档ID
*/
private void deleteNoteDocument(String documentId) {
try {
client.delete(deleteRequest -> deleteRequest
.index(NoteIndex.NAME)
.id(documentId));
} catch (IOException e) {
log.error("删除笔记文档异常", e);
}
}
/**
* 同步笔记索引
*
* @param noteId 笔记ID
*/
private void syncNoteIndex(Long noteId) {
// 从数据库查询 Elasticsearch 索引数据
List<Map<String, Object>> result = selectMapper.selectEsNoteIndexData(noteId, null);
// 遍历查询结果,将每条记录同步到 Elasticsearch
for (Map<String, Object> recordMap : result) {
// 创建索引请求对象,指定索引名称
IndexRequest<Object> request = IndexRequest.of(indexRequest -> indexRequest
.index(NoteIndex.NAME)
// 设置文档的 ID使用记录中的主键 “id” 字段值
.id(String.valueOf(recordMap.get(NoteIndex.FIELD_NOTE_ID)))
// 设置文档的内容,使用查询结果的记录数据
.document(recordMap));
// 将数据写入 Elasticsearch 索引
try {
client.index(request);
} catch (IOException e) {
log.error("写入 Elasticsearch 索引异常", e);
}
}
}
/**
* 将列数据解析为 Map
*
* @param columns 列数据
* @return Map
*/
private Map<String, Object> parseColumns2Map(List<CanalEntry.Column> columns) {
Map<String, Object> map = Maps.newHashMap();
columns.forEach(column -> {
if (Objects.isNull(column)) return;
map.put(column.getName(), column.getValue());
});
return map;
}
}

View File

@@ -0,0 +1,39 @@
package com.hanserwei.hannote.search.biz.config;
import co.elastic.clients.elasticsearch.ElasticsearchClient;
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 jakarta.annotation.Resource;
import org.apache.http.HttpHost;
import org.elasticsearch.client.RestClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ElasticsearchConfig {
@Value("${elasticsearch.host}")
private String host;
@Resource
private ObjectMapper objectMapper;
@Bean
public ElasticsearchClient elasticsearchClient() {
// 1. 创建底层 RestClient低级客户端
RestClient restClient = RestClient
.builder(HttpHost.create(host))
.build();
// 3. 构建传输层
ElasticsearchTransport transport = new RestClientTransport(restClient, new JacksonJsonpMapper(objectMapper));
// 4. 创建高层次的 Elasticsearch 客户端
return new ElasticsearchClient(transport);
}
}

View File

@@ -0,0 +1,26 @@
package com.hanserwei.hannote.search.biz.controller;
import com.hanserwei.framework.biz.operationlog.aspect.ApiOperationLog;
import com.hanserwei.hannote.search.biz.service.ExtDictService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/search")
@Slf4j
public class ExtDictController {
@Resource
private ExtDictService extDictService;
@GetMapping("/ext/dict")
@ApiOperationLog(description = "热更新词典")
public ResponseEntity<String> extDict() {
return extDictService.getHotUpdateExtDict();
}
}

View File

@@ -0,0 +1,38 @@
package com.hanserwei.hannote.search.biz.controller;
import com.hanserwei.framework.biz.operationlog.aspect.ApiOperationLog;
import com.hanserwei.framework.common.response.PageResponse;
import com.hanserwei.framework.common.response.Response;
import com.hanserwei.hannote.search.biz.model.vo.SearchNoteReqVO;
import com.hanserwei.hannote.search.biz.model.vo.SearchNoteRspVO;
import com.hanserwei.hannote.search.biz.service.NoteService;
import com.hanserwei.hannote.search.dto.RebuildNoteDocumentReqDTO;
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);
}
// ===================================== 对其他服务提供的接口 =====================================
@PostMapping("/note/document/rebuild")
@ApiOperationLog(description = "用户文档重建")
public Response<Long> rebuildDocument(@Validated @RequestBody RebuildNoteDocumentReqDTO rebuildNoteDocumentReqDTO) {
return noteService.rebuildDocument(rebuildNoteDocumentReqDTO);
}
}

View File

@@ -0,0 +1,39 @@
package com.hanserwei.hannote.search.biz.controller;
import com.hanserwei.framework.biz.operationlog.aspect.ApiOperationLog;
import com.hanserwei.framework.common.response.PageResponse;
import com.hanserwei.framework.common.response.Response;
import com.hanserwei.hannote.search.biz.model.vo.SearchUserReqVO;
import com.hanserwei.hannote.search.biz.model.vo.SearchUserRspVO;
import com.hanserwei.hannote.search.biz.service.UserService;
import com.hanserwei.hannote.search.dto.RebuildUserDocumentReqDTO;
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 UserController {
@Resource
private UserService userService;
@PostMapping("/user")
@ApiOperationLog(description = "搜索用户")
public PageResponse<SearchUserRspVO> searchUser(@RequestBody @Validated SearchUserReqVO searchUserReqVO) {
return userService.searchUser(searchUserReqVO);
}
// ===================================== 对其他服务提供的接口 =====================================
@PostMapping("/user/document/rebuild")
@ApiOperationLog(description = "用户文档重建")
public Response<Long> rebuildDocument(@Validated @RequestBody RebuildUserDocumentReqDTO rebuildUserDocumentReqDTO) {
return userService.rebuildDocument(rebuildUserDocumentReqDTO);
}
}

View File

@@ -0,0 +1,27 @@
package com.hanserwei.hannote.search.biz.domain.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
@Mapper
public interface SelectMapper {
/**
* 查询笔记文档所需的全字段数据
* @param noteId 笔记 ID
* @return 笔记文档所需全字段数据
*/
List<Map<String, Object>> selectEsNoteIndexData(@Param("noteId") Long noteId, @Param("userId") Long userId);
/**
* 查询用户索引所需的全字段数据
*
* @param userId 用户 ID
* @return 用户索引所需全字段数据
*/
List<Map<String, Object>> selectEsUserIndexData(@Param("userId") Long userId);
}

View File

@@ -0,0 +1,37 @@
package com.hanserwei.hannote.search.biz.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Objects;
@Getter
@AllArgsConstructor
public enum NotePublishTimeRangeEnum {
// 一天内
DAY(0),
// 一周内
WEEK(1),
// 半年内
HALF_YEAR(2),
;
private final Integer code;
/**
* 根据类型 code 获取对应的枚举
*
* @param code 类型 code
* @return 枚举
*/
public static NotePublishTimeRangeEnum valueOf(Integer code) {
for (NotePublishTimeRangeEnum notePublishTimeRangeEnum : NotePublishTimeRangeEnum.values()) {
if (Objects.equals(code, notePublishTimeRangeEnum.getCode())) {
return notePublishTimeRangeEnum;
}
}
return null;
}
}

View File

@@ -0,0 +1,39 @@
package com.hanserwei.hannote.search.biz.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Objects;
@Getter
@AllArgsConstructor
public enum NoteSortTypeEnum {
// 最新
LATEST(0),
// 最新点赞
MOST_LIKE(1),
// 最多评论
MOST_COMMENT(2),
// 最多收藏
MOST_COLLECT(3),
;
private final Integer code;
/**
* 根据类型 code 获取对应的枚举
*
* @param code 类型 code
* @return 枚举
*/
public static NoteSortTypeEnum valueOf(Integer code) {
for (NoteSortTypeEnum noteSortTypeEnum : NoteSortTypeEnum.values()) {
if (Objects.equals(code, noteSortTypeEnum.getCode())) {
return noteSortTypeEnum;
}
}
return null;
}
}

View File

@@ -0,0 +1,18 @@
package com.hanserwei.hannote.search.biz.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public enum NoteStatusEnum {
BE_EXAMINE(0), // 待审核
NORMAL(1), // 正常展示
DELETED(2), // 被删除
DOWNED(3), // 被下架
;
private final Integer code;
}

View File

@@ -0,0 +1,15 @@
package com.hanserwei.hannote.search.biz.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public enum NoteVisibleEnum {
PUBLIC(0), // 公开,所有人可见
PRIVATE(1); // 仅自己可见
private final Integer code;
}

View File

@@ -0,0 +1,23 @@
package com.hanserwei.hannote.search.biz.enums;
import com.hanserwei.framework.common.exception.BaseExceptionInterface;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public enum ResponseCodeEnum implements BaseExceptionInterface {
// ----------- 通用异常状态码 -----------
SYSTEM_ERROR("SEARCH-10000", "出错啦,后台小哥正在努力修复中..."),
PARAM_NOT_VALID("SEARCH-10001", "参数错误"),
// ----------- 业务异常状态码 -----------
;
// 异常码
private final String errorCode;
// 错误信息
private final String errorMsg;
}

View File

@@ -0,0 +1,103 @@
package com.hanserwei.hannote.search.biz.exception;
import com.hanserwei.framework.common.exception.ApiException;
import com.hanserwei.framework.common.response.Response;
import com.hanserwei.hannote.search.biz.enums.ResponseCodeEnum;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import java.util.Optional;
@SuppressWarnings("LoggingSimilarMessage")
@ControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
/**
* 捕获自定义业务异常
*
* @return Response.fail(e)
*/
@ExceptionHandler({ApiException.class})
@ResponseBody
public Response<Object> handleApiException(HttpServletRequest request, ApiException e) {
log.warn("{} request fail, errorCode: {}, errorMessage: {}", request.getRequestURI(), e.getErrorCode(), e.getErrorMsg());
return Response.fail(e);
}
/**
* 捕获参数校验异常
*
* @return Response.fail(errorCode, errorMessage)
*/
@ExceptionHandler({MethodArgumentNotValidException.class})
@ResponseBody
public Response<Object> handleMethodArgumentNotValidException(HttpServletRequest request, MethodArgumentNotValidException e) {
// 参数错误异常码
String errorCode = ResponseCodeEnum.PARAM_NOT_VALID.getErrorCode();
// 获取 BindingResult
BindingResult bindingResult = e.getBindingResult();
StringBuilder sb = new StringBuilder();
// 获取校验不通过的字段,并组合错误信息,格式为: email 邮箱格式不正确, 当前值: '123124qq.com';
Optional.of(bindingResult.getFieldErrors()).ifPresent(errors -> {
errors.forEach(error ->
sb.append(error.getField())
.append(" ")
.append(error.getDefaultMessage())
.append(", 当前值: '")
.append(error.getRejectedValue())
.append("'; ")
);
});
// 错误信息
String errorMessage = sb.toString();
log.warn("{} request error, errorCode: {}, errorMessage: {}", request.getRequestURI(), errorCode, errorMessage);
return Response.fail(errorCode, errorMessage);
}
/**
* 捕获 guava 参数校验异常
*
* @return Response.fail(ResponseCodeEnum.PARAM_NOT_VALID)
*/
@ExceptionHandler({IllegalArgumentException.class})
@ResponseBody
public Response<Object> handleIllegalArgumentException(HttpServletRequest request, IllegalArgumentException e) {
// 参数错误异常码
String errorCode = ResponseCodeEnum.PARAM_NOT_VALID.getErrorCode();
// 错误信息
String errorMessage = e.getMessage();
log.warn("{} request error, errorCode: {}, errorMessage: {}", request.getRequestURI(), errorCode, errorMessage);
return Response.fail(errorCode, errorMessage);
}
/**
* 其他类型异常
*
* @param request 请求
* @param e 异常
* @return Response.fail(ResponseCodeEnum.SYSTEM_ERROR)
*/
@ExceptionHandler({Exception.class})
@ResponseBody
public Response<Object> handleOtherException(HttpServletRequest request, Exception e) {
log.error("{} request error, ", request.getRequestURI(), e);
return Response.fail(ResponseCodeEnum.SYSTEM_ERROR);
}
}

View File

@@ -0,0 +1,70 @@
package com.hanserwei.hannote.search.biz.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,40 @@
package com.hanserwei.hannote.search.biz.index;
public class UserIndex {
/**
* 索引名称
*/
public static final String NAME = "user";
/**
* 用户ID
*/
public static final String FIELD_USER_ID = "id";
/**
* 昵称
*/
public static final String FIELD_USER_NICKNAME = "nickname";
/**
* 头像
*/
public static final String FIELD_USER_AVATAR = "avatar";
/**
* 小憨书ID
*/
public static final String FIELD_USER_HAN_NOTE_ID = "han_note_id";
/**
* 发布笔记总数
*/
public static final String FIELD_USER_NOTE_TOTAL = "note_total";
/**
* 粉丝总数
*/
public static final String FIELD_USER_FANS_TOTAL = "fans_total";
}

View File

@@ -0,0 +1,37 @@
package com.hanserwei.hannote.search.biz.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; // 默认值为第一页
/**
* 笔记类型null综合 / 0图文 / 1视频
*/
private Integer type;
/**
* 排序null不限 / 0最新 / 1最多点赞 / 2最多评论 / 3最多收藏
*/
private Integer sort;
/**
* 发布时间范围null不限 / 0一天内 / 1一周内 / 2半年内
*/
private Integer publishTimeRange;
}

View File

@@ -0,0 +1,70 @@
package com.hanserwei.hannote.search.biz.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;
@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 String updateTime;
/**
* 被点赞总数
*/
@JsonAlias("like_total")
private String likeTotal;
/**
* 被评论数
*/
private String commentTotal;
/**
* 被收藏数
*/
private String collectTotal;
}

View File

@@ -0,0 +1,22 @@
package com.hanserwei.hannote.search.biz.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 SearchUserReqVO {
@NotBlank(message = "搜索关键词不能为空")
private String keyword;
@Min(value = 1, message = "页码不能小于 1")
private Integer pageNo = 1; // 默认值为第一页
}

View File

@@ -0,0 +1,57 @@
package com.hanserwei.hannote.search.biz.model.vo;
import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
@JsonIgnoreProperties(ignoreUnknown = true)
public class SearchUserRspVO {
/**
* 用户ID
*/
@JsonProperty("id")
private Long userId;
/**
* 昵称
*/
private String nickname;
/**
* 昵称:关键词高亮
*/
private String highlightNickname;
/**
* 头像
*/
private String avatar;
/**
* 小憨书ID
*/
@JsonAlias("han_note_id")
private String hanNoteId;
/**
* 笔记发布总数
*/
@JsonAlias("note_total")
private Integer noteTotal;
/**
* 粉丝总数
*/
@JsonAlias("fans_total")
private String fansTotal;
}

View File

@@ -0,0 +1,13 @@
package com.hanserwei.hannote.search.biz.service;
import org.springframework.http.ResponseEntity;
public interface ExtDictService {
/**
* 获取热更新词典
*
* @return 热更新词典
*/
ResponseEntity<String> getHotUpdateExtDict();
}

View File

@@ -0,0 +1,26 @@
package com.hanserwei.hannote.search.biz.service;
import com.hanserwei.framework.common.response.PageResponse;
import com.hanserwei.framework.common.response.Response;
import com.hanserwei.hannote.search.biz.model.vo.SearchNoteReqVO;
import com.hanserwei.hannote.search.biz.model.vo.SearchNoteRspVO;
import com.hanserwei.hannote.search.dto.RebuildNoteDocumentReqDTO;
public interface NoteService {
/**
* 搜索笔记
*
* @param searchNoteReqVO 搜索笔记请求
* @return 搜索笔记响应
*/
PageResponse<SearchNoteRspVO> searchNote(SearchNoteReqVO searchNoteReqVO);
/**
* 重建笔记文档
*
* @param rebuildNoteDocumentReqDTO 重建笔记文档请求
* @return 响应
*/
Response<Long> rebuildDocument(RebuildNoteDocumentReqDTO rebuildNoteDocumentReqDTO);
}

View File

@@ -0,0 +1,26 @@
package com.hanserwei.hannote.search.biz.service;
import com.hanserwei.framework.common.response.PageResponse;
import com.hanserwei.framework.common.response.Response;
import com.hanserwei.hannote.search.biz.model.vo.SearchUserReqVO;
import com.hanserwei.hannote.search.biz.model.vo.SearchUserRspVO;
import com.hanserwei.hannote.search.dto.RebuildUserDocumentReqDTO;
public interface UserService {
/**
* 搜索用户
*
* @param searchUserReqVO 搜索用户请求
* @return 搜索用户响应
*/
PageResponse<SearchUserRspVO> searchUser(SearchUserReqVO searchUserReqVO);
/**
* 重建用户文档
*
* @param rebuildUserDocumentReqDTO 重建用户文档请求
* @return 响应
*/
Response<Long> rebuildDocument(RebuildUserDocumentReqDTO rebuildUserDocumentReqDTO);
}

View File

@@ -0,0 +1,54 @@
package com.hanserwei.hannote.search.biz.service.impl;
import com.hanserwei.hannote.search.biz.service.ExtDictService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.stream.Collectors;
@Service
@Slf4j
public class ExtDictServiceImpl implements ExtDictService {
/**
* 热更新词典路径
*/
@Value("${elasticsearch.hotUpdateExtDict}")
private String hotUpdateExtDict;
@Override
public ResponseEntity<String> getHotUpdateExtDict() {
try {
// 获取文件的最后修改时间
Path path = Paths.get(hotUpdateExtDict);
long lastModifiedTime = Files.getLastModifiedTime(path).toMillis();
// 生成 ETag使用文件内容的哈希值
String fileContent = Files.lines(path).collect(Collectors.joining("\n"));
String eTag = String.valueOf(fileContent.hashCode());
// 设置响应头
HttpHeaders headers = new HttpHeaders();
headers.set("ETag", eTag);
// 设置内容类型为 UTF-8
headers.setContentType(MediaType.valueOf("text/plain;charset=UTF-8"));
// 返回文件内容和 HTTP 头部
return ResponseEntity.ok()
.headers(headers)
.lastModified(lastModifiedTime) // 请求头中设置 Last-Modified
.body(fileContent);
} catch (Exception e) {
log.error("==> 获取热更新词典异常: ", e);
}
return null;
}
}

View File

@@ -0,0 +1,330 @@
package com.hanserwei.hannote.search.biz.service.impl;
import cn.hutool.core.collection.CollUtil;
import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.elasticsearch._types.SortOptions;
import co.elastic.clients.elasticsearch._types.SortOrder;
import co.elastic.clients.elasticsearch._types.query_dsl.*;
import co.elastic.clients.elasticsearch.core.IndexRequest;
import co.elastic.clients.elasticsearch.core.SearchRequest;
import co.elastic.clients.elasticsearch.core.SearchResponse;
import co.elastic.clients.elasticsearch.core.search.Highlight;
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.constant.DateConstants;
import com.hanserwei.framework.common.response.PageResponse;
import com.hanserwei.framework.common.response.Response;
import com.hanserwei.framework.common.utils.DateUtils;
import com.hanserwei.framework.common.utils.NumberUtils;
import com.hanserwei.hannote.search.biz.domain.mapper.SelectMapper;
import com.hanserwei.hannote.search.biz.enums.NotePublishTimeRangeEnum;
import com.hanserwei.hannote.search.biz.enums.NoteSortTypeEnum;
import com.hanserwei.hannote.search.biz.index.NoteIndex;
import com.hanserwei.hannote.search.biz.model.vo.SearchNoteReqVO;
import com.hanserwei.hannote.search.biz.model.vo.SearchNoteRspVO;
import com.hanserwei.hannote.search.biz.service.NoteService;
import com.hanserwei.hannote.search.dto.RebuildNoteDocumentReqDTO;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@Service
@Slf4j
public class NoteServiceImpl implements NoteService {
@Resource
private ElasticsearchClient client;
@Resource
private SelectMapper selectMapper;
@Override
public PageResponse<SearchNoteRspVO> searchNote(SearchNoteReqVO searchNoteReqVO) {
// 查询关键词
String keyword = searchNoteReqVO.getKeyword();
// 当前页码
Integer pageNo = searchNoteReqVO.getPageNo();
// 笔记类型
Integer type = searchNoteReqVO.getType();
// 排序方式
Integer sort = searchNoteReqVO.getSort();
// 发布时间范围
Integer publishTimeRange = searchNoteReqVO.getPublishTimeRange();
// --- 2. 分页参数 ---
int pageSize = 10;
int from = (pageNo - 1) * pageSize;
//条件查询
// 创建查询条件
// "query": {
// "bool": {
// "must": [
// {
// "multi_match": {
// "query": "壁纸",
// "fields": [
// "title^2.0",
// "topic^1.0"
// ]
// }
// }
// ],
// "filter": [
// {
// "term": {
// "type": {
// "value": 0
// }
// }
// }
// ]
// }
// },
BoolQuery.Builder boolQueryBuilder = new BoolQuery.Builder();
MultiMatchQuery multiMatchQuery = MultiMatchQuery.of(m -> m
.query(keyword)
// 新客户端推荐在字段名中直接附加权重
.fields(NoteIndex.FIELD_NOTE_TITLE + "^2.0", NoteIndex.FIELD_NOTE_TOPIC)
);
boolQueryBuilder.must(multiMatchQuery);
// 3.2. 构建 term (filter)
if (Objects.nonNull(type)) {
boolQueryBuilder.filter(f -> f
.term(t -> t
.field(NoteIndex.FIELD_NOTE_TYPE)
.value(type) // .value() 会自动处理 Integer, Long, String 等
)
);
}
// 按发布时间范围过滤
NotePublishTimeRangeEnum notePublishTimeRangeEnum = NotePublishTimeRangeEnum.valueOf(publishTimeRange);
if (Objects.nonNull(notePublishTimeRangeEnum)) {
// 结束时间
String endTime = LocalDateTime.now().format(DateConstants.DATE_FORMAT_Y_M_D_H_M_S);
// 开始时间
String startTime = null;
switch (notePublishTimeRangeEnum) {
case DAY -> startTime = DateUtils.localDateTime2String(LocalDateTime.now().minusDays(1)); // 一天之前的时间
case WEEK -> startTime = DateUtils.localDateTime2String(LocalDateTime.now().minusWeeks(1)); // 一周之前的时间
case HALF_YEAR ->
startTime = DateUtils.localDateTime2String(LocalDateTime.now().minusMonths(6)); // 半年之前的时间
}
// 设置时间范围
if (StringUtils.isNoneBlank(startTime)) {
String finalStartTime = startTime;
boolQueryBuilder.filter(f -> f.range(r -> r
.term(t -> t.field(NoteIndex.FIELD_NOTE_CREATE_TIME)
.gte(finalStartTime)
.lte(endTime)))
);
}
}
BoolQuery boolQuery = boolQueryBuilder.build();
// --- 4. 构建排序 (Sort) 和 FunctionScore ---
Query finalQuery;
List<SortOptions> sortOptions = CollUtil.newArrayList();
NoteSortTypeEnum noteSortTypeEnum = NoteSortTypeEnum.valueOf(sort);
if (Objects.nonNull(noteSortTypeEnum)) {
// 4.1. CASE 1: 按字段排序
finalQuery = boolQuery._toQuery(); // 查询主体就是 bool 查询
switch (noteSortTypeEnum) {
// 按笔记发布时间降序
case LATEST -> sortOptions.add(SortOptions.of(s -> s
.field(f -> f.field(NoteIndex.FIELD_NOTE_CREATE_TIME).order(SortOrder.Desc))
));
// 按笔记点赞量降序
case MOST_LIKE -> sortOptions.add(SortOptions.of(s -> s
.field(f -> f.field(NoteIndex.FIELD_NOTE_LIKE_TOTAL).order(SortOrder.Desc))
));
// 按评论量降序
case MOST_COMMENT -> sortOptions.add(SortOptions.of(s -> s
.field(f -> f.field(NoteIndex.FIELD_NOTE_COMMENT_TOTAL).order(SortOrder.Desc))
));
// 按收藏量降序
case MOST_COLLECT -> sortOptions.add(SortOptions.of(s -> s
.field(f -> f.field(NoteIndex.FIELD_NOTE_COLLECT_TOTAL).order(SortOrder.Desc))
));
}
} else {
// 4.2. CASE 2: 综合排序 (Function Score)
// 综合排序,按 _score 降序
sortOptions.add(SortOptions.of(s -> s.field(f -> f.field("_score").order(SortOrder.Desc))));
// 4.2.1. 构建 function_score 的 functions 列表
List<FunctionScore> functions = new ArrayList<>();
// Function 1: like_total
functions.add(FunctionScore.of(fs -> fs
.fieldValueFactor(fvf -> fvf
.field(NoteIndex.FIELD_NOTE_LIKE_TOTAL)
.factor(0.5) // 新版客户端使用 double
.modifier(FieldValueFactorModifier.Sqrt)
.missing(0.0) // missing 值也为 double
)
));
// 创建 FilterFunctionBuilder 数组
// "functions": [
// {
// "field_value_factor": {
// "field": "like_total",
// "factor": 0.5,
// "modifier": "sqrt",
// "missing": 0
// }
// },
// {
// "field_value_factor": {
// "field": "collect_total",
// "factor": 0.3,
// "modifier": "sqrt",
// "missing": 0
// }
// },
// {
// "field_value_factor": {
// "field": "comment_total",
// "factor": 0.2,
// "modifier": "sqrt",
// "missing": 0
// }
// }
// ],
// Function 2: collect_total
functions.add(FunctionScore.of(fs -> fs
.fieldValueFactor(fvf -> fvf
.field(NoteIndex.FIELD_NOTE_COLLECT_TOTAL)
.factor(0.3)
.modifier(FieldValueFactorModifier.Sqrt)
.missing(0.0)
)
));
// Function 3: comment_total
functions.add(FunctionScore.of(fs -> fs
.fieldValueFactor(fvf -> fvf
.field(NoteIndex.FIELD_NOTE_COMMENT_TOTAL)
.factor(0.2)
.modifier(FieldValueFactorModifier.Sqrt)
.missing(0.0)
)
));
// 4.2.2. 构建 FunctionScoreQuery
FunctionScoreQuery functionScoreQuery = FunctionScoreQuery.of(fsq -> fsq
.query(boolQuery._toQuery()) // 基础查询
.functions(functions) // 评分函数
.scoreMode(FunctionScoreMode.Sum) // 对应 score_mode
.boostMode(FunctionBoostMode.Sum) // 对应 boost_mode
);
finalQuery = functionScoreQuery._toQuery(); // 最终查询是 function_score
}
// --- 5. 构建高亮 (Highlight) ---
HighlightField titleHighlight = HighlightField.of(hf -> hf
.preTags("<strong>")
.postTags("</strong>")
);
Highlight highlight = Highlight.of(h -> h.fields(NamedValue.of(NoteIndex.FIELD_NOTE_TITLE, titleHighlight)));
// --- 6. 构建最终的 SearchRequest ---
SearchRequest searchRequest = SearchRequest.of(s -> s
.index(NoteIndex.NAME)
.query(finalQuery) // 设置查询
.sort(sortOptions) // 设置排序
.from(from) // 设置分页
.size(pageSize) // 设置分页
.highlight(highlight) // 设置高亮
);
// --- 7. 执行查询和解析响应 ---
List<SearchNoteRspVO> searchNoteRspVOS = new ArrayList<>();
long total = 0;
try {
log.info("==> SearchRequest: {}", searchRequest.toString());
// 执行查询请求
SearchResponse<SearchNoteRspVO> searchResponse = client.search(searchRequest, SearchNoteRspVO.class);
if (searchResponse.hits().total() != null) {
total = searchResponse.hits().total().value();
}
log.info("==> 命中文档总数, hits: {}", total);
List<Hit<SearchNoteRspVO>> hits = searchResponse.hits().hits();
for (Hit<SearchNoteRspVO> hit : hits) {
// 获取source
SearchNoteRspVO source = hit.source();
// 7.3. 获取高亮字段
String highlightedTitle = null;
Map<String, List<String>> highlightFields = hit.highlight();
if (highlightFields.containsKey(NoteIndex.FIELD_NOTE_TITLE)) {
highlightedTitle = highlightFields.get(NoteIndex.FIELD_NOTE_TITLE).getFirst();
}
if (source != null) {
Long noteId = source.getNoteId();
String cover = source.getCover();
String title = source.getTitle();
String highlightTitle = source.getHighlightTitle();
String avatar = source.getAvatar();
String nickname = source.getNickname();
String updateTime = source.getUpdateTime();
String likeTotal = source.getLikeTotal();
String collectTotal = source.getCollectTotal();
String commentTotal = source.getCommentTotal();
searchNoteRspVOS.add(SearchNoteRspVO.builder()
.noteId(noteId)
.cover(cover)
.title(title)
.highlightTitle(highlightTitle)
.avatar(avatar)
.nickname(nickname)
.updateTime(DateUtils.formatRelativeTime(LocalDateTime.parse(updateTime, DateConstants.DATE_FORMAT_Y_M_D_H_M_S)))
.highlightTitle(highlightedTitle)
.likeTotal(likeTotal == null ? "0" : NumberUtils.formatNumberString(Long.parseLong(likeTotal)))
.collectTotal(collectTotal == null ? "0" : NumberUtils.formatNumberString(Long.parseLong(collectTotal)))
.collectTotal(commentTotal == null ? "0" : NumberUtils.formatNumberString(Long.parseLong(commentTotal)))
.build());
}
}
} catch (IOException e) {
log.error("==> 搜索笔记异常: {}", e.getMessage());
}
return PageResponse.success(searchNoteRspVOS, pageNo, total);
}
@Override
public Response<Long> rebuildDocument(RebuildNoteDocumentReqDTO rebuildNoteDocumentReqDTO) {
Long noteId = rebuildNoteDocumentReqDTO.getId();
// 从数据库查询 Elasticsearch 索引数据
List<Map<String, Object>> result = selectMapper.selectEsNoteIndexData(noteId, null);
// 遍历查询结果,将每条记录同步到 Elasticsearch
for (Map<String, Object> recordMap : result) {
IndexRequest<Object> request = IndexRequest.of(r -> r
// 创建索引请求对象,指定索引名称
.index(NoteIndex.NAME)
// 设置文档的 ID使用记录中的主键 “id” 字段值
.id((String.valueOf(recordMap.get(NoteIndex.FIELD_NOTE_ID))))
// 设置文档的内容,使用查询结果的记录数据
.document(recordMap));
try {
// 将数据写入 Elasticsearch 索引
client.index(request);
} catch (IOException e) {
log.error("==> 同步笔记数据异常: {}", e.getMessage());
}
}
return Response.success();
}
}

View File

@@ -0,0 +1,149 @@
package com.hanserwei.hannote.search.biz.service.impl;
import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.elasticsearch._types.SortOptions;
import co.elastic.clients.elasticsearch._types.SortOrder;
import co.elastic.clients.elasticsearch._types.query_dsl.Query;
import co.elastic.clients.elasticsearch._types.query_dsl.TextQueryType;
import co.elastic.clients.elasticsearch.core.IndexRequest;
import co.elastic.clients.elasticsearch.core.SearchRequest;
import co.elastic.clients.elasticsearch.core.SearchResponse;
import co.elastic.clients.elasticsearch.core.search.Highlight;
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.response.Response;
import com.hanserwei.framework.common.utils.NumberUtils;
import com.hanserwei.hannote.search.biz.domain.mapper.SelectMapper;
import com.hanserwei.hannote.search.biz.index.UserIndex;
import com.hanserwei.hannote.search.biz.model.vo.SearchUserReqVO;
import com.hanserwei.hannote.search.biz.model.vo.SearchUserRspVO;
import com.hanserwei.hannote.search.biz.service.UserService;
import com.hanserwei.hannote.search.dto.RebuildUserDocumentReqDTO;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@Slf4j
@Service
public class UserServiceImpl implements UserService {
@Resource
private ElasticsearchClient client;
@Resource
private SelectMapper selectMapper;
@Override
public PageResponse<SearchUserRspVO> searchUser(SearchUserReqVO searchUserReqVO) {
// --- 2. 获取请求参数 ---
String keyword = searchUserReqVO.getKeyword();
Integer pageNo = searchUserReqVO.getPageNo();
// --- 3. 设置分页 ---
int pageSize = 10; // 假设每页大小为10
int from = (pageNo - 1) * pageSize;
// --- 4. 构建 multi_match 查询 ---
Query multiMatchQuery = Query.of(q -> q
.multiMatch(m -> m
.query(keyword)
.type(TextQueryType.PhrasePrefix)
.fields(UserIndex.FIELD_USER_NICKNAME, UserIndex.FIELD_USER_HAN_NOTE_ID)
)
);
// --- 5. 构建排序 ---
SortOptions sortOptions = SortOptions.of(so -> so
.field(f -> f
.field(UserIndex.FIELD_USER_FANS_TOTAL)
.order(SortOrder.Desc)));
// --- 6. 构建高亮 ---
HighlightField nikeNameHighlight = HighlightField.of(hf -> hf
.preTags("<strong>")
.postTags("</strong>")
);
Highlight highlight = Highlight.of(h -> h
.fields(NamedValue.of(UserIndex.FIELD_USER_NICKNAME, nikeNameHighlight)));
// --- 7. 构建 SearchRequest ---
SearchRequest searchRequest = SearchRequest.of(s -> s
.index(UserIndex.NAME)
.query(multiMatchQuery)
.sort(sortOptions)
.highlight(highlight)
.from(from)
.size(pageSize));
// --- 8. 执行查询和解析响应 ---
List<SearchUserRspVO> searchUserRspVOS = new ArrayList<>();
long total = 0;
try {
log.info("==> searchRequest: {}", searchRequest);
SearchResponse<SearchUserRspVO> searchResponse = client.search(searchRequest, SearchUserRspVO.class);
// 8.2. 处理响应
if (searchResponse.hits().total() != null) {
total = searchResponse.hits().total().value();
}
log.info("==> 命中文档总数, hits: {}", total);
List<Hit<SearchUserRspVO>> hits = searchResponse.hits().hits();
for (Hit<SearchUserRspVO> hit : hits) {
// 获取source
SearchUserRspVO source = hit.source();
// 8.3. 获取高亮字段
String highlightNickname = null;
Map<String, List<String>> highlightFiled = hit.highlight();
if (highlightFiled.containsKey(UserIndex.FIELD_USER_NICKNAME)) {
highlightNickname = highlightFiled.get(UserIndex.FIELD_USER_NICKNAME).getFirst();
}
if (source != null) {
Long userId = source.getUserId();
String nickname = source.getNickname();
String avatar = source.getAvatar();
String hanNoteId = source.getHanNoteId();
Integer noteTotal = source.getNoteTotal();
String fansTotal = source.getFansTotal();
searchUserRspVOS.add(SearchUserRspVO.builder()
.userId(userId)
.nickname(nickname)
.highlightNickname(highlightNickname)
.avatar(avatar)
.hanNoteId(hanNoteId)
.noteTotal(noteTotal)
.fansTotal(fansTotal == null ? "0" : NumberUtils.formatNumberString(Long.parseLong(fansTotal)))
.build());
}
}
} catch (IOException e) {
log.error("==> search error: {}", e.getMessage());
}
return PageResponse.success(searchUserRspVOS, pageNo, total);
}
@Override
public Response<Long> rebuildDocument(RebuildUserDocumentReqDTO rebuildUserDocumentReqDTO) {
Long userId = rebuildUserDocumentReqDTO.getId();
// 从数据库查询 Elasticsearch 索引数据
List<Map<String, Object>> result = selectMapper.selectEsUserIndexData(userId);
// 遍历查询结果,将每条记录同步到 Elasticsearch
for (Map<String, Object> recordMap : result) {
IndexRequest<Object> request = IndexRequest.of(indexRequest -> indexRequest
// 创建索引请求对象,指定索引名称
.index(UserIndex.NAME)
// 设置文档的 ID使用记录中的主键 “id” 字段值
.id((String.valueOf(recordMap.get(UserIndex.FIELD_USER_ID))))
// 设置文档的内容,使用查询结果的记录数据
.document(recordMap));
// 将数据写入 Elasticsearch 索引
try {
client.index(request);
} catch (IOException e) {
log.error("==> 同步用户数据到 Elasticsearch 索引中失败: " + e.getMessage());
}
}
return Response.success();
}
}

View File

@@ -0,0 +1,9 @@
server:
port: 8092 # 项目启动的端口
spring:
profiles:
active: dev # 默认激活 dev 本地开发环境
servlet:
multipart:
max-file-size: 20MB # 单个文件最大大小

View File

@@ -0,0 +1,19 @@
spring:
application:
name: han-note-search # 应用名称
profiles:
active: dev # 默认激活 dev 本地开发环境
cloud:
nacos:
discovery:
enabled: true # 启用服务发现
group: DEFAULT_GROUP # 所属组
namespace: han-note # 命名空间
server-addr: 127.0.0.1:8848 # 指定 Nacos 配置中心的服务器地址
config:
server-addr: http://127.0.0.1:8848 # 指定 Nacos 配置中心的服务器地址
prefix: ${spring.application.name} # 配置 Data Id 前缀,这里使用应用名称作为前缀
group: DEFAULT_GROUP # 所属组
namespace: han-note # 命名空间
file-extension: yaml # 配置文件格式
refresh-enabled: true # 是否开启动态刷新

View File

@@ -0,0 +1,58 @@
<configuration>
<!-- 引用 Spring Boot 的 logback 基础配置 -->
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>
<!-- 应用名称 -->
<property scope="context" name="appName" value="search"/>
<!-- 自定义日志输出路径,以及日志名称前缀 -->
<property name="LOG_FILE" value="./logs/${appName}.%d{yyyy-MM-dd}"/>
<!-- 每行日志输出的格式 -->
<property name="LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n"/>
<!-- 文件输出 -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<!-- 日志文件的命名格式 -->
<fileNamePattern>${LOG_FILE}-%i.log</fileNamePattern>
<!-- 保留 30 天的日志文件 -->
<maxHistory>30</maxHistory>
<!-- 单个日志文件最大大小 -->
<maxFileSize>10MB</maxFileSize>
<!-- 日志文件的总大小0 表示不限制 -->
<totalSizeCap>0</totalSizeCap>
<!-- 重启服务时,是否清除历史日志,不推荐清理 -->
<cleanHistoryOnStart>false</cleanHistoryOnStart>
</rollingPolicy>
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<pattern>${LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- 异步写入日志,提升性能 -->
<appender name="ASYNC_FILE" class="ch.qos.logback.classic.AsyncAppender">
<!-- 是否丢弃日志, 0 表示不丢弃。默认情况下,如果队列满 80%, 会丢弃 TRACE、DEBUG、INFO 级别的日志 -->
<discardingThreshold>0</discardingThreshold>
<!-- 队列大小。默认值为 256 -->
<queueSize>256</queueSize>
<appender-ref ref="FILE"/>
</appender>
<!-- 本地 dev 开发环境 -->
<springProfile name="dev">
<include resource="org/springframework/boot/logging/logback/console-appender.xml"/>
<root level="INFO">
<appender-ref ref="CONSOLE"/> <!-- 输出控制台日志 -->
<appender-ref ref="ASYNC_FILE"/> <!-- 打印日志到文件中。PS: 本地环境下,如果不想打印日志到文件,可注释掉此行 -->
</root>
</springProfile>
<!-- 其它环境 -->
<springProfile name="prod">
<include resource="org/springframework/boot/logging/logback/console-appender.xml"/>
<root level="INFO">
<appender-ref ref="ASYNC_FILE"/> <!-- 生产环境下,仅打印日志到文件中 -->
</root>
</springProfile>
</configuration>

View File

@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.hanserwei.hannote.search.biz.domain.mapper.SelectMapper">
<select id="selectEsNoteIndexData" resultType="map" parameterType="map">
select n.id,
n.title,
n.topic_name as topic,
n.type,
SUBSTRING_INDEX(n.img_uris, ',', 1) AS cover,
DATE_FORMAT(n.create_time, '%Y-%m-%d %H:%i:%s') AS create_time,
DATE_FORMAT(n.update_time, '%Y-%m-%d %H:%i:%s') AS update_time,
u.nickname AS creator_nickname,
u.avatar AS creator_avatar,
IFNULL(nc.like_total, 0) as like_total,
IFNULL(nc.collect_total, 0) as collect_total,
IFNULL(nc.comment_total, 0) as comment_total
from t_note n
left join t_user u on n.creator_id = u.id
left join t_note_count nc on n.id = nc.note_id
where n.visible = 0
and n.`status` = 1
<if test="noteId != null">
and n.id = #{noteId}
</if>
<if test="userId != null">
and u.id = #{userId}
</if>
<if test="noteId != null">
limit 1
</if>
</select>
<select id="selectEsUserIndexData" resultType="map" parameterType="map">
select u.id,
u.nickname,
u.avatar,
u.han_note_id,
IFNULL(uc.note_total, 0) as note_total,
IFNULL(uc.fans_total, 0) as fans_total
from t_user u
left join t_user_count uc on u.id = uc.user_id
where u.`status` = 0
and u.is_deleted = 0
<if test="userId != null">
and u.id = #{userId}
</if>
</select>
</mapper>

View File

@@ -0,0 +1,38 @@
package com.hanserwei.hannote.search;
import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.elasticsearch._types.SortOrder;
import co.elastic.clients.elasticsearch.core.SearchResponse;
import com.hanserwei.hannote.search.biz.model.vo.SearchUserRspVO;
import lombok.SneakyThrows;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
public class ElasticsearchClientTest {
@Autowired
private ElasticsearchClient client;
@Test
@SneakyThrows
public void testClient() {
SearchResponse<SearchUserRspVO> response = client.search(s -> s
.index("user")
.query(q -> q
.multiMatch(mm -> mm
.query("Han")
.fields("nickname", "han_note_id")
)
)
.sort(so -> so
.field(f -> f.field("fans_total").order(SortOrder.Desc))
)
.from(0)
.size(10),
SearchUserRspVO.class
);
response.hits().hits().forEach(hit -> System.out.println(hit.source()));
}
}