刚接触 JeecgBoot 时,很多朋友都会遇到一个看起来有点莫名其妙的现象:用代码生成器生成的列表页面,明明啥也没配,一打开数据永远是按创建时间倒序排的。你可以在查询条件区域加各种过滤条件,但排序这件事就像被什么东西写死了一样,新数据永远排在最前面。
这个现象说复杂不算复杂,但真要解释清楚,牵扯到生成代码、前端表格组件、后端 QueryWrapper、Mapper XML 好几个地方。我最早一次处理这个“默认时间排序”问题时,先在后端代码里翻了一圈,没找到任何 orderBy,一度以为是框架底层加的,后来才发现问题是前端表格列配置引发的。今天我就把这个“默认时间排序”从产生到消除的完整链路捋一遍,顺带把排查过程中容易踩的坑,比如 del_flag 默认值导致新增数据不显示,也一起说清楚。适合正在用 JeecgBoot 做二次开发、或者刚用代码生成器拉完项目准备接入业务逻辑的开发者参考。
1. 先看懂 JeecgBoot 为什么会有“默认时间排序”
1.1 代码生成器给自己留的“后门”
JeecgBoot 的代码生成器会生成一套完整的前后端 CRUD 代码,从实体类、Mapper、Service、Controller 到 Vue 列表页一份不少。生成器在读取表结构时,会默认把 create_time 作为列表排序字段,这直接导致你在生成的 ServiceImpl 或 Controller 里经常能看到类似这样的代码:
java复制queryWrapper.orderByDesc("create_time");
或者定向排序的写法:
java复制queryWrapper.orderBy("create_time", false);
这行代码就是“默认时间排序”最直白的来源。很多开发者拿到生成代码后,注意力全在业务字段、表单校验和按钮事件上,很少会专门去看 QueryWrapper 上有没有挂排序,所以列表一打开就是新数据在前,也没觉得哪里不对。直到某天业务方说“我要按某业务字段排”,你才知道这个隐形的 orderByDesc 一直在默默工作。
代码生成器之所以要默认加上 create_time 倒序,本质上是产品设计上的习惯:大多数后台管理系统希望新数据排在最前面,方便用户快速看到最新内容。这个习惯在日志、订单、公告等场景是合理的,但到了字典管理、菜单树、配置表这类需要按固定顺序展示的场景,反而成了干扰。
1.2 前端表格列配置里藏着默认排序
另一条更容易被忽略的“默认时间排序”来源在前端。JeecgBoot 的列表页基于 Ant Design Vue 的 a-table 组件,当 columns 配置里给 createTime 列定义了排序属性和默认排序方向时,表格控件会在第一次渲染时就记住这个排序状态,并在加载数据请求里自动带上排序参数。
典型的配置长这样:
js复制{
title: '创建时间',
dataIndex: 'createTime',
sorter: true,
defaultSortOrder: 'descend',
}
这里的 sorter: true 表示表头允许点击排序,defaultSortOrder: 'descend' 表示默认按这一列降序排列。也就是说,只要这个配置存在,页面一打开,a-table 就会主动在后端接口请求中追加 column=createTime&order=desc 这两个参数。
后端收到参数后,JeecgBoot 的 QueryGenerator 会解析这两个参数,自动在 SQL 末尾拼上 ORDER BY create_time DESC。所以你后端代码里可能一行 orderBy 都没写,列表依然按时间倒序,这就是“默认时间排序”最隐蔽的藏身之处。
1.3 底层 SQL 与逻辑删除字段的连带关系
在讨论排序时,有一个字段经常被顺带扯出来:del_flag。JeecgBoot 的列表查询默认都会拼上 AND del_flag = '0' 这个条件,这是逻辑删除的标配,和排序本身没有关系。但实际排错时,两者经常混在一起出现。
尤其是当表结构里 del_flag 字段没有默认值,而后端新增逻辑也没有显式赋值时,新增记录写入数据库后 del_flag 就是 NULL。查询条件 del_flag = '0' 永远匹配不到 NULL,这条新增数据就直接从列表里“消失”了。这种问题一旦碰上,很容易让人误以为是排序或缓存问题,在列表页折腾半天。后面我会专门用一节讲这个坑,先在这里提个醒。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 四条定位线索,找到排序到底写在哪
2.1 从 Controller 到 Service:先看这段生成代码
定位默认排序的第一站是 Controller 里的 /list 接口。JeecgBoot 大多数查询接口结构都差不多:
java复制@GetMapping("/list")
public Result<IPage<Xxx>> queryPageList(Xxx xxx,
@RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo,
@RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize,
HttpServletRequest req) {
QueryWrapper<Xxx> queryWrapper = QueryGenerator.initQueryWrapper(xxx, req.getParameterMap());
Page<Xxx> page = new Page<>(pageNo, pageSize);
IPage<Xxx> pageList = xxxService.page(page, queryWrapper);
return Result.OK(pageList);
}
这段代码本身是干净利落的。QueryGenerator 负责根据前端传的查询条件组装 QueryWrapper,但它默认并不会主动加排序,除非请求参数里带了 column 和 order。所以在这个基础上,如果列表还是按时间倒序,就要去看 ServiceImpl 里有没有重写 page 方法,或者在 Controller 里是否额外加了排序逻辑。
如果你改的是自定义接口,比如自己写的导出方法或统计方法,那更要逐个排查 queryWrapper 上的 orderBy 调用。我见过不少项目,Controller 里查询方法有几十个,每个方法都复制粘贴了一段 orderByDesc("create_time"),这种代码删起来不费劲,但容易漏,建议用 IDE 的全局搜索搜一下 orderByDesc 和 create_time,把结果逐个过一遍。
2.2 Mapper XML 的 order by 悄悄躲在 if 判空里
自定义分页查询或报表类查询,排序逻辑经常不在 Java 代码里,而是写在 Mapper XML 中。最隐蔽的写法是这种:
xml复制<select id="selectPageList" resultType="com.xxx.entity.Xxx">
select * from xxx
<where>
<if test="name != null and name != ''">
and name like concat('%', #{name}, '%')
</if>
</where>
<if test="sortColumn != null and sortColumn != ''">
order by ${sortColumn} ${sortOrder}
</if>
<if test="sortColumn == null or sortColumn == ''">
order by create_time desc
</if>
</select>
这种“没有指定排序字段时兜底按 create_time desc”的写法,是 XML 里最常出现的问题。你只搜 Java 代码根本找不到它,只有把 XML 文件逐行翻一遍才能发现。而且它的逻辑越看越合理:调用方不传排序时,给个默认排序总比乱序好。但对调用方来说,这个默认排序可能恰恰是业务上不想要的。
排查这一层的时候,建议在 IDE 里对所有 XML 文件做一次全局搜索,关键词就是 order by,重点看 Mapper 映射目录,尤其是分页查询和自定义报表 SQL。
2.3 前端 a-table 的 defaultSortOrder 与 sorter 参数
前面说过,前端表格的排序配置可以直接“指挥”后端拼 SQL。所以定位时不能只盯着后端。打开列表页对应的 .vue 文件,找到 a-table 组件:
vue复制<a-table
:columns="columns"
:data-source="tableData"
:loading="loading"
@change="handleTableChange"
/>
然后看 columns 的 createTime 列,只要出现 defaultSortOrder: 'descend',基本就是默认时间排序的源头之一。更复杂的情况是列表页使用了 JeecgBoot 的 useListPage 或类似封装,排序状态可能被同步到 URL 参数里,翻页后依然存在。这时候哪怕删了 defaultSortOrder,只要 URL 里还残留着旧的 column=createTime&order=desc,排序照样生效。
所以在定位阶段,最直接的判断方式是同时打开浏览器 Network 面板和后端 SQL 日志,看看接口请求的参数里有没有 column、order,后端执行的 SQL 里有没有对应的 ORDER BY。
2.4 用日志验证:打印 SQL 看排序来自哪层
要准确定位,最直接的办法是让 MyBatis 把 SQL 打出来。在 application.yml 里加一段配置:
yaml复制mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
然后重新启动后端,打开列表页面,控制台会输出实际执行的 SQL。根据 SQL 里的 ORDER BY 和接口请求参数,可以快速做如下判断:
| 请求参数情况 | SQL 中的排序 | 来源判断 |
|---|---|---|
| 有 column=createTime&order=desc | ORDER BY create_time DESC | 前端表格默认排序或用户点击表头 |
| 无 column/order 参数 | ORDER BY create_time DESC | 后端 Java 代码或 Mapper XML 写死 |
| 无 column/order 参数 | 无 ORDER BY | 前后端都没有默认排序,列表物理顺序 |
| 有 column/order 参数 | SQL 里出现多段 ORDER BY | 后端既写死了排序,前端又在传排序参数 |
这个日志技巧基本能一眼定位问题出在哪一层,省去来回翻代码的时间。测完记得把日志配置改回去,生产环境别开 StdOutImpl,日志量太大会拖垮磁盘。
3. 按需删除:四套修改方案与操作要点
3.1 只去掉默认时间倒序,列表保持原有查询顺序
如果你的需求就是不要任何默认排序,数据库表是什么顺序就返回什么顺序,操作分两部分。
前端部分:把 createTime 列配置里的 defaultSortOrder: 'descend' 删除。如果你不希望用户手动点击表头排序,把 sorter: true 也删掉或改成 false;如果还想保留手动排序,只删 defaultSortOrder 就行。
后端部分:在 ServiceImpl 或 Controller 里删掉 orderByDesc("create_time") 这类代码。同时在 Mapper XML 里查找有没有 order by create_time desc 的兜底语句,有就删掉,或者按 3.2 的方式改成其它字段。
这里必须提醒一句:严格意义上,不指定 ORDER BY 时数据库返回的顺序是不保证的。简单场景下表按自增主键插入,返回顺序基本等于插入顺序,够用;但一旦数据量变大、出现分页,或者有并发插入和删除操作,顺序就可能不稳定。所以如果业务对顺序没有明确要求,我仍然建议给一个固定的排序字段,哪怕就是 order by id asc,也比完全裸奔靠谱。
3.2 换成业务字段排序:sort_no 优先,create_time 兜底
这是实际项目里最常见的需求:列表默认按业务排序字段 sort_no 升序排列,sort_no 相同的记录再按创建时间倒序。后端写法:
java复制queryWrapper.orderByAsc("sort_no").orderByDesc("create_time");
如果用的是 Mapper XML,可以这样改:
xml复制<if test="sortColumn == null or sortColumn == ''">
order by sort_no asc, create_time desc
</if>
前端部分,去掉 createTime 列的 defaultSortOrder,如果希望用户能点击表头排序,可以在 sort_no 列配置 sorter: true,这样用户点击 sort_no 表头时,QueryGenerator 会把排序参数传给后端,拼到 SQL 上。
这里有个容易踩的细节:如果后端同时保留了兜底排序和前端传入的排序,就会出现“用户点了 A 列排序,结果 SQL 先按 B 列兜底,再按 A 列排”的情况。正确做法是在 Controller 里判断前端是否传了排序参数,传了就不再拼兜底排序,没传才加默认排序。典型逻辑:
java复制String column = req.getParameter("column");
String order = req.getParameter("order");
if (oConvertUtils.isEmpty(column) || oConvertUtils.isEmpty(order)) {
queryWrapper.orderByAsc("sort_no").orderByDesc("create_time");
}
这样一来,用户主动排序时完全按用户意图走,不主动排序时才走默认业务顺序。
3.3 前端表格去掉默认排序,让用户自己决定
有些业务场景希望列表初始不排序,但又允许用户点击表头自行升降序。这种需求的改动最小:把 createTime 列的 defaultSortOrder 删除,sorter: true 保留。刷新页面后,列表首次加载不再带排序参数,数据按后端的原始查询顺序返回;用户点击表头后,排序状态才会生效。
但这里有一个很容易被忽略的问题:a-table 的排序状态在用户点击过之后会一直存在组件实例中,部分项目还会把排序状态同步到 URL 参数里,刷新或翻页后依然生效。也就是说,光删 defaultSortOrder,并不能保证“每次进入页面都是无排序状态”。
如果你的列表页封装了 useListPage 或类似的钩子,建议检查一下它是否把 column、order 写进了路由参数。如果是,可以在页面初始化的钩子里重置表格排序状态,或者清除 URL 中相关参数。验证方法很简单:打开 Network 面板,刷新页面,看列表接口请求里还有没有 column 和 order,没有就说明默认排序已经去掉。
3.4 彻底一点:生成代码时直接设置排序字段
如果你是在项目初期,还没开始大规模开发,可以在代码生成器配置表单时就把排序字段定好。JeecgBoot 的代码生成器里有排序字段配置项,选择 create_time 时生成的代码会自动带 create_time 倒序,选择 sort_no 时生成的就是 sort_no 升序。方向也能选,一般升序降序都有。
这种方式的好处是从源头避免“默认时间排序”,生成出来的代码直接符合业务预期。但要注意:如果模块已经生成并且已经基于它做了后续开发,比如加了自定义校验、改过查询逻辑、加过按钮事件,那就不建议重新生成覆盖。直接手工改三处排序代码反而更安全。代码生成器在覆盖时不会保留你后期加的代码,这是新手最容易翻车的地方。
4. 删除默认排序过程中容易踩的坑
4.1 改了不生效:前端缓存与后端 target 目录
这种问题非常常见,改动明明做了,代码看起来也没问题,但刷新页面后排序还是老样子。原因通常不在逻辑本身,而在缓存和编译产物。
先看前端。如果你跑的是 npm run dev,一般改完保存就会热更新,但浏览器缓存、Vite 或 Webpack 的模块缓存偶尔会把旧配置留住,需要强制刷新或清缓存。如果是打包产物部署,那必须重新 build,再把新的静态文件部署上去,否则线上还是老代码。
再看后端。Java 代码改动后需要重新编译,如果你改的是 Mapper XML,还要检查 target/classes 下对应的 XML 是否已经更新。有时候 IDE 编译不彻底,或者项目存在多个模块、多个同名 XML 文件,实际加载的并非你改的那一个。最稳妥的做法是重新 clean 一下再编译,并在日志里看一下 MyBatis 加载的 mapper-locations 路径指向哪里。
还有一种情况是列表数据被 Redis 缓存了,接口直接返回缓存结果,压根没走 SQL。这种问题排查起来最费劲,因为你会发现自己改的代码完全没被执行。遇到数据不对,先检查有没有缓存层,把缓存清掉再看结果。
4.2 del_flag 默认值没设置,新增数据直接“消失”
这个坑和排序没有直接关系,但在删除默认时间排序的排查过程中很容易撞上。现象是:列表里旧数据都在,新增一条记录后刷新页面,新记录却不见了。打开数据库一看,记录明明存在,create_time 也有值,就是 del_flag 字段的值是 NULL。
JeecgBoot 的基础查询都会带逻辑删除条件,SQL 一般是 AND del_flag = '0'。NULL 永远不等于字符串 '0',所以这条记录就被过滤掉了。产生 NULL 的原因通常是表结构里 del_flag 没有默认值,而后端新增逻辑也没有显式设置这个字段,前端新增表单更不会传 del_flag。
解决办法有几种,可以组合使用。一是改表结构设置默认值:
sql复制ALTER TABLE your_table MODIFY COLUMN del_flag varchar(1) DEFAULT '0' COMMENT '删除状态 0正常 1删除';
二是在后端实体类上给 del_flag 字段加自动填充:
java复制@TableField(fill = FieldFill.INSERT)
private String delFlag;
然后实现 MetaObjectHandler,在 insertFill 方法里给 delFlag 赋默认值 "0"。
还有一种不推荐但很直接的土办法,是前端新增表单里显式传 delFlag: '0',虽然能解决问题,但标准做法还是从数据库和后端统一处理。
4.3 时间字段为空时的排序表现
把默认时间排序改成按 create_time asc 或 desc 兜底时,如果表里存在 create_time 为 NULL 的旧数据,排序结果可能和预期不一致。MySQL 里 NULL 被认为是最小值,所以 create_time asc 时空值排最前,create_time desc 时空值排最后。
如果业务上希望空值永远排在最后,可以这样做:
sql复制order by (create_time is null) asc, create_time desc
或者用 IFNULL 把空值替换成一个足够小的时间:
sql复制order by IFNULL(create_time, '1970-01-01 00:00:00') desc
但要注意,用 IFNULL 包裹 create_time 会让字段上的索引失效,数据量大时查询性能会明显下降。更推荐的方式是在查询条件里过滤掉 create_time 为空的数据,或者在应用层做空值兜底逻辑。
4.4 前端排序参数再次覆盖后端设置
后端把默认排序改成了 sort_no asc,也删掉了历史遗留的 orderByDesc,结果打开前端页面一看,列表还是按 create_time 倒序。这个问题前面提过一次,但值得单独再强调:前端 a-table 的排序状态是“会记忆”的。
用户如果之前点击过 createTime 表头,排序状态就会一直存在组件里,刷新也不一定重置。更麻烦的是,部分项目把排序状态塞进路由参数,翻页、关闭弹窗、甚至重新进入页面都可能恢复原状。你改的是后端默认排序,前端却一直在传旧的排序参数,后端只能乖乖执行。
处理思路有两个方向。一是在前端重置排序状态,进入页面时清空表格组件的排序配置,同时清掉 URL 中残留的 column/order 参数。二是在后端判断是否信任前端排序参数:如果业务上不需要用户自定义排序,Controller 里可以忽略 column/order,或者在 QueryGenerator 初始化之后手动清除这两个参数。但这个操作会让表头点击排序功能失效,做之前要和业务方确认。
我在实际项目里通常倾向于保留用户手动排序能力,同时在后端做好“没有前端排序参数时才加默认排序”的逻辑,两边各管一段,既不冲突又能满足大多数场景。删完排序之后,记得从头到尾点一遍列表页的分页、刷新、排序、重置按钮,确认所有交互下的排序行为都符合预期,再上生产。
