网站搭建实操(八)后台管理-搜索服务

forum-search-service 搜索服务

搜素使用 Elasticsearch

为什么搜索服务要用 Elasticsearch

MySQL 搜索的问题

问题 说明 影响
全表扫描 LIKE '%keyword%' 无法使用索引 百万级数据时查询极慢(秒级)
不支持分词 搜索"Spring框架"无法匹配"Spring Boot" 搜索结果不准确
不支持相关性排序 无法按匹配度排序 用户体验差
不支持拼音搜索 输入"spring"无法匹配"Spring" 大小写敏感
不支持高亮 需要手动处理 开发成本高
不支持同义词 搜索"电脑"无法匹配"计算机" 召回率低

Elasticsearch vs MySQL 对比

对比项 MySQL Elasticsearch
本质 关系型数据库 分布式搜索引擎
数据量 千万级性能下降 PB级仍保持毫秒响应
搜索速度 秒级到分钟级 毫秒级
分词 不支持 支持中文分词(IK)
相关性排序 不支持 支持(TF-IDF/BM25)
高亮 需手动实现 自动支持
拼音搜索 需手动实现 支持拼音插件
同义词 需手动实现 支持同义词配置
分布式 需手动分库分表 原生支持

搜索流程

ES详解链接:
ES详解

1.模块结构

text 复制代码
forum-search-service/
├── pom.xml
├── src/main/java/com/forum/search/
│   ├── SearchServiceApplication.java
│   ├── controller/
│   │   └── SearchController.java
│   ├── dto/
│   │   ├── SearchDTO.java
│   │   └── SearchResultVO.java
│   ├── service/
│   │   ├── SearchService.java
│   │   └── impl/
│   │       └── SearchServiceImpl.java
│   ├── config/
│   │   ├── ElasticsearchConfig.java
│   │   └── Knife4jConfig.java
│   └── listener/
│       └── PostSyncListener.java
└── src/main/resources/
    └── application.yml

2.具体实现

pom

java 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>forum-backend</artifactId>
        <groupId>org.example</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>forum-search-service</artifactId>
    <packaging>jar</packaging>
    <description>搜索服务 - 提供全文搜索功能</description>

    <dependencies>
        <!-- Service模块 -->
        <dependency>
            <groupId>org.example</groupId>
            <artifactId>forum-service</artifactId>
            <version>1.0-SNAPSHOT</version>
        </dependency>

        <!-- Core模块 -->

        <dependency>
            <groupId>org.example</groupId>
            <artifactId>forum-core</artifactId>
            <version>1.0-SNAPSHOT</version>
        </dependency>

        <!-- Spring Boot Web -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- Elasticsearch -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
        </dependency>

        <!-- Knife4j -->
        <dependency>
            <groupId>com.github.xiaoymin</groupId>
            <artifactId>knife4j-spring-boot-starter</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <mainClass>com.forum.search.SearchServiceApplication</mainClass>
                    <finalName>forum-search-service</finalName>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

启动类

java 复制代码
package com.forum.search;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.ComponentScan;

@SpringBootApplication
@ComponentScan(basePackages = {"com.forum"})
@MapperScan("com.forum.dao.mapper")
public class SearchServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(SearchServiceApplication.class, args);
        System.out.println("========================================");
        System.out.println("搜索服务启动成功!");
        System.out.println("访问地址: http://localhost:8085");
        System.out.println("API文档: http://localhost:8085/doc.html");
        System.out.println("========================================");
    }
}

DTO类

java 复制代码
package com.forum.search.dto;


import com.forum.common.result.PageRequest;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;

@Data
@EqualsAndHashCode(callSuper = true)
@ApiModel(description = "搜索请求")
public class SearchDTO extends PageRequest {

    @ApiModelProperty(value = "搜索关键词", required = true)
    private String keyword;

    @ApiModelProperty(value = "搜索类型 1:帖子 2:用户", example = "1")
    private Integer type = 1;

    @ApiModelProperty(value = "版块ID过滤")
    private Long categoryId;

    @ApiModelProperty(value = "用户ID过滤")
    private Long userId;
}

SearchResultVO

java 复制代码
package com.forum.search.dto;

import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;

import java.time.LocalDateTime;

@Data
@ApiModel(description = "搜索结果")
public class SearchResultVO {

    @ApiModelProperty(value = "ID")
    private Long id;

    @ApiModelProperty(value = "标题")
    private String title;

    @ApiModelProperty(value = "内容摘要")
    private String summary;

    @ApiModelProperty(value = "用户ID")
    private Long userId;

    @ApiModelProperty(value = "用户名")
    private String username;

    @ApiModelProperty(value = "版块ID")
    private Long categoryId;

