mybatis攔截器+CCJSqlParser實現解耦數據權限


前言

從工作以來經手了好多個從0-1的項目,所以也寫了很多很多次權限相關的代碼,但每次的數據權限實現都不理想,每接入一個新的功能頁面都要針對各個接口進行數據過濾,由其是一些不清楚權限設計的同學想寫個功能,還要去弄明白權限的那一堆事才可以,然后過濾的邏輯就會耦合在各個業務代碼中合,簡直就是被代碼支配的恐懼。那有什么好的辦法能做好解耦呢

方案與實現

數據權限無非就是通過sql,過濾掉無權限的數據,以及攔截那些無權限數據的操作。那我們直接攔截sql,將攔截語句拼裝進去不就行啦~

使用自定義注解來標識哪些Mapper方法需要被攔截
創建mybatis intercept攔截器,檢測方法是否有自定義注解
使用CCJSqlParser解析並改寫SQL
覆蓋原SQL

數據表設計

CREATE TABLE `data_group_ref` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主鍵',
  `data_id` bigint(20) NOT NULL DEFAULT '0' COMMENT '數據id',
  `data_type` tinyint(4) NOT NULL DEFAULT '0' COMMENT '數據類型',
  `group_id` bigint(20) NOT NULL DEFAULT '0' COMMENT '用戶組id',
......
) ENGINE=InnoDB  DEFAULT CHARSET=utf8 COMMENT='數據與用戶組的對應關系表';

在小數據量下,我們只需要一個數據與用戶組的關系表即可。

data_id 對應數據表的主鍵id
data_type 是數據類型標識,因為系統可能會有很多數據表需要做權限,每個表都創一個關系表,就會出現表爆炸,所以全放一個關系表里用type來區分就好
group_id 是對應的用戶組主鍵id ,每一個數據可以對應多個組,那就會有多條記錄

自定義注解

