数据权限初稿,能用就行

This commit is contained in:
b2baccline
2020-09-28 18:28:46 +08:00
parent 545e6459a7
commit a156b426e7
11 changed files with 827 additions and 8 deletions

View File

@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>ballcat-starters</artifactId>
<groupId>com.hccake</groupId>
<version>${revision}</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>ballcat-spring-boot-starter-datascope</artifactId>
<dependencies>
<dependency>
<groupId>com.github.jsqlparser</groupId>
<artifactId>jsqlparser</artifactId>
<version>3.2</version>
</dependency>
<!-- slf4j日志 -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis</artifactId>
<version>3.5.5</version>
<scope>compile</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,36 @@
package com.hccake.ballcat.common.datascope;
import net.sf.jsqlparser.expression.Alias;
import net.sf.jsqlparser.expression.Expression;
import java.util.Collection;
/**
* @author Hccake 2020/9/28
* @version 1.0
*/
public interface DataScope {
/**
* 数据所对应的资源
* @return 资源标识
*/
String getResource();
/**
* 该资源相关的所有表,推荐使用 Set 类型。 <br/>
* 如需忽略表名大小写判断,则可以使用 TreeSet并设置忽略大小写的自定义Comparator。 <br/>
* eg. new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
* @return tableNames
*/
Collection<String> getTableNames();
/**
* 根据表名和表别名,动态生成的 where/or 筛选条件
* @param tableName 表名
* @param tableAlias 表别名,可能为空
* @return 数据规则表达式
*/
Expression getExpression(String tableName, Alias tableAlias);
}

View File

@@ -0,0 +1,42 @@
package com.hccake.ballcat.common.datascope;
import java.util.HashMap;
import java.util.Map;
/**
* DataScope 持有类 内部维护一个 ThreadLocal用于存储当前用户的所有 DataScope
*
* @author Hccake 2020/9/27
* @version 1.0
*/
public class DataScopeHolder {
private DataScopeHolder() {
}
private static final ThreadLocal<Map<String, DataScope>> DATA_SCOPE_LOCAL = new InheritableThreadLocal<>();
public static Map<String, DataScope> getDataScopes() {
return DataScopeHolder.DATA_SCOPE_LOCAL.get();
}
public static void setDataScopes(Map<String, DataScope> dataScopes) {
DataScopeHolder.DATA_SCOPE_LOCAL.set(dataScopes);
}
public static DataScope putDataScope(String key, DataScope dataScope) {
if (DataScopeHolder.getDataScopes() == null) {
setDataScopes(new HashMap<>(8));
}
return DataScopeHolder.getDataScopes().put(key, dataScope);
}
public static DataScope removeDataScope(String key) {
return DataScopeHolder.getDataScopes().remove(key);
}
public static void clear() {
DATA_SCOPE_LOCAL.remove();
}
}

View File

@@ -0,0 +1,27 @@
package com.hccake.ballcat.common.datascope.annotation;
import java.lang.annotation.*;
/**
* 数据权限注解,注解在 Mapper类 或者 对应方法上 用于提供该 mapper 对应表,所需控制的实体信息
* @author Hccake 2020/9/27
* @version 1.0
*/
@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DataPermission {
/**
* 资源类型
* @return 资源类型数组
*/
String[] resources();
/**
* 用于在全局开启或者关闭数据权限时,对指定类或者指定方法进行开关控制
* @return boolean 默认返回 true
*/
boolean enabled() default true;
}

View File

@@ -0,0 +1,20 @@
package com.hccake.ballcat.common.datascope.handler;
import com.hccake.ballcat.common.datascope.DataScope;
import lombok.Getter;
import lombok.Setter;
import java.util.ArrayList;
import java.util.List;
/**
* @author Hccake 2020/9/28
* @version 1.0
*/
@Getter
@Setter
public class DataPermissionHandler {
List<DataScope> dataScopes = new ArrayList<>();
}

View File