    @ApiModelProperty(value = "版块名称")
    private String categoryName;

    @ApiModelProperty(value = "浏览量")
    private Integer viewCount;

    @ApiModelProperty(value = "回复数")
    private Integer replyCount;

    @ApiModelProperty(value = "高亮标题")
    private String highlightTitle;

    @ApiModelProperty(value = "高亮内容")
    private String highlightContent;

    @ApiModelProperty(value = "创建时间")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime createdTime;

    @ApiModelProperty(value = "相关性得分")
    private Float score;
}

Elasticsearch文档实体

java 复制代码
package com.forum.search.entity;

import lombok.Data;
import org.springframework.data.annotation.Id;
import org.springframework.data.elasticsearch.annotations.Document;
import org.springframework.data.elasticsearch.annotations.Field;
import org.springframework.data.elasticsearch.annotations.FieldType;

import java.time.LocalDateTime;

@Data
@Document(indexName = "forum_post")
public class PostDocument {

    @Id
    private Long id;

    @Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
    private String title;

    @Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
    private String content;

    @Field(type = FieldType.Long)
    private Long userId;

    @Field(type = FieldType.Long)
    private Long categoryId;

    @Field(type = FieldType.Integer)
    private Integer viewCount;

    @Field(type = FieldType.Integer)
    private Integer replyCount;

    @Field(type = FieldType.Integer)
    private Integer likeCount;

    @Field(type = FieldType.Date)
    private LocalDateTime createdTime;

    @Field(type = FieldType.Date)
    private LocalDateTime updatedTime;
}

Repository

java 复制代码
package com.forum.search.repository;


import com.forum.search.entity.PostDocument;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.elasticsearch.annotations.Query;
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface PostSearchRepository extends ElasticsearchRepository<PostDocument, Long> {

    /**
     * 根据标题或内容搜索
     */
    Page<PostDocument> findByTitleContainingOrContentContaining(String title, String content, Pageable pageable);

    /**
     * 自定义搜索查询
     */
    @Query("{\"bool\": {\"must\": [{\"match\": {\"title\": \"?0\"}}]}}")
    Page<PostDocument> searchByTitle(String keyword, Pageable pageable);

    /**
     * 按版块搜索
     */
    Page<PostDocument> findByCategoryId(Long categoryId, Pageable pageable);

    /**
     * 按用户搜索
     */
    Page<PostDocument> findByUserId(Long userId, Pageable pageable);
}

服务类

java 复制代码
package com.forum.search.service;

import com.forum.common.result.PageResult;
import com.forum.search.dto.SearchDTO;
import com.forum.search.dto.SearchResultVO;

public interface SearchService {

    /**
     * 全文搜索
     */
    PageResult<SearchResultVO> search(SearchDTO dto);

    /**
     * 索引帖子
     */
    void indexPost(Long postId);

    /**
     * 批量索引帖子
     */
    void indexAllPosts();

    /**
     * 删除索引
     */
    void deletePostIndex(Long postId);

    /**
     * 清空所有索引
     */
    void clearAllIndexes();
}

实现类

java 复制代码
package com.forum.search.service.impl;

import cn.hutool.core.bean.BeanUtil;
import com.forum.common.exception.BusinessException;
import com.forum.common.result.PageResult;
import com.forum.dao.entity.PostEntity;
import com.forum.dao.mapper.PostMapper;
import com.forum.search.dto.SearchDTO;
import com.forum.search.dto.SearchResultVO;
import com.forum.search.entity.PostDocument;
import com.forum.search.repository.PostSearchRepository;
import com.forum.search.service.SearchService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.stream.Collectors;

@Slf4j
@Service
@RequiredArgsConstructor
public class SearchServiceImpl implements SearchService {

    private final PostSearchRepository postSearchRepository;
    private final PostMapper postMapper;

    @Override
    public PageResult<SearchResultVO> search(SearchDTO dto) {
        log.info("搜索: keyword={}, type={}, page={}, size={}",
                dto.getKeyword(), dto.getType(), dto.getPageNum(), dto.getPageSize());

        if (StringUtils.isBlank(dto.getKeyword())) {
            throw new BusinessException("搜索关键词不能为空");
        }

        Pageable pageable = PageRequest.of(dto.getPageNum() - 1, dto.getPageSize());
        Page<PostDocument> page;

        // 根据搜索类型执行不同搜索
        if (dto.getType() == 1) {
            // 帖子搜索
            page = postSearchRepository.findByTitleContainingOrContentContaining(
                    dto.getKeyword(), dto.getKeyword(), pageable);
        } else {
            // 用户搜索(待实现)
            page = Page.empty();
        }

        List<SearchResultVO> records = page.getContent().stream()
                .map(this::convertToVO)
                .collect(Collectors.toList());

        return PageResult.of(dto.getPageNum(), dto.getPageSize(), page.getTotalElements(), records);
    }

