第九章. common-core 工具类(七):SqlUtil 与 PageQuery 分页封装

第九章. common-core 工具类(七):SqlUtilPageQuery 分页封装

摘要:本章我们将深入 SqlUtil。在 MyBatis-Plus (MP) 已经解决大部分 SQL 注入的背景下,我们将通过分析 RVP 框架的 分页实体 PageQuery,来理解 SqlUtil 存在的真正价值。它并不是 MP 的替代品,而是 RVP 在处理 前端动态排序字符串 时的“最后一道防线”。

在上一章中,我们深入剖析了 ReflectUtils,它继承了 Hutool ReflectUtil 并提供了核心增强。我们掌握了 Hutool 反射的基础用法(newInstance, setFieldValue, invoke),并重点实战了 RVP 独有的 invokeGetterinvokeSetter 在多级嵌套属性访问中的应用。

现在,我们来看 utils 包下一个非常“特殊”的工具类——SqlUtil

在 RVP 这种深度整合了 MyBatis-Plus (MP) 的项目中,我们几乎所有的 SQL 操作(尤其是 ORDER BYWHERE)都是通过 MP 的 Wrapper 来动态、安全地构建的。

这就带来了一个尖锐的问题:既然 MP 已经帮我们防止了 SQL 注入,RVP 为什么还要单独提供一个 SqlUtil?它在“二开”中的真实用途到底是什么?

本章,我们将从这个“灵魂拷问”出发,深入 SqlUtil 的设计意图和 RVP 框架中 唯一 使用它的真实场景——PageQuery 实体。

本章学习路径
SqlUtil 与 PageQuery 分页封装


9.1. SqlUtil 源码解析:“白名单”与“黑名单”

我们首先必须理解 SqlUtil 这个工具本身。

9.1.1. 文件定位与源码概览

我们打开 SqlUtil 的源码:

文件路径ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/sql/SqlUtil.java

这个类非常简洁,其核心就是两个 String 常量(正则表达式)和三个 static 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
package org.dromara.common.core.utils.sql;
// ...
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class SqlUtil {

/**
* 定义常用的 sql关键字 (黑名单)
*/
public static String SQL_REGEX = "...|and |extractvalue|updatexml|sleep|exec |...";

/**
* 仅支持字母、数字、下划线、空格、逗号、小数点(白名单)
*/
public static final String SQL_PATTERN = "[a-zA-Z0-9_\\ \\,\\.]+";

/**
* 检查字符,防止注入绕过 (白名单应用)
*/
public static String escapeOrderBySql(String value) {
if (StringUtils.isNotEmpty(value) && !isValidOrderBySql(value)) {
throw new IllegalArgumentException("参数不符合规范,不能进行查询");
}
return value;
}

/**
* 验证 order by 语法是否符合规范 (白名单校验)
*/
public static boolean isValidOrderBySql(String value) {
return value.matches(SQL_PATTERN);
}

/**
* SQL关键字检查 (黑名单应用)
*/
public static void filterKeyword(String value) {
// ...
String[] sqlKeywords = StringUtils.split(SQL_REGEX, "\\|");
for (String sqlKeyword : sqlKeywords) {
if (StringUtils.indexOfIgnoreCase(value, sqlKeyword) > -1) {
throw new IllegalArgumentException("参数存在SQL注入风险");
}
}
}
}

SqlUtil 提供了两种截然不同的安全校验策略:“白名单”和“黑名单”。

9.1.2. “白名单”校验:SQL_PATTERNisValidOrderBySql

  • SQL_PATTERN = "[a-zA-Z0-9_\\ \\,\\.]+";
    • 这是一个 白名单 正则表达式。它定义了“允许”的字符集:
      • a-zA-Z0-9_:字母、数字、下划线(合法的字段名)
      • \\:空格(例如 ORDER BY id asc
      • \\,:逗号(例如 id,create_time
      • \\.:点(例如 user.id
  • isValidOrderBySql(String value)
    • 此方法用于判断 value 字符串是否 完全由 SQL_PATTERN 中的“安全字符”组成。
    • 只要 value 中出现 任何 白名单之外的字符(例如 *;()-),此方法都会返回 false

9.1.3. “黑名单”校验:SQL_REGEXfilterKeyword

  • SQL_REGEX = "...|and |extractvalue|updatexml|sleep|..."
    • 这是一个 黑名单 字符串。它列出了所有“禁止”出现的 SQL 注入高危关键字。
    • 注意:关键字(如 select)后面特意带了一个空格,这是为了防止误杀(例如字段名 username 包含 user)。
  • filterKeyword(String value)
    • 此方法会遍历 SQL_REGEX 列表,检查 value 中是否 包含 任意一个“高危关键字”(忽略大小写)。
    • 只要 value 包含(indexOfIgnoreCase > -1)了 SQL_REGEX 中的任意一个(例如 " and "),此方法就会 立即抛出异常

9.1.4. 核心方法:escapeOrderBySql (白名单的应用)

escapeOrderBySqlisValidOrderBySql 的“应用层”封装。它 只使用“白名单”SQL_PATTERN)。

它的逻辑是:

  1. 检查 value 是否非空。
  2. 如果非空,就调用 isValidOrderBySql(value) 进行白名单校验。
  3. 如果校验 不通过!isValidOrderBySqltrue),说明 value 包含了非法字符(如 ;),立即抛出 IllegalArgumentException,终止程序。
  4. 如果校验通过,原样返回 value

9.2. SqlUtil 的存在价值:为何 MyBatis-Plus (MP) 不够用?

现在我们理解了 SqlUtil 是一个“校验器”。但正如我们之前讨论的,MyBatis-Plus (MP) 本身就防 SQL 注入,为什么还需要它?

9.2.1. MP 的安全机制:Wrapper 与预编译

MyBatis-Plus (MP) 框架的核心价值之一就是 防止 SQL 注入。它通过 Wrapper(条件构造器)和 预编译PreparedStatement)机制,确保了 WHERE 条件的安全。

1
2
3
4
5
6
7
// MP 的“安全”用法 (WHERE)
// 1. 业务代码
new QueryWrapper<User>().eq("user_name", userNameInput);
// 2. 最终执行的 SQL (伪代码)
// SELECT * FROM sys_user WHERE user_name = ?
// 3. 传入的值 "zhangsan; DROP TABLE sys_user"
// 只会被当作一个普通的字符串 "zhangsan; DROP TABLE sys_user",而不会执行 DROP

对于排序,MP 同样提供了 类型安全 的方法:

1
2
// MP 的“安全”用法 (ORDER BY)
new QueryWrapper<User>().orderByAsc(User::getCreateTime);

这种写法通过 Lambda 表达式引用 User::getCreateTime,在编译期就固定了 create_time 字段,无法 通过前端传参来动态修改这个排序字段。

9.2.2. 框架的痛点:前端动态排序与原生 SQL 拼接风险

MP 的安全用法 orderByAsc(User::getCreateTime) 虽然安全,但它“不够灵活”。在 RVP 这种高度工程化的后台中,前端表格需要支持 点击任意列头(如“用户 ID”、“登录时间”、“状态”)进行动态排序。

前端发送的请求(封装到 PageQuery DTO 中)通常是这样的:
{ "orderByColumn": "id,createTime", "isAsc": "asc,desc" }

后端 Controller 拿到这个 orderByColumn 字符串("id,createTime")后,无法直接用于类型安全的 Lambda。如果直接将这个字符串拼接到 SQL 中,MP 的安全机制就失效了。

这就是 MP 机制之外的“风险点”

1
2
3
4
5
// 危险的“拼接” SQL (Bad Practice)
String orderByColumn = "id; DROP TABLE sys_user"; // 黑客传入
// MP Wrapper 提供的原生 SQL 拼接
wrapper.last("ORDER BY " + orderByColumn);
// 灾难:SQL 注入成功!

9.2.3. SqlUtil 的定位:原生 ORDER BY 字符串的“校验器”

SqlUtil 正是为了堵住 9.2.2 中的“风险点”而诞生的。

不是 MyBatis-Plus 的替代品,它只做一件事:在 RVP 框架必须拼接原生 ORDER BY 字符串时,充当“最后一道防线”,确保这个字符串是“干净的”

