动态排序这个需求,几乎每个后台管理系统都躲不掉。前端一个排序组件,后端接收字段名和排序方向,拼到SQL里就完事——听起来简单,但真正上过生产的人都知道,这里面全是坑。我最近重构一套老系统时,因为排序引发了一次线上慢查询报警,ORDER BY子句被注入了额外表达式,直接把一个核心接口拖到了三秒以上。痛定思痛,我把所有排序逻辑收敛到一个MyBatis全局拦截器里统一处理,既堵住了SQL注入的口子,又把排序字段强制约束到索引列上,做了一层兜底。这篇文章就把这套方案的完整思路、实现细节和踩坑过程写出来,给正在为动态排序发愁的人一个可以直接抄作业的参照。
1. 动态排序的痛点复盘:一次线上慢查询的根因
1.1 事故现场:ORDER BY拼接引发的功能与性能双重灾难
先说那次事故。系统里有个订单列表接口,业务方反馈偶尔会“转圈”,后来直接接到了监控告警:某条SQL平均耗时2.8秒,高峰期甚至超过5秒。我拉出慢日志一看,SQL长这样:
sql复制SELECT * FROM t_order o
LEFT JOIN t_user u ON o.user_id = u.id
WHERE o.status = 1
ORDER BY o.total_amount DESC, (SELECT COUNT(1) FROM t_log l WHERE l.order_id = o.id) DESC
LIMIT 20
表面看,这个子查询排序确实很慢。但问题在于——这个排序条件根本不是业务方主动写的,而是通过前端传参拼接进去的。也就是说,有人在排序字段里塞了一段“额外惊喜”,后端没做任何校验,直接拼到了SQL尾部。再往深挖,发现不只是注入风险:正常用户传一个没有索引的字段来做排序,同样会导致全表filesort,数据量一大照样崩。
这类问题在动态排序场景里非常普遍。大多数团队的做法是“谁接需求谁处理”,于是每个接口都有自己的一套排序校验逻辑,有的压根没校验,有的只做了简单白名单,有的干脆禁用了动态排序功能。结果就是:同一个系统里,十个接口有十种排序处理方式,安全性和性能完全看开发当时的心情。
1.2 排序场景和WHERE场景的注入差异:为什么#{}保护不了ORDER BY
很多初学者会疑惑,MyBatis里WHERE条件我都用#{},为什么排序字段不能用#{}?这俩根本不是一回事。#{}最终渲染成JDBC的占位符?,由PreparedStatement把参数当纯数据处理,无论你传什么,它都只是“值”。但ORDER BY后面跟的是列名、表达式,属于SQL结构的一部分,不是数据值。
我也见过有人试图把排序字段也写成#{sortField},结果SQL变成:
sql复制ORDER BY ?
数据库根本不知道你要按哪个列排序,因为这里必须是一个列标识符,不是绑定变量。所以,排序字段在MyBatis里通常只能靠${}拼接,或者Java代码拼好再传进去。${}是什么?是纯字符串替换,传进来的内容原封不动塞进SQL。这就天然打开了注入通道:比如传id DESC; DROP TABLE xx这种破坏性语句,或者传id, (SELECT 1 FROM (SELECT SLEEP(3))a)做时间盲注,再或者像我们那次事故一样,传一个子查询拖垮性能。
把动态排序全部交给${},相当于默认相信所有前端用户都是好人,这显然不符合生产环境的基本假设。
1.3 常见“半吊子”方案的缺陷
在决定做全局拦截器之前,我评估过几种常见方案,各有各的尴尬:
- 业务层手动校验白名单:每个Service方法里写一段
if (!allowFields.contains(sortField)) throw ...。看着没问题,但分散在几十个方法里,很容易漏改、漏放行。而且白名单和SQL里的表别名、字段下划线命名耦合在一起,复制粘贴出错率极高。 - XML里用
<choose>枚举排序分支:比如<choose><when test="sortField=='createTime'">ORDER BY create_time</when>...</choose>。这个方案SQL可读性还行,但每个查询都要写一大段枚举,列表查询一多,XML膨胀得没法看,加一个排序字段要改动所有相关查询。 - 直接用MyBatis-Plus的wrapper排序:新项目可以用,但老项目里大量手写XML SQL,总不能为了排序全部重写一遍吧?而且wrapper排序同样不解决白名单问题,只是换了个拼接场景。
所以我的结论很明确:需要一个全局的、可配置的、对业务代码入侵最小的统一处理层。MyBatis拦截器就是干这个的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 防注入与索引兜底的设计思路:白名单和字段映射
2.1 永远不要信任前端字段名:白名单是唯一的可靠防线
动态排序的防注入,业界有几种思路。有人觉得用正则过滤掉特殊字符就够了,比如去掉;、(、)、空格等。但正则黑名单永远有绕过的可能,你拦了SELECT,人家编码一下或者用/**/注释分隔就绕过去了。真正的安全做法,是反过来用白名单:我只允许你在我指定的字段列表里选,不在列表里的一律不认。
这个列表的设计要结合具体表结构。以订单表为例,可排序字段通常是:id、create_time、update_time、total_amount、status,这些字段在核心查询链路中都应该有索引覆盖。白名单不是简单的“能排序的字段”,而应该是“能排序且走索引的字段”。
我设计了一个SortFieldRegistry注册表,按表维度维护“前端字段名 -> 数据库列名”的映射,同时配置该表的主键字段作为兜底排序字段。比如:
java复制public class SortFieldRegistry {
private static final Map<String, TableSortConfig> TABLE_CONFIGS = new ConcurrentHashMap<>();
static {
// 订单表的排序白名单,value为数据库真实列名(含别名则写别名)
TableSortConfig orderConfig = new TableSortConfig("t_order");
orderConfig.setAlias("o");
orderConfig.addField("id", "o.id", true); // 主键
orderConfig.addField("createTime", "o.create_time", true);
orderConfig.addField("updateTime", "o.update_time", true);
orderConfig.addField("totalAmount", "o.total_amount", true);
orderConfig.addField("status", "o.status", false); // 非索引列,但可以排序
TABLE_CONFIGS.put("t_order", orderConfig);
}
public static TableSortConfig getConfig(String tableName) {
return TABLE_CONFIGS.get(tableName);
}
}
这里有个细节:我区分了“索引列”和“非索引列”,但默认策略是所有白名单字段都允许排序。真正做索引兜底的时候,是当白名单校验不通过时,降级到主键字段,而不是直接返回错误。为什么不直接拒绝?因为很多前端列表页的排序是用户可以自由切换的,如果传了一个未配置的字段就报错,体验很差;而自动降级到主键排序,至少保证功能不中断、性能可控。
2.2 字段名规范化:驼峰转下划线、表别名和边界情况
前端传的排序字段,习惯上是Java风格的驼峰命名,比如createTime,而数据库列是下划线风格create_time。如果直接在注册表里写死映射,倒是可以省去转换,但这要求开发者在配置时特别小心,前端字段一变,后端注册表也要跟着改。
我采用的方案是:注册表里存储“前端规范名 -> 数据库列名”,如果数据库列名没显式配置,就默认把驼峰转下划线。这个转换逻辑要注意几个边界:
- 连续的缩写字母,比如
userID,常规转换结果是user_i_d,这不对。简单规则是按大写字母切分,但遇到ID、URL这类专有名词就会切错。 - 所以我在实现里除了默认转换,还允许在注册表里直接配置列名覆盖,转换只作为兜底。
表别名的问题同样不能忽视。老系统里大量SQL是带别名的,比如FROM t_order o,拼接排序时写ORDER BY o.create_time DESC,既清晰又能避免多表JOIN时列名歧义。所以在注册表里,我设计别名跟随表配置走,拼接排序字段时直接用已配置好的带别名列名。
还有一类情况:排序字段压根不在当前查询的表里,比如前端传了userName,但这条SQL只查了订单表,没有JOIN用户表。这种情况有两种处理:直接降级主键排序,或者如果需要支持跨表排序,必须在SQL层面JOIN对应的表。在拦截器里做SQL改写去自动JOIN不现实,所以我的策略就是:不在当前表白名单里的字段,一律降级主键排序,保证SQL不会因为“Unknown column”直接报错。
2.3 为什么用“降级兜底”而不是“校验失败报错”
这一点我想单独强调一下。很多防注入方案做成了“不合法就抛异常或者忽略”,但我最终选择了降级。核心原因有三个:
第一,线上问题不只是安全问题,还有可用性问题。前端列表页的排序参数有时候是后端自己拼接的,比如某些报表会传一个复杂的排序条件。如果拦截器直接拒绝,后端拼接逻辑出错时,接口就全线飘红;而降级只是排序效果不对,不影响功能。
第二,降级策略让拦截器对业务代码的“杀伤力”降到最低。接入这套方案时,我没有要求所有团队把所有排序字段都配置完整,而是先配置一部分核心字段,其余自动兜底到主键。这样即使有没适配到的接口,也不会崩,只是排序不太对,便于渐进式治理。
第三,从索引兜底的角度看,主键排序是最稳的兜底项。InnoDB的主键是聚簇索引,行数据本身就按主键有序,排序时几乎不需要额外成本。哪怕查询条件过滤出来的结果集有几万行,按主键排序也比按某些非索引列排序快一个数量级。
3. 全局拦截器的完整实现:拦截点、参数识别与SQL改写
3.1 拦截点选择:为什么盯上StatementHandler.prepare而不是Executor.query
MyBatis的四大核心对象都能拦截:Executor、StatementHandler、ParameterHandler、ResultSetHandler。动态排序这个场景,我需要拿到“解析完成的SQL”和“当前请求的参数”,在SQL真正交给JDBC之前把它改掉。
一开始我想拦截Executor.query,因为在这里能拿到MappedStatement和参数对象。但后来发现一个问题:如果SQL里已经有ORDER BY,在Executor.query阶段处理,我需要自己解析SQL去找ORDER BY位置并替换,而StatementHandler.prepare阶段拿到的是同一个BoundSql,操作逻辑是一样的。
更重要的是,StatementHandler.prepare是SQL即将创建PreparedStatement的最后一个入口。在它之前修改BoundSql里的SQL,后续所有流程(参数绑定、执行、缓存)拿到的都是新SQL,不会出现改了一半不一致的情况。
拦截器的定义:
java复制@Intercepts({
@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})
})
public class DynamicSortInterceptor implements Interceptor {
private static final Logger log = LoggerFactory.getLogger(DynamicSortInterceptor.class);
@Override
public Object intercept(Invocation invocation) throws Throwable {
StatementHandler statementHandler = realTarget(invocation.getTarget());
BoundSql boundSql = statementHandler.getBoundSql();
SortParam sortParam = extractSortParam(boundSql.getParameterObject());
if (sortParam == null || StringUtils.isBlank(sortParam.getSortField())) {
return invocation.proceed();
}
String sql = boundSql.getSql();
String newSql = SqlSortBuilder.build(sql, sortParam);
if (!newSql.equals(sql)) {
ReflectUtil.setFieldValue(boundSql, "sql", newSql);
log.debug("[DynamicSort] 改写SQL完成,排序字段={}, 方向={}", sortParam.getSortField(), sortParam.getSortOrder());
}
return invocation.proceed();
}
private StatementHandler realTarget(Object target) {
if (target instanceof RoutingStatementHandler) {
MetaObject metaObject = SystemMetaObject.forObject(target);
return (StatementHandler) metaObject.getValue("delegate");
}
return (StatementHandler) target;
}
}
这里有个坑必须注意:StatementHandler的实际实现是RoutingStatementHandler,它内部包装了PreparedStatementHandler或CallableStatementHandler等。如果直接对RoutingStatementHandler取getBoundSql(),虽然也能拿到,但为了拿到真实的delegate对象,更稳妥的做法是通过MetaObject拆一层。否则后面如果你还想拿ParameterHandler做参数处理,可能会碰到类型转换问题。
3.2 参数传递约定:sortField和sortOrder从哪来
排序参数从哪拿,是个看似简单但容易出问题的设计。我定的约定是:接口入参里统一使用sortField和sortOrder两个字段,不管你是POJO、Map还是单独的@Param参数,只要包含这两个字段名,拦截器就能认出来。
提取参数的逻辑要兼容三种常见场景:
java复制private SortParam extractSortParam(Object parameterObject) {
if (parameterObject == null) {
return null;
}
String sortField = null;
String sortOrder = null;
if (parameterObject instanceof Map) {
Map<?, ?> paramMap = (Map<?, ?>) parameterObject;
Object fieldObj = paramMap.get("sortField");
Object orderObj = paramMap.get("sortOrder");
if (fieldObj != null) sortField = fieldObj.toString();
if (orderObj != null) sortOrder = orderObj.toString();
} else {
// 反射读取POJO属性
try {
PropertyDescriptor fieldDescriptor = new PropertyDescriptor("sortField", parameterObject.getClass());
Method readMethod = fieldDescriptor.getReadMethod();
if (readMethod != null) {
Object value = readMethod.invoke(parameterObject);
if (value != null) sortField = value.toString();
}
PropertyDescriptor orderDescriptor = new PropertyDescriptor("sortOrder", parameterObject.getClass());
Method orderReadMethod = orderDescriptor.getReadMethod();
if (orderReadMethod != null) {
Object value = orderReadMethod.invoke(parameterObject);
if (value != null) sortOrder = value.toString();
}
} catch (Exception ignored) {
// 没有这两个属性的对象,直接忽略
}
}
if (StringUtils.isBlank(sortField)) {
return null;
}
SortParam param = new SortParam();
param.setSortField(sortField.trim());
param.setSortOrder(StringUtils.isBlank(sortOrder) ? "ASC" : sortOrder.trim());
return param;
}
多个参数的情况要注意:如果Mapper方法签名是(UserQuery query, @Param("sortField") String sortField),那parameterObject是一个ParamMap,里面既有query对象,也有sortField字符串。MyBatis会把带有@Param注解的参数以注解值为key放进去,同时还有param1、param2这种位置key。我这里对Map的处理逻辑是直接按sortField这个key取,如果有就用。如果没加@Param注解,Map里的key就变成param1、param2,这时候按“sortField”取不到。
所以实际项目中,我要求所有需要对排序生效的Mapper方法,要么在参数对象里带上sortField属性,要么用@Param("sortField")显式声明。这也是侵入最小的接入成本。
3.3 核心SQL改写:解析表名、追加或替换ORDER BY
拿到排序参数之后,真正难啃的部分是SQL改写。这块逻辑我封装在SqlSortBuilder里,核心流程分三步:
第一步,识别当前SQL的“主表”和别名。我用的方式是从FROM关键字开始,匹配第一个表名。比如:
sql复制SELECT o.* FROM t_order o WHERE o.status = 1
解析结果是主表t_order,别名o。解析时用正则比字符串搜索可靠一些:
java复制private static final Pattern FROM_PATTERN = Pattern.compile(
"\\bFROM\\s+([a-zA-Z_][a-zA-Z0-9_]*)(?:\\s+(?:AS\\s+)?([a-zA-Z_][a-zA-Z0-9_]*))?",
Pattern.CASE_INSENSITIVE
);
注意:这个正则在有子查询、括号、UNION的场景会乱。我一开始也栽在这儿,后来补了个保护逻辑——如果SQL里有(且出现在FROM之前,或者包含UNION,就跳过改写,只做日志记录。复杂SQL的动态排序,最好在业务层面自己保证字段安全,而不是靠拦截器硬扛。
第二步,查注册表,确定排序列。拿到了主表名,去SortFieldRegistry里取配置。如果表没配置,或者字段不在白名单里,直接用配置的主键字段(如果表配置了)或者默认的id。
java复制public static String build(String originalSql, SortParam sortParam) {
TableSortConfig config = resolveTableConfig(originalSql);
if (config == null) {
// 没有表配置,无法安全处理,直接放回原SQL
return originalSql;
}
String sortColumn = config.resolveColumn(sortParam.getSortField());
String sortDirection = normalizeDirection(sortParam.getSortOrder());
String newOrderBy = " ORDER BY " + sortColumn + " " + sortDirection;
// 如果已存在ORDER BY,替换
int orderByIndex = findOrderByIndex(originalSql);
if (orderByIndex > 0) {
return originalSql.substring(0, orderByIndex) + newOrderBy
+ originalSql.substring(findStatementEnd(originalSql, orderByIndex));
}
// 没有ORDER BY,追加到SQL末尾,但要处理可能存在的LIMIT
int limitIndex = findLimitIndex(originalSql);
if (limitIndex > 0) {
return originalSql.substring(0, limitIndex) + newOrderBy + " "
+ originalSql.substring(limitIndex);
}
return originalSql + newOrderBy;
}
第三步,排序方向校验。排序方向只有两个值:ASC和DESC,大小写不敏感。凡是传了其他值的,一律按ASC处理。这样从方向上又堵住了一层注入。
findOrderByIndex和findLimitIndex这两个方法,我用的是正则匹配,同时要避免匹配到子查询里头的ORDER BY或者LIMIT。虽然这种简单解析不完美,但结合上面对子查询的跳过策略,实际效果完全够用。
3.4 反射修改BoundSql与JDK版本的兼容
BoundSql里的sql字段是final修饰的,要修改它只能靠反射:
java复制public static void setFieldValue(Object target, String fieldName, Object value) {
try {
Field field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
} catch (NoSuchFieldException | IllegalAccessException e) {
throw new RuntimeException("反射设置字段失败: " + fieldName, e);
}
}
这一步在我们的环境里没问题:项目从JDK8到JDK17都跑过,setAccessible(true)对MyBatis自身类的字段修改没有遇到模块限制。但如果你的项目未来升到JDK21以上的版本,或者用了比较激进的SecurityManager配置,反射这块要提前在测试环境验证一下。
另外有一个并发注意点:MyBatis的BoundSql是每次执行都new的,请求之间不共享,所以这里对sql字段的修改不存在线程安全问题。我最初以为这个方法可能在多线程下会互相干扰,翻源码确认了才放心。
还要提醒一句:如果你在使用MyBatis-Plus的PaginationInnerInterceptor之类的分页插件,拦截器顺序非常重要。分页插件通常会改写SQL添加LIMIT,如果我们的排序拦截器在分页插件之后执行,则在排序之前分页已经处理完了,限定的行数就错了。我的建议是:把这个排序拦截器放在分页插件前面,也就是配置顺序时排在前面,让分页插件在排序处理完成后,基于完整的排序结果再做分页。
4. 多数据库兼容与测试实践
4.1 MySQL/PostgreSQL/Oracle方言的注意点
动态排序的核心是拼接ORDER BY,这个语法在MySQL、PostgreSQL、Oracle里基本兼容。但有几个细节差异得单独处理:
- 关键字冲突:有些字段名是数据库关键字,比如
order、group、desc。白名单映射的目的之一就是避免直接用原始字段名。我们配置映射列名时,可以写成反引号或双引号包裹的形式,比如MySQL里写`o`.`order`,PostgreSQL里写"o"."order"。这个配置灵活性要在注册表里留出来。 - 排序NULL值的位置:MySQL里
NULL默认排在最前面(升序时),PostgreSQL默认排在最后,Oracle也是默认最后。如果产品对空值排序有特殊要求,需要拼接NULLS FIRST或NULLS LAST。但注意MySQL 8.0之前不支持这个语法,需要兼容的话就只能保持默认行为。我在拦截器里通过一个配置项控制是否需要追加NULLS LAST,默认关闭。 - 多租户或Schema前缀:公共库查询有时会带Schema前缀,比如
FROM public.t_order。我的表名解析正则已经支持了schema.table这种格式,但注册表的key要写成完整前缀,匹配才能生效。
这些差异不需要在拦截器里写大量判断,我的做法是:注册表的列名配置完整包含方言相关的包装符号,拦截器只是透明地把它拼到ORDER BY后面。方言的差异性由配置去消化,代码逻辑保持平台无关。
4.2 单元测试:用H2数据库模拟真实场景
做这种SQL改写的功能,不写单元测试就是在给自己埋雷。我用H2数据库模拟了一套完整环境,覆盖表格里的几个关键场景:
| 测试场景 | 输入 | 期望结果 |
|---|---|---|
| 白名单命中 | sortField=createTime, sortOrder=DESC | ORDER BY o.create_time DESC |
| 白名单未命中 | sortField=userName | 降级为 ORDER BY o.id ASC |
| 非法排序方向 | sortField=id, sortOrder=DROP | 降级为 ORDER BY o.id ASC |
| 已存在ORDER BY | SQL含ORDER BY o.status ASC |
替换为新的ORDER BY o.create_time DESC |
| SQL末尾含LIMIT | SQL含LIMIT 20 |
ORDER BY ... LIMIT 20(顺序正确) |
| 子查询场景 | SQL含子查询 | 不做改写,原样返回 |
H2支持MySQL兼容模式,启动参数加MODE=MySQL,基本能模拟线上行为。测试代码里直接跑真实Mapper,验证结果集顺序对不对。
有一个用例我印象特别深:替换已有ORDER BY时,如果原SQL里ORDER BY出现在子查询里,而我却没有跳过,结果把子查询的ORDER BY替换掉,外层查询反而没有排序。这就是我前面说的“带子查询直接跳过”策略的来源。测试用例逼着我把这个边界补上了。
4.3 性能验证与线上表现
防注入只是解决了安全问题,索引兜底是为了性能。我在一个1000万行的订单表上做了验证:
- 排序字段为白名单中的索引列
create_time:SQL执行耗时稳定在80ms到120ms,走索引扫描,没有filesort。 - 排序字段为非白名单字段(如
total_amount无索引):如果业务上必须支持,就在这个列上补索引;如果业务可接受,则让它降级到主键排序。在拦截器里,我通过扩展配置allowNonIndexSort=false,让这类列直接降级主键。 - 恶意传参(子查询、函数嵌套):被白名单机制拦截,SQL根本不会拼上这些内容。
上线运行两周,核心列表接口的P95耗时从原来的900ms降到了180ms左右。更重要的是,监控里不再出现因为用户乱传排序字段而导致的慢SQL告警。
5. 踩坑清单与后续扩展思路
5.1 我在实现过程中踩过的坑
这套拦截器前前后后改了四版,好多坑都是实测才暴露出来的。
第一个坑就是RoutingStatementHandler的包装问题。第一版我直接强转StatementHandler拿BoundSql,结果在分页插件配合的时候,拿到的SQL是已经被分页插件处理过的,排序的位置不对,导致分页结果集顺序错误。后来改成通过MetaObject拆出delegate,问题解决。这也印证了:拦截器的执行顺序必须仔细推演,不能想当然。
第二个坑是表名解析的误伤。项目里有一条SQL是SELECT xxx FROM (SELECT ... FROM t_order) t WHERE ...,主表解析居然把子查询里的t_order当作主表,于是排序字段被替换到内层子查询去了,外层排序丢失,列表顺序完全乱了。后来加了“FROM之前有左括号则跳过”的保护逻辑。这个保护会牺牲一部分复杂SQL的动态排序能力,但换来的是稳定和安全,值。
第三个坑是MyBatis缓存相关的。项目里开了二级缓存,最初我担心修改BoundSql会不会影响缓存Key。翻源码确认了:缓存Key在Executor.query里通过createCacheKey生成,而它使用的是执行阶段的BoundSql。我们的拦截器在prepare阶段修改SQL,早于缓存Key的生成,所以缓存Key对应的是新SQL,不会出现缓存错乱。但如果你在别的拦截器里也修改BoundSql,就要特别注意拦截顺序,最好用@Intercepts的注解顺序定义明确。
第四个坑是参数提取时,POJO里没有sortField属性的情况。第一版反射代码直接抛异常,导致所有不带排序参数的查询全部报错。后来改成try-catch包裹并返回null,还要在日志里打成debug级别,避免无意义的告警刷屏。
5.2 联动思考:和MyBatis动态SQL、批量插入等场景的关系
这次做排序拦截器,也让我重新梳理了MyBatis的职责边界。像<if>这种动态SQL,是MyBatis在解析阶段就处理掉的,我们拦截器看到的是最终拼接好的SQL。所以如果XML里有动态排序的<choose>分支,和我们拦截器的逻辑会重复,建议把排序部分从XML里全部移除,统一交给拦截器。这样SQL文件干净很多,排序规则也收敛到一处。
对于MyBatis-Plus的批量插入、逻辑删除这类特性,和排序拦截器本身没有冲突,但有一个值得注意的点:逻辑删除字段如果被配置成了排序白名单,查出来的数据顺序可能不符合预期,因为逻辑删除的数据通常是历史数据,时间字段含义可能比较特殊。这属于业务语义问题,不是技术问题,配置白名单时需要业务方一起确认。
5.3 这套方案还能扩展出什么
全局拦截器已经把“排序参数 -> 安全SQL”这个链路跑通了,基于同样的位置和参数,其实可以延伸更多能力:
- 字段级权限控制:不同角色只允许按特定字段排序,比如普通用户只能按创建时间排序,管理员才能按金额排序。注册表里再加一个角色维度即可。
- 敏感字段脱敏排序:有些字段需要脱敏展示,比如手机号前几位打码,这种字段不可直接排序(排序结果会泄露信息)。拦截器可以在白名单阶段就把这类字段挡掉,强制降级主键。
- 排序审计:在拦截器里记录每次动态排序的字段、方向、SQL、调用来源,方便后续分析哪些字段真的被高频使用,反过来指导索引建设。
这些扩展都不需要改业务代码,只需要在拦截器的配置和日志层面加强,这是统一收口方案最大的杠杆效应。
最后再分享一点实操体会:做这种全局拦截器,一定要把“降级”思想贯穿始终。很多时候我们设计安全方案,容易走极端——要么不拦,要么直接拒绝。但在真实系统里,最稳的做法是给一个安全的默认行为。拦截器兜底到主键排序,看起来只是一个小小的设计决策,却让整套方案在上线过程中几乎零成本地平滑落地,没有引发一次因为排序字段不合法导致的接口报错。如果你的系统也在被动态排序的安全和性能问题困扰,这个思路可以直接拿去用,你会回来感谢我的。