@@ -0,0 +1,61 @@
package com.hccake.ballcat.common.datascope.interceptor;
import com.hccake.ballcat.common.datascope.processor.DataScopeSqlProcessor;
import com.hccake.ballcat.common.datascope.util.PluginUtils;
import lombok.RequiredArgsConstructor;
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.SqlCommandType;
import org.apache.ibatis.plugin.*;
import java.sql.Connection;
import java.util.Properties;
/**
* 数据权限拦截器
*
* @author Hccake 2020/9/28
* @version 1.0
*/
@RequiredArgsConstructor
@Intercepts({
@Signature(type = StatementHandler.class, method = "prepare", args = { Connection.class, Integer.class }) })
public class DataPermissionInterceptor implements Interceptor {
private final DataScopeSqlProcessor dataScopeSqlProcessor;
@Override
public Object intercept(Invocation invocation) throws Throwable {
// 第一版,测试用
Object target = invocation.getTarget();
StatementHandler sh = (StatementHandler) target;
PluginUtils.MPStatementHandler mpSh = PluginUtils.mpStatementHandler(sh);
MappedStatement ms = mpSh.mappedStatement();
SqlCommandType sct = ms.getSqlCommandType();
PluginUtils.MPBoundSql mpBs = mpSh.mPBoundSql();
// TODO 根据用户权限判断是否需要拦截,例如管理员可以查看所有,则直接放行
// TODO 动态生成 DataPermissionHandler, 根据注解进行一些此次 sql 执行中需要忽略的点
if (sct == SqlCommandType.SELECT) {
mpBs.sql(dataScopeSqlProcessor.parserSingle(mpBs.sql(), null));
}
else if (sct == SqlCommandType.INSERT || sct == SqlCommandType.UPDATE || sct == SqlCommandType.DELETE) {
mpBs.sql(dataScopeSqlProcessor.parserMulti(mpBs.sql(), null));
}
return invocation.proceed();
}
@Override
public Object plugin(Object target) {
if (target instanceof StatementHandler) {
return Plugin.wrap(target, this);
}
return target;
}
@Override
public void setProperties(Properties properties) {
}
}

View File

@@ -0,0 +1,114 @@
package com.hccake.ballcat.common.datascope.parser;
import lombok.extern.slf4j.Slf4j;
import net.sf.jsqlparser.JSQLParserException;
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
import net.sf.jsqlparser.statement.Statement;
import net.sf.jsqlparser.statement.Statements;
import net.sf.jsqlparser.statement.delete.Delete;
import net.sf.jsqlparser.statement.insert.Insert;
import net.sf.jsqlparser.statement.select.Select;
import net.sf.jsqlparser.statement.update.Update;
/**
* https://github.com/JSQLParser/JSqlParser
*
* @author miemie hccake
* @since 2020-06-22
*/
@Slf4j
public abstract class JsqlParserSupport {
public String parserSingle(String sql, Object obj) {
if (log.isDebugEnabled()) {
log.debug("original SQL: " + sql);
}
try {
Statement statement = CCJSqlParserUtil.parse(sql);
return processParser(statement, 0, sql, obj);
}
catch (JSQLParserException e) {
throw new RuntimeException(String.format("Failed to process, Error SQL: %s", sql), e);
}
}
public String parserMulti(String sql, Object obj) {
if (log.isDebugEnabled()) {
log.debug("original SQL: " + sql);
}
try {
// fixed github pull/295
StringBuilder sb = new StringBuilder();
Statements statements = CCJSqlParserUtil.parseStatements(sql);
int i = 0;
for (Statement statement : statements.getStatements()) {
if (i > 0) {
sb.append(";");
}
sb.append(processParser(statement, i, sql, obj));
i++;
}
return sb.toString();
}
catch (JSQLParserException e) {
throw new RuntimeException(String.format("Failed to process, Error SQL: %s", sql), e);
}
}
/**
* 执行 SQL 解析
* @param statement JsqlParser Statement
* @return sql
*/
protected String processParser(Statement statement, int index, String sql, Object obj) {
if (log.isDebugEnabled()) {
log.debug("SQL to parse, SQL: " + sql);
}
if (statement instanceof Insert) {
this.processInsert((Insert) statement, index, sql, obj);
}
else if (statement instanceof Select) {
this.processSelect((Select) statement, index, sql, obj);
}
else if (statement instanceof Update) {
this.processUpdate((Update) statement, index, sql, obj);
}
else if (statement instanceof Delete) {
this.processDelete((Delete) statement, index, sql, obj);
}
sql = statement.toString();
if (log.isDebugEnabled()) {
log.debug("parse the finished SQL: " + sql);
}
return sql;
}
/**
* 新增
*/
protected void processInsert(Insert insert, int index, String sql, Object obj) {
throw new UnsupportedOperationException();
}
/**
* 删除
*/
protected void processDelete(Delete delete, int index, String sql, Object obj) {
throw new UnsupportedOperationException();
}
/**
* 更新
*/
protected void processUpdate(Update update, int index, String sql, Object obj) {
throw new UnsupportedOperationException();
}
/**
* 查询
*/
protected void processSelect(Select select, int index, String sql, Object obj) {
throw new UnsupportedOperationException();
}
}