它通过 escapeOrderBySql(白名单)确保前端传入的 orderByColumn 字符串中,绝对不包含 任何 ;, *, ( 等危险字符,从而在根源上杜绝了 ORDER BY 注入。


9.3. 真实场景追踪:PageQuery 如何应用 SqlUtil

现在,我们定位到 RVP 框架中 唯一 使用 SqlUtil 的地方:PageQuery 实体类。

9.3.1. 实体定位:PageQuery (分页 DTO)

PageQuery 是 RVP 中所有分页查询 Controller 的标准入参。它是一个 DTO (Data Transfer Object),专门用于 承接 前端(如 plus-ui)发送过来的分页和排序参数。

文件路径ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/core/page/PageQuery.java

PageQuery 的核心字段:

1
2
3
4
5
6
7
8
@Data
public class PageQuery implements Serializable {
private Integer pageSize; // 分页大小
private Integer pageNum; // 当前页数
private String orderByColumn; // 排序列 (例如: "id,createTime")
private String isAsc; // 排序方向 (例如: "desc" 或 "asc,desc")
// ...
}

9.3.2. 源码追踪:build() -> buildOrderItem()

PageQuery 的核心方法是 build(),它负责将这个 DTO 转换为 MP 认识的 Page<T> 对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 位于 PageQuery.java
public <T> Page<T> build() {
// ... 处理分页参数 ...
Page<T> page = new Page<>(pageNum, pageSize);

// 【核心】构建排序项
List<OrderItem> orderItems = buildOrderItem();

// 将(已校验的)排序项添加到 MP 的 Page 对象中
if (CollUtil.isNotEmpty(orderItems)) {
page.addOrder(orderItems);
}
return page;
}

build() 方法将排序的“脏活累活”委托给了私有方法 buildOrderItem()

9.3.3. 【SqlUtil 唯一登场点】escapeOrderBySql 的作用

buildOrderItem() 是本章的核心,SqlUtil 在这里扮演了“守门员”的角色。

我们来精读 buildOrderItem()关键首行

1
2
3
4
5
6
7
8
9
10
11
12
13
// 位于 PageQuery.java
private List<OrderItem> buildOrderItem() {
if (StringUtils.isBlank(orderByColumn) || StringUtils.isBlank(isAsc)) {
return null;
}

// 1. 【SqlUtil 登场】
// 使用“白名单”校验 orderByColumn 字符串
// 如果 orderByColumn = "id; drop table",将在这一行抛出异常
String orderBy = SqlUtil.escapeOrderBySql(orderByColumn);

// ... (后续逻辑) ...
}

分析
SqlUtil.escapeOrderBySql(orderByColumn) 是整个安全体系的 第一道关卡

  1. 黑客传入orderByColumn = "id; DROP TABLE users"
  2. escapeOrderBySql 调用 isValidOrderBySql
  3. isValidOrderBySql 使用 SQL_PATTERN(白名单)进行 matches 校验。
  4. "id; DROP TABLE users" 中包含了 非法字符 ;
  5. isValidOrderBySql 返回 false
  6. escapeOrderBySql 抛出 IllegalArgumentException("参数不符合规范...")
  7. build() 方法执行失败,Controller 层捕获异常并返回错误信息给前端。
  8. SQL 注入防御成功

9.3.4. 源码概览:PageQuery 实体对排序逻辑的完整封装

在通过 SqlUtil 的安全校验后,buildOrderItem 继续执行后续的业务逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
private List<OrderItem> buildOrderItem() {
// ... (SqlUtil 校验已通过) ...
String orderBy = SqlUtil.escapeOrderBySql(orderByColumn);

// 1. 驼峰转下划线 (前端传 createTime -> 后端用 create_time)
orderBy = StringUtils.toUnderScoreCase(orderBy);

// 2. 兼容前端 (前端可能传 "ascending" -> 转为 "asc")
isAsc = StringUtils.replaceEach(isAsc, new String[]{"ascending", "descending"}, new String[]{"asc", "desc"});

// 3. 按逗号切割,支持多字段排序
String[] orderByArr = orderBy.split(StringUtils.SEPARATOR);
String[] isAscArr = isAsc.split(StringUtils.SEPARATOR);

// 4. 校验排序方向和排序字段的数量是否匹配
if (isAscArr.length != 1 && isAscArr.length != orderByArr.length) {
throw new ServiceException("排序参数有误");
}

List<OrderItem> list = new ArrayList<>();
// 5. 循环构建 MP 的安全排序对象 (OrderItem)
for (int i = 0; i < orderByArr.length; i++) {
String orderByStr = orderByArr[i];
String isAscStr = isAscArr.length == 1 ? isAscArr[0] : isAscArr[i];

if ("asc".equals(isAscStr)) {
list.add(OrderItem.asc(orderByStr));
} else if ("desc".equals(isAscStr)) {
list.add(OrderItem.desc(orderByStr));
} else {
throw new ServiceException("排序参数有误"); // 必须是 asc 或 desc
}
}
return list;
}

总结PageQuery 这个 DTO 通过 buildOrderItem 方法,完美地实现了 从“前端不安全的动态排序列字符串”到“MP 安全的 List<OrderItem> 对象”的转换,而 SqlUtil.escapeOrderBySql 正是这个转换过程中不可或缺的第一道“安全门”。


9.4. 测试准备:创建 SqlUtilTest (main 方法)

SqlUtil 是一个纯 Java 工具类,不依赖 Spring 容器。我们可以使用 main 方法来测试它。

9.4.1. 创建 utils.test.SqlUtilTest.java

我们在 ruoyi-demo 模块中创建测试类:

文件路径ruoyi-modules/ruoyi-demo/src/main/java/org/dromara/demo/utils/test/SqlUtilTest.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package org.dromara.demo.utils.test;

import cn.hutool.log.Log;
import cn.hutool.log.LogFactory;
// 导入 RVP 的 SqlUtil
import org.dromara.common.core.utils.sql.SqlUtil;

/**
* SqlUtil 工具类实战
* (纯 Java 测试,无需 Spring)
*/
public class SqlUtilTest {

private static final Log console = LogFactory.get();

// psvm
public static void main(String[] args) {
console.info("SqlUtil 测试开始...");

// ... 我们将在这里调用测试方法 ...
}

// ... 测试方法 ...
}

9.4.2. 编写 main 方法

我们将在 main 方法中调用后续的测试。


9.5. 防线(一):isValidOrderBySqlescapeOrderBySql

这组方法是基于**“白名单”**(SQL_PATTERN)的校验,这也是 PageQuery 正在使用的防线。

9.5.1. 源码解析:SQL_PATTERN 正则表达式

我们再看一眼“白名单”规则:public static final String SQL_PATTERN = "[a-zA-Z0-9_\\ \\,\\.]+";
只允许 字母数字下划线空格逗号 这几种字符存在。

9.5.2. 实战 isValidOrderBySql:合法的 vs 非法的

isValidOrderBySql 方法用于检查一个字符串是否 完全 由上述安全字符组成。

我们在 SqlUtilTest 中创建 testWhiteList 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// ...
public class SqlUtilTest {
// ... main ...
public static void main(String[] args) {
console.info("SqlUtil 测试开始...");
testWhiteList();
}

/**
* 测试 1:白名单校验 (isValidOrderBySql)
*/
public static void testWhiteList() {
console.info("--- 1. 测试 isValidOrderBySql (白名单) ---");

// --- 合法输入 ---
console.info("【'id'】是否合法: {}",
SqlUtil.isValidOrderBySql("id"));

console.info("【'id,create_time'】是否合法: {}",
SqlUtil.isValidOrderBySql("id,create_time"));

console.info("【'user.id asc'】是否合法: {}",
SqlUtil.isValidOrderBySql("user.id asc"));

// --- 恶意输入 ---
console.info("【'id; drop table'】是否合法: {}",
SqlUtil.isValidOrderBySql("id; drop table"));

console.info("【'*'】是否合法: {}",
SqlUtil.isValidOrderBySql("*"));

console.info("【'name()'】是否合法: {}",
SqlUtil.isValidOrderBySql("name()"));
}
// ...
}

运行 main 方法,控制台输出

1
2
3
4
5
6
7
8
... INFO ... SqlUtil 测试开始...
... INFO ... --- 1. 测试 isValidOrderBySql (白名单) ---
... INFO ... 【'id'】是否合法: true
... INFO ... 【'id,create_time'】是否合法: true
... INFO ... 【'user.id asc'】是否合法: true
... INFO ... 【'id; drop table'】是否合法: false
... INFO ... 【'*'】是否合法: false
... INFO ... 【'name()'】是否合法: false

分析:白名单的校验非常严格。任何可能导致 SQL 语法改变的特殊字符(如 ;, *, (, ))都会被直接拦截,返回 false

9.5.3. 实战 escapeOrderBySql:安全通过 vs 抛出异常

escapeOrderBySqlPageQuery真正使用 的方法。它调用 isValidOrderBySql,如果返回 false,它会 抛出异常 来中断程序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ... 在 testWhiteList() 方法中继续添加 ...

console.info("--- 2. 测试 escapeOrderBySql (白名单应用) ---");

// 场景 1:安全输入
try {
String safeSql = SqlUtil.escapeOrderBySql("id,create_time");
console.info("【安全输入】通过: {}", safeSql);
} catch (Exception e) {
console.error("【安全输入】异常: {}", e.getMessage());
}

// 场景 2:恶意输入
try {
String dangerSql = SqlUtil.escapeOrderBySql("id; drop table users");
console.info("【恶意输入】通过: {}", dangerSql);
} catch (Exception e) {
console.error("【恶意输入】被拦截,异常: {}", e.getMessage());
}
// ...

运行 main 方法(接上文输出)

1
2
3
... INFO ... --- 2. 测试 escapeOrderBySql (白名单应用) ---
... INFO ... 【安全输入】通过: id,create_time
... ERROR ... 【恶意输入】被拦截,异常: 参数不符合规范,不能进行查询

结论escapeOrderBySql 成功扮演了“断路器”的角色,它在检测到非法字符 ; 时,立即抛出异常,阻止了恶意 SQL 的下一步拼接。


9.6. 防线(二):filterKeyword(“黑名单”校验)

SqlUtil 提供了第二种防御机制:filterKeyword,它基于“黑名单”(SQL_REGEX)。

9.6.1. 源码解析:SQL_REGEX 关键字列表

public static String SQL_REGEX = "...|and |extractvalue|updatexml|sleep|exec |insert |select |delete |..."

filterKeyword 方法会检查输入值是否 包含indexOfIgnoreCase)这个列表中的 任意一个 关键字。