    @Override
    public void indexPost(Long postId) {
        log.info("索引帖子: postId={}", postId);

        PostEntity post = postMapper.selectById(postId);
        if (post == null || post.getStatus() != 1) {
            log.warn("帖子不存在或已删除,跳过索引: postId={}", postId);
            return;
        }

        PostDocument document = new PostDocument();
        BeanUtil.copyProperties(post, document);
        postSearchRepository.save(document);

        log.info("帖子索引成功: postId={}", postId);
    }

    @Override
    public void indexAllPosts() {
        log.info("开始批量索引所有帖子");

        List<PostEntity> posts = postMapper.selectList(null);
        List<PostDocument> documents = posts.stream()
                .filter(post -> post.getStatus() == 1)
                .map(post -> {
                    PostDocument doc = new PostDocument();
                    BeanUtil.copyProperties(post, doc);
                    return doc;
                })
                .collect(Collectors.toList());

        postSearchRepository.saveAll(documents);
        log.info("批量索引完成,共索引{}条帖子", documents.size());
    }

    @Override
    public void deletePostIndex(Long postId) {
        log.info("删除帖子索引: postId={}", postId);
        postSearchRepository.deleteById(postId);
    }

    @Override
    public void clearAllIndexes() {
        log.info("清空所有索引");
        postSearchRepository.deleteAll();
    }

    private SearchResultVO convertToVO(PostDocument document) {
        SearchResultVO vo = new SearchResultVO();
        BeanUtil.copyProperties(document, vo);

        // 高亮处理
        vo.setHighlightTitle(document.getTitle());
        vo.setHighlightContent(getSummary(document.getContent()));

        return vo;
    }

    private String getSummary(String content) {
        if (StringUtils.isBlank(content)) {
            return "";
        }
        return content.length() > 200 ? content.substring(0, 200) + "..." : content;
    }
}

controller

java 复制代码
package com.forum.search.controller;

import com.forum.common.base.BaseController;
import com.forum.common.result.PageResult;
import com.forum.common.result.Result;
import com.forum.search.dto.SearchDTO;
import com.forum.search.dto.SearchResultVO;
import com.forum.search.service.SearchService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;

@Slf4j
@Api(tags = "搜索服务")
@RestController
@RequestMapping("/api/search")
@RequiredArgsConstructor
public class SearchController extends BaseController {

    private final SearchService searchService;

    @ApiOperation("全文搜索")
    @GetMapping("/posts")
    public Result<PageResult<SearchResultVO>> search(@Valid SearchDTO dto) {
        PageResult<SearchResultVO> result = searchService.search(dto);
        return success(result);
    }

    @ApiOperation("索引帖子(管理员)")
    @PostMapping("/index/{postId}")
    public Result<Void> indexPost(@PathVariable Long postId) {
        if (!isAdmin()) {
            return error("无权限操作");
        }
        searchService.indexPost(postId);
        return success();
    }

    @ApiOperation("批量索引所有帖子(管理员)")
    @PostMapping("/index/all")
    public Result<Void> indexAllPosts() {
        if (!isAdmin()) {
            return error("无权限操作");
        }
        searchService.indexAllPosts();
        return success();
    }

    @ApiOperation("健康检查")
    @GetMapping("/health")
    public Result<String> health() {
        return success("搜索服务正常运行");
    }
}
相关推荐
深入云栈26 分钟前
CompleteFuture VS CompletableFuture:Netty 为何自研 Future
java·架构
choumou_M1 小时前
SpringBoot_7:用户资源的上传与修改
java·spring boot·后端
user_admin_god1 小时前
一体化数据归集接口详细设计说明
java·大数据·spring boot·后端·spring
LlmCraft|大模型工程实践2 小时前
08. Docker Compose 一键编排:多容器应用的指挥家
java·spring cloud·eureka
珍珠先生2 小时前
第14章 设计模式入门:单例、工厂、建造者与代理
java
坐吃山猪2 小时前
JDK 8 到 JDK 21 新特性知识要点
java·开发语言·windows
坐吃山猪2 小时前
【多线程】FutureTask多线程底层实现
java·开发语言·数据库
weixin199701080162 小时前
[特殊字符]《从0到1:闲鱼开放平台授权登录 + AccessToken 刷新 + 聚石塔部署完整链路》(附Python源码)
java·数据库·python
Freak嵌入式2 小时前
RP2040 PIO 编程模型与状态机原理:从硬件架构到工作逻辑全解析
java·大数据·开发语言·单片机·嵌入式硬件·硬件架构
kakawzw2 小时前
Netty源码笔记
java·服务器·后端