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

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("搜索服务正常运行");
    }
}
相关推荐
Joy T8 小时前
Agent 确定性上下文设计:从 Prompt 参数注入到能力分层
java·开发语言·微服务·架构·prompt·agent context
Elasticsearch10 小时前
Kibana:创建包含时间序列图表的仪表板
elasticsearch
m0_5873830011 小时前
广州 24 小时自助健身房解决方案技术实现与实战指南
java·spring·小程序·架构·需求分析
曦夜日长11 小时前
Git系统篇,Git本地使用(一):从“究极进化版“到三个区域的底层原理
大数据·linux·git·elasticsearch
·云扬·11 小时前
Prompt 工程与安全:从 System 设计到注入防御
java·人工智能·安全·ai·prompt
代码不停12 小时前
自动化测试(java)
java·开发语言·selenium·自动化
鬼手点金14 小时前
opencode-rules 插件
java·开发语言·前端·人工智能·chrome·git·github
paopaokaka_luck15 小时前
滑雪场管理系统(AI智能助手流式对话,ECharts 运营数据分析、教练、雪具与场地一体化预约,钱包支付退款、订单核销与服务评价)
java·人工智能·spring boot·网络协议·数据分析·echarts
Wang's Blog15 小时前
Java 项目实战: 交友项目 - Dubbo 的内部结构与一次调用的完整链路
java·dubbo
Wang's Blog15 小时前
Java 项目实战: 交友项目 - Dubbo 入门引导
java·dubbo·交友