9.6.2. 【二开场景】何时使用?(为何 PageQuery 不用它?)

在“二开”中,我们必须思考:PageQuery 为什么 不用 filterKeyword,而是用了 escapeOrderBySql

  1. escapeOrderBySql(白名单)更严格:它只认 [a-z0-9_ ,.]*() 都不认识,直接拒绝。
  2. filterKeyword(黑名单)更宽松:它只检查 select, drop 等关键字。如果你传入 *name()filterKeyword允许通过 的,因为 *name() 不在它的“黑名单”上。
  3. ORDER BY 的特殊性ORDER BY 后面只应该出现 字段名。字段名是受“白名单”严格约束的。
  4. WHERE 的特殊性WHERE 后面的值(例如 name = '张三')可能包含各种字符,此时用“白名单”会误杀。

结论

  • escapeOrderBySql(白名单)专用于 ORDER BY 排序字段名PageQuery 的选择是正确的。
  • filterKeyword(黑名单)专用于 WHERE 条件中的值,当你无法使用 MP 的 ? 预编译,且必须拼接 WHERE 值时,作为最后的防御手段。(二开时极不推荐,应始终使用 Wrapper.eq())。

9.6.3. 实战 filterKeywordselect vs select (带空格) 的陷阱

filterKeyword 有一个非常容易误解的“陷阱”。

