若依RuoYi分页支持POST吗?PageHelper JSON请求失效原因与三种解法
我先说结论若依的分页不排斥 POST。网上很多文章说“RuoYi 分页只能走 GET”包括不少从表单页面复制出来、随手加了一行method:post的同学卡在了“返回 total 一直是 0、点击第二页又跳回第一页”的问题上最后绕回去用 GET 了。这个结论我不太认同与其说是框架写死了请求方式不如说是大家在改造 POST 时没有把分页参数送到 PageHelper 手里。RuoYi 的分页底层是 PageHelper它其实不关心你是 GET 还是 POST它关心的是pageNum、pageSize这两个值在 SQL 执行前能不能被正确读到。对需要把列表查询从 GET 改成 POST 的同学来说这篇文章可以当成一份实操笔记看。我会先把 RuoYi 的分页链路完整拆一遍找出“POST 请求分页失效”的根源再给出三种可落地的改造方案覆盖前端 api 封装、后端 Controller 和常用的请求包装器顺便把我在真实项目里踩过的一些坑也整理出来。内容以 RuoYi-Vue 前后端分离版本为例后面统称若依。1. 先从“POST 改完永远第一页”说起1.1 问题现场复现很多项目为了列表查询条件的统一性和请求体安全会想把查询接口从 GET 改成 POST。改之前代码通常是若依生成器默认的样子SysUserController 里是GetMapping(/list)SysUser 作为查询条件参数接收前端 API 文件里是method: get表格组件通过params传递分页参数。这时页面一切正常。接着大家会做两步改动。后端GetMapping(/list)改成PostMapping(/list)前端 api 文件从params: query改成data: query列表页面把分页参数也并到 query 对象里一起发送。重新刷新页面后会发现列表数据还在但左下角的总条数不对点击第 2 页、第 3 页没有任何反应永远返回第一页的数据。如果后端在 Controller 方法上顺手加了RequestBody SysUser user还可能遇到完全查不到任何数据的情况。这两类问题本质上不是同一个错误但根源都指向同一个点分页插件根本没有拿到前端传过来的页码。我在排查时是按这个顺序来定位的。1.2 顺着调用链追到 TableSupport如果你对若依的列表请求流程还不算陌生那一定见过这三个类BaseController、PageUtils、TableSupport。用户列表请求进来以后Controller 方法调用startPage()这个方法内部实际去构建一个 PageDomain而 PageDomain 的数据来源是TableSupport.buildPageRequest()。下面是不带异常处理的简化流程// BaseController 里的封装 protected void startPage() { PageUtils.startPage(); }// PageUtils 里的核心逻辑 public static void startPage() { PageDomain pageDomain TableSupport.buildPageRequest(); Integer pageNum pageDomain.getPageNum(); Integer pageSize pageDomain.getPageSize(); String orderBy SqlUtil.escapeOrderBySql(pageDomain.getOrderBy()); PageHelper.startPage(pageNum, pageSize, orderBy).setReasonable(true); }也就是说RuoYi 把“从请求中提取分页参数”这件事做成了统一入口Controller 根本不用关心分页字段是怎么来的。这个设计对 GET 请求很友好因为 GET 的查询参数会全部进入request.getParameterMap()TableSupport从 request parameter 里能直接拿到pageNum和pageSize。问题恰恰在这里当前端用 POST 且请求体是 JSON 时request.getParameterMap()是空的。浏览器或 axios 发送的 JSON 请求体是原始字节流Servlet 容器不会主动把它解析成 parameter key-value。于是TableSupport拿不到任何分页字段最终PageHelper.startPage()会用一个兜底默认值去执行页码固定在 1每页大小固定在 10。这就是为什么页面永远只有第一页。如果你在这个基础上又给 Controller 方法加了RequestBody情况会更隐蔽。RequestBody能让业务查询条件完整绑定到 SysUser但 PageHelper 依然走的是TableSupport它依然拿不到 body 里的分页字段。两条读取路径完全不一致造成“条件生效了、分页没生效”的怪异局面。2. 误区复盘不是请求方法的问题是参数可见性的问题2.1 若依并没有在框架层限制 GETRuoYi 里分页相关的源码我翻来翻去也没看到类似if (GET.equals(request.getMethod()))之类的判断。很多初学者会从前端默认 API 写法反推框架设计export function listUser(query) { return request({ url: /system/user/list, method: get, params: query }) }这是若依代码生成器生成的标准写法看起来像是“分页列表必须走 GET”。但认真想一下就会明白GET 只是若依列表页的前端习惯不是后端能力的上限。PageHelper 本身由PageHelper.startPage(pageNum, pageSize, orderBy)触发它在 Mapper 执行前把参数放入 ThreadLocal等下一个 SQL 查询执行时自动拼接 limit 语句。只要这条 SQL 前面的任意位置能拿到 pageNum 和 pageSize请求方式根本不重要。哪怕是 POST 请求只要请求体的编码方式是application/x-www-form-urlencodedServlet 容器也会把 key-value 放进request.getParameterMap()RuoYi 的分页代码就能正常读到页码。这也是为什么有些同学把列表改成 POST 后“碰巧能用”因为他们用 form 表单或 axios 拼接字符串传参没有遇到 JSON body 的坑。2.2 关键结论分页插件看不见 JSON body把几种常见请求方式过一遍结论会更清楚请求方式与内容类型request.getParameterMap()能否读到分页参数RuoYi 默认分页代码能否生效GET URL query能能POST application/x-www-form-urlencoded能能POST application/json不能不能POST text/plain不能不能RuoYi 的 TableSupport 走的是 Servlet 标准的 parameter 机制而 axios 默认发送 JSON 时参数写在原始请求体里。JSON 只有被RequestBody或专门的 JSON 解析器读取后才能映射成 Java 对象。这两个体系之间天然隔着一段距离。想支持 POST JSON核心就一句话要么想办法把 JSON 里的pageNum、pageSize等字段搬到request.getParameterMap()里要么干脆绕过 TableSupport在 SQL 执行前手动把分页参数塞给 PageHelper。后面的三种方案全是围绕这个核心展开的。3. POST 分页的三种落地姿势3.1 方案一前端改成 POST但请求体保持表单编码这是改动量最小的方案。如果你不想动后端 Controller也不打算引入额外过滤器可以在前端 api 封装层做一个小转换。页面还是正常传 query 对象发送时把对象转成 URL 编码字符串同时把请求头设置成application/x-www-form-urlencoded。import request from /utils/request import qs from qs export function listUser(query) { return request({ url: /system/user/list, method: post, headers: { Content-Type: application/x-www-form-urlencoded;charsetUTF-8 }, data: qs.stringify(query) }) }后端 Controller 不用加RequestBody保持原来的参数接收方式PostMapping(/list) public TableDataInfo list(SysUser user) // 注意不要加 RequestBody { startPage(); ListSysUser list userService.selectUserList(user); return getDataTable(list); }Spring MVC 从 request parameter 里绑定对象属性表单编码的 body 会进 parameter所以 SysUser 里的 userName、status 等查询条件能绑上RuoYi 的 TableSupport 也能读到页码。这个方案适合只想小范围改、不想折腾后端的老项目。但这个方案有几个明显短板。第一若依的查询参数偶尔会出现params[beginTime]、params[endTime]这类 BaseEntity 预留参数qs 能处理但遇到复杂嵌套对象时代码维护起来不够直观。第二列表搜索条件如果包含数组、对象或文件表单编码会变得很难受。第三前后端明明说好用 JSON最后发出去的是表单接口文档容易对不上。所以我实际项目中很少把这个当成“终极改造”更多是应急用。特别是在临时需要给第三方系统提供 POST 形式的分页接口时后端不想大改前端又不是我维护那就让对接方自己把参数做成表单格式两边代码都不动。3.2 方案二后端手动读取 body把分页参数直接给 PageHelper如果你希望保留 POST JSON body 的语义那更推荐在后端做改造。最直观的做法是Controller 使用RequestBody接收一个查询对象这个对象里包含分页字段和业务查询字段然后手动调用 PageHelper。先定义一个分页基础类。若依传统的 BaseController 分页方式依赖 PageDomain但 PageDomain 是给 TableSupport 用的它并不会自动从RequestBody里取值。所以我自己会建立一个独立的分页查询基类import com.fasterxml.jackson.annotation.JsonIgnore; public class PageQuery { /** 当前页码 */ private Integer pageNum 1; /** 每页显示条数 */ private Integer pageSize 10; /** 排序列 */ private String orderByColumn; /** 排序方向 */ private String isAsc asc; public Integer getPageNum() { return pageNum; } public void setPageNum(Integer pageNum) { this.pageNum pageNum; } public Integer getPageSize() { return pageSize; } public void setPageSize(Integer pageSize) { this.pageSize pageSize; } public String getOrderByColumn() { return orderByColumn; } public void setOrderByColumn(String orderByColumn) { this.orderByColumn orderByColumn; } public String getIsAsc() { return isAsc; } public void setIsAsc(String isAsc) { this.isAsc isAsc; } JsonIgnore public String getOrderBy() { if (StringUtils.isEmpty(orderByColumn)) { return ; } return StringUtils.toUnderScoreCase(orderByColumn) isAsc; } }查询对象再去继承这个分页基础类同时包含业务过滤字段public class SysUserPageQuery extends PageQuery { private String userName; private String status; private String phonenumber; private String beginTime; private String endTime; // getter/setter 省略 }Controller 改造如下import com.github.pagehelper.PageHelper; import com.ruoyi.common.core.domain.model.LoginUser; import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.common.utils.StringUtils; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; PreAuthorize(ss.hasPermi(system:user:list)) PostMapping(/list) public TableDataInfo list(RequestBody(required false) SysUserPageQuery query) { if (query null) { query new SysUserPageQuery(); } String orderBy SqlUtil.escapeOrderBySql(query.getOrderBy()); PageHelper.startPage(query.getPageNum(), query.getPageSize(), orderBy); SysUser user new SysUser(); user.setUserName(query.getUserName()); user.setStatus(query.getStatus()); user.setPhonenumber(query.getPhonenumber()); user.getParams().put(beginTime, query.getBeginTime()); user.getParams().put(endTime, query.getEndTime()); ListSysUser list userService.selectUserList(user); return getDataTable(list); }注意这里我没有调用原来的startPage()因为这个方法内部会走 TableSupport它拿不到 body 参数。PageHelper 的职责是拦截数据库查询只要你把起始页和每页大小传进去了后续第一次查询就会自动分页。它本身和请求参数来源解耦所以这种手动传参的方式在逻辑上是成立的。这个方案的好处很明显Controller 对 JSON body 的支持是显式、可控的。缺点是得为每个需要 POST 分页的接口额外建一个继承了 PageQuery 的查询对象如果项目里要改的接口很多工作量不小。对只想临时支持某个报表系统或第三方对接倒是够用。3.3 方案三用请求包装器把 body JSON 映射为 parameter第三个方案最省前端和后端接口调整但对 Servlet 原理要求高一些。思路是写一个 Filter当请求是 POST 且 Content-Type 为 JSON 时把 body 字符串读出来解析成 Map然后将pageNum、pageSize、orderByColumn、isAsc这些字段放入自定义的 request 包装器再放行。这样 TableSupport 从request.getParameterMap()里又能读到分页参数RuoYi 原来的startPage()自然恢复作用。这里有一个技术难点request 的 body 只能读一次。如果 Filter 里先读了 body后面 Controller 再使用RequestBody就会读到空流。所以需要缓存 body 并重写getInputStream()/getReader()让后续读取从缓存的字节数组里拿。下面是包装器的核心框架public class CacheBodyRequestWrapper extends HttpServletRequestWrapper { private final byte[] bodyBytes; private final MapString, String[] extraParams new HashMap(); public CacheBodyRequestWrapper(HttpServletRequest request, String body, MapString, Object jsonMap) throws IOException { super(request); this.bodyBytes body.getBytes(StandardCharsets.UTF_8); if (jsonMap ! null) { for (Map.EntryString, Object entry : jsonMap.entrySet()) { Object value entry.getValue(); if (value null) { extraParams.put(entry.getKey(), new String[]{}); } else if (value instanceof Collection? collection) { extraParams.put(entry.getKey(), collection.stream() .map(String::valueOf).toArray(String[]::new)); } else { extraParams.put(entry.getKey(), new String[]{String.valueOf(value)}); } } } } Override public String getParameter(String name) { String[] values getParameterValues(name); if (values null || values.length 0) { return null; } return values[0]; } Override public String[] getParameterValues(String name) { String[] original super.getParameterValues(name); String[] extra extraParams.get(name); if (original null || original.length 0) { return extra; } if (extra null || extra.length 0) { return original; } String[] merged Arrays.copyOf(original, original.length extra.length); System.arraycopy(extra, 0, merged, original.length, extra.length); return merged; } Override public MapString, String[] getParameterMap() { MapString, String[] result new HashMap(super.getParameterMap()); for (Map.EntryString, String[] entry : extraParams.entrySet()) { if (result.containsKey(entry.getKey())) { result.put(entry.getKey(), entry.getValue()); } else { result.put(entry.getKey(), entry.getValue()); } } return result; } Override public ServletInputStream getInputStream() throws IOException { ByteArrayInputStream byteArrayInputStream new ByteArrayInputStream(bodyBytes); return new ServletInputStream() { Override public int read() throws IOException { return byteArrayInputStream.read(); } Override public boolean isFinished() { return byteArrayInputStream.available() 0; } Override public boolean isReady() { return true; } Override public void setReadListener(ReadListener readListener) { // 按需实现 } }; } Override public BufferedReader getReader() throws IOException { return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8)); } }Filter 里做拦截和 JSON 解析。如果 JSON 解析失败直接放行原始请求避免影响其他接口Component public class PagePostFilter implements Filter { private static final String CONTENT_TYPE_JSON application/json; Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) servletRequest; HttpServletResponse response (HttpServletResponse) servletResponse; if (POST.equalsIgnoreCase(request.getMethod()) request.getContentType() ! null request.getContentType().startsWith(CONTENT_TYPE_JSON)) { String body readBody(request); MapString, Object jsonMap parseBody(body); CacheBodyRequestWrapper wrapper new CacheBodyRequestWrapper(request, body, jsonMap); chain.doFilter(wrapper, response); return; } chain.doFilter(request, response); } // 读取 body、解析 JSON 的辅助方法省略 }这个方案看起来很“通用”能让你在几乎不改 Controller 的情况下继续使用 RuoYi 默认的列表方法甚至前端可以放心地发送 JSON。但我强烈建议不要为了这个方案把所有 POST JSON 请求都包一层。它有几个实践代价每个 POST JSON 请求都会缓存请求体大字段上传接口会被白白吃掉内存。body 解析后的 Map 与request.getParameterMap()混合遇到同名参数会发成合并逻辑容易产生歧义。若依中很多查询条件不是简单字符串像params[beginTime]这样的参数如果被错误平铺到 parameter 里可能破坏原查询逻辑。我自己的落地方案通常是方案二为主、方案三为辅。只给路由路径包含/list或/page的核心接口加这个 Filter控制拦截范围不全局拦截。对比项方案一表单编码方案二手动 PageHelper方案三请求包装器是否保留 POST JSON否转为表单是是后端 Controller 改动量几乎为零每个接口要调整初始写一次核心逻辑即可是否影响其他接口无无需要控制拦截范围适合场景临时对接、应急调整明确以 POST JSON 为标准的长期项目通用组件、RuoYi 二次开发平台4. 实操示例用户管理页 POST JSON 分页改造下面用若依用户管理页做一个完整的最小改造示例。这个例子既包含前端页面也包含后端查询对象和 Mapper 的复用实践性更强。4.1 后端创建查询对象用户查询条件包含用户名、手机号、状态、时间范围。我直接把查询对象放在 controller 包下不一定非要建一堆 DTO 包。Data EqualsAndHashCode(callSuper true) public class SysUserQuery extends SysUser { /** 当前页码默认 1 */ private Integer pageNum 1; /** 每页大小默认 10 */ private Integer pageSize 10; /** 排序列名 */ private String orderByColumn; /** 排序方向 */ private String isAsc; /** 开始时间 */ private String beginTime; /** 结束时间 */ private String endTime; }为什么不直接让 Controller 接收SysUser因为 SysUser 本身是业务实体用它承载分页参数会破坏原有语义。继承 SysUser 能保留 MyBatis 的绑定字段映射查询条件可以直接透传到selectUserList(SysUser user)很省事。时间范围也可以塞到 SysUser 的 params 属性里但那样 JSON 结构会变成params: { beginTime: ... }前端传参更绕。我选择直接把 beginTime、endTime 平铺在查询对象上Controller 里再转存。4.2 后端 Controller 改造RestController RequestMapping(/system/user) public class SysUserController extends BaseController { Autowired private ISysUserService userService; PreAuthorize(ss.hasPermi(system:user:list)) PostMapping(/list) public TableDataInfo list(RequestBody(required false) SysUserQuery query) { if (query null) { query new SysUserQuery(); } SysUser condition new SysUser(); condition.setUserName(query.getUserName()); condition.setPhonenumber(query.getPhonenumber()); condition.setStatus(query.getStatus()); condition.getParams().put(beginTime, query.getBeginTime()); condition.getParams().put(endTime, query.getEndTime()); String orderBy SqlUtil.escapeOrderBySql( StringUtils.isBlank(query.getOrderByColumn()) ? : StringUtils.toUnderScoreCase(query.getOrderByColumn()) query.getIsAsc()); PageHelper.startPage(query.getPageNum(), query.getPageSize(), orderBy).setReasonable(true); ListSysUser list userService.selectUserList(condition); return getDataTable(list); } }几个细节我再强调一下。StringUtils.toUnderScoreCase是把前端传来的驼峰列名转成数据库下划线风格这是若依自带工具。不加的话前端如果传orderByColumn: createTimeMyBatis 拼接 order by 时可能报列名不存在。setReasonable(true)是 PageHelper 的合理化参数。若依原版PageUtils.startPage()里也保留了这一行。它保证页码小于 1 时自动归一超过总页数时自动归到最后一页。手动开启能保持和原来一致的行为。最终我还是通过condition去调用原来的userService.selectUserList。这样 Service 和 Mapper XML 完全不用动MyBatis 只要看到非空字段就会拼接 where 条件。4.3 前端 api 文件调整api/system/user/index.js 的改动很小export function listUser(query) { return request({ url: /system/user/list, method: post, data: query }) }原来的params: query改成了data: query。由于 RuoYi 的 request.js 封装会在请求头里默认带上 JSON Content-Type所以发送的就是标准 POST JSON。前端页面getList方法里query 对象和分页字段通常是分开的。ElementUI 的分页组件切换页码时会触发handleQuery它把查询条件收集好之后再通过 listUser 发送。若依页面里标准结构是这样的getList() { this.loading true const query { pageNum: this.queryParams.pageNum, pageSize: this.queryParams.pageSize, userName: this.queryParams.userName, status: this.queryParams.status, phonenumber: this.queryParams.phonenumber, beginTime: this.queryParams.params this.queryParams.params.beginTime, endTime: this.queryParams.params this.queryParams.params.endTime } listUser(query) .then(response { this.userList response.rows this.total response.total this.loading false }) }如果你原来的 Vue 页面沿用了若依搜索表单会有一点需要注意。若依搜索表单里的params.beginTime是封在queryParams.params里的一个子对象后端直接接收 SysUser 时SysUser 里也有 params 字段。所以列表搜索能匹配。现在改成 SysUserQuery 后我额外平铺了 beginTime/endTime前端如果还是传params:{beginTime:...}则后端query.getBeginTime()拿不到值。改成上面这种扁平写法再由后端塞回 params两边都能对上。4.4 验证改造成果启动项目后打开用户管理页在浏览器开发者工具 Network 面板观察请求。正常情况下能看到一个 POST 请求路径是/dev-api/system/user/list请求载荷是 JSON里面包含 pageNum、pageSize、orderByColumn 和 isAsc。响应里的 total 应该是数据库总数rows 是当前页数据。点击第 2 页后再看请求载荷pageNum 应该变为 2。如果请求体里 pageNum 变了后端返回的仍是第一页那大概率是后端用了原来的startPage()它读不到 body 数据。如果返回的 total 是 0先检查后端有没有把请求体正确绑定到 SysUserQuery 上再检查 SysUserQuery 的 getter 是否完整。5. 常见问题与踩坑实录这个主题下我在群里被问到最多的几种情况基本集中在这几个点上。Q1后端方法签名保持SysUser user前端改成 POST JSON为什么查询条件全空因为SysUser user不带RequestBody时Spring 把它当作 model attribute 绑定数据来源是 request parameter。POST JSON body 不在 parameter 里所以绑不到。解决办法要么加RequestBody把参数类型改成你自己的查询对象要么使用方案一让前端发表单编码数据。Q2加RequestBody后RuoYi 的startPage()不报错但分页无效。不报错是正常的。startPage()内部走 TableSupportTableSupport 从request.getParameterMap()取值。JSON body 不会自动进 parameter map所以 pageNum、pageSize 是 null。此时分页插件会走默认值或直接抛出“分页参数错误”。你真正要做的不是调用原来的startPage()而是手动PageHelper.startPage(pageNum, pageSize, orderBy)。Q3启动后报PageHelper 分页插件查询异常。一般是因为startPage()之后第一条执行的 SQL 不是目标列表 SQL。比如 Service 里先查了用户角色关联数据、先做了数据权限查询再查用户列表。PageHelper 会对第一个查询生效导致目标 SQL 反而没有分页。排查方式是把PageHelper.startPage的位置尽量靠近目标 Mapper 查询中间不要穿插其他查询操作。Q4接口改 POST 后前端出现 404 或 405。先确认后端 Controller 是否真的改成了PostMapping。如果没有前端 POST 到原 GET 路径上路由不存在Spring 会返回 404 或 405。另外看一下若依网关层的 CORS 配置。若依前后端分离开发时浏览器会先发 OPTIONS 预检如果网关只放行 GET 请求POST 会被拦截表现像是接口不存在。Q5ElementUI 分页组件的 layout 是 total、size、prev、pager、next点击总条数没变。这不是请求方式问题而是反馈的数据结构问题。若依的getDataTable返回的是{ rows: [], total: 0 }前端取 response.total。如果后端手动返回对象没有用 getDataTable 包装total 字段可能取不到。检查 Network 响应体的结构确保有 rows 和 total 两个字段。Q6项目同时用了 MyBatis-Plus 和 PageHelperPOST 分页总是不对。这是两个分页插件的冲突。若依从某个版本开始对 MyBatis-Plus 有额外支持如果项目里同时存在 MyBatis-Plus 的PaginationInnerInterceptor和 PageHelper两个插件会分别尝试解析 SQL互相干扰。建议统一使用一种。用 MyBatis-Plus 时分页对象直接用PageT不再需要PageHelper.startPage()。Q7POST JSON 发送大量过滤条件Filter 缓存 body 后接口变慢。请求包装器会把整个 body 读入内存如果列表过滤条件里带着很长的文本字段内存和 CPU 成本都会上升。建议把过滤放到 doFilter 之后而不是每次都缓存。如果只是分页参数需要读取也可以只解析前几 KB不完整缓存整个 JSON。缓存整个 body 的方案只适合报文偏小、频率可控的业务。Q8POST 分页下orderByColumn传createTime报 SQL 语法错误。前端传的是驼峰数据库列名下划线需要先用StringUtils.toUnderScoreCase()转换。同时记得用SqlUtil.escapeOrderBySql过滤掉分号、注释等特殊字符否则有 SQL 注入风险。6. 项目落地时我的选择与建议若依的分页组件确实优秀但它的优秀体现在对 GET 场景的默认封装顺手而不是真的只接受 GET。理解清楚这一点之后POST 分页不应再是拦路虎。我自己在维护的项目里定过一条不成文规则列表查询这类读取接口如果查询条件复杂、字段多、后续可能扩展就尽量统一走 POST JSON如果只是简单的名称模糊搜索、状态筛选那保留 GET 也未尝不可。改造时我会优先把分页字段穷尽到一个基类 PageQuery 里避免每个 Controller 都复制粘贴 pageNum、pageSize 三个 getter。6.1 一个少踩坑的落地套路最后分享一个我带团队时常用的套路。新接口如果一开始就要支持 POST JSON 分页我不会等到写完再回头改而是从 Controller 入口就把对象建模清楚。分页字段放 PageQuery 基类业务过滤字段放子类Service 的入参保持业务对象Controller 里负责两个对象之间的转换。代码看起来多几行但后续维护时最不容易出问题。PostMapping(/list) public TableDataInfo list(RequestBody(required false) SomePageQuery query) { query query null ? new SomePageQuery() : query; PageHelper.startPage(query.getPageNum(), query.getPageSize(), SqlUtil.escapeOrderBySql(query.getOrderBy())); // 转换查询条件调用 service return getDataTable(list); }注意 PageHelper 的 pageNum 和 pageSize 默认值。如果前端漏传直接空指针报错的机会虽然不大但要兜底。我在 PageQuery 基类里给默认值同时在 Controller 入口判断空对象相当于双保险。6.2 关于“把分页参数放进参数对象”的另一层思考把分页参数和业务查询参数放在同一个请求对象里是很多团队反对的认为分页属于“控制参数”不该和业务查询条件混在一起。理论上这个分层很有道理但实际对接前端时如果你让前端把分页参数放进请求头、其他查询条件放 body后端 TableSupport 为了兼容请求头反而要额外写代码。我试过一次这种“优雅拆分”结果前端在 enctype、header 上反复出问题最后又回到了统一放 body。所以这里我的经验是对外提供 HTTP 接口可以让分页字段和查询字段平级共存在系统内部Controller 和服务层之间还是要保持查询对象干净。一层有一层的职责别把所有维度都压在一个对象里。6.3 顺带提醒分页性能优化不靠请求方式热搜词里我看到不少关于“分页查询慢怎么用 Redis 优化”的咨询。结合 POST 分页改造提一句请求方式只是传输形式真正决定性能的是 SQL 和表结构。若依默认的 PageHelper 在数据量超过几十万后深分页需要扫描很多无用的 offset 行这是数据库通用问题。要优化可以走覆盖索引、延迟关联、游标分页等方式跟 GET 还是 POST 没有关系。如果你因为列表慢而把 GET 改成 POST那是完全走错了方向。开发若依项目时很多问题并不是框架能力不够而是我们对框架的预设不对。分页支持 POST 这件事也是如此——理解清楚参数读取链路后你会发现其实可以有很多种解法。希望这篇笔记能帮你在下一次改造 POST 分页时少踩几个坑。