☰
MyBatis数据权限控制实战:基于JSqlParser拦截SQL注入权限条件
2026/9/30 17:45:02 网站建设 项目流程

简介:基于Mybatis实现数据权限控制的原理与落地方法文档,适合使用Mybatis和MySQL的Java后端开发者,尤其面向To B系统需要按角色动态过滤数据集的场景。文档先从认证与授权、RBAC模型谈起,明确URL、界面元素和数据三类资源;随后重点讲解依托Mybatis Plugin机制实现数据权限控制的过程,包括拦截Executor、StatementHandler等接口,借助jsqlparser解析SQL,通过PermissionRule保存角色与过滤规则,在查询执行前自动拼装条件,做到无需也不能由研发编码控制。还对方案优缺点做了分析:减少接口复杂度、提高灵活性,但依赖Mybatis和特定数据库方言。包内为1个PDF文件,大小93KB,内容紧凑,适合想快速了解思路的读者。该资源已有4592人学习,可作为数据权限控制设计的参考。

1. 为什么数据权限控制不能只靠菜单:一次真实的“越权查询”翻车

去年做的一个 To B 订单系统上线第二周,运营反馈说“助理角色能导出全公司对账单”。排查下来,菜单和按钮都控制住了,但底层那个select * from order where status = ?的接口,助理角色一样能调,只是前端按钮没显示而已。这就是 Java + Mybatis 项目里最典型的数据权限漏洞:前端菜单挡住的是入口,挡不住数据本身。我们最终用 Mybatis 插件机制 + jsqlparser 在 SQL 层做了数据权限控制,同一个请求方法,不同角色返回不同数据集,而且研发代码里完全不用写if (role == xxx)这种判断。这篇文章把这套方案的模型设计、拦截器实现、SQL 解析细节和踩过的坑完整拆一遍,适合已经在用 Mybatis + MySQL 的后端团队参考。

2. 控制模型先行:PermissionRule 的四个字段怎么设计、规则表怎么落库

2.1 四个字段各管什么:roles、fromEntity、exps、ruleComment

数据权限控制的第一步不是写代码,而是把规则模型定下来。我们项目里最核心的实体类是PermissionRule,字段非常克制,只有四个,但每一个都直接对应 SQL 拼接的某个环节。