@Target(value = {ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface DataAuthSelect {
	//數據類型, data_group_ref表中的 data_type 字段值
	int type() default 0;
}

這里我只列出type用來告知攔截器需要過濾的數據類型,大家可以根據自己的業務進行擴展

攔截器

針對攔截器的原理,網上有好多資料,我就不再反復闡述,大家可參考
https://blog.csdn.net/weixin_39494923/article/details/91534658

@Intercepts({@Signature(method = "query", type = Executor.class,
        args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})})
@Component
public class DataAuthSelectIntercept implements Interceptor {

    private static final Logger logger = LoggerFactory.getLogger(DataAuthSelectIntercept.class);

    @Autowired
    HttpServletRequest request;

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        MappedStatement mappedStatement = (MappedStatement) invocation.getArgs()[0];
        //沒自定義注解直接按通過算
        DataAuthSelect dataAuth = getDataAuth(mappedStatement);
        if (dataAuth == null) {
            return invocation.proceed();
        }
        //沒登錄是異常
        UserInfo user = (UserInfo) request.getSession().getAttribute("userInfo");
        if (user == null) {
            throw new Exception("獲取用戶登錄信息失敗");
        }
        //超級管理員不過濾
        if (user.isSuperAdmin()) {
            return invocation.proceed();
        }

        //根據自己的業務寫更多的過濾判斷.....

        //如果獲取用戶組失敗,或者為全部權限,則直接通過
        String groupStr = getGroupStr(user);
        if (StringUtils.isBlank(groupStr)) {
            return invocation.proceed();
        }
        //拼裝sql(這里是關鍵!!!)
        BoundSql boundSql = mappedStatement.getBoundSql(invocation.getArgs()[1]); 
        String orgSql = boundSql.getSql(); //獲取到當前需要被執行的SQL
        String authSql = makeSql(orgSql, groupStr, dataAuth); //進行數據權限過濾組裝 
        //替換
        MappedStatement newStatement = newMappedStatement(mappedStatement, new BoundSqlSqlSource(boundSql));
        MetaObject msObject = MetaObject.forObject(newStatement, new DefaultObjectFactory(), new DefaultObjectWrapperFactory(), new DefaultReflectorFactory());
        msObject.setValue("sqlSource.boundSql.sql", authSql);
        invocation.getArgs()[0] = newStatement;
        logger.debug("baseSql:{} authSql:{}", orgSql, authSql);
        return invocation.proceed();
    }

    /**
    * 通過反射獲取mapper方法是否加了自定義注解
    */
    private DataAuthSelect getDataAuth(MappedStatement mappedStatement) throws ClassNotFoundException {
        DataAuthSelect dataAuth = null;
        String id = mappedStatement.getId();
        String className = id.substring(0, id.lastIndexOf("."));
        String methodName = id.substring(id.lastIndexOf(".") + 1);
        final Class<?> cls = Class.forName(className);
        final Method[] methods = cls.getMethods();
        for (Method method : methods) {
            if (method.getName().equals(methodName) && method.isAnnotationPresent(DataAuthSelect.class)) {
                dataAuth = method.getAnnotation(DataAuthSelect.class);
                break;
            }
        }
        return dataAuth;
    }

    /**
    * 獲取當前登錄的用戶配置的用戶組信息 (group_id)
    */
    private String getGroupStr(UserInfo user) {
        //分局自己的業務邏輯實現
        return "1,2,3,4,5";
    }

    /**
    * 核心代碼: 將原SQL 進行解析並拼裝 一個子查詢  id in ( 數據權限過濾SQL ) 
    */
    private String makeSql(String sql, String groupStr, DataAuthSelect dataAuth) throws JSQLParserException {
        CCJSqlParserManager parserManager = new CCJSqlParserManager();
        Select select = (Select) parserManager.parse(new StringReader(sql));
        PlainSelect plain = (PlainSelect) select.getSelectBody();
        Table fromItem = (Table) plain.getFromItem();
        //有別名用別名,無別名用表名,防止字段沖突報錯
        String mainTableName = fromItem.getAlias() == null ? fromItem.getName() : fromItem.getAlias().getName();
        //構建子查詢
        String dataAuthSql = mainTableName + ".id in ( select data_id from data_group_ref where " + mainTableName + ".id = data_id and data_type = " + dataAuth.type() + " and group_id in (" + groupStr + ")" + ")";
        if (plain.getWhere() == null) {
            plain.setWhere(CCJSqlParserUtil.parseCondExpression(dataAuthSql));
        } else {
            plain.setWhere(new AndExpression(plain.getWhere(), CCJSqlParserUtil.parseCondExpression(dataAuthSql)));
        }
        return select.toString();
    }

    private MappedStatement newMappedStatement(MappedStatement ms, SqlSource newSqlSource) {
        MappedStatement.Builder builder =
                new MappedStatement.Builder(ms.getConfiguration(), ms.getId(), newSqlSource, ms.getSqlCommandType());
        builder.resource(ms.getResource());
        builder.fetchSize(ms.getFetchSize());
        builder.statementType(ms.getStatementType());
        builder.keyGenerator(ms.getKeyGenerator());
        if (ms.getKeyProperties() != null && ms.getKeyProperties().length != 0) {
            StringBuilder keyProperties = new StringBuilder();
            for (String keyProperty : ms.getKeyProperties()) {
                keyProperties.append(keyProperty).append(",");
            }
            keyProperties.delete(keyProperties.length() - 1, keyProperties.length());
            builder.keyProperty(keyProperties.toString());
        }
        builder.timeout(ms.getTimeout());
        builder.parameterMap(ms.getParameterMap());
        builder.resultMaps(ms.getResultMaps());
        builder.resultSetType(ms.getResultSetType());
        builder.cache(ms.getCache());
        builder.flushCacheRequired(ms.isFlushCacheRequired());
        builder.useCache(ms.isUseCache());

        return builder.build();
    }


    private class BoundSqlSqlSource implements SqlSource {
        private BoundSql boundSql;

        public BoundSqlSqlSource(BoundSql boundSql) {
            this.boundSql = boundSql;
        }

        @Override
        public BoundSql getBoundSql(Object parameterObject) {
            return boundSql;
        }
    }

至此我們已經完成了數據權限所有的代碼編寫。
一起看下原始sql與拼裝后sql的區別

select id,name from test where is_deleted = 0
select id,name from test where is_deleted = 0 and id in ( select data_id from data_group_ref where test.id = data_id and data_type = 1 and group_id in ("1,2,3") )

為什么使用子查詢不用join? 因為子查詢兼容更強,需要改寫的sql更少,如果用join 原sql是單查詢,那組裝時還要對 select的字段進行加別名,能少做點事就少做點吧~

使用與規范

讓我們一起來體驗下解耦后的快了吧,我們只需一行代碼,在對應需要做權限的mapper中加一個注解!
對應的sql就會自動被增加數據權限的過濾,不需要了解權限的設計,不需要自己反復的拼接那條一樣的sql。

	@Select(value = "SELECT * FROM lcp_rule WHERE id = #{id} and is_deleted = 0")
	@DataAuthSelect(type = 1)
	Rule selectByIdAuth(Serializable id);

雖然我們只定義了對sql的攔截,但是我們卻能夠實現 列表、查看、編輯、刪除 等等常見數據權限限制!
列表與查看不用多說,本身就是查詢。 編輯與刪除,是update與delete ,我們雖然沒有攔截,但通常在進行修改與刪除之前,我們需要進行一個 getbyid的查詢來確保數據存在。一但進行這個查詢,不就又命中我們的攔截,如果返回空數據,c層直接返回無權限即可~


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM