很多人在若依(Ruoyi)项目里做分页查询时,纠结过同一个问题:接口默认是 GET,如果业务要求改成 POST,分页还能不能用?我当初刚接手一个若依前后端分离项目时,也踩过这个坑——需求方明确要求查询接口不能带 GET,结果接口一改成 POST,pageNum、pageSize 全丢了,列表直接查全表。当时第一反应也是“若依的框架层可能限制了分页只能 GET”,后来翻源码才发现,问题根本不在请求方法上,而在于 startPage() 读取参数的方式。这篇就把我翻源码验证、实际改造成 POST 分页的整个过程整理出来,顺便把那些“POST 请求参数读不到”的隐藏原因一次讲透。
这一套内容既适合刚开始学若依的新手快速理解分页链路,也适合已经用了一两年若依、想按后端规范把查询接口统一改成 POST 的老手参考。如果你正在被“分页失效”“pageNum 为空”“查全表”这类问题折磨,读这一篇应该能省下不少时间。
1. 误区从哪来:先别给若依判刑
1.1 一次让我多干三天活的真实经历
之前的项目中,前端负责人提了一个规范:所有数据查询接口都走 POST,避免 GET 请求参数出现在访问日志里。当时我天真地觉得若依查询接口本来就是一个普通 Spring MVC Controller,把 @GetMapping 改成 @PostMapping 就行。
改完第一个列表接口后,前端反馈列表数据完全没分页,一次把全表数据返回来,浏览器直接卡死。我随口跟同事说了一句“若依的分页不支持 POST”,然后开始在设计上绕路——尝试写一个拦截器把 POST 请求统一转成 GET,还想过重写 PageHelper 的启动逻辑。绕了一天发现方案都不优雅,才下定决心去翻源码。
这一翻,发现之前的结论完全是错的。若依框架本身从来没有判断过“当前是 GET 还是 POST”,它的分页插件只管从 request 参数里拿 pageNum 和 pageSize,拿到就分页,拿不到就返回全量。真正导致参数丢失的,是我前端发送请求时把参数放进了 JSON body,而后端读的却是 request.getParameter()。
到现在我还记得,当时那种“绕路”方案如果真做上线,后续每个接口都要背着兜底逻辑,风险非常高。
1.2 源码里的关键:startPage 到底在做什么
以我用的若依经典版 3.8.x 为例,分页入口通常是 Controller 里的这段写法:
java复制@GetMapping("/list")
public TableDataInfo list(SysUser user) {
startPage();
List<SysUser> list = userService.selectUserList(user);
return getDataTable(list);
}
startPage() 的底层逻辑会走到 TableSupport.buildPageRequest()。
java复制public static PageDomain buildPageRequest() {
String pageNum = ServletUtils.getParameter(PAGE_NUM);
String pageSize = ServletUtils.getParameter(PAGE_SIZE);
// 还有 orderByColumn、isAsc 等字段
if (StringUtils.isNotNull(pageNum) && StringUtils.isNotNull(pageSize)) {
PageDomain pageDomain = new PageDomain();
pageDomain.setPageNum(Integer.parseInt(pageNum));
pageDomain.setPageSize(Integer.parseInt(pageSize));
// ....
return pageDomain;
}
return null;
}
关键就在 ServletUtils.getParameter(PAGE_NUM)。如果你对 Java Web 的 Servlet 规范有印象,就会知道 getParameter() 并不是一个“只能 GET 用”的方法,它读取的是请求参数容器里的值。
对一次 HTTP 请求来说,getParameter() 能读到两类数据:
- URL query string 里的键值对,例如
/list?pageNum=1&pageSize=10。 - 请求头
Content-Type: application/x-www-form-urlencoded时,body 表单体里的键值对。
也就是说,只要分页参数是以 query 或 urlencoded 表单形式传过来的,不管你是 GET、POST 还是 PUT,getParameter() 都能拿到。
继续看 startPage() 的实现,你会发现它也从来没判断“必须 GET”:
java复制public static void startPage() {
PageDomain pageDomain = TableSupport.buildPageRequest();
Integer pageNum = pageDomain.getPageNum();
Integer pageSize = pageDomain.getPageSize();
if (StringUtils.isNotNull(pageNum) && StringUtils.isNotNull(pageSize)) {
String orderBy = SqlUtil.escapeOrderBySql(pageDomain.getOrderBy());
PageHelper.startPage(pageNum, pageSize, orderBy);
}
}
真正让分页失效的,是你把参数放进了 JSON body,而 getParameter() 默认读不到 JSON 的字段。一句话:不是若依不支持 POST,是“POST + JSON body + getParameter”这种组合天然不兼容。
1.3 一张表看懂:什么场景能分页,什么场景会失效
我把项目里最常见的几种情况列了一张表,测试结论可以拿来即用:
| 请求方式 | 参数放哪里 | Content-Type | 分页是否生效 |
|---|---|---|---|
| GET | URL query | 无 | 生效 |
| POST | URL query | 无 | 生效 |
| POST | 表单体 key=value 格式 | application/x-www-form-urlencoded | 生效 |
| POST | JSON body | application/json | 默认不生效 |
| POST | FormData | multipart/form-data | 默认不生效 |
| PUT | URL query | 无 | 生效 |
| DELETE | URL query | 无 | 生效 |
这套测试结论放在任何基于 Servlet 的 Spring MVC 项目里都成立,不限于若依。很多人会误以为“Spring MVC 能自动把 JSON body 绑定到对象参数,那 startPage() 也应该自动拿到”,因为这是两条不同的链路:
- Controller 方法参数如果是普通实体对象,Spring MVC 会根据请求体 Content-Type 决定用什么
HttpMessageConverter去解析,application/json对应的是 Jackson 的MappingJackson2HttpMessageConverter。 startPage()内部调用的是原生 Servlet APIgetParameter(),走的是 Tomcat 的Request解析逻辑,跟 Spring MVC 的消息转换器完全不是一回事。
看懂这一层,你在处理“只支持 GET”这类说法时就不会再被带偏了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 为什么若依默认用 GET:不是拍脑袋的决定
2.1 官方生成器与前端封装是根源
其实“若依分页只支持 GET”的印象和官方代码生成器有很大关系。用若依代码生成器生成的 Controller,接口基本都是@GetMapping。生成的前端 list 调用同样走:
js复制export function listUser(query) {
return request({
url: '/system/user/list',
method: 'get',
params: query
})
}
axios 里的 params 会追加到 URL query 上。而 element-ui 的分页组件在触发查询时,又会把 pageNum、pageSize 合并进 query 对象再传给 listUser。整个链路高度统一,只要你不改默认套路,分页永远不会出问题。
官方源代码里大量使用 GET,让很多人在潜意识里形成了“只有 GET 才能分页”的条件反射。可真相是,官方没有限制请求方法,只是默认提供的示例恰好全部用 GET 而已。
2.2 GET 和 POST 在查询场景的真实边界
很多团队要求查询用 POST,理由也很实在:
- 查询条件可能包含身份证号、手机号、姓名等敏感信息,GET 会把参数留在网关日志、Web 服务器日志、浏览器历史里,安全审计过不了。
- 复杂筛选条件可能超过几十个字段,URL query 太长既不美观也容易触达操作系统/网关的 URL 长度上限。
- 某些中间件会缓存 GET 请求,如果参数变了但 URL 没变化可能导致拿到旧数据。虽然加时间戳能解决,但不如 POST 省心。
但 GET 也不是毫无优势。分页请求天然是幂等查询,GET 的语义本身和“只读查询”吻合,能被浏览器预取、被缓存系统加速。而且调试 GET 接口实在方便,浏览器直接改 URL 就能再造一次请求。若依内部管理系统追求开发效率,默认 GET 是当时的最优选择。
我个人的结论是:如果你只做内部管理后台,GET 完全够用,没必要改;如果项目有对外 API 或数据安全要求,那 POST 分页是合理的,不该因为“默认是 GET”就妥协。
2.3 一个隐含参数:排序字段千万别忽略
如果你要切 POST 分页,请不要只顾 pageNum 和 pageSize。若依的 PageDomain 里还包含 orderByColumn(排序字段)和 isAsc(升序/降序)。很多从 GET 改成 POST 的接口,分页参数勉强能拿到了,但列表顺序全乱,经常是这两项没传对。
若依前端表格默认会在请求参数里带 orderByColumn 和 isAsc,后端拿到后拼成一个排序列。比如:
text复制orderByColumn=create_time
isAsc=asc
后端拼接时会对排序字段做一次安全过滤,转成真正执行的 SQL。所以后面做 POST 改造时,不要只想着 pageNum/pageSize 这两个参数,orderByColumn 和 isAsc 也要跟着一起想办法解决。
3. 实操:把若依列表接口改成 POST 分页的几种方案
3.1 方案 A:前端只改 method,参数仍走 params
如果你想改造成本最低,方案 A 是最合适的。把前端的 query 请求从 GET 改成 POST,但继续让参数走 axios 的 params,这样参数本质上仍是在 URL query 上,后端一行不用动。
js复制export function listUser(query) {
return request({
url: '/system/user/list',
method: 'post', // 只改这里
params: query // 仍然走 query
})
}
后端 Controller:
java复制@PostMapping("/list")
public TableDataInfo list(SysUser user) {
startPage();
List<SysUser> list = userService.selectUserList(user);
return getDataTable(list);
}
这个方案只绕过“请求方法必须是 GET”的表层限制,但没有实现“参数不进 URL”的诉求。如果你是为了减少敏感参数暴露,改用这种方式只是形式上好看,实际参数还在 URL 里,没有任何安全收益。
不过方案 A 非常适合快速验证“若依是否支持 POST 分页”这个问题。我当时就是先改成这样测了一次,发现分页正常,才排除了“框架限制”的嫌疑。
3.2 方案 B:POST + URLSearchParams,body 里传键值对
如果真的有“参数必须放 body”的要求,又想后端尽量不改进,那就把请求数据放 body,但 Content-Type 必须是 application/x-www-form-urlencoded,而不是 JSON。可以在前端把对象转成 URLSearchParams:
js复制export function listUser(query) {
const body = new URLSearchParams()
Object.keys(query).forEach(key => {
body.append(key, query[key])
})
return request({
url: '/system/user/list',
method: 'post',
data: body
})
}
这样 axios 会自动把 URLSearchParams 序列化成表单体,并设置 Content-Type 为 application/x-www-form-urlencoded。Tomcat 在解析 Servlet 请求时,遇到这种 contentType 会把 body 里的 key=value 合并到参数容器,所以后端 getParameter("pageNum") 能正常拿到。
后端接口仍然可以保持原来的“startPage + selectList”写法,基本不用改。这个方案适合做外部系统对接,尤其对方已经用表单格式 POST 数据到若依接口。但需要留意,如果是类似 Excel 文件列表查询、复杂嵌套 JSON 条件,表单格式很难表达层次结构,这时候应该考虑方案 C。
3.3 方案 C:后端提供 JSON body 兼容入口,分页参数手动交给 PageHelper
前面两个方案的弊端很直观:数据仍然没有被真正放进 JSON 结构里。在微服务接口或前后端真正分离的场景下,多数团队希望查询条件是一个 JSON 对象,结构清晰,也方便统一校验。
此时可以直接给 Controller 增加一个接 JSON body 的入口,前端把分页参数和查询条件放在同一个对象里传过来。后端在 Controller 里把分页条件提取出来,手动调用 PageHelper.startPage(),不再依赖 startPage() 从 request 参数里取。
假设业务对象是 SysUser,我们可以定义一个查询对象:
java复制public class SysUserQuery extends SysUser {
private Integer pageNum;
private Integer pageSize;
private String orderByColumn;
private String isAsc;
// getter / setter 省略
}
Controller 可以这样写:
java复制@PostMapping("/list")
@ResponseBody
public TableDataInfo list(@RequestBody SysUserQuery query) {
startPageByQuery(query);
List<SysUser> list = userService.selectUserList(query);
return getDataTable(list);
}
private void startPageByQuery(SysUserQuery query) {
if (StringUtils.isNull(query.getPageNum()) || StringUtils.isNull(query.getPageSize())) {
return;
}
String orderBy = SqlUtil.escapeOrderBySql(
query.getOrderByColumn() + " " + query.getIsAsc()
);
PageHelper.startPage(query.getPageNum(), query.getPageSize(), orderBy);
}
这种方式下,前端传参:
js复制export function listUser(data) {
return request({
url: '/system/user/list',
method: 'post',
data: {
pageNum: 1,
pageSize: 10,
orderByColumn: 'create_time',
isAsc: 'asc',
userName: 'admin'
}
})
}
优势非常明显:分页参数和查询条件都统一在一个 JSON 对象里,字段不会丢,遇到复杂条件也好扩展。要注意的是 selectUserList 会接收的是 SysUserQuery,它继承了 SysUser,所以在 MyBatis XML 里写 <if test="userName != null"> 一点问题没有。
若依新的 Vue3 版本和社区常见的 plus 系版本里,其实已经在用差不多这种思路做 PageQuery 对象封装了:通过继承实体类或额外包装,让 controller 支持体面地接收 JSON body 里的分页参数。所以你如果想把项目向新版本理念靠拢,方案 C 是转换成本最低的退路。
需要特别注意的是:如果你项目用的是 MyBatis-Plus,而不是经典 PageHelper,不能继续依赖名为 startPage() 的方法。MyBatis-Plus 分页需要把 Page 对象作为 Mapper 方法参数传入:
java复制Page<SysUser> page = new Page<>(query.getPageNum(), query.getPageSize());
IPage<SysUser> list = userMapper.selectSysUserPage(page, queryWrapper);
很多人在 MyBatis-Plus 项目里直接把若依的 startPage() 搬过来,结果分页没有生效,根本原因是 MyBatis-Plus 的拦截器不认识 PageHelper 设置的 ThreadLocal 分页参数。区分好项目用的是 PageHelper 还是 MyBatis-Plus,是改造前必须排查的一件事。
3.4 方案 D:过滤器缓存请求体,老代码一处不改
如果你有一大堆老 Controller,都已经写成 startPage() + @GetMapping 的形态,现在希望统一改成 POST + JSON body,但不想一个一个改方法签名,可以考虑写一个过滤器,把 JSON body 里的 pageNum、pageSize、orderByColumn、isAsc 字段提取出来,放到重写后的 request 参数容器里,这样下游调用 getParameter() 时仍然能拿到分页参数。
核心思路是先包一层 HttpServletRequestWrapper,重写 getParameter / getParameterValues 方法,让它优先从自己缓存的 map 里取值:
java复制public class PageParamRequestWrapper extends HttpServletRequestWrapper {
private final Map<String, String[]> params;
public PageParamRequestWrapper(HttpServletRequest request, Map<String, String[]> params) {
super(request);
this.params = params;
}
@Override
public String getParameter(String name) {
String[] values = params.get(name);
return values != null && values.length > 0 ? values[0] : null;
}
@Override
public String[] getParameterValues(String name) {
return params.get(name);
}
}
然后在过滤器里读取 JSON body,把分页字段挑出来:
java复制if (isJsonRequest(request)) {
String body = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8);
JSONObject jsonObject = JSON.parseObject(body);
if (jsonObject != null) {
Map<String, String[]> paramMap = new HashMap<>(request.getParameterMap());
addParam(paramMap, "pageNum", jsonObject.getString("pageNum"));
addParam(paramMap, "pageSize", jsonObject.getString("pageSize"));
addParam(paramMap, "orderByColumn", jsonObject.getString("orderByColumn"));
addParam(paramMap, "isAsc", jsonObject.getString("isAsc"));
request = new PageParamRequestWrapper(request, paramMap);
}
}
但这里有个非常容易踩的坑:Controller 如果还要用 @RequestBody 接收 JSON body,而过滤器已经把 getInputStream() 读过了,Spring MVC 再读会抛异常。所以真正落地时,不仅要把参数容器包装了,还需要缓存 body,并重写 getInputStream() 和 getReader(),让下游可以反复读取。这套逻辑完整写出来会有不少零碎代码。
方案 D 适合老项目做“无侵入升级”。但如果你正在新写接口,我不推荐用这种方式,因为它会让“分页参数从哪里来”这件事变得很隐晦,项目里的新人看了很难理解。除非实在没办法,否则方案 C 的可维护性远好于方案 D。
4. 改造现场:一次真实列表接口的 POST 化实践
4.1 改造前的问题定义
当时用户管理列表的查询条件升级为多选部门树 + 时间范围 + 多个普通字段,前端希望全部传 JSON,降低拼接 query 的复杂度。原接口大概长这样:
java复制@GetMapping("/list")
public TableDataInfo list(SysUser user) {
startPage();
List<SysUser> list = sysUserService.selectUserList(user);
return getDataTable(list);
}
由于查询条件是 JSON 对象,前端把整个对象放到了 data 里。请求结构是:
http复制POST /system/user/list HTTP/1.1
Content-Type: application/json
{
"pageNum": 1,
"pageSize": 10,
"deptId": "100",
"userName": "admin"
}
不出意外,后端直接查了全表。我通过 debug 查看 TableSupport.buildPageRequest() 返回的 PageDomain 是 null,因为 getParameter("pageNum") 拿到的就是 null。
4.2 按方案 C 改造的完整路径
我最后选了方案 C,因为团队更在意接口数据结构的清晰度。先定义 SysUserQuery 继承 SysUser 并补齐分页字段;再调整 Controller:
java复制@PostMapping("/list")
@ResponseBody
public TableDataInfo list(@RequestBody SysUserQuery query) {
PageDomain pageDomain = new PageDomain();
pageDomain.setPageNum(query.getPageNum());
pageDomain.setPageSize(query.getPageSize());
pageDomain.setOrderByColumn(query.getOrderByColumn());
pageDomain.setIsAsc(query.getIsAsc());
if (StringUtils.isNotNull(pageDomain.getPageNum()) && StringUtils.isNotNull(pageDomain.getPageSize())) {
String orderBy = SqlUtil.escapeOrderBySql(
pageDomain.getOrderByColumn() + " " + pageDomain.getIsAsc()
);
PageHelper.startPage(pageDomain.getPageNum(), pageDomain.getPageSize(), orderBy);
}
List<SysUser> list = sysUserService.selectUserList(query);
return getDataTable(list);
}
这里我没有让 startPage() 自己从 request 取,而是把参数显示地从 query 对象中拿出来交回给 PageHelper。看到这段代码的人可以很明确知道:分页参数取自请求体 JSON,而不是 URL query。
同时顺手做了一层参数兜底:如果 pageNum 或 pageSize 为 null,则不触发分页,返回全量数据并记录一份警告日志,方便排查。建议大家在分页方法里都保留这个可观察性,不然线上遇到“接口直接查全表”时,排查起来会很痛苦。
4.3 前端 request 封装调整
前端我额外封装了一个 post list 方法,避免每个接口重复写 Content-Type:
js复制export function listSysUser(data) {
return request({
url: '/system/user/list',
method: 'post',
data: data
})
}
在调用时直接把 queryParam 传进去。element-ui 的 el-pagination 里 handleQuery 方法需要保证 queryParam 里始终有 pageNum 和 pageSize:
js复制handleQuery() {
this.queryParam.pageNum = 1
listSysUser(this.queryParam).then(res => {
this.userList = res.rows
this.total = res.total
})
}
改成 POST 之后,我特意用公司 API 网关试了一把,网关日志里不会再打印敏感查询参数,这一条也彻底解决了审计同事的要求。
4.4 实测后最容易遇到的问题
改造完后我还在测试环境把原来的 GET 用例全部重新跑过,发现有两个问题经常出现:
第一,接口签名改了以后,文档同步没跟上。前端同事从某个老文档里复制了 GET 方式,请求直接 405,排查浪费了小半天。改造期间最好在接口文档里标注“原 GET 地址已废弃,新地址为 POST /system/user/list”。
第二,查询条件和分页字段发生字段命名冲突。有些同事习惯在业务表里直接用 page_size 这样的字段名,和分页参数同名,在 MyBatis 的映射里就会出现歧义。这里建议所有 Controller 的查询对象都保留一套标准的 pageNum/pageSize/orderByColumn/isAsc,数据库字段如果也有同名,就在 XML 里取别名。
5. 排查速查表与个人建议
5.1 常见问题对照表
| 现象 | 可能原因 | 排查步骤 |
|---|---|---|
| 改成 POST JSON 后直接查全表 | 后端用了 startPage(),但参数在 JSON body 里 |
先用浏览器或 Swagger 发 POST + urlencoded 验证;或改用方案 C |
| POST 表单也查全表 | 请求 Content-Type 不是 form-urlencoded | 检查前端是否转成了 URLSearchParams 或 FormData |
| 只有第一页有数据,点下一页还是第一页 | 前端修改页码时没有把最新 pageNum 塞进查询对象 |
在 handleCurrentChange 中重置/更新 queryParam.pageNum |
| 总数对,但数据乱序/重复 | 只传了 pageNum/pageSize,没传排序 | 确认后端拼接了 orderByColumn 和 isAsc |
| 在 MyBatis-Plus 项目里用了 PageHelper | 框架不匹配 | 检查项目依赖,用 MyBatis-Plus 的 Page 对象 |
| 换成 POST 后接口抛出 Body 已读异常 | 过滤器提前读了 body | 在 wrapper 中做 body 缓存并重写 getInputStream |
5.2 我在这个改造里的最终体会
项目里真正收益最大的不是把注解从 GET 换成 POST,而是通过这一次改动把“分页参数从哪里取”的规则彻底梳理清楚了。若依默认写的分页链路没什么高深魔法,它只是把一个非常基础的 Servlet 行为固化成了 startPage() 这个入口。只要你了解 getParameter 能读到什么、不能读什么,POST 请求怎么传参数就一清二楚。
在选了方案 C 之后,我又把项目里另几个列表接口统一改成了同一风格,并约定新开发接口一律用 POST + JSON body 接收查询条件。后来遇到供应商系统对接,对方希望我用 POST 表单推送筛选条件时,我也能立刻明白该走方案 B,而不是改底层框架。搞清楚原理的最大好处就是:场景变了,你依然知道要怎么灵活应对。
如果让我给一条最实用的操作建议,那就是:新代码不要总想着用过滤器、拦截器一层层传参来兼容旧逻辑,直接在 Controller 层把分页参数显式抽出来,比什么黑科技都稳。代码能一眼看懂,永远比耍小聪明重要。
