最近在给一个 Jeecgboot 项目调列表接口时,遇到了一个特别常见的需求:把列表默认的创建时间倒序(create_time desc)改成业务需要的排序,甚至干脆去掉这个默认排序。本来以为只是删两行代码的事,结果翻了一遍前端列表逻辑、后端 Controller、QueryGenerator 和 Mapper XML,才发现这条"默认时间排序"是分好几层进来的。这篇文章我就把这个过程完整拆一遍,从定位到修改再到踩坑,希望能帮同样被 Jeecgboot 默认排序困扰的人少走点弯路。
这个内容主要适合两类人:一类是刚接触 Jeecgboot、想知道列表数据为什么总是按创建时间排的开发者;另一类是已经把项目跑起来了,正在为"怎么让列表按自己的字段排序"挠头的同学。下面我按排查问题的思路来讲,先把默认时间排序的来源搞清楚,再给出删改方案,最后聊几个删除之后容易踩的坑。
1. 默认时间排序到底从哪一层进来的
1.1 先定位问题:一条列表请求的完整链路
要删除默认时间排序,第一步不是改代码,而是先搞清楚这条排序是从哪里带出来的。Jeecgboot 的列表查询链路大致是这样的:
前端 Vue 列表页发起请求 → Controller 接收查询参数 → QueryGenerator 构建 QueryWrapper → Service 调用 MyBatis-Plus 的 page 方法 → 执行 SQL 返回分页结果。
这中间任何一个环节都可能把排序条件塞进去。我见过最典型的两种情况:一种是后端代码里写死了 orderByDesc("create_time"),另一种是前端列表初始化时自动带了 column=create_time&order=desc 这样的参数。如果只改一个地方,另一个地方还是会悄悄把排序加回来。
所以定位问题的时候,第一步是打开浏览器控制台,看列表接口实际发出的请求参数里有没有 column 和 order 这两个字段。如果有,说明前端传了排序参数;如果没有,那大概率是后端代码里有默认排序逻辑。
1.2 QueryGenerator 的排序处理逻辑
QueryGenerator 是 Jeecgboot 里非常核心的一个查询条件构造器,它的作用是把前端传过来的查询参数自动转换成 MyBatis-Plus 的 QueryWrapper。排序相关的那段逻辑,在 Jeecgboot 的各版本里写法略有不同,但核心思路是一致的:
java复制if (oConvertUtils.isNotEmpty(column)) {
// 根据前端传的column和order拼接排序
if ("asc".equalsIgnoreCase(order)) {
queryWrapper.orderByAsc(SqlUtil.getSqlField(column));
} else {
queryWrapper.orderByDesc(SqlUtil.getSqlField(column));
}
}
这段代码本身没什么问题,前端传排序字段,后端按字段排序,这是正常的接口行为。但问题往往出在另一个地方:很多自研功能或者生成代码里,会额外加一段"兜底排序",大概长这样:
java复制if (oConvertUtils.isEmpty(column)) {
queryWrapper.orderByDesc("create_time");
}
这段代码的意思就是:如果前端没有传排序参数,就默认按创建时间倒序。很多 Jeecgboot 项目的列表"默认按时间排序",就是这么来的。
1.3 前端到底传了什么参数
再看前端。Jeecgboot 的前端列表页通常基于 useTable 或者 ListMixin 这类封装来写,表格组件的排序配置会在请求时自动带上。有的版本里,列表初始化时如果没有显式关闭排序,前端会自动传 column=create_time&order=desc,这也会导致后端收到排序参数。
我在实际项目里遇到过一个很隐蔽的情况:前端表格列配置里根本没有显示排序箭头,但请求里依然带着 column=create_time&order=desc。后来查代码才发现,是某个公共 mixin 里写死了默认的 sort 配置。
所以前端也要检查一下,尤其是用了公共封装的项目,可能所有人都被一个默认排序拖着走。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 删除默认时间排序的几种改法
2.1 最省事的做法:改后端 Controller,去掉兜底排序
如果确认后端 Controller 里有 orderByDesc("create_time") 这段兜底逻辑,最简单的办法就是把它注释掉或者删掉。
以 Jeecgboot 生成的代码为例,改造前大概是这样的:
java复制@GetMapping("/list")
public Result<IPage<SysDemo>> queryPageList(SysDemo sysDemo,
@RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo,
@RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize,
@RequestParam(name = "column", required = false) String column,
@RequestParam(name = "order", required = false) String order) {
QueryGenerator<SysDemo> queryGenerator = new QueryGenerator<>();
QueryWrapper<SysDemo> queryWrapper = queryGenerator.initQueryWrapper(sysDemo, null);
// 这里的兜底排序就是想删掉的目标
if (oConvertUtils.isEmpty(column)) {
queryWrapper.orderByDesc("create_time");
}
Page<SysDemo> page = new Page<>(pageNo, pageSize);
IPage<SysDemo> pageList = sysDemoService.page(page, queryWrapper);
return Result.ok(pageList);
}
改造后,把那个 if 判断删掉,或者根据业务需要改成别的排序字段。比如业务希望列表默认按编码升序排,就可以改成:
java复制queryWrapper.orderByAsc("code");
注意这里有个细节:如果前端本身传了排序参数,QueryGenerator 会按前端参数排序。如果你在 Controller 里又手动加了一个排序,MyBatis-Plus 的 QueryWrapper 会同时保留两段排序,最终 SQL 可能变成 ORDER BY code ASC, create_time DESC。这可能不是你想要的效果,所以改成业务排序时,最好把原来的兜底排序逻辑完整替换掉,而不是叠加。
2.2 兼容前端排序控件的做法:只在无参数时不排序
有些场景下,你不想彻底关闭排序,而是希望"前端不传排序参数时,数据库也不按时间排;前端传了排序参数时,仍然能正常排序"。这种情况只需要把兜底排序拆掉,保留前端参数驱动的那部分即可。
QueryGenerator 自带的排序解析是保留的,所以你只需要确认 Controller 里没有额外的兜底 orderByDesc("create_time"),前端也没有默认传排序参数。如果前端 mixin 里有默认参数,那就把这个默认配置去掉。
这样处理后,列表接口的行为就变成了:不传 column/order 时,没有任何 ORDER BY;传了 column/order 时,按传入字段排序。这是最干净、最符合常规后端接口习惯的做法。
2.3 从 SQL 层面兜底:Mapper XML 里控制 ORDER BY
还有一种情况是查询走了自定义 SQL,不是 QueryWrapper 自动生成的。比如业务复杂,Controller 里直接调用了自定义 Mapper 方法,SQL 写在 XML 里,里面可能写死了 ORDER BY create_time DESC。
这种场景无法通过改 Controller 解决,必须把 XML 里的排序改掉。举个例子:
xml复制<select id="queryDemoList" resultType="com.example.entity.SysDemo">
select * from sys_demo
<where>
<if test="name != null and name != ''">
and name like concat('%', #{name}, '%')
</if>
</where>
ORDER BY create_time DESC
</select>
这里的 ORDER BY create_time DESC 就是需要删除或替换的部分。如果希望改成业务字段排序,同样把 create_time 换成业务字段即可。如果希望完全交给前端控制,可以把排序字段和排序方向作为参数传入 SQL,用 <choose> 和 <if> 做动态排序。
在 XML 里做动态排序时,有一个安全问题必须注意:ORDER BY 后面的字段名不能直接拼接前端参数,否则存在 SQL 注入风险。MyBatis 的 #{} 不能用在 ORDER BY 字段上,只能在 order by ${sortField} 里使用,而 ${} 是直接拼接,列名必须做白名单校验。更稳妥的做法是在 Controller 里校验排序字段属于允许的字段集合,再传入 XML。
3. 删除默认排序后容易踩的坑
3.1 分页数据出现重复和乱序
这个坑我踩过不止一次。MySQL 的 InnoDB 引擎在没有 ORDER BY 时,返回顺序是不保证的。删掉默认时间排序后,如果分页查询没有新的排序条件,可能第一页和第二页的数据出现重复,或者翻页后顺序错乱。
举个例子:第一次查询返回了 id 为 1、3、5、7 的记录,第二次查询由于执行计划变化,可能返回 1、4、6、7,这会导致同一页数据在不同时间刷新时都不一样。
所以删掉 create_time 排序之前,建议先想清楚一件事:你的列表是否需要一个稳定的输出顺序?如果不需要排序,至少加一个主键排序来保证分页稳定:
java复制queryWrapper.orderByAsc("id");
这样做不仅不会破坏"不按时间排序"的诉求,还能让分页结果稳定可预期。
3.2 delflag 没有默认值导致新增记录查不到
Jeecgboot 里大部分表都有逻辑删除字段 delflag,配合 MyBatis-Plus 的 @TableLogic 注解实现逻辑删除。正常来说,这个字段在数据库里应该有默认值 0,新增记录时不传也会自动是 0。
但很多开发者在建表时没有给 delflag 设置默认值,插入时又没显式赋值,导致记录里的 delflag 是 NULL。虽然列表查询时 MyBatis-Plus 会自动追加 AND delflag = 0,但 NULL 和 0 不相等,这条新增记录就永远查不出来。
这个场景和排序看着没关系,但我在排查"列表数据少了一条、新增数据不见了"的问题时,经常发现有人第一反应是排序问题,查了半天最后发现是 delflag 没有默认值。所以这里专门提醒一下:如果你删除了默认时间排序后,发现新增的数据在列表里看不到,先去查一下 delflag 字段,看数据库里存的是不是 NULL。
3.3 自定义排序字段的 SQL 注入风险
删除默认排序后,很多业务会想着做成"用户点表头排序",也就是把前端传的排序字段直接拼到 ORDER BY 里。这个需求本身没问题,但实现方式要谨慎。
前面提到过,排序字段不能直接用 #{},很多新手会直接写成:
java复制queryWrapper.orderByAsc(column);
如果 column 是用户传入的,这个字段值会被拼进 SQL。虽然 MyBatis-Plus 的 QueryWrapper 对字段名有校验,但在 ORDER BY 这个特殊位置,仍然存在注入风险。比较稳妥的做法是做白名单:
java复制private static final Set<String> ALLOWED_SORT_FIELDS = new HashSet<>(Arrays.asList(
"id", "code", "name", "status", "update_time"
));
if (ALLOWED_SORT_FIELDS.contains(column)) {
queryWrapper.orderByAsc(column);
}
这样既实现了动态排序,又不会引入安全问题。用 orderByAsc 时 MyBatis-Plus 内部会做一定的字段处理,但仍然不要放松校验。
3.4 前端表格排序状态残留
还有一个容易忽略的地方是前端表格组件的排序状态。Jeecgboot 的列表页基于 Ant Design Vue 的 Table 组件,排序状态可能被保留在组件内部。即使用户没有主动点排序,组件也可能因为上一次的交互状态,在刷新时自动带上排序参数。
遇到这种情况,即使后端已经把默认排序删干净了,前端请求依然会带 column=xxx&order=asc,然后数据继续按某个字段排。排查时如果发现后端已经确认没有兜底排序了,就去前端看表格组件的 defaultSortOrder 或 sortOrder 配置,看看是不是有残留。
另外,Jeecgboot 的 useTable 封装在部分版本里有一个 isorter 参数,它控制是否把表格排序参数传到后端。如果不想让某列参与排序,可以在列配置里把 sorter: false 加上。
4. 一个真实排查案例:从现象到改完验证
4.1 现象与业务诉求
我接手的一个项目里,有一个流程单据列表,业务方反馈:新录入的单据总是排在最后面,希望打开页面时最新单据显示在最前面,但要按一个业务编号排序,不能按创建时间排。
这个现象很典型,基本可以确定是"前端不带排序参数,后端兜底按 create_time 倒序"导致的。但业务方的诉求不只是"把时间排序去掉",而是要换成另一个字段排序。
4.2 定位链路与根因
我打开浏览器控制台,刷新列表页,观察请求参数。结果发现请求里根本没有 column 和 order 参数,也就是说前端没有主动传排序。再看后端 Controller 代码,果然在 queryPageList 方法里看到了熟悉的兜底排序逻辑:
java复制if (oConvertUtils.isEmpty(column)) {
queryWrapper.orderByDesc("create_time");
}
根因确认:前端不传排序参数时,后端强制按创建时间倒序,导致新增单据排在最前面,业务方想要的是按单据编号排序。
4.3 修改方案与验证结果
因为业务方要求默认按单据编号排序,我直接把这个兜底排序改成了:
java复制if (oConvertUtils.isEmpty(column)) {
queryWrapper.orderByAsc("bill_no");
}
同时保留了前端传参排序的能力,用户如果点表头排序,仍然可以覆盖默认排序。
改完后重启服务,刷新页面,列表默认按 bill_no 升序排列。我又按了几次表头排序、切换了几页数据,确认分页稳定,没有出现重复数据。再测试了一下新增单据后的列表顺序,新增的单据按照业务编号落在对应位置,符合业务预期。
这个案例想说明的是:删除默认时间排序本身不复杂,千万不要一把梭把所有排序都删了。先搞清楚你要的到底是什么行为:是完全不排序,还是换成另一种排序?两种诉求的实现方式不一样,改动的代码也不一样。
5. 说点个人经验
项目里处理 Jeecgboot 默认时间排序这类问题,我个人的体会是:先看请求参数,再看后端代码,最后才动 SQL。这个顺序能帮你少做很多无用功。
具体来说,第一个要查的是浏览器控制台里的请求参数,有没有 column 和 order,这决定了问题在前端还是后端。第二个要查的是 Controller 里有没有 orderByDesc("create_time") 这类兜底代码。第三个才看 XML 里的 ORDER BY。很多人一上来就翻 XML,翻半天找不到问题,其实排序根本不在 XML 里。
还有一个小技巧,排查时可以临时在 MyBatis-Plus 的配置里打开 SQL 日志,或者用 p6spy 打印完整 SQL。看到实际执行的 SQL,排序从哪里来的就一目了然了。
最后再提醒一次:如果你真的决定彻底不排序,记得给分页查询补一个主键排序,哪怕业务上完全不关心顺序,也要保证分页数据的物理稳定性。这不是多余的代码,是分页场景下的基本保障。
