場景

業務場景中,需要對某些表中的數據做水平的數據隔離,比如某些表中如果含有某個字段,比如store_id(門店id)這個字段,

則對某些有對應門店權限的用户角色開放數據,如果請求的用户沒有對該門店的權限,則自動對sql進行攔截添加where條件。

當然如果同一張表,又必須要查詢全量數據,又可以通過添加自定義註解的方式,跳過數據隔離,返回全量數據。

並且如果用户沒有任何門店的權限,或其他類似權限限制,則直接不執行查詢,返回數據為空。


實現

新建SpringBoot項目,並引入相關依賴

如下依賴特別關注:

<!--MybatisPlus依賴-->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.5.1</version>
        </dependency>
        <dependency>
            <groupId>org.aspectj</groupId>
            <artifactId>aspectjrt</artifactId>
            <version>1.9.7</version>
        </dependency>
        <dependency>
            <groupId>org.aspectj</groupId>
            <artifactId>aspectjweaver</artifactId>
            <version>1.9.7</version>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-aspects</artifactId>
        </dependency>

注意:

mybatis-plus-boot-starter 3.5.1 已包含 JSqlParser 依賴

所以此處不需要額外引入如下依賴:

<dependency>
    <groupId>com.github.jsqlparser</groupId>
    <artifactId>jsqlparser</artifactId>
    <version>4.3</version> <!-- MyBatis-Plus 3.5.1 使用的版本 -->
</dependency>

另外還需引入其它非關鍵依賴,按需選擇:

<!-- spring-boot -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!-- spring-boot-test -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
            <exclusions>
                <exclusion>
                    <groupId>org.junit.vintage</groupId>
                    <artifactId>junit-vintage-engine</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <!-- lombok -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.26</version>
            <scope>provided</scope>
        </dependency>
        <!-- 數據庫連接 -->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>

添加mybatisplus的配置類,在配置類中實現初始化表緩存、定期刷新表緩存、註冊數據隔離攔截器操作

代碼實現如下:

@Configuration
@MapperScan("com.badao.demo.mapper")
public class MybatisPlusConfig {

    // 關鍵功能:
    // 1. 初始化時掃描數據庫表結構(initTableCache)
    // 2. 定時刷新表結構緩存(scheduleCacheRefresh)
    // 3. 註冊MyBatis-Plus攔截器鏈
    public MybatisPlusConfig(DataSource dataSource) {
        this.dataSource = dataSource;
        initTableCache();
        //每5分鐘刷新緩存(應對錶結構變更)
        scheduleCacheRefresh();
    }

    // 數據源
    private final DataSource dataSource;
    // 模式名稱
    public static final String DATABASE_B_GAS_STATION = "test";
    // 含有門店id字段的數據表
    // 使用ConcurrentHashMap保證線程安全
    private final Set<String> tablesWithStoreId = Collections.newSetFromMap(new ConcurrentHashMap<>());

    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        // 註冊數據水平隔離攔截器
        interceptor.addInnerInterceptor(new StoreDataInterceptor(tablesWithStoreId));
        // 註冊分頁攔截器
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        return interceptor;
    }


    /**
     * 初始化表緩存
     */
    private void initTableCache() {
        try (Connection conn = dataSource.getConnection()) {
            DatabaseMetaData metaData = conn.getMetaData();

            Map<String, Set<String>> tableColumnsMap = new HashMap<>();
            try (ResultSet columns = metaData.getColumns(DATABASE_B_GAS_STATION, null, "%", "%")) {
                while (columns.next()) {
                    String tableName = columns.getString("TABLE_NAME").toLowerCase();
                    String columnName = columns.getString("COLUMN_NAME").toLowerCase();
                    tableColumnsMap.computeIfAbsent(tableName, k -> new HashSet<>()).add(columnName);
                }
            }

            // 獲取所有表
            try (ResultSet tables = metaData.getTables(DATABASE_B_GAS_STATION, null, "%", new String[]{"TABLE"})) {
                while (tables.next()) {
                    String tableName = tables.getString("TABLE_NAME").toLowerCase();
                    Set<String> columns = tableColumnsMap.getOrDefault(tableName, Collections.emptySet());

                    if (columns.contains(StoreDataInterceptor.STORE_ID)) {
                        tablesWithStoreId.add(tableName);
                    }
                }
            }
        } catch (Exception e) {
            throw new RuntimeException("SellerIso: Failed to init table cache", e);
        }
    }

    /**
     * 定時刷新表結構緩存
     */
    private void scheduleCacheRefresh() {
        //刷新表結構緩存
        ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
        scheduler.scheduleAtFixedRate(this::initTableCache, 5, 5, TimeUnit.MINUTES);
    }
}

數據隔離攔截器實現代碼

public class StoreDataInterceptor implements InnerInterceptor {

    private final Set<String> tablesWithStoreId;

    //隔離字段column
    public static final String STORE_ID = "store_id";

    public StoreDataInterceptor(Set<String> tablesWithStoreId) {
        this.tablesWithStoreId = tablesWithStoreId;
    }

    /**
     * 優先級高於SQL改寫
     * 若返回false,則不會觸發後續的beforeQuery(SQL重寫邏輯)
     */
    @Override
    public boolean willDoQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) {
        // 指定跳過數據隔離
        if (SkipDataIsolation.getMethodSkipDataIsolation()) {
            return true;
        }
        // 其它業務邏輯則不予查詢,比如獲取請求頭中的數據做權限校驗,完全禁止無權限的查詢(如未登錄用户)
//        if(!CollectionUtils.isEmpty(UserContextHolder.getStoreIds())
//        {
//            return false;
//        }
        return true;
    }

    @Override
    public void beforeQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds,
                            ResultHandler resultHandler, BoundSql boundSql) {
        // 如果用户是超管用户則跳過攔截器-自己添加邏輯判斷
        if (false) {
            return;
        }
        // 指定跳過數據隔離
        if (SkipDataIsolation.getMethodSkipDataIsolation()) {
            return;
        }

        String sql = boundSql.getSql();
        try {
            //解析SQL並重寫
            Select select = (Select) CCJSqlParserUtil.parse(sql);
            SelectBody selectBody = select.getSelectBody();

            // 遞歸處理所有SELECT部分
            processSelectBody(selectBody);

            PluginUtils.mpBoundSql(boundSql).sql(select.toString());
        } catch (Exception e) {
            System.out.println(e.getMessage());
        }
    }

    /**
     * 處理PlainSelect
     */
    private void processSelectBody(SelectBody selectBody) {
        if (selectBody instanceof PlainSelect) {
            processPlainSelect((PlainSelect) selectBody);
        } else if (selectBody instanceof SetOperationList) {
            // 處理UNION/INTERSECT等
            for (SelectBody body : ((SetOperationList) selectBody).getSelects()) {
                processSelectBody(body);
            }
        }
        // 其他類型如WithItem暫不處理
    }

    /**
     * 處理FROM項
     */
    private void processPlainSelect(PlainSelect plainSelect) {
        // 1. 處理FROM項
        Map<String, String> aliasTableMap = new HashMap<>();
        processFromItem(plainSelect.getFromItem(), aliasTableMap);

        // 2. 處理JOIN表
        if (plainSelect.getJoins() != null) {
            for (Join join : plainSelect.getJoins()) {
                processFromItem(join.getRightItem(), aliasTableMap);
            }
        }

        // 3. 添加條件到當前SELECT
        addConditionsToSelect(plainSelect, aliasTableMap);

        // 4. 遞歸處理子查詢
        processSubQueries(plainSelect);
    }

    /**
     * 處理查詢
     */
    private void processFromItem(FromItem fromItem, Map<String, String> aliasTableMap) {
        if (fromItem instanceof Table) {
            Table table = (Table) fromItem;
            String tableName = table.getName().toLowerCase();
            String alias = table.getAlias() != null ?
                    table.getAlias().getName().toLowerCase() : tableName;

            // 緩存別名映射
            aliasTableMap.put(alias, tableName);
        } else if (fromItem instanceof SubSelect) {
            // 處理子查詢
            processSelectBody(((SubSelect) fromItem).getSelectBody());
        }
    }

    /**
     * 處理子查詢
     */
    private void processSubQueries(PlainSelect plainSelect) {
        // 1. 處理WHERE子句中的子查詢
        if (plainSelect.getWhere() != null) {
            plainSelect.getWhere().accept(new SafeExpressionVisitor());
        }

        // 2. 處理SELECT列表中的子查詢
        for (SelectItem item : plainSelect.getSelectItems()) {
            item.accept(new SafeSelectItemVisitor());
        }
    }

    /**
     * 添加查詢條件到SELECT
     */
    private void addConditionsToSelect(PlainSelect plainSelect, Map<String, String> aliasTableMap) {
        // 檢查哪些表需要添加條件
        for (Map.Entry<String, String> entry : aliasTableMap.entrySet()) {
            String alias = entry.getKey();
            String tableName = entry.getValue();
            //List<String> storeIds = UserContextHolder.getStoreIds();
            //此處用模擬數據示例
            List<String> storeIds = new ArrayList(){{
                this.add("1");
                this.add("2");
            }};
            //對含store_id的表自動添加條件:
            if (tablesWithStoreId.contains(tableName)) {
                handleSelectSql(alias, plainSelect, storeIds);
            }
        }
    }

    /**
     * 創建查詢表達式
     */
    private static void handleSelectSql(String alias, PlainSelect plainSelect,
                                        List<String> companyChannelIds) {
        // 創建條件表達式
        Column channelColumn = new Column(alias + "." + StoreDataInterceptor.STORE_ID);

        // 創建表達式列表
        ExpressionList expressionList = new ExpressionList();
        // 手動初始化expressions列表
        expressionList.setExpressions(new ArrayList<>());

        for (String id : companyChannelIds) {
            expressionList.getExpressions().add(new StringValue(id));
        }

        // 構建條件表達式:WHERE (store_id IN (1,2) OR store_id IS NULL)
        // 創建IN表達式
        InExpression inExpression = new InExpression(channelColumn, expressionList);
        // 添加or 數據隔離字段is null 條件避免聯表查詢時未能關聯數據導致全部數據被過濾
        IsNullExpression isNullExpression = new IsNullExpression();
        isNullExpression.setLeftExpression(channelColumn);
        OrExpression orExpression = new OrExpression(isNullExpression, inExpression);
        // 調整or條件優先級 加()
        Parenthesis parenthesis = new Parenthesis(orExpression);

        // 獲取現有WHERE條件
        Expression where = plainSelect.getWhere();
        plainSelect.setWhere(where == null ? parenthesis : new AndExpression(where, parenthesis));
    }

    /**
     * 避免查詢無限遞歸
     */
    private class SafeExpressionVisitor extends ExpressionVisitorAdapter {
        private final Set<Object> visitedObjects = Collections.newSetFromMap(new IdentityHashMap<>());

        @Override
        public void visit(SubSelect subSelect) {
            // 防止SubSelect無限遞歸
            if (visitedObjects.add(subSelect)) {
                try {
                    // 限制遞歸深度
                    if (visitedObjects.size() < 50) {
                        processSelectBody(subSelect.getSelectBody());
                    }
                } catch (Exception e) {
                    System.out.println(e.getMessage());
                } finally {
                    visitedObjects.remove(subSelect);
                }
            }
        }

        @Override
        public void visit(AllColumns allColumns) {
            // 關鍵:避免處理AllColumns時的無限遞歸
            // 在JSqlParser 4.3中,這裏不能調用super.visit(allColumns)
        }
    }

    /**
     * 避免查詢無限遞歸
     */
    private class SafeSelectItemVisitor extends SelectItemVisitorAdapter {
        @Override
        public void visit(SelectExpressionItem item) {
            try {
                item.getExpression().accept(new SafeExpressionVisitor());
            } catch (Exception e) {
                System.out.println(e.getMessage());
            }
        }
    }
}