View File

@@ -0,0 +1,257 @@
package com.hccake.ballcat.common.datascope.processor;
import com.hccake.ballcat.common.datascope.handler.DataPermissionHandler;
import com.hccake.ballcat.common.datascope.parser.JsqlParserSupport;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import net.sf.jsqlparser.expression.BinaryExpression;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.NotExpression;
import net.sf.jsqlparser.expression.Parenthesis;
import net.sf.jsqlparser.expression.operators.conditional.AndExpression;
import net.sf.jsqlparser.expression.operators.conditional.OrExpression;
import net.sf.jsqlparser.expression.operators.relational.ExistsExpression;
import net.sf.jsqlparser.expression.operators.relational.InExpression;
import net.sf.jsqlparser.expression.operators.relational.ItemsList;
import net.sf.jsqlparser.schema.Column;
import net.sf.jsqlparser.schema.Table;
import net.sf.jsqlparser.statement.delete.Delete;
import net.sf.jsqlparser.statement.insert.Insert;
import net.sf.jsqlparser.statement.select.*;
import net.sf.jsqlparser.statement.update.Update;
import java.util.List;
import java.util.Objects;
/**
* 数据权限 sql 处理器 参考 mybatis-plus 租户拦截器,解析 sql where 部分,进行查询表达式注入
*
* @author Hccake 2020/9/26
* @version 1.0
*/
@RequiredArgsConstructor
@Slf4j
public class DataScopeSqlProcessor extends JsqlParserSupport {
private final DataPermissionHandler dataPermissionHandler;
/**
* select 类型SQL处理
* @param select jsqlparser Statement Select
*/
@Override
protected void processSelect(Select select, int index, String sql, Object obj) {
processSelectBody(select.getSelectBody());
List<WithItem> withItemsList = select.getWithItemsList();
if (withItemsList != null && withItemsList.size() != 0) {
withItemsList.forEach(this::processSelectBody);
}
}
protected void processSelectBody(SelectBody selectBody) {
if (selectBody == null) {
return;
}
if (selectBody instanceof PlainSelect) {
processPlainSelect((PlainSelect) selectBody);
}
else if (selectBody instanceof WithItem) {
WithItem withItem = (WithItem) selectBody;
processSelectBody(withItem.getSelectBody());
}
else {
SetOperationList operationList = (SetOperationList) selectBody;
if (operationList.getSelects() != null && operationList.getSelects().size() > 0) {
operationList.getSelects().forEach(this::processSelectBody);
}
}
}
/**
* insert 类型SQL处理
* @param insert jsqlparser Statement Insert
*/
@Override
protected void processInsert(Insert insert, int index, String sql, Object obj) {
// insert 暂时不处理
}
/**
* update 类型SQL处理
* @param update jsqlparser Statement Update
*/
@Override
protected void processUpdate(Update update, int index, String sql, Object obj) {
update.setWhere(this.injectExpression(update.getWhere(), update.getTable()));
}
/**
* delete 类型SQL处理
* @param delete jsqlparser Statement Delete
*/
@Override
protected void processDelete(Delete delete, int index, String sql, Object obj) {
delete.setWhere(this.injectExpression(delete.getWhere(), delete.getTable()));
}
/**
* 处理 PlainSelect
*/
protected void processPlainSelect(PlainSelect plainSelect) {
FromItem fromItem = plainSelect.getFromItem();
Expression where = plainSelect.getWhere();
processWhereSubSelect(where);
if (fromItem instanceof Table) {
Table fromTable = (Table) fromItem;
// #1186 github
plainSelect.setWhere(injectExpression(plainSelect.getWhere(), fromTable));
}
else {
processFromItem(fromItem);
}
List<Join> joins = plainSelect.getJoins();
if (joins != null && joins.size() > 0) {
joins.forEach(j -> {
processJoin(j);
processFromItem(j.getRightItem());
});
}
}
/**
* 处理where条件内的子查询
* <p>
* 支持如下: 1. in 2. = 3. > 4. < 5. >= 6. <= 7. <> 8. EXISTS 9. NOT EXISTS
* <p>
* 前提条件: 1. 子查询必须放在小括号中 2. 子查询一般放在比较操作符的右边
* @param where where 条件
*/
protected void processWhereSubSelect(Expression where) {
if (where == null) {
return;
}
if (where instanceof FromItem) {
processFromItem((FromItem) where);
return;
}
if (where.toString().indexOf("SELECT") > 0) {
// 有子查询
if (where instanceof BinaryExpression) {
// 比较符号 , and , or , 等等
BinaryExpression expression = (BinaryExpression) where;
processWhereSubSelect(expression.getLeftExpression());
processWhereSubSelect(expression.getRightExpression());
}
else if (where instanceof InExpression) {
// in
InExpression expression = (InExpression) where;
ItemsList itemsList = expression.getRightItemsList();
if (itemsList instanceof SubSelect) {
processSelectBody(((SubSelect) itemsList).getSelectBody());
}
}
else if (where instanceof ExistsExpression) {
// exists
ExistsExpression expression = (ExistsExpression) where;
processWhereSubSelect(expression.getRightExpression());
}
else if (where instanceof NotExpression) {
// not exists
NotExpression expression = (NotExpression) where;
processWhereSubSelect(expression.getExpression());
}
else if (where instanceof Parenthesis) {
Parenthesis expression = (Parenthesis) where;
processWhereSubSelect(expression.getExpression());
}
}
}
/**
* 处理子查询等
*/
protected void processFromItem(FromItem fromItem) {
if (fromItem instanceof SubJoin) {
SubJoin subJoin = (SubJoin) fromItem;
if (subJoin.getJoinList() != null) {
subJoin.getJoinList().forEach(this::processJoin);
}
if (subJoin.getLeft() != null) {
processFromItem(subJoin.getLeft());
}
}
else if (fromItem instanceof SubSelect) {
SubSelect subSelect = (SubSelect) fromItem;
if (subSelect.getSelectBody() != null) {
processSelectBody(subSelect.getSelectBody());
}
}
else if (fromItem instanceof ValuesList) {
log.debug("Perform a subquery, if you do not give us feedback");
}
else if (fromItem instanceof LateralSubSelect) {
LateralSubSelect lateralSubSelect = (LateralSubSelect) fromItem;
if (lateralSubSelect.getSubSelect() != null) {
SubSelect subSelect = lateralSubSelect.getSubSelect();
if (subSelect.getSelectBody() != null) {
processSelectBody(subSelect.getSelectBody());
}
}
}
}
/**
* 处理联接语句
*/
protected void processJoin(Join join) {
if (join.getRightItem() instanceof Table) {
Table fromTable = (Table) join.getRightItem();
join.setOnExpression(injectExpression(join.getOnExpression(), fromTable));
}
}
/**
* 根据 DataScope ,将数据过滤的表达式注入原本的 where/or 条件
* @param currentExpression Expression where/or
* @param table 表信息
* @return 修改后的 where/or 条件
*/
private Expression injectExpression(Expression currentExpression, Table table) {
// TODO 重写 dataPermissionHandler
// TODO 当用户检索到 dataScope 所属字段时,需要判断该值是否在 scope 中,然后将其合并
String tableName = table.getName();
Expression dataFilterExpression = dataPermissionHandler.getDataScopes().stream()
.filter(x -> x.getTableNames().contains(tableName))
.map(x -> x.getExpression(tableName, table.getAlias())).filter(Objects::nonNull)
.reduce(AndExpression::new).orElse(null);
if (currentExpression == null) {
return dataFilterExpression;
}
if (dataFilterExpression == null) {
return currentExpression;
}
if (currentExpression instanceof OrExpression) {
return new AndExpression(new Parenthesis(currentExpression), dataFilterExpression);
}
else {
return new AndExpression(currentExpression, dataFilterExpression);
}
}
/**
* 根据当前表是否有别名,动态对字段名前添加表别名 eg. 表名: table_1 as t 原始字段column1 返回: t.column1
* @param table 表信息
* @param column 字段名
* @return 原始字段名,或者添加了表别名的字段名
*/
protected Column getAliasColumn(Table table, String column) {
StringBuilder columnBuilder = new StringBuilder();
if (table.getAlias() != null) {
columnBuilder.append(table.getAlias().getName()).append(".");
}
columnBuilder.append(column);
return new Column(columnBuilder.toString());
}
}