我们在 SqlUtilTest 中创建 testBlackList 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// ...
public class SqlUtilTest {
// ... main 和 testWhiteList ...
public static void main(String[] args) {
// ...
// testWhiteList();
testBlackList();
}

/**
* 测试 2:黑名单校验 (filterKeyword)
*/
public static void testBlackList() {
console.info("--- 3. 测试 filterKeyword (黑名单) ---");

// 场景 1:安全输入
try {
SqlUtil.filterKeyword("id");
console.info("【'id'】通过");
} catch (Exception e) {
console.error("【'id'】被拦截: {}", e.getMessage());
}

// 场景 2:【陷阱】不带空格的 'select'
try {
SqlUtil.filterKeyword("select");
console.info("【'select'】(不带空格) 通过 (这是个陷阱)");
} catch (Exception e) {
console.error("【'select'】(不带空格) 被拦截: {}", e.getMessage());
}

// 场景 3:【真实攻击】带空格的 'select '
try {
SqlUtil.filterKeyword("select ");
console.error("【'select '】(带空格) 通过"); // 这行不应该被执行
} catch (Exception e) {
console.info("【'select '】(带空格) 被拦截: {}", e.getMessage());
}

// 场景 4:忽略大小写
try {
SqlUtil.filterKeyword(" OR "); // 包含 "or "
console.error("【' OR '】(忽略大小写) 通过");
} catch (Exception e) {
console.info("【' OR '】(忽略大小写) 被拦截: {}", e.getMessage());
}
}
// ...
}

运行 main 方法,控制台输出

1
2
3
4
5
... INFO ... --- 3. 测试 filterKeyword (黑名单) ---
... INFO ... 【'id'】通过
... INFO ... 【'select'】(不带空格) 通过 (这是个陷阱)
... INFO ... 【'select '】(带空格) 被拦截: 参数存在SQL注入风险
... INFO ... 【' OR '】(忽略大小写) 被拦截: 参数存在SQL注入风险

分析
filterKeyword 的黑名单 SQL_REGEX 中包含的是 "select "(带空格)。

  • indexOfIgnoreCase("select", "select ") 返回 -1(不匹配),因此 SqlUtil.filterKeyword("select") 会被放行!
  • indexOfIgnoreCase("select ", "select ") 返回 0(匹配),因此 SqlUtil.filterKeyword("select ") 会被拦截
  • 这是 RVP 为了防止误杀(例如 username 包含 user)而故意设计的,我们在使用时必须清楚这个“带空格”的约定。