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

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

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

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

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

在 RVP 这种深度整合了 MyBatis-Plus (MP) 的项目中,我们几乎所有的 SQL 操作(尤其是 ORDER BY 和 WHERE)都是通过 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_PATTERN 与 isValidOrderBySql

  • 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_REGEX 与 filterKeyword

  • 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 (白名单的应用)

escapeOrderBySql 是 isValidOrderBySql 的“应用层”封装。它 只使用“白名单”(SQL_PATTERN)。

它的逻辑是:

  1. 检查 value 是否非空。
  2. 如果非空,就调用 isValidOrderBySql(value) 进行白名单校验。
  3. 如果校验 不通过(!isValidOrderBySql 为 true),说明 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. 防线(一):isValidOrderBySql 与 escapeOrderBySql

这组方法是基于**“白名单”**(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 抛出异常

escapeOrderBySql 是 PageQuery 中 真正使用 的方法。它调用 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. 实战 filterKeyword:select 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)而故意设计的,我们在使用时必须清楚这个“带空格”的约定。