public class PermissionRule { private static final Log log = LogFactory.getLog(PermissionRule.class); /** * 适用角色列表 * 格式如: ,RoleA,RoleB, */ private String roles; /** * 主实体,多表联合时匹配的表名 * 格式如: ,SystemCode,User, */ private String fromEntity; /** * 过滤表达式字段 * {uid} 替换为当前用户的 userId * {bid} 替换为当前用户的 businessId * {me} main entity 主实体名称 * {me.a} main entity alias 主实体别名 */ private String exps; /** * 规则说明 */ private String ruleComment; // getter / setter 省略 }

这里最容易被忽略的是roles和fromEntity为什么设计成带前后逗号的字符串。因为匹配逻辑用的不是 equals,而是indexOf("," + roleStr + ","),这样一条规则可以同时挂多个角色、多张表,匹配时不会出现RoleA误伤RoleAB的问题。这种设计在做规则加载时非常省事,一条配置就能覆盖“主管和经理都能看本部门订单”这种场景,不用拆成多条记录。

2.2 exps 表达式里的占位符约定:{uid}、{bid}、{me}、{me.a}

exps是整个模型的核心,它本质上是一段 SQL 片段,通过四个占位符在运行时被替换成真实值。表结构设计好之后,我一般会先把规则表落库,规则内容直接写在rule_config表里,而不是写死在 Java 代码中,这样权限调整时只需要 DBA 改一条配置,不需要发版。

举几个实际配置例子,第一个是“用户只能看自己的订单”:

roles: ,ROLE_USER, fromEntity: ,sys_order, exps: create_user = {uid}

第二个是“部门主管可以看本部门订单,但不能看其他部门”:

roles: ,ROLE_MANAGER, fromEntity: ,sys_order, exps: dept_id in (select dept_id from sys_dept where manager_id = {uid})

第三个是多表场景:「订单主表 + 订单明细表」的 join 查询,规则直接写在明细表的 on 条件上:

roles: ,ROLE_MANAGER, fromEntity: ,sys_order_detail, exps: {me.a}.create_user = {uid}

这里的{me}和{me.a}设计得非常巧妙。{me}表示主实体名称,{me.a}表示主实体别名。SQL 解析时如果主表没写别名,{me.a}就会被替换成表名本身,从而避免出现sys_order.create_user = 1这种带表名却漏掉别名的语法错误。实际写规则时,我强烈建议统一给主表起别名,否则 join 场景下条件拼接很容易翻车。

提示:所有占位符的替换逻辑都必须集中在 PermissionHelper 的同一个方法里完成,不要散落到业务代码中,否则后续排查“为什么这条 SQL 没注入权限条件”时会非常痛苦。

2.3 规则如何加载与匹配:从数据库到运行时筛选

规则在系统启动时加载进内存,运行时不查库,否则每个请求都查一次权限表,性能上完全扛不住。规则表我一般这样设计:

字段类型说明
idbigint主键
role_codesvarchar(255)角色编码,前后带逗号
entity_tablesvarchar(255)匹配的表名,前后带逗号
expr_sqltext过滤表达式,含占位符
rule_commentvarchar(255)规则说明
statustinyint1 启用,0 停用

加载完规则后,每次请求进来,拦截器先拿当前用户的角色列表去匹配roles字段,再拿 SQL 里解析出来的表名去匹配fromEntity字段,两个条件同时命中的规则才参与条件拼接。注意一个用户可能命中多条规则,所以PermissionHelper里接收的是一个List<PermissionRule>,循环遍历逐条匹配,而不是只处理一条。这个加载逻辑看起来简单,但实际项目里最容易出问题的恰恰是这里,后面的避坑章节我会专门讲“多条规则互相覆盖”的坑。

3. 拦截器落地:基于 MyBatis Executor 插件注入权限条件

3.1 插件签名与生命周期:@Intercepts 拦截 update 和 query

先明确一点:Mybatis 插件机制允许拦截 Executor、ParameterHandler、ResultSetHandler、StatementHandler 这四个接口的若干方法。数据权限控制要改的是最终执行的 SQL,所以拦截点放在 Executor 上最关键。我们看一下 PermissionHelper 的头部定义:

@Intercepts({ @Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class}), @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}) }) public class PermissionHelper implements Interceptor { // 逻辑在 intercept() 中处理 }

@Signature里的method = "update"实际上对应 Mybatis 的更新操作(Update、Delete 都走这个方法),method = "query"对应查询操作(Select)。拦截到之后,我们拿到了MappedStatement、参数对象、RowBounds和ResultHandler,其中MappedStatement里可以取出BoundSql,进而取得原始 SQL 字符串。拿到 SQL 之后再根据SqlCommandType判断是查询还是更新,分别走不同的处理方法。

这里有一个实操细节要注意:插件拦截到query时,RowBounds参数用来支持分页,如果项目里还接了 PageHelper,两个插件会形成一条拦截链,顺序不同结果完全不同。我的习惯是把数据权限插件放在 PageHelper 之前,这样分页插件拿到的是已经注入完权限条件的 SQL,count查询也会自动带上权限条件,否则会出现“列表带了权限过滤但总数没过滤”这种玄幻问题。

3.2 processSelectSql 完整实现:解析、匹配、替换、拼接

核心处理逻辑就是这个processSelectSql方法,它完成四件事:解析 SQL、识别主表、匹配规则、注入条件。完整代码见下方,这是我从线上项目里整理出来的版本,去掉了部分业务无关的工具调用:

private String processSelectSql(String sql, List<PermissionRule> rules, UserPrincipal principal) { try { String replaceSql = null; // 1. 解析SQL为Select对象 Select select = (Select) CCJSqlParserUtil.parse(sql); PlainSelect selectBody = (PlainSelect) select.getSelectBody(); String mainTable = null; // 2. 识别主表:from 后面跟的是普通表还是子查询 if (selectBody.getFromItem() instanceof Table) { mainTable = ((Table) selectBody.getFromItem()).getName().replace("`", ""); } else if (selectBody.getFromItem() instanceof SubSelect) { // 子查询作为主表时,递归处理子查询内部的SQL replaceSql = processSelectSql( ((SubSelect) selectBody.getFromItem()).getSelectBody().toString(), rules, principal); if (!StringUtils.isEmpty(replaceSql)) { sql = sql.replace( ((SubSelect) selectBody.getFromItem()).getSelectBody().toString(), replaceSql); } } // 3. 获取主表别名 String mainTableAlias = mainTable; try { mainTableAlias = selectBody.getFromItem().getAlias().getName(); } catch (Exception e) { log.debug("当前sql中, " + mainTable + " 没有设置别名"); } String condExpr = null; // 4. 遍历规则,匹配角色和主表 for (PermissionRule rule : rules) { for (Object roleStr : principal.getRoles()) { if (rule.getRoles().indexOf("," + roleStr + ",") == -1) { continue; } // 4.1 主表匹配规则 if (rule.getFromEntity().indexOf("," + mainTable + ",") != -1) { condExpr = rule.getExps() .replace("{uid}", principal.getUserId().toString()) .replace("{bid}", principal.getBusinessId().toString()) .replace("{me}", mainTable) .replace("{me.a}", mainTableAlias); if (selectBody.getWhere() == null) { selectBody.setWhere(CCJSqlParserUtil.parseCondExpression(condExpr)); } else { selectBody.setWhere(new AndExpression( selectBody.getWhere(), CCJSqlParserUtil.parseCondExpression(condExpr))); } } // 4.2 主表不匹配时,匹配join表 try { for (Join j : selectBody.getJoins()) { if (rule.getFromEntity().indexOf("," + ((Table) j.getRightItem()).getName() + ",") != -1) { String joinTable = ((Table) j.getRightItem()).getName(); String joinTableAlias = j.getRightItem().getAlias().getName(); condExpr = rule.getExps() .replace("{uid}", principal.getUserId().toString()) .replace("{bid}", principal.getBusinessId().toString()) .replace("{me}", joinTable) .replace("{me.a}", joinTableAlias); if (j.getOnExpression() == null) { j.setOnExpression(CCJSqlParserUtil.parseCondExpression(condExpr)); } else { j.setOnExpression(new AndExpression( j.getOnExpression(), CCJSqlParserUtil.parseCondExpression(condExpr))); } } } } catch (Exception e) { log.debug("当前sql没有join的部分!"); } } } // 5. 兼容分页写法 if (sql.indexOf("limit ?,?") != -1 && select.toString().indexOf("LIMIT ? OFFSET ?") != -1) { sql = select.toString().replace("LIMIT ? OFFSET ?", "limit ?,?"); } else { sql = select.toString(); } } catch (JSQLParserException e) { log.error("change sql error .", e); } return sql; }

这段代码有几个关键点需要重点说明。

第一,第 4.1 步用的是setWhere+AndExpression,效果是把权限条件作为额外的AND分支追加到原查询条件之后。比如原 SQL 是select * from sys_order where status = 1,注入后的结果是where status = 1 and create_user = 1001。如果原 SQL 没有where,就直接setWhere(parseCondExpression(condExpr))。

第二,第 4.2 步处理的是from后面主表不匹配、但 join 表命中的情况。这里注入条件是加到on表达式上而不是 where 上,这样在多表关联时能保证过滤条件作用于关联后的结果集,不会因为 join 的语义差异导致数据漂移。注意joinTableAlias获取时getAlias().getName()没有做判空,实际项目里 join 表没写别名时会抛 NPE,被外层 catch 吞掉,导致这条规则静默失效,这个坑在避坑章节会再讲。

第三,第 5 步的分页兼容逻辑是给那些同时使用limit ?,?和LIMIT ? OFFSET ?两种分页风格的项目准备的。jsqlparser解析后输出的是标准写法LIMIT ? OFFSET ?,但老版本的 Mybatis 分页参数绑定写的是limit ?,?,两者混用会导致分页参数位置错乱。这里检测到原 SQL 带limit ?,?就强制将解析结果还原回这种写法。

3.3 update/delete 语句怎么处理:条件拼接的不同策略

select 语句通过setWhere注入条件,update 和 delete 就不能这么简单粗暴了。我们线上项目的常见做法是:在 update/delete 的 where 后面也追加and (权限条件),但前提是必须保证原 SQL 有 where 子句。如果一个 delete 语句没有写 where,权限插件一定不能自作主张给整表删除加条件,否则会导致业务逻辑静默变更。

常见处理方式是这样:

private String processUpdateSql(String sql, List<PermissionRule> rules, UserPrincipal principal) { try { Statement statement = CCJSqlParserUtil.parseStatements(sql).getStatements().get(0); if (statement instanceof Update) { Update update = (Update) statement; String tableName = update.getTable().getName().replace("`", ""); // 查找匹配该表的规则并拼接条件 for (PermissionRule rule : rules) { if (rule.getFromEntity().indexOf("," + tableName + ",") == -1) { continue; } String condExpr = rule.getExps() .replace("{uid}", principal.getUserId().toString()) .replace("{me}", tableName) .replace("{me.a}", tableName); if (update.getWhere() == null) { throw new RuntimeException("数据权限拦截失败:update语句缺少where条件, table=" + tableName); } update.setWhere(new AndExpression(update.getWhere(), CCJSqlParserUtil.parseCondExpression(condExpr))); return update.toString(); } } // delete 语句同理 } catch (JSQLParserException e) { log.error("change update sql error .", e); } return sql; }

这里我故意让“无 where 的 update 抛出异常”,而不是放行。因为这类 SQL 往往是代码里漏写了条件,一旦放行并且被权限条件误伤,就会变成“你以为删了一条,实际删了全表”。宁可让运维在日志里看到异常,也不能让权限插件把一个危险的 SQL 变成合法的批量操作。这个取舍在真实项目里救过我们一次。

4. JSqlParser 在做什么:主表、join 表与条件注入的细节

4.1 为什么 SQL 解析选 JSqlParser:文本替换的边界

做数据权限控制之前,最容易想到的方案是字符串匹配:拿规则里的表名去sql.contains(tableName)判断,然后直接在 sql 后面追加条件。这个方案在单表单条件时能用,但一旦涉及多表 join、子查询、表别名,就会全面崩盘。比如select * from sys_order o join sys_user u on o.user_id = u.id,如果规则是sys_user表,你用字符串匹配会同时命中sys_order和sys_user,最后把条件同时注入到两张表上,数据直接被过滤没了。

JSqlParser 的价值在于它把 SQL 拆成了结构化对象:Select、PlainSelect、Table、Join、Expression。我们操作的是内存里的对象图,而不是文本,所以能精确知道哪张表是主表、哪张表是 join 表、当前 where 条件是什么、on 条件是什么。这也决定了方案的技术边界:

能力支持情况说明
单表查询支持通过 from 识别表,把权限条件追加到 where
多表 join支持按 join 右表匹配规则,条件加到 on
嵌套子查询部分支持子查询作为主表时需递归处理,有坑
union 查询不支持解析结构无法简单映射为主表
数据库函数/特殊语法不稳定某些 MySQL 方言会导致解析异常

页面展示的这套方案只适配了 MySQL 的常见 SQL 形态,如果业务里大量使用了union、FOR UPDATE、STRAIGHT_JOIN等特殊语法,需要额外扩展解析逻辑,否则就会走进避坑章节里说的“解析翻车”场景。

4.2 主表与 join 表的匹配逻辑:fromEntity 到底匹配谁

规则配置里fromEntity写的是数据库表名,而且是蛇形命名。比如实体类叫SysOrder,数据库表是sys_order,那fromEntity必须写成,sys_order,,不能写驼峰格式,因为解析器从 SQL 里拿到的就是表名,不是实体类名。

主表和 join 表的识别逻辑用一段代码来区分:

// from 后面直接跟的表是主表 if (selectBody.getFromItem() instanceof Table) { mainTable = ((Table) selectBody.getFromItem()).getName().replace("`", ""); } // join 后面跟的表是 join 表 for (Join j : selectBody.getJoins()) { Table joinTable = (Table) j.getRightItem(); String joinTableName = joinTable.getName().replace("`", ""); }

实际开发中最容易踩的坑是:业务系统统一给所有 SQL 都加了反引号,像select * from \sys_order` where ...,如果PermissionRule.fromEntity配置的是sys_order,代码里不做replace("", "")处理的话,匹配永远失败。所以上面代码在拿到表名后的第一件事就是去掉反引号,这个细节不写出来,新接手的同事一定会在这个位置 debug 一整天。

4.3 where 与 on 的条件合并:AndExpression 的用法和坑

条件合并的核心工具是AndExpression。它的语义是“两个条件同时成立”,等价于 SQL 里的AND (expr1 AND expr2)。需要特别注意的是:权限条件必须用括号包裹,否则遇到多条件组合时会出现运算优先级错误。

比如原 where 是status = 1 OR type = 2,权限条件直接代入status = 1 OR type = 2 AND create_user = 1001,实际执行时因为 AND 优先级更高,会先算type = 2 AND create_user = 1001,导致查询结果把status = 1 AND type != 2的数据也放出来了。我们的处理方式是把权限表达式原样放入AndExpression,并且要求配置的exps自带括号,比如:

roles: ,ROLE_MANAGER, fromEntity: ,sys_order, exps: (dept_id = {uid} OR dept_id in (select dept_id from sys_dept where manager_id = {uid}))

这类复杂表达式在加上AndExpression之后,生成的 SQL 会是:

where status = 1 AND (dept_id = 1001 OR dept_id in (select dept_id from sys_dept where manager_id = 1001))

只要exps配置的表达式自身结构完整,用AndExpression合并就不会出现语义变化。反过来,如果你把exps写成dept_id = {uid} OR dept_id in (...)这种不带括号的形式,注入后的 SQL 语义就完全错了。所以规则配置必须约定“表达式自包含括号”,在写入规则表时就校验格式,不要等到运行时才发现。

5. 避坑手册:数据权限插件在真实项目里翻车的六个瞬间

5.1 复杂 SQL 解析直接翻车:union 和特殊函数让权限静默失效

现象:某条业务 SQL 用了union all合并两个查询,上线后这条接口完全没有权限过滤,任何角色都能查全量数据。排查时发现日志里没有抛异常,但processSelectSql返回的还是原 SQL。

原因:JSqlParser 解析union时,select.getSelectBody()返回的不是PlainSelect而是SetOperationList,代码里直接强转(PlainSelect)抛了ClassCastException,被最外层 catch 捕获后静默返回原 SQL。

解决:在强转前加类型判断,遇到SetOperationList时拆出每个 Select 单独处理,或者直接记录告警日志并返回原 SQL。我们的选择是拆出每个Select递归调用processSelectSql,保证 union 的两个分支都注入条件,前提是每条分支都要能匹配到规则。

5.2 表名带别名后规则匹配不到

现象:SQL 写成select * from sys_order so where ...,规则fromEntity配置的是,sys_order,,但日志里解析出来的mainTable始终带别名,匹配失败。

原因:selectBody.getFromItem()返回的Table对象里,getName()返回的是表名,getAlias()才是别名。但部分版本里如果表名和别名解析方式有差异,直接getName().replace("","")之后拿到的可能是带 schema 前缀的完整名字,比如db.sys_order`。

解决:拿到表名后用.toLowerCase()并做一次split("\\.")取最后一段,再参与规则匹配。规则配置统一小写,避免大小写不一致导致indexOf失败。

5.3 多条规则命中时,后面的覆盖前面的

现象:用户同时拥有ROLE_MANAGER和ROLE_ADMIN,配置表里两条规则都匹配sys_order表,结果实际生效的只有其中一条,另一条被静默覆盖。

原因:看processSelectSql的循环,rules遍历中没有在匹配成功后break,而是继续循环,后面的规则再次命中时直接覆盖了condExpr变量,但 SQL 只注入了一次。

解决:最稳妥的做法是“每条匹配成功的表只注入第一条规则并立即break”。如果确实需要多条规则的过滤条件取交集,就把多个exps用AND拼接成一个完整的condExpr后再注入。线上项目里我们限定为一条规则命中后就短路,规则设计上尽量合并同类项。

5.4 分页插件导致 limit 参数错乱

现象:项目同时用了 PageHelper 和数据权限插件,列表接口第 2 页起显示的数据错乱,总数和明细对不上。日志里 SQL 变成了limit ? offset ?,?,参数绑定明显错位。

原因:jsqlparser 解析后再生成 SQL 时,会把limit ?,?改写成标准LIMIT ? OFFSET ?,但 Mybatis 的BoundSql参数映射还停留在原来的limit ?,?占位符位置,导致参数错位。代码里的第 5 步实际上就是在做这种兼容。

解决:保留limit ?,?与LIMIT ? OFFSET ?的互转逻辑,同时把数据权限插件放在分页插件前执行,确保分页插件解析到的已经是注入后的 SQL。上线前写一个专门的测试用例,覆盖第 2 页、第 3 页的分页场景。

5.5 子查询作为主表时规则完全失效

现象:SQL 是select * from (select * from sys_order where status = 1) t,配置的fromEntity匹配sys_order却始终不生效。

原因:fromItem不是Table而是SubSelect,代码走的是递归处理分支,mainTable仍然是 null,后面主表匹配和 join 匹配全部跳过。

解决:子查询分支里不依赖mainTable,而是递归处理子查询后把替换后的 SQL 整个写回原 SQL。要注意递归后sql.replace的字符串必须和原 SQL 完全一致,包括空格和大小写,否则替换不成功。

注意:这个坑在原文代码中就存在,我在线上改造时把子查询分支单独抽出,做了单元测试覆盖,才彻底解决。以后凡是遇到“权限条件怎么也加不上”的问题,先检查 SQL 里有没有嵌套子查询。

5.6 {uid} 取不到或取错用户:异步线程丢失登录上下文

现象:定时任务、MQ 消费线程里执行的 SQL 完全没有权限过滤,或者过滤条件用的是上一个用户的信息。

原因:登录用户信息存在 ThreadLocal 里,线程池中的线程复用时,ThreadLocal 没有清理,导致拿到脏数据。异步场景下新线程根本没有 set 过用户信息,principal为 null。

解决:intercept()方法第一行就做空值校验,拿不到用户上下文时直接抛业务异常,不允许静默放行。使用线程池时在任务的 finally 块中清理ThreadLocal,或者从请求头、MQ 消息体中显式传递 userId,不依赖隐式上下文。集群环境下可以考虑把用户上下文放到 Redis,按requestId存取。

6. 验证方法:改完代码怎么证明数据权限真的生效了

6.1 前置条件:把 MyBatis 日志打开,SQL 原形毕露

数据权限这种底层拦截,肉眼是看不到效果的,必须通过日志确认注入后的 SQL 长什么样。在application.yml里配置:

mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

运行后控制台会输出每条 SQL 的完整形式。构造一个ROLE_MANAGER的测试账号,请求订单列表,看到 SQL 里出现and create_user = 1001这样的条件,就说明插件生效了。

6.2 典型测试用例:同接口不同角色,断言 SQL 的 where 部分

推荐直接用现有单测框架,写一个专门验证权限插件的用例。核心思路是:同一个 Mapper 方法,在两个线程里分别模拟不同角色的用户上下文,调用一次查询,然后用一个SQLInterceptor收集实际执行的 SQL,断言 where 条件是否包含规则中的过滤片段。

@Test public void testDataPermission() { // 模拟两个角色的登录上下文 UserContext.set(UserContext.of(1001L, ",ROLE_MANAGER,")); List<Order> managerList = orderMapper.selectList(new QueryWrapper<>()); UserContext.clear(); UserContext.set(UserContext.of(1002L, ",ROLE_USER,")); List<Order> userList = orderMapper.selectList(new QueryWrapper<>()); UserContext.clear(); // 断言两个角色返回的数据集不同 Assertions.assertNotEquals(managerList.size(), userList.size()); }

这一步能验证功能是否正确。下一步上线前检查清单:

检查项判断依据
count 查询是否也带了权限条件日志中select count(*)是否包含注入条件
分页插件是否生效第 2 页数据与总数一致
join 查询的 on 条件日志中 join 的 on 后面是否追加了过滤条件
子查询场景内层查询是否命中规则
update/delete 无 where 是否被拦截是否有 RuntimeException 抛出

6.3 压测与上线前检查清单

插件是在 SQL 执行链路里做字符串解析和替换的,jsqlparser 解析本身有开销。我们的压测数据显示,在 QPS 3000 的接口上,注入权限条件之后 P99 延迟增加约 8ms,对绝大多数 To B 系统完全可接受。但如果你的系统是每秒上万 QPS 的 To C 场景,建议在插件入口加一个开关,通过配置中心动态开启和关闭,压测时先全量放行,确认瓶颈不在插件层再打开。

还有一个习惯想分享:从那以后我每次上线涉及权限的改动,都会强制走一遍“两个角色跑同一个接口,比对日志中 SQL 和返回数据集”的测试流程。数据权限这种功能,一旦上线出问题,不是功能不可用,而是数据泄露,性质完全不同。希望这套方案和踩坑记录能帮你把数据权限控制这条路走得稳一点。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询