按部门控制数据权限,一个注解 + 一个切面 + 一行 SQL 就够了

0 阅读5分钟

写在开头

大家好,我是kele。

现在很多低代码平台都有自己的角色访问控制(RBAC, Role-Based Access Control)模型,RBAC 主要做四件事:

  1. 定义角色:角色代表一类用户群体,具有相似的权限需求和操作范围,例如管理员、普通用户、审批人员等角色
  2. 定义权限:权限决定用户可以执行的操作或访问的资源,包括模块级权限(访问某个模块)和功能级权限(执行具体操作)
  3. 用户与角色关联:用户被分配一个或多个角色,从而继承角色所拥有的权限
  4. 访问控制:系统在用户访问资源时,先进行身份验证(鉴别),再根据角色权限判断是否允许操作(授权)

这一套角色授权模型可以细化到Controller接口访问以及按钮级别的权限,比如为系统管理员分配的菜单为用户管理,审计日志列表;为审批人员分配的都是业务菜单。

还有,同样一个列表分页查询,市级部门与区级部门都具有某个接口的访问权限,但市级部门就是能看到比区级部门更多的数据,这就需要数据访问的权限控制,本篇文章我会通过一个demo来和大家说一说数据访问权限的控制流程。

demo 的 git 链接我放在了文章结尾

一、需求先拆清楚

第一件事,要标识每个用户能看多大的范围。

数据权限级别,常见的有 4 种。

  • 全部数据,没限制
  • 本部门数据,只能看自己所在的部门
  • 本部门及下级部门数据,主管用的,能看到自己的子部门
  • 仅本人数据,员工用的,只能看自己创建或者分配给自己的

在企业级项目中,每个数据表都有很多必须要有的固定字段,比如del_flag表示删除标记,create_by表示创建人等等,如图所示,其中的dept_id就是用来控制部门权限的,假设市级所属的dept_id列表为1001、10010,区级所属的dept_id为10010,那对于同一个接口,我们只需要通过在select查询时,在where子句中自动拼接select xxx from xxx where dept_id in (部门id列表) 就可以达到控制数据权限的目的。

第二件事,如何在需要控制数据权限的查询中,拼接部门列表?

如果每个接口都手写条件,那几十个接口就要写几十遍,复制粘贴的工作量先不说,哪天产品经理跑来加个「按区域」的数据权限,你就哭吧。

所以我们要的不是在业务代码里加条件,而是用一套机制,自动把权限过滤条件拼到 SQL 里去。

这就是我后面要讲的三件套。

二、三件套到底怎么搭

整个方案的核心就是三个东西。

第一个,@DataScope自定义注解,贴在 Service 的方法上,告诉系统「这个方法要走数据权限过滤」。

第二个,DataScopeAspect 切面,方法执行前,系统读出当前用户的数据权限级别,按规则拼出一段 SQL 条件,塞到查询参数里。

第三个,MyBatis 的 XML 里的一行 ${params.dataScope} 占位符,负责把这串 SQL 条件真正拼到 WHERE 子句里。

三个东西串起来,整套数据权限就通了。你不用在每个业务接口里改一行代码。

下面我把这三个东西的代码分别讲一下。

2.1 自定义注解

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DataScope {
    String deptAlias() default "d";
    String userAlias() default "u";
}

里面的 deptAlias 和 userAlias 是什么意思呢。

因为数据权限最终要拼到 SQL 里去,SQL 里有别名,比如部门表可能叫 d,用户表可能叫 u。这两个字段是让你告诉切面「我要过滤的部门表在你的 SQL 里叫啥名字」,默认值就够大部分场景用了。

业务方法上这样用,贴一行注解就行。

@DataScope(deptAlias = "d", userAlias = "u")
public List<SysUser> selectUserList(SysUser query) {
    return sysUserMapper.selectUserList(query);
}

完事了,业务代码完全不用改。

2.2 AOP 切面

切面是这个方案的灵魂。

它做的事只有一件,就是在方法执行前,把 SQL 过滤条件拼出来,塞进方法参数里。

@Aspect
@Component
public class DataScopeAspect {

    @Before("@annotation(dataScope)")
    public void doBefore(JoinPoint joinPoint, DataScope dataScope) {
        LoginUser loginUser = LoginUserHolder.get();
        if (loginUser == null) {
            return;
        }

        String sqlString = buildDataScopeSql(loginUser, dataScope);

        for (Object arg : joinPoint.getArgs()) {
            if (arg instanceof BaseEntity baseEntity) {
                baseEntity.getParams().put("dataScope", sqlString);
                break;
            }
        }
    }

    private String buildDataScopeSql(LoginUser user, DataScope dataScope) {
        String deptAlias = dataScope.deptAlias();
        String userAlias = dataScope.userAlias();
        Integer scope = user.getDataScope();

        if (scope == null) {
            return "1 = 1";
        }

        return switch (scope) {
            case 3 -> deptAlias + ".dept_id IN ("
                    + "SELECT dept_id FROM sys_user_dept WHERE user_id = " + user.getUserId() + ")";
            case 4 -> deptAlias + ".dept_id IN ("
                    + "SELECT sd.dept_id FROM sys_dept sd "
                    + "WHERE EXISTS ("
                    + "SELECT 1 FROM sys_user_dept ud "
                    + "WHERE ud.user_id = " + user.getUserId()
                    + " AND (sd.dept_id = ud.dept_id "
                    + "OR FIND_IN_SET(ud.dept_id, sd.ancestors))"
                    + "))";
            case 5 -> userAlias + ".user_id = " + user.getUserId();
            default -> "1 = 1";
        };
    }
}

