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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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; // 默认值为第一页
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.hanserwei.hannote.search.biz.service;
|
||||
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
public interface ExtDictService {
|
||||
|
||||
/**
|
||||
* 获取热更新词典
|
||||
*
|
||||
* @return 热更新词典
|
||||
*/
|
||||
ResponseEntity<String> getHotUpdateExtDict();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
server:
|
||||
port: 8092 # 项目启动的端口
|
||||
|
||||
spring:
|
||||
profiles:
|
||||
active: dev # 默认激活 dev 本地开发环境
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 20MB # 单个文件最大大小
|
||||
@@ -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 # 是否开启动态刷新
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user