news 2026/11/4 3:33:58

RuoYi数据权限控制实战:车间设备台账从越权到精准可见

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
RuoYi数据权限控制实战:车间设备台账从越权到精准可见

1. 数据权限控制为什么总在车间设备管理上翻车

车间设备台账这个模块,我在好几个项目里都碰到过同一个问题:设备列表页明明只该显示本车间的三十几台设备,登录之后却把全厂两千多台一次性列了出来。功能权限配得整整齐齐,"设备查询"菜单谁都点得开,但点开之后能看到哪些数据,完全是另一回事。这正是RuoYi 框架里数据权限控制要解决的核心问题,而车间设备管理恰好是这个问题暴露得最彻底的场景之一。

设备管理的麻烦在于它的数据归属是"多重"的:一台冲压机既属于某个车间(部门维度),又归属某个设备工程师(人员维度),还可能被借给邻车间共用(共享维度),更要命的是,车间主任、设备科长、班组长、维修工这四个角色,对同一批设备应该看到的范围完全不同。功能权限管的是"能不能进这个页面",数据权限管的是"进去之后能看到哪几行",两件事混在一起做,代码很快就会烂掉。

这篇内容面向的是正在用 RuoYi 做企业级后台、并且遇到了"数据越权可见"或"数据权限不生效"的开发同学。我会从框架的底层切面机制讲起,把@DataScope注解、DataScopeAspect切面、${params.dataScope}占位这套链路彻底拆开,然后以车间设备台账为样本,给出从建表、写 Mapper、加注解、配角色到验证的完整可复现步骤。中间会穿插我自己踩过的坑,比如"仅本人"档位查不出数据、别名写错导致 SQL 报错、前端传参把 SQL 片段注入进来这类真问题。看完你应该能直接把这套东西搬到自己的业务表上,不用再翻框架源码猜。

先把结论摆出来:RuoYi 的数据权限不是"自动"的,它是半自动的——框架负责把权限条件拼成一段 SQL 片段塞进参数里,但这段片段能不能正确生效,完全取决于你在 Mapper XML 里有没有正确埋占位符、表别名有没有对上、Service 方法有没有加注解。中间任何一个环节断掉,数据权限就是静默失效,不会报错,也不会提示,你只会看到列表里多出来的那些不该看到的设备。

2. RuoYi 数据权限的底层机制拆解

2.1 sys_role.data_scope 的五档数据范围

RuoYi 把数据权限的粒度收敛到了角色上,sys_role表里有一个data_scope字段,字符型,取值只有五个:

取值含义生效条件典型角色
1全部数据权限不做任何过滤系统管理员、设备总监
2自定义数据权限按sys_role_dept中配置的部门集合过滤跨车间协作的设备科长
3本部门数据权限只匹配dept_id = 当前用户部门车间主任
4本部门及以下数据权限匹配本部门及所有子孙部门分厂厂长
5仅本人数据权限只匹配user_id = 当前用户设备维修工、点检员

这个设计的聪明之处在于,它把"数据可见范围"抽象成了部门树和人员两个维度。绝大多数企业的组织架构本来就长成一棵树,设备、工单、物料、客户这些业务对象基本都能挂到某个部门节点上,所以五档够用。不够用的时候才走"自定义"档,用手工勾选的部门集合来兜底。

有个细节容易被忽略:data_scope是角色级的,而一个用户往往有多个角色。RuoYi 的处理方式是把用户所有角色的权限条件用OR连起来,取并集。也就是说,一个用户如果既是"车间主任"(本部门)又是"设备科长"(自定义了三个车间),那他最终能看到的就是本部门加上那三个车间的设备。这个并集逻辑是合理的,但也埋了个括号优先级的坑,后面会讲。

2.2 @DataScope 注解与 DataScopeAspect 切面

整套机制的入口是@DataScope注解,它有三个属性:

@Target({ ElementType.METHOD }) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface DataScope { /** 部门表的别名 */ String deptAlias() default ""; /** 用户表的别名 */ String userAlias() default ""; /** 权限字符(用于校验是否具备该权限) */ String permission() default ""; }

deptAlias和userAlias的作用是告诉切面:你拼出来的 SQL 条件里,部门字段和用户字段分别挂在哪个表别名上。这一点极其关键,也是新手最容易翻车的地方——如果你的设备查询 SQL 里部门表别名是d,注解里却写了deptAlias = "dept",切面会拼出dept.dept_id = ...,直接Unknown column报错。

切面类DataScopeAspect用@Before前置通知拦截带该注解的方法,核心流程分四步:

@Aspect @Component public class DataScopeAspect { public static final String DATA_SCOPE_ALL = "1"; public static final String DATA_SCOPE_CUSTOM = "2"; public static final String DATA_SCOPE_DEPT = "3"; public static final String DATA_SCOPE_DEPT_AND_CHILD = "4"; public static final String DATA_SCOPE_SELF = "5"; public static final String DATA_SCOPE = "dataScope"; @Before("@annotation(controllerDataScope)") public void doBefore(JoinPoint point, DataScope controllerDataScope) { clearDataScope(point); // 第一步:清掉历史残留 handleDataScope(point, controllerDataScope); // 第二步:重新拼装 } protected void handleDataScope(final JoinPoint joinPoint, DataScope controllerDataScope) { SysUser user = SecurityUtils.getLoginUser().getUser(); // 超级管理员直接放行 if (StringUtils.isNotNull(user) && !user.isAdmin()) { // 若注解声明了权限字符,需校验用户是否具备 String permission = controllerDataScope.permission(); if (StringUtils.isEmpty(permission) || SecurityUtils.hasPermi(permission)) { dataScopeFilter(joinPoint, user, controllerDataScope.deptAlias(), controllerDataScope.userAlias()); } } } }

clearDataScope这一步不是可有可无的。因为BaseEntity的params是个Map,同一个实体对象如果被复用于多次查询(比如先查列表再查详情),上一次拼进去的dataScope会残留下来,叠加成两段互相矛盾的 SQL。清理动作保证了每次都是干净的起点。

2.3 切面到底拼出了一段什么样的 SQL

真正的拼装逻辑在dataScopeFilter里,它遍历用户的所有角色,逐个判断data_scope取值,往StringBuilder里追加OR分支:

private void dataScopeFilter(JoinPoint joinPoint, SysUser user, String deptAlias, String userAlias) { StringBuilder sqlString = new StringBuilder(); for (SysRole role : user.getRoles()) { String dataScope = role.getDataScope(); if (DATA_SCOPE_ALL.equals(dataScope)) { sqlString = new StringBuilder(); // 全部权限,清空直接放行 break; } else if (DATA_SCOPE_CUSTOM.equals(dataScope)) { sqlString.append(StringUtils.format( " OR {}.dept_id IN ( SELECT dept_id FROM sys_role_dept WHERE role_id = {} ) ", deptAlias, role.getRoleId())); } else if (DATA_SCOPE_DEPT.equals(dataScope)) { sqlString.append(StringUtils.format(" OR {}.dept_id = {} ", deptAlias, user.getDeptId())); } else if (DATA_SCOPE_DEPT_AND_CHILD.equals(dataScope)) { sqlString.append(StringUtils.format( " OR {}.dept_id IN ( SELECT dept_id FROM sys_dept " + "WHERE dept_id = {} or find_in_set( {} , ancestors ) )", deptAlias, user.getDeptId(), user.getDeptId())); } else if (DATA_SCOPE_SELF.equals(dataScope)) { if (StringUtils.isNotBlank(userAlias)) { sqlString.append(StringUtils.format(" OR {}.user_id = {} ", userAlias, user.getUserId())); } else { sqlString.append(" OR 1=0 "); // 没有用户别名,只能查不到 } } } // 把 " AND ( ... ) " 存回 BaseEntity 的 params if (StringUtils.isNotBlank(sqlString.toString())) { Object params = joinPoint.getArgs()[0]; if (StringUtils.isNotNull(params) && params instanceof BaseEntity) { BaseEntity baseEntity = (BaseEntity) params; baseEntity.getParams().put(DATA_SCOPE, " AND (" + sqlString.substring(4) + ")"); } } }

substring(4)的作用是砍掉最早追加的那个" OR "前缀,让整段条件以一个干净的表达式开头,外面再包一层括号,最后加上AND前缀。假设当前用户部门 id 是 103,角色是"本部门及以下",拼出来的结果就是:

AND ( d.dept_id IN ( SELECT dept_id FROM sys_dept WHERE dept_id = 103 OR FIND_IN_SET(103, ancestors) ) )

sys_dept.ancestors存的是祖先链字符串,形如0,100,103。FIND_IN_SET(103, ancestors)判断 103 是否出现在这个逗号分隔串里,返回位置下标,为 0 表示不存在。这就是"本部门及以下"能用一条子查询搞定整棵子树的原因——不用递归,不用存储过程。代价是FIND_IN_SET用不上索引,部门表大到几千行时会有明显的性能衰减,后面性能部分会展开说。

2.4 ${params.dataScope} 为什么必须是 $ 而不是

Mapper XML 里的占位写法是整个链路最容易写错的一环:

${params.dataScope}

这里用的是$而不是#。#{}是预编译占位符,会被替换成?并走 JDBC 参数绑定;${}是字符串直接拼接,原样贴进 SQL。数据权限拼出来的是一段 SQL 结构,不是一个值,用#{}会被当成字符串字面量处理,整个条件就废掉了。

用${}的代价是注入风险。理论上,只要params.dataScope的值能被外部控制,就是一个 SQL 注入点。RuoYi 的防护是在clearDataScope里先把这个 key 删掉,再重新拼装,保证最终值是框架自己生成的。但这个前提是前端不能直接往 params 里塞东西——如果 Controller 的参数是BaseEntity子类,且没有对params做白名单过滤,攻击者完全可以通过params[dataScope]=xxx这样的参数名把自己的 SQL 片段带进来。这一点我在第 7 章会专门说怎么堵。

3. 车间设备台账的数据模型怎么设计

3.1 设备主表的字段规划

设备表的设计直接决定了数据权限能不能挂上去。核心原则只有一条:每一条业务数据都必须能明确回答"这条数据属于哪个部门、由谁负责"。答不上来,数据权限就无从谈起。

我用的设备主表结构大致是这样:

CREATE TABLE biz_equipment ( equipment_id BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '设备主键', equipment_code VARCHAR(64) NOT NULL COMMENT '设备编号,如 EQ-2024-0001', equipment_name VARCHAR(128) NOT NULL COMMENT '设备名称', model VARCHAR(64) DEFAULT NULL COMMENT '规格型号', dept_id BIGINT(20) NOT NULL COMMENT '归属部门ID', line_id BIGINT(20) DEFAULT NULL COMMENT '所属产线ID', manager_id BIGINT(20) DEFAULT NULL COMMENT '设备负责人用户ID', status CHAR(1) DEFAULT '0' COMMENT '状态(0正常 1停用 2维修中)', purchase_date DATE DEFAULT NULL COMMENT '购入日期', create_by VARCHAR(64) DEFAULT '' COMMENT '创建者', create_time DATETIME DEFAULT NULL COMMENT '创建时间', update_by VARCHAR(64) DEFAULT '' COMMENT '更新者', update_time DATETIME DEFAULT NULL COMMENT '更新时间', remark VARCHAR(500) DEFAULT NULL COMMENT '备注', PRIMARY KEY (equipment_id), UNIQUE KEY uk_equipment_code (equipment_code), KEY idx_dept (dept_id), KEY idx_manager (manager_id) ) ENGINE=InnoDB COMMENT='车间设备台账';

几个字段的取舍值得说明。dept_id是数据权限的挂载点,必须非空,且在建档时就要强制选择归属车间,不能留空——留空的设备在部门维度过滤下会彻底消失,变成"幽灵设备",谁都查不到,这种问题排查起来极其痛苦。manager_id是给"仅本人"档位用的,对应注解里的userAlias,维修工只能看到自己负责的设备,靠的就是这个字段。

line_id这个产线字段是我特意加的。很多企业的设备归属是"车间 → 工段/产线 → 设备"三层,而 RuoYi 的部门树天然只能表达"公司 → 分厂 → 车间"这种组织层级。把产线硬塞进部门树会污染组织架构,影响其他模块。更干净的做法是:权限按部门树过滤,产线作为普通业务条件筛选。也就是说,产线不参与数据权限判定,只参与用户主动选择的查询过滤,这个边界划清楚,后面会省很多事。

3.2 角色划分与数据范围映射

设备管理模块我一般会配四到五个角色,每个角色对应一档数据范围:

角色名称data_scope说明实际可见范围
设备管理员1 全部设备科最高权限全厂所有设备
设备科长2 自定义勾选重点车间指定若干车间的设备
车间主任4 本部门及以下含下属工段本车间 + 下属工段设备
班组长3 本部门只看本工段本工段设备
维修工/点检员5 仅本人只看自己负责的manager_id = 自己

这套映射有个隐含前提:组织架构里"工段"必须作为"车间"的子部门存在。如果企业实际是"车间下面没有工段这一层、产线独立管理",那"本部门及以下"档就退化成"本部门",班组长和车间主任看到的东西一样。这种情况要么调整组织架构,要么走自定义档位手工勾选。

注意:不要试图用"本部门及以下"去覆盖产线维度。部门树的一条子树是一条链,产线是横向切面,两者的交集用部门树表达不出来。硬要表达的后果是部门表膨胀、其他模块的部门下拉框变成几百项,用户体验直接崩掉。

3.3 BaseEntity 里的 params 是隐形载体

BaseEntity里那个params字段,是整个数据权限机制的运输通道:

public class BaseEntity implements Serializable { private static final long serialVersionUID = 1L; /** 请求参数 */ private Map<String, Object> params; public Map<String, Object> getParams() { if (params == null) { params = new HashMap<>(); } return params; } public void setParams(Map<String, Object> params) { this.params = params; } }

注意getParams()里的懒初始化。这个写法是有意为之:切面在往params里 put 之前必须调用一次getParams(),否则 NPE。如果某个实体类自己重写了getParams()且没做空判断,切面就会抛异常,而异常被 AOP 吞掉之后的表现就是"数据权限静默失效"。我在一个项目里就因为某个同事给实体加了 Lombok 的@Data又手写了 getter,导致params返回 null,排查了整整一个下午。

设备实体继承BaseEntity之后,既拿到了createBy/createTime/updateBy/updateTime/remark这些审计字段,也拿到了params这个运输通道,一举两得:

public class Equipment extends BaseEntity { private Long equipmentId; private String equipmentCode; private String equipmentName; private String model; private Long deptId; private Long lineId; private Long managerId; private String status; private Date purchaseDate; // 非表字段,用于列表展示 private String deptName; private String managerName; // getter / setter 省略 }

4. 把数据权限真正接到设备列表上的六个步骤

4.1 第一步:确认部门数据是干净的

这一步九成人会跳过,然后被坑。数据权限的所有条件最终都落在sys_dept上,如果部门表里的ancestors字段是错的,后面全白搭。

先跑一条检查 SQL:

-- 检查 ancestors 是否与实际父子关系一致 SELECT d.dept_id, d.parent_id, d.dept_name, d.ancestors FROM sys_dept d WHERE d.del_flag = '0' ORDER BY d.parent_id, d.order_num;

健康的ancestors长这样:根节点是0,一级部门是0,100,二级部门是0,100,101,依此类推。新增部门时 RuoYi 会自动维护这个字段,但如果有人直接改数据库、或者从旧系统导数据时手工填错,就会出现"某车间的设备永远查不到"或者"某个人能看到不该看的车间"这种诡异现象。

修数据的方法是从根往下重算一遍,用脚本递归更新,不要在页面上一个个点,几百个部门点不完。重算完了记得清一下部门缓存,RuoYi 的部门树在 Redis 里有缓存,不清的话要等过期才生效。

4.2 第二步:Mapper XML 里埋好占位符

设备列表的查询 SQL 要同时带上部门表关联和数据权限占位:

<select id="selectEquipmentList" parameterType="Equipment" resultMap="EquipmentResult"> SELECT e.equipment_id, e.equipment_code, e.equipment_name, e.model, e.dept_id, e.line_id, e.manager_id, e.status, e.purchase_date, e.create_by, e.create_time, e.remark, d.dept_name AS deptName, u.nick_name AS managerName FROM biz_equipment e LEFT JOIN sys_dept d ON e.dept_id = d.dept_id LEFT JOIN sys_user u ON e.manager_id = u.user_id <where> e.del_flag = '0' <if test="equipmentCode != null and equipmentCode != ''"> AND e.equipment_code LIKE CONCAT('%', #{equipmentCode}, '%') </if> <if test="equipmentName != null and equipmentName != ''"> AND e.equipment_name LIKE CONCAT('%', #{equipmentName}, '%') </if> <if test="deptId != null"> AND e.dept_id = #{deptId} </if> <if test="status != null and status != ''"> AND e.status = #{status} </if> <!-- 数据权限占位,必须放在 where 内部 --> ${params.dataScope} </where> ORDER BY e.equipment_code </select>

三个要点必须同时满足:占位符写在<where>标签内部;部门表别名是d,与后面注解里的deptAlias完全一致;用户表别名是u,与userAlias一致。

占位符为什么必须在<where>内部?因为切面拼出来的片段是以" AND ("开头的。<where>标签的智能之处在于会自动去掉开头多余的AND,所以片段放进去正好接上前面的条件。如果放到<where>外面,就成了FROM ... AND (...),语法直接爆炸。

4.3 第三步:Service 实现类上加注解

注解要加在Service 实现类的方法上,不能加在接口上,也不能加在 Mapper 上:

@Service public class EquipmentServiceImpl implements IEquipmentService { @Autowired private EquipmentMapper equipmentMapper; @Override @DataScope(deptAlias = "d", userAlias = "u") public List<Equipment> selectEquipmentList(Equipment equipment) { return equipmentMapper.selectEquipmentList(equipment); } @Override @DataScope(deptAlias = "d", userAlias = "u") public int selectEquipmentCount(Equipment equipment) { return equipmentMapper.selectEquipmentCount(equipment); } }

userAlias = "u"是给"仅本人"档位用的。如果一个方法只涉及设备表、没有关联用户表,那userAlias可以留空,但当用户角色恰好是"仅本人"时,切面会拼出OR 1=0,这个用户什么都查不到。这是设计上的合理行为(没有用户字段就没法判断"本人的数据"),但表现上容易被误判成 bug。所以只要业务上有"只看自己负责"的需求,SQL 里就必须关联用户表。

还有一个隐形陷阱:Spring AOP 基于代理,同类内部方法调用不走代理,注解不生效。如果你在EquipmentServiceImpl里写了个exportEquipment()方法内部调this.selectEquipmentList(),数据权限不会加进去。导出泄露全量数据的事故,十有八九是这么来的。

4.4 第四步:Controller 层避开分页的坑

Controller 的写法看着简单,但顺序不能错:

@RestController @RequestMapping("/biz/equipment") public class EquipmentController extends BaseController { @Autowired private IEquipmentService equipmentService; @PreAuthorize("@ss.hasPermi('biz:equipment:list')") @GetMapping("/list") public TableDataInfo list(Equipment equipment) { startPage(); // 必须在这里,紧跟 service 调用 List<Equipment> list = equipmentService.selectEquipmentList(equipment); return getDataTable(list); } }

startPage()做的是PageHelper.startPage(pageNum, pageSize),它把分页参数存进 ThreadLocal,拦截紧接着执行的第一条 SQL。所以startPage()和selectEquipmentList()之间不能插入任何其他查询。

我在一个项目里遇到过这样的写法:Service 的selectEquipmentList方法内部先查了一次产线字典做数据转换,再执行设备查询。结果分页作用在了字典查询上,设备列表变成了全量返回。表现是"分页失效 + 数据权限看起来也没了",实际只是分页错位引发的连带现象。这种问题的排查思路是先看 SQL 日志里分页参数挂在哪条语句上,很容易定位。

4.5 第五步:角色页面上配好数据范围

代码写完之后,必须到"系统管理 → 角色管理"里给每个角色设置数据范围。这一步是纯配置,但配错的概率极高,主要是两个地方:

第一个是"自定义数据权限"档位。选了这一档,页面上会出现一个部门树勾选框,必须勾选具体部门并保存到sys_role_dept表,否则这个角色的可见范围是空的——注意,是空,不是全部。很多同学选了"自定义"但忘了勾部门,登录后发现什么都看不到,以为是代码问题。

第二个是菜单权限和数据权限的对应关系。@DataScope注解本身可以带permission属性做二次校验,比如@DataScope(deptAlias = "d", userAlias = "u", permission = "biz:equipment:list")。带上之后,只有拥有该权限字符的用户才会触发过滤逻辑。这个设计是为了防止某些内部调用场景被误加条件,但如果你把权限字符写错了,过滤就永远不触发,表现为"数据权限完全没效果"。

4.6 第六步:用不同账号验证四种可见范围

验证这一步我建议认真做,别只用一个管理员账号点两下就完事。准备四个测试账号,分别绑不同角色,逐个登录看列表条数:

测试账号角色预期可见验证要点
设备管理员(全部)全厂 N 台条数等于总台账数
设备科长(自定义)勾选的 2 个车间条数等于这 2 个车间的设备数之和
车间主任(本部门及以下)本车间 + 下属工段切换部门层级看条数是否随之变化
维修工(仅本人)manager_id = 自己的设备条数应等于自己负责的设备数,可能为 0

验证"仅本人"这一档时特别容易出问题。如果列表里出现了别人的设备,先查 SQL 日志,看拼出来的是u.user_id = xxx还是1=0。如果是1=0且条数为 0,说明userAlias没传或者设备表和用户表没关联上。如果拼的是u.user_id但结果还是全量,那就是占位符没生效,回头检查 Mapper 里的${params.dataScope}是不是写成了#{params.dataScope}。

5. 复杂场景下的扩展做法

5.1 设备跨部门借用怎么处理

现实里设备是会流动的。A 车间的数控机床借给 B 车间用三个月,B 车间主任该不该在系统里看到它?按纯部门权限,看不到,因为dept_id还是 A 车间。业务上又确实需要看到,否则 B 车间没法做点检记录。

我用的方案是加一张共享表,把"临时可见"这件事从权限模型里剥离出来:

CREATE TABLE biz_equipment_share ( share_id BIGINT(20) NOT NULL AUTO_INCREMENT, equipment_id BIGINT(20) NOT NULL COMMENT '设备ID', dept_id BIGINT(20) NOT NULL COMMENT '被共享的部门ID', begin_time DATETIME DEFAULT NULL COMMENT '生效时间', end_time DATETIME DEFAULT NULL COMMENT '失效时间', create_by VARCHAR(64) DEFAULT '', PRIMARY KEY (share_id), KEY idx_equip (equipment_id), KEY idx_dept (dept_id) ) ENGINE=InnoDB COMMENT='设备跨部门共享';

关键问题来了:共享关系是"或"关系(本部门设备或共享给我的设备),而DataScopeAspect拼出来的片段是以AND开头的,直接拼进 SQL 会变成"且"关系,结果一条都查不出来。

正确做法是在切面里增加一个分支,把共享条件作为OR的一部分塞进括号内:

else if (DATA_SCOPE_SHARE.equals(dataScope)) { sqlString.append(StringUtils.format( " OR {}.equipment_id IN ( SELECT equipment_id FROM biz_equipment_share " + "WHERE dept_id = {} AND (end_time IS NULL OR end_time > NOW()) ) ", deptAlias, user.getDeptId())); }

注意这里deptAlias实际传的是设备表别名,因为过滤字段是equipment_id。这个写法有点别扭,更干净的是给@DataScope加一个bizAlias属性专门指向业务主表别名。不管用哪种,原则是:共享条件必须和其他OR分支在同一层括号里,否则会被AND的优先级吃掉。

5.2 按产线过滤而不是按部门

前面说过产线不参与数据权限,但如果企业规模小、产线就是核心管理单元,没有车间这一层,那就得让权限直接落到产线上。两条路。

第一条路是把产线做成部门树的叶子节点,dept_type标记为"产线"。好处是完全复用现有机制,一行切面代码都不用改;坏处是污染组织架构,部门下拉框、通讯录、审批流全都会被产线数据干扰。

第二条路是扩展切面,新增一种数据范围"按产线",把角色和产线的对应关系存到扩展表里:

CREATE TABLE sys_role_line ( role_id BIGINT(20) NOT NULL COMMENT '角色ID', line_id BIGINT(20) NOT NULL COMMENT '产线ID', PRIMARY KEY (role_id, line_id) ) ENGINE=InnoDB COMMENT='角色与产线关联';

切面里追加分支:

sqlString.append(StringUtils.format( " OR {}.line_id IN ( SELECT line_id FROM sys_role_line WHERE role_id = {} ) ", bizAlias, role.getRoleId()));

我倾向第二条路,虽然要多写几十行代码,但组织架构保持干净,长期维护成本低得多。用第一条路省下来的时间,后面会花在跟各部门解释"为什么通讯录里出现了产线"上。

5.3 多表关联时的别名管理

设备详情页往往要联五六张表:设备主表、部门表、用户表、产线表、维保记录表、备件表。这时候deptAlias传错一个字符,SQL 就报Unknown column 'dept.dept_id' in 'where clause'。

规范做法是给每个业务模块固定一套别名约定,写进团队文档:设备模块主表e、部门d、用户u;工单模块主表w、部门d、用户u;备件模块主表p、部门d、用户u。所有模块统一用d和u,注解里也统一写deptAlias = "d", userAlias = "u"。这样复制粘贴到任何模块都不会错,比每个模块自己发明一套别名靠谱得多。

还有个隐蔽问题:如果查询里出现了两个部门关联(比如"归属部门"和"使用部门"),d别名只能指向其中一个。这时候数据权限按哪个部门过滤,必须先和业务方确认清楚。含糊其辞的后果是上线后两边都觉得数据不对。

5.4 导出和统计报表千万别漏

列表加了数据权限,导出 Excel 却导出了全量数据,这是最典型的数据泄露事故。原因往往是导出方法走了另一个 Mapper 查询,而那个查询没埋${params.dataScope},或者 Service 的导出方法忘了加@DataScope。

正确做法是导出直接复用列表查询:

@Override @DataSource(DataSourceType.SLAVE) // 如需从库查询 public List<Equipment> selectEquipmentList(Equipment equipment) { return equipmentMapper.selectEquipmentList(equipment); } // Controller 里导出时用同一个 service 方法 @PostMapping("/export") public void export(HttpServletResponse response, Equipment equipment) { List<Equipment> list = equipmentService.selectEquipmentList(equipment); ExcelUtil<Equipment> util = new ExcelUtil<>(Equipment.class); util.exportExcel(response, list, "设备台账"); }

统计报表要更小心。报表 SQL 一般有GROUP BY和聚合函数,数据权限条件加在WHERE里和加在HAVING里结果可能不同。我的经验是:先把带数据权限的明细数据查出来放进临时表或用子查询包一层,再在外层做聚合。虽然多一次查询,但结果绝对正确,比在报表里查出一堆对不上的数字强。

6. 常见故障与排查速查表

6.1 列表返回了全量数据

最常见的现象:普通账号登录,列表里出现了别人的设备,条数和管理员一样。

排查顺序按这个来:先看 SQL 日志,确认执行的语句里有没有AND (d.dept_id IN ...)这一段。没有的话,继续往下分:注解是不是加在接口上而不是实现类上?方法是不是被同类内部调用绕过了代理?permission属性写的权限字符当前用户是不是没有?角色的data_scope是不是配成了1?占位符是不是写成了#{}?

如果 SQL 里有这段条件但结果还是全量,那问题就在数据本身:sys_role_dept里是不是配了根节点,根节点的dept_id匹配了整个部门树。曾经有个项目把"自定义数据权限"勾选了公司根节点,效果等同于全部权限,配的人还以为自己只勾了一个部门。

6.2 SQL 语法错误:where 后面多了一个 AND

报错信息通常是You have an error in your SQL syntax ... near 'AND ('。原因是占位符写在了<where>标签外面,或者手写 SQL 时在${params.dataScope}前面多写了一个AND。

切面拼出来的片段自带AND前缀,你只需要保证它被<where>包裹,或者前面有确定的WHERE 1=1。两种情况都对,但别混着来。

6.3 分页参数丢失或者分页失效

表现是页码、每页条数不生效,或者总条数不对。根本原因是PageHelper依赖 ThreadLocal 里的分页参数去拦截第一条SQL。startPage()之后执行的第一条语句如果不是列表查询,分页就挂错了地方。

还有一种情况是自定义的selectEquipmentCount方法也被PageHelper拦截了,导致总数被分页参数污染。RuoYi 的TableDataInfo里总条数取自PageInfo,正常情况下不会错,但如果手写了new PageInfo(list).getTotal()而 list 不是Page类型,总数会退化成当前页条数。列表显示"共 10 条"但实际有 2000 条的分页 bug,多半就是这个原因。

6.4 多角色叠加时条件串味

一个用户有三个角色,分别是"本部门"、"仅本人"、"自定义",切面会拼出三段用OR连接的条件,外面包一层括号,理论上没问题。但如果某个手写的 SQL 里把${params.dataScope}放在了OR的后面,比如:

WHERE e.del_flag = '0' OR e.status = '1' ${params.dataScope}

括号层级就被破坏了,实际语义变成A OR B AND (C),因为AND优先级高于OR,结果完全不可控。规矩很简单:占位符前面永远不要出现裸的OR。

6.5 超级管理员看不到数据

RuoYi 里user_id = 1的用户默认是超级管理员,切面会跳过过滤。如果你的系统把user_id = 1的账号停用了,或者把管理员权限给了别的账号,就会出现"管理员也看不到数据"的情况。检查sys_user.user_id和SysUser.isAdmin()的判断逻辑,别自己魔改这段。

6.6 问题速查表

现象最可能的根因处理方式
列表全量返回注解没生效或占位符写成#{}查 SQL 日志确认条件是否存在
SQL 语法错误占位符在<where>外移入<where>内部
仅本人档查不到数据未传userAlias或未关联用户表补别名并LEFT JOIN sys_user
分页总数不对自定义 count 语句被拦截调整startPage位置或改用框架自带 count
只在导出时泄露导出走了独立查询复用带注解的列表方法
切换角色不生效角色缓存未刷新清理 Redis 中的用户信息缓存
某部门设备查不到sys_dept.ancestors数据错重算部门树并清缓存
自定义档位看不到任何数据sys_role_dept未配置到角色页面勾选部门并保存

7. 性能与安全上必须守住的几条线

7.1 部门树大了之后 FIND_IN_SET 会拖垮列表

"本部门及以下"档位用的是dept_id IN (SELECT dept_id FROM sys_dept WHERE dept_id = X OR FIND_IN_SET(X, ancestors))。子查询在设备表数据量小的时候完全无感,但设备台账上万行、部门表几千行的时候,这个子查询会退化成全表扫描,列表响应从 200ms 掉到 3 秒。

我在一个项目里做过对比测试:部门表 1200 行、设备表 3.5 万行,未加索引优化时列表平均耗时 2800ms;把子查询结果缓存到 Redis(key 为部门 id,value 为该部门及下属的 id 集合,TTL 10 分钟)之后,耗时降到 380ms。

另一种思路是给sys_dept加一个dept_path字段存完整路径,用LIKE '0,100,103%'来替代FIND_IN_SET,能走索引但要注意前缀匹配的边界(103会误匹配1030),需要在路径末尾加分隔符规避。选哪种取决于团队对缓存的接受度,缓存方案实现简单但有一致性延迟,路径方案一致性最好但要改表结构。

7.2 params 是注入面,必须封死

前面提过,${params.dataScope}直接拼 SQL,如果params能被前端控制就是注入点。RuoYi 新版本在BaseEntity的params上做了处理,但升级不及时的项目仍然暴露着。

防御手段有两条,建议都做:

第一条是在全局的请求参数处理里对params的 key 做白名单,只允许业务真正需要的键通过,dataScope这类框架内部键一律剔除:

@Component public class ParamFilter implements Filter { private static final Set<String> WHITE_LIST = new HashSet<>(Arrays.asList("beginTime", "endTime", "shareUserId")); @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { Map<String, String[]> params = req.getParameterMap(); for (String key : params.keySet()) { if (key.startsWith("params[") && !WHITE_LIST.contains( key.substring(7, key.length() - 1))) { throw new ServletException("非法参数: " + key); } } chain.doFilter(req, resp); } }

第二条是 Controller 干脆不用BaseEntity直接接收前端参数,改成用@RequestParam或者显式的 DTO 对象,把params从外部输入路径上彻底断掉。这个改造成本略高,但一劳永逸。

7.3 切面顺序和事务的相互影响

DataScopeAspect是@Before通知,执行时机在方法进入之前,天然在事务开启之前,一般不会有问题。但如果项目里有其他切面也操作params(比如某些报表模块会往params里塞时间范围),切面顺序错了就会互相覆盖。

解决方式是给切面显式指定顺序:

@Aspect @Component @Order(Ordered.HIGHEST_PRECEDENCE + 10) public class DataScopeAspect { ... }

让数据权限切面尽量早执行,其他切面后执行,覆盖的风险就小很多。另外,如果业务方法上标了@Transactional(readOnly = true),注意readOnly只对连接做提示,不影响切面逻辑,别把数据权限失效归咎于事务注解。

7.4 日志里别把完整 SQL 打出去

调试数据权限的时候大家都会开 SQL 日志,但生产环境记得关。${params.dataScope}拼出来的条件里包含部门 id、用户 id、角色 id,日志落到文件里,配合其他信息足够推断出组织架构。安全审计角度这不算严重问题,但没必要留着。

提示:调试期可以用logging.level.你的mapper包=debug单独开某个 Mapper 的日志,比全局开 SQL 日志精准,也更容易定位问题。

最后分享一个小技巧。数据权限出了问题,最快的定位方式是直接在数据库里手动执行拼出来的完整 SQL。把 MyBatis 日志里那段带AND (d.dept_id ...)的语句复制出来,在客户端跑一遍,看返回的行数对不对,再拿这个行数和页面上的条数对比。如果数据库里查出来是 30 行、页面显示 3000 行,那问题一定在参数传递或分页;如果数据库里查出来就是 3000 行,那就是权限条件本身没拼对。这一步能把排查范围瞬间砍掉一半,比盯着代码看快得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/4 3:32:40

Oracle 11.2.0.3 OPatch 补丁报错排查与回滚实战

简介&#xff1a;这是一份面向Oracle Database 11.2.0.3&#xff08;Linux x86-64&#xff09;环境的数据库补丁集更新包&#xff0c;对应补丁号20760997&#xff0c;包含2015年7月发布的CPU关键补丁更新。它主要服务于需要维护存量Oracle实例的DBA、系统运维与安全合规人员&am…

作者头像 李华
网站建设 2026/11/4 3:32:39

数据库DELETE操作全解析:从语法到企业级实践

1. 数据库删除操作的核心价值与风险作为一名从业十年的数据库工程师&#xff0c;我处理过无数次数据删除请求。DELETE语句看似简单&#xff0c;却可能是SQL中最危险的操作之一。记得有一次&#xff0c;某电商平台运维人员在高峰期误执行了无条件的DELETE FROM orders&#xff0…

作者头像 李华
网站建设 2026/11/4 3:32:17

高德MCP与AI对话集成:打破App孤岛的技术实践

1. 项目背景与核心痛点在移动互联网深度发展的今天&#xff0c;我们正面临一个日益严重的"App孤岛"现象。每个应用程序都像一座信息孤岛&#xff0c;彼此之间缺乏有效的数据流通渠道。以导航场景为例&#xff1a;当我们在即时通讯软件中收到朋友发来的聚餐地址&#…

作者头像 李华
网站建设 2026/11/4 3:32:15

移动GPU中TBR架构与RenderPass优化实践

1. 理解TBR架构与RenderPass的本质在移动GPU领域&#xff0c;Tile-Based Rendering&#xff08;TBR&#xff09;架构已经成为主流设计方案。与传统的Immediate Mode Rendering&#xff08;IMR&#xff09;架构不同&#xff0c;TBR将整个帧缓冲区划分为多个小块&#xff08;Tile…

作者头像 李华
网站建设 2026/11/4 3:31:23

Axure RP本地原型白屏排查:Chrome CRX插件与本地HTTP服务

简介&#xff1a;这是一份面向Axure RP使用者的Chrome浏览器扩展包&#xff0c;版本0.6.3&#xff0c;主要用于把原型设计与预览测试延伸到浏览器环境&#xff0c;减少在设计与浏览器之间反复切换&#xff0c;适合产品经理、交互设计师及需要远程共享原型的协作团队。压缩包共1…

作者头像 李华
网站建设 2026/11/4 3:30:43

Oracle索引分裂:性能优化与实战解决方案

1. 索引分裂现象解析&#xff1a;数据库变慢的隐形杀手第一次遇到生产库查询性能断崖式下降时&#xff0c;我花了三天时间排查才锁定这个元凶。当时一个核心订单表的查询响应从200ms骤增至8秒&#xff0c;最终发现是索引分裂导致的性能劣化。这种问题就像血管里的血栓——平时毫…

作者头像 李华