【项目扩展实战|第8篇】Spring Boot + MyBatis 分页条件查询接口优化
前言
前面我们已经写过商品详情缓存、文件上传等功能。这一篇继续回到后台管理系统里非常高频的一个接口:分页条件查询。
只要是后台列表页面,基本都离不开分页查询。比如:
- 商品列表分页查询
- 用户列表分页查询
- 订单列表分页查询
- 操作日志分页查询
- 文件记录分页查询
如果只是查全部数据,接口很简单;但真实后台一般都需要支持条件筛选,例如商品名称、状态、价格区间、创建时间范围等。
这一篇我们就以商品列表为例,实现一个完整的分页条件查询接口,并顺带优化参数对象、分页返回结构和 MyBatis 动态 SQL。
一、分页条件查询的整体需求
我们要实现的接口是:
GET /admin/product/page支持这些查询条件:
- 当前页码
page - 每页条数
pageSize - 商品名称关键字
keyword - 商品状态
status - 最低价格
minPrice - 最高价格
maxPrice - 创建开始时间
beginTime - 创建结束时间
endTime
返回结果大概是:
{"code":200,"message":"操作成功","data":{"total":100,"rows":[]}}这里使用total + rows的结构,前端分页组件比较容易使用。
二、准备分页返回对象
先定义一个通用分页返回类。
packagecom.example.common;importlombok.AllArgsConstructor;importlombok.Data;importlombok.NoArgsConstructor;importjava.util.List;@Data@NoArgsConstructor@AllArgsConstructorpublicclassPageResult<T>{privateLongtotal;privateList<T>rows;}文字说明
PageResult是一个通用类,不只商品列表能用,用户列表、订单列表、日志列表也都能用。
其中:
total表示总记录数rows表示当前页数据
前端拿到total后,就可以计算总页数;拿到rows后,就可以渲染表格。
三、添加 PageHelper 依赖
分页可以自己写limit,也可以使用 PageHelper。
这里使用 PageHelper,因为它和 MyBatis 配合比较方便。
<dependency><groupId>com.github.pagehelper</groupId><artifactId>pagehelper-spring-boot-starter</artifactId><version>1.4.7</version></dependency>文字说明
PageHelper 的核心用法是:
PageHelper.startPage(page,pageSize);List<ProductVO>list=productMapper.selectPage(queryDTO);Page<ProductVO>pageResult=(Page<ProductVO>)list;它会自动帮我们在 SQL 后面加分页语句,并统计总记录数。
四、准备查询参数对象
不要把所有查询参数都散落在 Controller 方法参数里,更推荐封装成一个 DTO。
packagecom.example.dto;importlombok.Data;importorg.springframework.format.annotation.DateTimeFormat;importjava.math.BigDecimal;importjava.time.LocalDateTime;@DatapublicclassProductPageQueryDTO{privateIntegerpage=1;privateIntegerpageSize=10;privateStringkeyword;privateIntegerstatus;privateBigDecimalminPrice;privateBigDecimalmaxPrice;@DateTimeFormat(pattern="yyyy-MM-dd HH:mm:ss")privateLocalDateTimebeginTime;@DateTimeFormat(pattern="yyyy-MM-dd HH:mm:ss")privateLocalDateTimeendTime;}文字说明
这个 DTO 封装了所有分页查询条件。
这里给page和pageSize设置了默认值:
privateIntegerpage=1;privateIntegerpageSize=10;这样前端如果没传分页参数,也不会直接报错。
时间参数加了:
@DateTimeFormat(pattern="yyyy-MM-dd HH:mm:ss")这样 Spring MVC 才能把字符串时间转换成LocalDateTime。
例如前端传:
beginTime=2026-07-01 00:00:00后端就能正常接收。
五、准备商品列表 VO
分页列表通常不需要返回商品详情里的全部字段,所以单独定义一个列表 VO。
packagecom.example.vo;importlombok.Data;importjava.math.BigDecimal;importjava.time.LocalDateTime;@DatapublicclassProductPageVO{privateLongid;privateStringname;privateBigDecimalprice;privateIntegerstock;privateStringcoverUrl;privateIntegerstatus;privateLocalDateTimecreateTime;}文字说明
列表页一般展示核心字段即可,比如:
- 商品 ID
- 商品名称
- 价格
- 库存
- 封面
- 状态
- 创建时间
如果列表页字段太多,会导致接口响应变大,也会让前端表格变得很复杂。
六、Controller 层实现
packagecom.example.controller;importcom.example.common.PageResult;importcom.example.common.Result;importcom.example.dto.ProductPageQueryDTO;importcom.example.service.ProductService;importcom.example.vo.ProductPageVO;importlombok.RequiredArgsConstructor;importorg.springframework.web.bind.annotation.*;@RestController@RequestMapping("/admin/product")@RequiredArgsConstructorpublicclassAdminProductController{privatefinalProductServiceproductService;@GetMapping("/page")publicResult<PageResult<ProductPageVO>>page(ProductPageQueryDTOqueryDTO){PageResult<ProductPageVO>pageResult=productService.page(queryDTO);returnResult.success(pageResult);}}文字说明
这里没有使用@RequestBody。
因为分页条件查询一般是 GET 请求,参数通常放在 URL 后面,例如:
GET /admin/product/page?page=1&pageSize=10&keyword=手机&status=1Spring MVC 会自动把这些请求参数封装到ProductPageQueryDTO中。
Controller 层依旧保持简单,只负责接收参数、调用 Service、返回结果。
七、Service 层实现分页逻辑
1. Service 接口
packagecom.example.service;importcom.example.common.PageResult;importcom.example.dto.ProductPageQueryDTO;importcom.example.vo.ProductPageVO;publicinterfaceProductService{PageResult<ProductPageVO>page(ProductPageQueryDTOqueryDTO);}2. Service 实现类
packagecom.example.service.impl;importcom.example.common.PageResult;importcom.example.common.ResultCode;importcom.example.dto.ProductPageQueryDTO;importcom.example.exception.BusinessException;importcom.example.mapper.ProductMapper;importcom.example.service.ProductService;importcom.example.vo.ProductPageVO;importcom.github.pagehelper.Page;importcom.github.pagehelper.PageHelper;importlombok.RequiredArgsConstructor;importorg.springframework.stereotype.Service;importjava.util.List;@Service@RequiredArgsConstructorpublicclassProductServiceImplimplementsProductService{privatestaticfinalintMAX_PAGE_SIZE=100;privatefinalProductMapperproductMapper;@OverridepublicPageResult<ProductPageVO>page(ProductPageQueryDTOqueryDTO){checkPageParams(queryDTO);PageHelper.startPage(queryDTO.getPage(),queryDTO.getPageSize());List<ProductPageVO>list=productMapper.selectPage(queryDTO);Page<ProductPageVO>page=(Page<ProductPageVO>)list;returnnewPageResult<>(page.getTotal(),page.getResult());}privatevoidcheckPageParams(ProductPageQueryDTOqueryDTO){if(queryDTO==null){thrownewBusinessException(ResultCode.PARAM_ERROR);}if(queryDTO.getPage()==null||queryDTO.getPage()<=0){queryDTO.setPage(1);}if(queryDTO.getPageSize()==null||queryDTO.getPageSize()<=0){queryDTO.setPageSize(10);}if(queryDTO.getPageSize()>MAX_PAGE_SIZE){queryDTO.setPageSize(MAX_PAGE_SIZE);}if(queryDTO.getMinPrice()!=null&&queryDTO.getMaxPrice()!=null&&queryDTO.getMinPrice().compareTo(queryDTO.getMaxPrice())>0){thrownewBusinessException(ResultCode.PARAM_ERROR.getCode(),"最低价格不能大于最高价格");}if(queryDTO.getBeginTime()!=null&&queryDTO.getEndTime()!=null&&queryDTO.getBeginTime().isAfter(queryDTO.getEndTime())){thrownewBusinessException(ResultCode.PARAM_ERROR.getCode(),"开始时间不能晚于结束时间");}}}文字说明
Service 层主要做三件事。
第一,校验分页参数:
checkPageParams(queryDTO);防止前端传入异常参数,比如page=-1、pageSize=99999。
第二,开启分页:
PageHelper.startPage(queryDTO.getPage(),queryDTO.getPageSize());注意:PageHelper.startPage必须紧挨着下一次 MyBatis 查询执行。
第三,封装分页结果:
returnnewPageResult<>(page.getTotal(),page.getResult());这样前端拿到的就是统一分页格式。
八、Mapper 层实现
1. Mapper 接口
packagecom.example.mapper;importcom.example.dto.ProductPageQueryDTO;importcom.example.vo.ProductPageVO;importorg.apache.ibatis.annotations.Mapper;importjava.util.List;@MapperpublicinterfaceProductMapper{List<ProductPageVO>selectPage(ProductPageQueryDTOqueryDTO);}2. Mapper XML 动态 SQL
<?xml version="1.0" encoding="UTF-8" ?><!DOCTYPEmapperPUBLIC"-//mybatis.org//DTD Mapper 3.0//EN""https://mybatis.org/dtd/mybatis-3-mapper.dtd"><mappernamespace="com.example.mapper.ProductMapper"><selectid="selectPage"resultType="com.example.vo.ProductPageVO">select id, name, price, stock, cover_url, status, create_time from product<where><iftest="keyword != null and keyword != ''">and name like concat('%', #{keyword}, '%')</if><iftest="status != null">and status = #{status}</if><iftest="minPrice != null">and price>= #{minPrice}</if><iftest="maxPrice != null">and price<= #{maxPrice}</if><iftest="beginTime != null">and create_time>= #{beginTime}</if><iftest="endTime != null">and create_time<= #{endTime}</if></where>order by create_time desc, id desc</select></mapper>文字说明
这里使用了 MyBatis 动态 SQL。
<where>标签会自动处理where和多余的and。
比如只传了status时,最终 SQL 类似:
select...fromproductwherestatus=?orderbycreate_timedesc,iddesc如果什么条件都没传,就不会拼接where,最终查询全部商品分页。
关键字查询使用:
name like concat('%', #{keyword}, '%')价格和时间范围分别使用:
price >= #{minPrice} price <= #{maxPrice} create_time >= #{beginTime} create_time <= #{endTime}这就是后台列表条件查询里非常常见的写法。
九、分页查询接口测试示例
1. 查询第一页
GET /admin/product/page?page=1&pageSize=10表示查询第一页,每页 10 条。
2. 根据名称模糊查询
GET /admin/product/page?page=1&pageSize=10&keyword=手机表示查询名称中包含“手机”的商品。
3. 根据状态查询
GET /admin/product/page?page=1&pageSize=10&status=1表示查询上架商品。
4. 根据价格区间查询
GET /admin/product/page?page=1&pageSize=10&minPrice=100&maxPrice=500表示查询价格在 100 到 500 之间的商品。
5. 根据时间范围查询
GET /admin/product/page?page=1&pageSize=10&beginTime=2026-07-01 00:00:00&endTime=2026-07-05 23:59:59表示查询指定时间范围内创建的商品。
十、为什么要限制 pageSize
如果不限制pageSize,前端或者恶意请求可能传:
pageSize=100000这样一次查询就可能查出大量数据,导致:
- 数据库压力变大
- 接口响应变慢
- 内存占用升高
- 前端渲染卡顿
所以 Service 里做了限制:
if(queryDTO.getPageSize()>MAX_PAGE_SIZE){queryDTO.setPageSize(MAX_PAGE_SIZE);}后台管理系统中,一般每页 10、20、50 条已经够用,最大限制到 100 比较合理。
十一、涉及知识点
1. PageHelper
PageHelper 是 MyBatis 常用分页插件。
它可以自动帮我们拼接分页 SQL,并计算总数。
使用时要注意:
PageHelper.startPage(page,pageSize);必须放在 Mapper 查询之前。
2. MyBatis 动态 SQL
动态 SQL 适合处理可选查询条件。
常用标签有:
<where><if><set><foreach>
本篇主要使用<where>和<if>。
3. DTO 和 VO 分层
ProductPageQueryDTO用来接收查询参数。
ProductPageVO用来返回列表数据。
这样比直接用实体类更清晰,也更适合前后端接口设计。
4. 模糊查询
模糊查询常见写法是:
namelikeconcat('%',#{keyword}, '%')注意不要直接拼接字符串,避免 SQL 注入风险。
5. 时间范围查询
时间范围查询是后台列表中非常常见的需求。
前端传字符串时间,后端通过@DateTimeFormat转成LocalDateTime,Mapper 再用于 SQL 查询。
十二、常见问题
1. PageHelper 为什么没有生效?
常见原因有:
- 没有引入 PageHelper starter
PageHelper.startPage没有紧挨着 Mapper 查询- Mapper 查询之前执行了其他查询
- 返回类型转换方式不正确
正确顺序是:
PageHelper.startPage(page,pageSize);List<ProductPageVO>list=productMapper.selectPage(queryDTO);Page<ProductPageVO>page=(Page<ProductPageVO>)list;2. GET 请求能不能接收对象?
可以。
只要参数名和 DTO 字段名一致,Spring MVC 会自动封装。
例如:
?page=1&pageSize=10&keyword=手机可以封装到:
ProductPageQueryDTO3. 查询条件为空时会不会 SQL 报错?
不会。
因为<where>会自动处理条件。
如果所有<if>都不满足,就不会生成where。
4. 排序字段能不能让前端传?
可以,但要谨慎。
不能直接把前端传入的排序字段拼进 SQL,否则有 SQL 注入风险。
更安全的做法是后端定义白名单,比如只允许按create_time、price、stock排序。
十三、实际开发建议
1. 分页参数一定要兜底
不要完全相信前端传参。
后端要处理:
- 页码为空
- 页码小于 1
- 每页条数为空
- 每页条数过大
2. 列表接口不要返回过多字段
列表页只返回展示需要的字段。
详情页再返回完整信息。
这样接口更轻,前端也更好处理。
3. 模糊查询字段要考虑索引
like '%关键字%'在数据量大时可能导致索引效果不好。
如果商品量很大,可以考虑:
- 只支持前缀匹配
- 增加搜索字段
- 使用全文索引
- 接入 Elasticsearch
4. 后台查询要注意组合条件
后台列表的核心不只是分页,而是多个条件组合查询。
所以动态 SQL 是这一类接口必须掌握的内容。
十四、总结
这一篇我们完成了 Spring Boot + MyBatis 分页条件查询接口。
整个流程包括:
- 定义分页返回对象
PageResult - 定义查询参数 DTO
- 定义列表返回 VO
- Controller 接收 GET 查询参数
- Service 校验分页参数并调用 PageHelper
- Mapper 使用动态 SQL 组合查询条件
- 返回统一分页结构给前端
相比基础 CRUD,这一篇更接近后台管理系统真实列表页的写法。
分页条件查询是非常高频的能力,不管是商品、用户、订单、日志,基本都能套用这一套思路。学会这篇之后,后面写后台列表接口会顺很多。