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

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("搜索服务正常运行");
    }
}
相关推荐
天河归来8 小时前
第三方接口不稳定,如何让核心任务继续完成
java·大数据
Knight_AL9 小时前
Java 文件下载时文件名中的 `#` 被转换成 `%23`?一次 URL 编码与解码问题排查
java·开发语言
砚底藏山河10 小时前
python量化自定义周期 K 线合成
java·开发语言·数据库·python·金融
猿究院_夏燊11 小时前
MySQL 的四种隔离级别
java·数据库·mysql
码流子11 小时前
移动收费怎么做?手持终端+App+收费中心的架构实践
java·开发语言·架构
霸道流氓气质12 小时前
AI 流式对话与实时聊天平台:SSE 解码竞态、有状态会话隔离与背压取消的 Vue3 + Spring AI 生产级实战
java·人工智能·spring
云创智城-yuncitys12 小时前
YunParking 4.0:JDK 17 与 Spring Boot 3.2 的停车云升级
java·开发语言·spring boot
文丑颜不良啊12 小时前
LeetCode1661~1680题目解析
java·leetcode
子非鱼@Itfuture12 小时前
关于前后端联调中登录状态/登录信息失效?
java·前端
郭小李12 小时前
openFeign入门
java·开发语言·前端