你看到那个 LoginUserHolder.get() 是 ThreadLocal,从里头拿当前登录的用户。ThreadLocal 在 web 项目里通常用一个拦截器在请求进来时塞进当前线程,请求结束清掉。企业级项目一般会接入 Spring Security 或者 Shiro 来做认证授权,这套登录上下文就是从那里来的。

还有几个点我想强调一下。

第一个,切面是把过滤条件塞进方法参数,而不是用 ThreadLocal 把它传到 MyBatis 里去。这样业务方法的形参随便取,叫 query 也行,叫 param 也行,切面不用关心你叫什么名字,只要它是 BaseEntity 就行。

第二个,case 4 里的 FIND_IN_SET 是 MySQL 的语法,在 PostgreSQL 里要换成 string_to_array。我后面会单独说这块。

第三个,你看这个 switch 里只拼「本部门」、「本部门及下级」、「仅本人」、「全部」四种结果,没有任何 HTTP 入参能影响这个字符串的内容。所以即使我后面要用 ${} 拼接,也不会出 SQL 注入问题。这个点很重要,我后面还会再强调。

2.3 MyBatis XML 里的一行 SQL

切面把条件塞进 params 之后,MyBatis 这边只要把它读出来拼到 WHERE 就完了。

<select id="selectUserList" resultType="SysUser">
    SELECT u.user_id, u.user_name, d.dept_name
    FROM sys_user u
    LEFT JOIN sys_dept d ON u.dept_id = d.dept_id
    <where>
        <if test="params.dataScope != null and params.dataScope != ''">
            AND ${params.dataScope}
        </if>
        <if test="userName != null and userName != ''">
            AND u.user_name LIKE CONCAT('%', #{userName}, '%')
        </if>
    </where>
</select>

你看到下面的代码,注意这行。

AND ${params.dataScope}

是 ${},不是 #{}。

#{} 是预编译参数绑定,MyBatis 会把它翻译成 ? 然后 PreparedStatement 帮你转义,这种是安全的,但不能用在表名、字段名、SQL 片段这种「不是值」的地方。

${} 是直接字符串替换,能拼任何东西进来,但要小心被 SQL 注入。

我们这里用 ${} 是故意的,因为前面切面拼出来的字符串就是一个完整的 SQL 片段,不是一个值。这种「受控的注入」反而比绑定参数更灵活,但前提是字符串内容完全由代码生成,不接任何外部输入。

到这里,整个方案就跑通了。任何新增的、需要在列表里看数据的接口,只要在 Service 方法上挂一个 @DataScope,XML 里加一行 AND ${params.dataScope},权限就自动生效了。

三、踩坑分享

怎么查一个部门的所有下级部门

业务里有一类场景,要求「部门主管能看到自己部门和自己所有下级部门的数据」。

我第一反应是递归查,部门表里加一个 parent_id,从当前部门开始一直找子节点,直到叶子节点。

写起来是这样的。

WITH RECURSIVE dept_tree AS (
    SELECT dept_id, parent_id FROM sys_dept WHERE dept_id = 100
    UNION ALL
    SELECT sd.dept_id, sd.parent_id FROM sys_dept sd
    INNER JOIN dept_tree dt ON sd.parent_id = dt.dept_id
)
SELECT * FROM dept_tree;

这个查询能跑,逻辑也很清晰,但是有一个问题,递归 CTE 在 MySQL 8 之前不支持,在某些 ORM 里也不好集成。而且递归查询每次都要跑一次,部门层级深了之后性能会下滑。

后来我用了一个更简单粗暴的办法,在部门表里加一个冗余字段 ancestors,存从根到这个部门的完整路径。

比如「后端组」的 ancestors 就是 '0,100,101'。

  • 0 表示根
  • 100 表示研发中心
  • 101 表示后端组

写入部门的时候维护一下这个字段,查询的时候用 MySQL 的 FIND_IN_SET 一句话搞定。你想想看,省掉了递归 SQL 那一大坨,是不是清爽多了。

SELECT dept_id FROM sys_dept sd WHERE EXISTS (
    SELECT 1 FROM sys_user_dept ud
    WHERE ud.user_id = 100
    AND (sd.dept_id = ud.dept_id OR FIND_IN_SET(ud.dept_id, sd.ancestors))
);

FIND_IN_SET('101', '0,100,101') 会返回它在不在这个路径里。这样查某个用户能看到的所有子孙部门就一句话的事,连递归都不用。

注意这个 FIND_IN_SET 是 MySQL 的方言,如果你换到 PostgreSQL,要换成 string_to_array + unnest + = ANY() 这种组合。这块是我的实战经验,建议做技术选型的时候就考虑好。

写在结尾

如果你刚好遇到类似需求,希望这篇文章能帮你省下琢磨的几个小时。如果有更好的实现思路,也欢迎在评论区告诉我,毕竟我这两年经验里踩过的坑还不够多,视野也还不够广。

既然看到这里了,如果觉得不错,随手点个赞、转发给你周围的朋友,你的支持就是我更新的动力~

这篇里的代码和踩坑记录,平时也会发在我的公众号「可乐不是Code」,主要写 Spring Boot 企业级开发里真实遇到的那些问题。用得上可以搜来看看。

谢谢你看我的文章,我们,下次再见。


git链接:gitee.com/kelez/data-…