代碼如下:

注意:

1、willDoQuery中

核心作用

攔截器開關控制

決定是否允許當前SQL查詢繼續執行(true放行,false攔截)

與跳過機制集成

通過檢查SkipDataIsolation的線程狀態,實現動態攔截控制

方法調用時機

sequenceDiagram
    MyBatis->>StoreDataInterceptor: 執行查詢前
    StoreDataInterceptor->>willDoQuery: 檢查攔截條件
    alt 返回true
        MyBatis->>DB: 正常執行查詢
    else 返回false
        MyBatis->>調用方: 直接返回空結果
    end

優先級高於SQL改寫

若返回false,則不會觸發後續的beforeQuery(SQL重寫邏輯)

典型使用場景

完全禁止無權限的查詢(如未登錄用户)

快速跳過無需處理的查詢類型(如特定Mapper方法)

2、beforeQuery中

如果用户是超管用户則跳過攔截器-自己添加邏輯判斷

addConditionsToSelect添加查詢條件中SELECT中,獲取當前用户的門店id權限使用模擬數據演示效果。

正常應該是從權限控制相關業務中獲取,此處注意使用時修改。

自定義跳過數據隔離註解實現

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface SkipDataIsolationAnnotation {
}

跳過數據隔離切面實現

/**
 * 跳過數據隔離切面
 */
@Aspect
@Component
public class SkipDataIsolationAspect {

    //Around增強:在方法執行前後插入邏輯
    @Around("@annotation(skipDataIsolationAnnotation)")
    public Object handleSkipDataIsolation(ProceedingJoinPoint joinPoint,
                                          SkipDataIsolationAnnotation skipDataIsolationAnnotation) throws Throwable {
        try {
            //進入方法時設置ThreadLocal標誌為true
            SkipDataIsolation.setMethodSkipDataIsolation(true);// 設置線程標誌
            return joinPoint.proceed();
        } finally {
            //通過try-finally確保異常時也能清理狀態
            SkipDataIsolation.methodClear(); // 清理線程狀態
        }
    }
}

上下文控制器實現

/**
 * 上下文控制器
 */
public class SkipDataIsolation {

    // 單次sql語句級別跳過數據隔離: 使用ThreadLocal存儲跳過數據隔離的標誌,默認不跳過value=false
    private static final ThreadLocal<Boolean> SKIP_DATA_ISOLATION = ThreadLocal.withInitial(() -> false);
    // 方法級別跳過數據隔離: 使用ThreadLocal存儲跳過數據隔離的標誌, 默認不跳過value=false
    private static final ThreadLocal<Boolean> SKIP_DATA_ISOLATION_METHOD = ThreadLocal.withInitial(() -> false);

    /**
     * 單次sql級別:設置跳過數據隔離標誌
     */
    public static void setSkipDataIsolation(Boolean skip) {
        SKIP_DATA_ISOLATION.set(skip);
    }

    /**
     * 單次sql級別:獲取跳過數據隔離標誌
     */
    public static Boolean getSkipDataIsolation() {
        return SKIP_DATA_ISOLATION.get();
    }

    /**
     * 單次sql級別:清理ThreadLocal,防止內存泄漏
     */
    public static void clear() {
        SKIP_DATA_ISOLATION.remove();
    }

    /**
     * 方法級別:設置跳過數據隔離標誌
     */
    public static void setMethodSkipDataIsolation(Boolean skip) {
        SKIP_DATA_ISOLATION_METHOD.set(skip);
    }

    /**
     * 方法級別:獲取跳過數據隔離標誌
     */
    public static Boolean getMethodSkipDataIsolation() {
        return SKIP_DATA_ISOLATION_METHOD.get();
    }

    /**
     * 方法級別:清理ThreadLocal,防止內存泄漏
     */
    public static void methodClear() {
        SKIP_DATA_ISOLATION_METHOD.remove();
    }
}

測試效果

新建一個包含store_id字段的表,並生成5條數據,其中有兩條數據store_id為1和2。

新建兩個controller並且一個添加跳過數據隔離註解,一個不添加,執行同樣的mp的條件查詢。

不進行數據隔離的查詢效果

SpringBoot 系列教程 Mybatis+註解整合篇 - 小灰灰Blog的個人空間 -_數據

帶數據隔離的效果