View File

@@ -0,0 +1,163 @@
/*
* Copyright (c) 2011-2020, baomidou (jobob@qq.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
* <p>
* https://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package com.hccake.ballcat.common.datascope.util;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.executor.parameter.ParameterHandler;
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.ParameterMapping;
import org.apache.ibatis.reflection.MetaObject;
import org.apache.ibatis.reflection.SystemMetaObject;
import org.apache.ibatis.session.Configuration;
import java.lang.reflect.Proxy;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/**
* 插件工具类
*
* @author TaoYu , hubin
* @since 2017-06-20
*/
public abstract class PluginUtils {
public static final String DELEGATE_BOUNDSQL_SQL = "delegate.boundSql.sql";
/**
* 获得真正的处理对象,可能多层代理.
*/
@SuppressWarnings("unchecked")
public static <T> T realTarget(Object target) {
if (Proxy.isProxyClass(target.getClass())) {
MetaObject metaObject = SystemMetaObject.forObject(target);
return realTarget(metaObject.getValue("h.target"));
}
return (T) target;
}
/**
* 给 BoundSql 设置 additionalParameters
* @param boundSql BoundSql
* @param additionalParameters additionalParameters
*/
public static void setAdditionalParameter(BoundSql boundSql, Map<String, Object> additionalParameters) {
additionalParameters.forEach(boundSql::setAdditionalParameter);
}
public static MPBoundSql mpBoundSql(BoundSql boundSql) {
return new MPBoundSql(boundSql);
}
public static MPStatementHandler mpStatementHandler(StatementHandler statementHandler) {
statementHandler = realTarget(statementHandler);
MetaObject object = SystemMetaObject.forObject(statementHandler);
return new MPStatementHandler(SystemMetaObject.forObject(object.getValue("delegate")));
}
/**
* {@link org.apache.ibatis.executor.statement.BaseStatementHandler}
*/
public static class MPStatementHandler {
private final MetaObject statementHandler;
MPStatementHandler(MetaObject statementHandler) {
this.statementHandler = statementHandler;
}
public ParameterHandler parameterHandler() {
return get("parameterHandler");
}
public MappedStatement mappedStatement() {
return get("mappedStatement");
}
public Executor executor() {
return get("executor");
}
public MPBoundSql mPBoundSql() {
return new MPBoundSql(boundSql());
}
public BoundSql boundSql() {
return get("boundSql");
}
public Configuration configuration() {
return get("configuration");
}
@SuppressWarnings("unchecked")
private <T> T get(String property) {
return (T) statementHandler.getValue(property);
}
}
/**
* {@link BoundSql}
*/
public static class MPBoundSql {
private final MetaObject boundSql;
private final BoundSql delegate;
MPBoundSql(BoundSql boundSql) {
this.delegate = boundSql;
this.boundSql = SystemMetaObject.forObject(boundSql);
}
public String sql() {
return delegate.getSql();
}
public void sql(String sql) {
boundSql.setValue("sql", sql);
}
public List<ParameterMapping> parameterMappings() {
List<ParameterMapping> parameterMappings = delegate.getParameterMappings();
return new ArrayList<>(parameterMappings);
}
public void parameterMappings(List<ParameterMapping> parameterMappings) {
boundSql.setValue("parameterMappings", Collections.unmodifiableList(parameterMappings));
}
public Object parameterObject() {
return get("parameterObject");
}
public Map<String, Object> additionalParameters() {
return get("additionalParameters");
}
@SuppressWarnings("unchecked")
private <T> T get(String property) {
return (T) boundSql.getValue(property);
}
}
}

View File

@@ -0,0 +1,64 @@
package com.hccake.ballcat.common.datascope.test;
import com.hccake.ballcat.common.datascope.DataScope;
import com.hccake.ballcat.common.datascope.DataScopeHolder;
import com.hccake.ballcat.common.datascope.handler.DataPermissionHandler;
import com.hccake.ballcat.common.datascope.processor.DataScopeSqlProcessor;
import net.sf.jsqlparser.expression.Alias;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.StringValue;
import net.sf.jsqlparser.expression.operators.relational.ExpressionList;
import net.sf.jsqlparser.expression.operators.relational.InExpression;
import net.sf.jsqlparser.schema.Column;
import org.junit.Test;
import java.util.*;
/**
* @author Hccake 2020/9/28
* @version 1.0
*/
public class SqlParseTest {
@Test
public void test() {
DataScope dataScope = new DataScope() {
final String columnId = "order_id";
@Override
public String getResource() {
return "order";
}
@Override
public Collection<String> getTableNames() {
Set<String> tableNames = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
tableNames.addAll(Arrays.asList("t_order", "t_order_info"));
return tableNames;
}
@Override
public Expression getExpression(String tableName, Alias tableAlias) {
Column column = new Column(tableAlias == null ? columnId : tableAlias.getName() + "." + columnId);
ExpressionList expressionList = new ExpressionList();
expressionList.setExpressions(Arrays.asList(new StringValue("1"), new StringValue("2")));
return new InExpression(column, expressionList);
}
};
DataPermissionHandler dataPermissionHandler = new DataPermissionHandler();
List<DataScope> list = new ArrayList<>();
list.add(dataScope);
dataPermissionHandler.setDataScopes(list);
DataScopeSqlProcessor dataScopeSqlProcessor = new DataScopeSqlProcessor(dataPermissionHandler);
DataScopeHolder.putDataScope("order", dataScope);
String sql = "select o.order_id,o.order_name,oi.order_price "
+ "from t_ORDER o left join t_order_info oi on o.order_id = oi.order_id "
+ "where oi.order_price > 100";
dataScopeSqlProcessor.parserSingle(sql, null);
}
}

View File

@@ -13,15 +13,16 @@
<packaging>pom</packaging>
<modules>
<module>ballcat-spring-boot-starter-log</module>
<module>ballcat-spring-boot-starter-job</module>
<module>ballcat-spring-boot-starter-swagger</module>
<module>ballcat-spring-boot-starter-storage</module>
<module>ballcat-spring-boot-starter-mail</module>
<module>ballcat-spring-boot-starter-easyexcel</module>
<module>ballcat-spring-boot-starter-redis</module>
<module>ballcat-spring-boot-starter-datascope</module>
<module>ballcat-spring-boot-starter-dingtalk</module>
<module>ballcat-spring-boot-starter-easyexcel</module>
<module>ballcat-spring-boot-starter-job</module>
<module>ballcat-spring-boot-starter-kafka</module>
<module>ballcat-spring-boot-starter-log</module>
<module>ballcat-spring-boot-starter-mail</module>
<module>ballcat-spring-boot-starter-redis</module>
<module>ballcat-spring-boot-starter-storage</module>
<module>ballcat-spring-boot-starter-swagger</module>
</modules>
</project>