mybatis-plus多租户兼容多字段租户标识

发布于:2025-09-11 ⋅ 阅读:(16) ⋅ 点赞:(0)

默认租户插件处理器的缺陷

在springboot工程中引入mybatis-plus的租户插件TenantLineInnerInterceptor,能简化我们的数据隔离操作,例如各类含租户用户登录权限的rest接口中,不需要再根据登录用户-set租户条件-触发查询,租户插件能帮我们省略手动插入条件的繁琐过程。

mybatis-plus默认仅支持单个字段的租户条件,实际使用场景中,我们的“租户”在系统中,可能是一个多类型的数据概念,例如菜单大类模块一可能一种用户能访问,菜单大类模块二是另一种用户能访问,两种模块用户权限都在同一管理菜单、权限、用户中进行配置,即用户区分类型,不同类型租户id属性来源不同。由于不同大类模块字段可能不一致,即“TenantId”在不同表中,是不同的字段名称,这时候使用原始的租户插件接口,就满足不了需求了。

public interface TenantLineHandler {

    /**
     * 获取租户 ID 值表达式,只支持单个 ID 值
     *
     * @return 租户 ID 值表达式
     */
    Expression getTenantId();

    /**
     * 获取租户字段名
     * 默认字段名叫: tenant_id
     *
     * @return 租户字段名
     */
    default String getTenantIdColumn() {
        return "tenant_id";
    }

    /**
     * 根据表名判断是否忽略拼接多租户条件
     * 默认都要进行解析并拼接多租户条件
     *
     * @param tableName 表名
     * @return 是否忽略, true:表示忽略,false:需要解析并拼接多租户条件
     */
    default boolean ignoreTable(String tableName) {
        return false;
    }

    /**
     * 忽略插入租户字段逻辑
     *
     * @param columns        插入字段
     * @param tenantIdColumn 租户 ID 字段
     * @return
     */
    default boolean ignoreInsert(List<Column> columns, String tenantIdColumn) {
        return columns.stream().map(Column::getColumnName).anyMatch(i -> i.equalsIgnoreCase(tenantIdColumn));
    }
}

上述接口中getTenantId和getTenantIdColumn是唯一的,无法区分不同表不同租户字段。

针对多字段条件租户插件的实现

改造步骤如下:

1、定义一个新的租户数据行处理器

如这里命名FixTenantLineHandler,

仅更新getTenantId和getTenantIdColumn方法,方便根据表名来判断取什么租户字段。

package com.infypower.vpp.security.permission;

import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.schema.Column;

import java.util.List;

/**
 * 租户处理器( TenantId 行级 )
 *
 * @author endcy
 * @see com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler
 */
public interface FixTenantLineHandler {

    /**
     * 获取租户 ID 值表达式,只支持单个 ID 值
     * <p>
     *
     * @return 租户 ID 值表达式
     */
    Expression getTenantId(String tableName);

    /**
     * 获取租户字段名
     * <p>
     * 默认字段名叫: tenant_id
     *
     * @return 租户字段名
     */
    default String getTenantIdColumn(String tableName) {
        return "tenant_id";
    }

    /**
     * 根据表名判断是否忽略拼接多租户条件
     * <p>
     * 默认都要进行解析并拼接多租户条件
     *
     * @param tableName 表名
     * @return 是否忽略, true:表示忽略,false:需要解析并拼接多租户条件
     */
    default boolean ignoreTable(String tableName) {
        return false;
    }

    /**
     * 忽略插入租户字段逻辑
     *
     * @param columns        插入字段
     * @param tenantIdColumn 租户 ID 字段
     * @return
     */
    default boolean ignoreInsert(List<Column> columns, String tenantIdColumn) {
        return columns.stream().map(Column::getColumnName).anyMatch(i -> i.equalsIgnoreCase(tenantIdColumn));
    }
}

2、实现自定义TenantLineHandler

下列TenantSecurityUtils是用户登录时注入的requestScope或者ThreadLocal存储的用户信息,包含不同租户类型信息及ID、管理员租户数据授权配置信息等,可自定义实现。

hasProperty方法会取mybatis-plus缓存的表信息,根据表名判断表映射属性是否包含不同租户id字段,这样做的好处是无需额外编码处理不同表对应不同属性名称配置判断,直接使用mybatis-plus原有的TableInfoHelper相关功能。

import cn.hutool.core.collection.CollUtil;
import com.baomidou.mybatisplus.core.metadata.TableInfo;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.gitee.coadmin.enums.UserIdentityTypeEnum;
import com.gitee.coadmin.utils.TenantSecurityUtils;
import com.infypower.vpp.common.CesConstant;
import lombok.extern.slf4j.Slf4j;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.LongValue;
import net.sf.jsqlparser.expression.operators.relational.ExpressionList;
import net.sf.jsqlparser.expression.operators.relational.InExpression;
import net.sf.jsqlparser.schema.Column;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

/**
 * ...
 *
 * @author endcy
 * @date 2025/9/9
 */
@Slf4j
@Component
public class FixTenantLineInnerInterceptor implements FixTenantLineHandler {

    private static final String TENANT_ID1_COLUMN = "tenant_id1";
    private static final String TENANT_ID1_NAME = "tenantId1";

    private static final String TENANT_ID2_COLUMN = "tenant_id2";
    private static final String TENANT_ID2_NAME = "tenantId2";

    private static final String TENANT_ID3_COLUMN = "tenant_id3";
    private static final String TENANT_ID3_NAME = "tenantId3";

    //存在租户id 字段但忽略租户过滤的数据表 逐行加
    private final List<String> IGNORE_TABLES = CollUtil.newArrayList(
            ""
    );

    @Override
    public Expression getTenantId(String tableName) {
        UserIdentityTypeEnum identityType = TenantSecurityUtils.getCurrentUserIdentityType();
        //如果是平台权限用户 可能有需要过滤租户类型1数据
        if (identityType == UserIdentityTypeEnum.PLATFORM) {
            Set<Long> tenantIds = TenantSecurityUtils.getCurrentPlatformUserTenantId1List();
            if (CollUtil.isEmpty(tenantIds)) {
                //默认返回平台管理权限
                return new LongValue(0L);
            }
            List<Expression> valueList = tenantIds.stream()
                                                  .map(LongValue::new)
                                                  .collect(Collectors.toList());
            return new InExpression(
                    new Column(getTenantIdColumn(tableName)),
                    new ExpressionList(valueList)
            );
        }
        return new LongValue(TenantSecurityUtils.getCurrentUserMajorIdentityId());
    }

    @Override
    public String getTenantIdColumn(String tableName) {
        String tenantColumn = TENANT_ID1_COLUMN;
        //平台理员绑定了特定租户权限
        if (hasProperty(tableName, TENANT_ID1_NAME) && hasProperty(tableName, TENANT_ID2_NAME)) {
            tenantColumn = TENANT_ID1_COLUMN;
            if (TenantSecurityUtils.getCurrentUserIdentityType() == UserIdentityTypeEnum.TENANT1) {
                tenantColumn = TENANT_ID2_COLUMN;
            }
        } else if (hasProperty(tableName, TENANT_ID2_NAME)) {
            tenantColumn = TENANT_ID2_COLUMN;
        } else if (hasProperty(tableName, TENANT_ID3_NAME)) {
            tenantColumn = TENANT_ID3_COLUMN;
        }
        //默认返回主租户字段
        log.debug(">>>> table:{} tenantColumn:{}", tableName, tenantColumn);
        return tenantColumn;
    }

    @Override
    public boolean ignoreTable(String tableName) {
        boolean ignore;
        if (IGNORE_TABLES.contains(tableName)) {
            return true;
        }
        //全数据管理员略过
        if (TenantSecurityUtils.getCurrentUserMajorIdentityId() == 0L
                && CollUtil.isEmpty(TenantSecurityUtils.getCurrentPlatformUserTenantId1List())) {
            return true;
        }
        if (!hasProperty(tableName, TENANT_ID1_NAME)
                && !hasProperty(tableName, TENANT_ID2_NAME)
                && !hasProperty(tableName, TENANT_ID3_NAME)) {
            ignore = true;
            log.debug(">>>> ignore tenant data for table:{}", tableName);
        } else {
            ignore = false;
        }
        if (ignore) {
            IGNORE_TABLES.add(tableName);
            return true;
        }
//        ignore = checkIgnoreTable(tableName);
        return false;
    }

    private boolean checkIgnoreTable(String tableName) {
        if (IGNORE_TABLES.contains(tableName.toLowerCase())) {
            return true;
        }
        //其他校验逻辑待拓展
        return false;
    }

    public static boolean hasProperty(String tableName, String propertyName) {
        TableInfo tableInfo = TableInfoHelper.getTableInfo(tableName);
        if (tableInfo == null) {
            return false;
        }
        // 检查字段列表是否包含该属性
        return tableInfo.getFieldList().stream().anyMatch(field -> field.getProperty().equals(propertyName));
    }

}

3、重写租户sql注入处理器

参考原TenantLineInnerInterceptor实现,使用tenantLineHandler,并替换需要传入表名的调用

package com.infypower.vpp.security.permission;

import com.baomidou.mybatisplus.core.plugins.InterceptorIgnoreHelper;
import com.baomidou.mybatisplus.core.toolkit.*;
import com.baomidou.mybatisplus.extension.parser.JsqlParserSupport;
import com.baomidou.mybatisplus.extension.plugins.inner.InnerInterceptor;
import com.baomidou.mybatisplus.extension.toolkit.PropertyMapper;
import lombok.*;
import net.sf.jsqlparser.expression.*;
import net.sf.jsqlparser.expression.operators.conditional.AndExpression;
import net.sf.jsqlparser.expression.operators.conditional.OrExpression;
import net.sf.jsqlparser.expression.operators.relational.*;
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 org.apache.ibatis.executor.Executor;
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.SqlCommandType;
import org.apache.ibatis.session.ResultHandler;
import org.apache.ibatis.session.RowBounds;

import java.sql.Connection;
import java.sql.SQLException;
import java.util.*;

/**
 * @author endcy
 * @since 2029/9/9
 * @see com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
@SuppressWarnings({"rawtypes"})
public class FixTenantLineInnerInterceptor2 extends JsqlParserSupport implements InnerInterceptor {

    private FixTenantLineHandler tenantLineHandler;

    @Override
    public void beforeQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException {
        ……
    }

    @Override
    public void beforePrepare(StatementHandler sh, Connection connection, Integer transactionTimeout) {
        ……
    }

    @Override
    protected void processSelect(Select select, int index, String sql, Object obj) {
        ……
    }

    protected void processSelectBody(SelectBody selectBody) {
        ……
    }

    @Override
    protected void processInsert(Insert insert, int index, String sql, Object obj) {
        String tableName = insert.getTable().getName();
        if (tenantLineHandler.ignoreTable(tableName)) {
            // 过滤退出执行
            return;
        }
        List<Column> columns = insert.getColumns();
        if (CollectionUtils.isEmpty(columns)) {
            // 针对不给列名的insert 不处理
            return;
        }
        String tenantIdColumn = tenantLineHandler.getTenantIdColumn(tableName);
        if (tenantLineHandler.ignoreInsert(columns, tenantIdColumn)) {
            // 针对已给出租户列的insert 不处理
            return;
        }
        columns.add(new Column(tenantIdColumn));

        // fixed gitee pulls/141 duplicate update
        List<Expression> duplicateUpdateColumns = insert.getDuplicateUpdateExpressionList();
        if (CollectionUtils.isNotEmpty(duplicateUpdateColumns)) {
            EqualsTo equalsTo = new EqualsTo();
            equalsTo.setLeftExpression(new StringValue(tenantIdColumn));
            equalsTo.setRightExpression(tenantLineHandler.getTenantId(tableName));
            duplicateUpdateColumns.add(equalsTo);
        }

        Select select = insert.getSelect();
        if (select != null) {
            this.processInsertSelect(tableName, select.getSelectBody());
        } else if (insert.getItemsList() != null) {
            // fixed github pull/295
            ItemsList itemsList = insert.getItemsList();
            if (itemsList instanceof MultiExpressionList) {
                ((MultiExpressionList) itemsList).getExpressionLists().forEach(el -> el.getExpressions().add(tenantLineHandler.getTenantId(tableName)));
            } else {
                ((ExpressionList) itemsList).getExpressions().add(tenantLineHandler.getTenantId(tableName));
            }
        } else {
            throw ExceptionUtils.mpe("Failed to process multiple-table update, please exclude the tableName or statementId");
        }
    }

    /**
     * update 语句处理
     */
    @Override
    protected void processUpdate(Update update, int index, String sql, Object obj) {
        final Table table = update.getTable();
        if (tenantLineHandler.ignoreTable(table.getName())) {
            // 过滤退出执行
            return;
        }
        update.setWhere(this.andExpression(table, update.getWhere()));
    }

    /**
     * delete 语句处理
     */
    @Override
    protected void processDelete(Delete delete, int index, String sql, Object obj) {
        ……
    }

    /**
     * delete update 语句 where 处理
     */
    protected BinaryExpression andExpression(Table table, Expression where) {
        //获得where条件表达式
        EqualsTo equalsTo = new EqualsTo();
        equalsTo.setLeftExpression(this.getAliasColumn(table));
        equalsTo.setRightExpression(tenantLineHandler.getTenantId(table.getName()));
        if (null != where) {
            if (where instanceof OrExpression) {
                return new AndExpression(equalsTo, new Parenthesis(where));
            } else {
                return new AndExpression(equalsTo, where);
            }
        }
        return equalsTo;
    }


    /**
     * 处理 insert into select
     * <p>
     * 进入这里表示需要 insert 的表启用了多租户,则 select 的表都启动了
     *
     * @param selectBody SelectBody
     */
    protected void processInsertSelect(String tableName, SelectBody selectBody) {
        PlainSelect plainSelect = (PlainSelect) selectBody;
        FromItem fromItem = plainSelect.getFromItem();
        if (fromItem instanceof Table) {
            // fixed gitee pulls/141 duplicate update
            processPlainSelect(plainSelect);
            appendSelectItem(tableName, plainSelect.getSelectItems());
        } else if (fromItem instanceof SubSelect) {
            SubSelect subSelect = (SubSelect) fromItem;
            appendSelectItem(tableName, plainSelect.getSelectItems());
            processInsertSelect(tableName, subSelect.getSelectBody());
        }
    }

    /**
     * 追加 SelectItem
     *
     * @param selectItems SelectItem
     */
    protected void appendSelectItem(String tableName, List<SelectItem> selectItems) {
        if (CollectionUtils.isEmpty(selectItems))
            return;
        if (selectItems.size() == 1) {
            SelectItem item = selectItems.get(0);
            if (item instanceof AllColumns || item instanceof AllTableColumns)
                return;
        }
        selectItems.add(new SelectExpressionItem(new Column(tenantLineHandler.getTenantIdColumn(tableName))));
    }

    /**
     * 处理 PlainSelect
     */
    protected void processPlainSelect(PlainSelect plainSelect) {
        ……
    }

    /**
     * 处理where条件内的子查询
     *
     * @param where where 条件
     */
    protected void processWhereSubSelect(Expression where) {
        ……
    }

    protected void processSelectItem(SelectItem selectItem) {
        ……
    }

    /**
     * 处理函数
     * <p>支持: 1. select fun(args..) 2. select fun1(fun2(args..),args..)<p>
     * <p> fixed gitee pulls/141</p>
     *
     * @param function
     */
    protected void processFunction(Function function) {
        ExpressionList parameters = function.getParameters();
        if (parameters != null) {
            parameters.getExpressions().forEach(expression -> {
                if (expression instanceof SubSelect) {
                    processSelectBody(((SubSelect) expression).getSelectBody());
                } else if (expression instanceof Function) {
                    processFunction((Function) expression);
                }
            });
        }
    }

    /**
     * 处理子查询等
     */
    protected void processFromItem(FromItem fromItem) {
        ……
    }

    /**
     * 处理 joins
     *
     * @param joins join 集合
     */
    private void processJoins(List<Join> joins) {
        ……
    }

    /**
     * 处理联接语句
     */
    protected void processJoin(Join join) {
        ……
    }

    /**
     * 处理条件
     */
    protected Expression builderExpression(Expression currentExpression, Table table) {
        EqualsTo equalsTo = new EqualsTo();
        equalsTo.setLeftExpression(this.getAliasColumn(table));
        equalsTo.setRightExpression(tenantLineHandler.getTenantId(table.getName()));
        if (currentExpression == null) {
            return equalsTo;
        }
        if (currentExpression instanceof OrExpression) {
            return new AndExpression(new Parenthesis(currentExpression), equalsTo);
        } else {
            return new AndExpression(currentExpression, equalsTo);
        }
    }

    /**
     * 租户字段别名设置
     * <p>tenantId 或 tableAlias.tenantId</p>
     *
     * @param table 表对象
     * @return 字段
     */
    protected Column getAliasColumn(Table table) {
        StringBuilder column = new StringBuilder();
        if (table.getAlias() != null) {
            column.append(table.getAlias().getName()).append(StringPool.DOT);
        }
        column.append(tenantLineHandler.getTenantIdColumn(table.getName()));
        return new Column(column.toString());
    }

    @Override
    public void setProperties(Properties properties) {
        PropertyMapper.newInstance(properties).whenNotBlank("tenantLineHandler",
                ClassUtils::newInstance, this::setTenantLineHandler);
    }
}


4、注入自定义租户插件

在mybatis-plus配置类中注入对应租户插件及其他数据权限插件等。

package com.infypower.vpp.config;

import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.DataPermissionInterceptor;
import com.infypower.vpp.security.permission.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * 配置文件
 * @author endcy
 * @since 2025/9/9
 */
@Slf4j
@Configuration
@RequiredArgsConstructor
public class MybatisPlusConfig {

    private final FixTenantLineInnerInterceptor tenantLineInnerInterceptor;

    /**
     * admin模块MP插件
     * 多租户插件、数据权限插件
     */
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();

        // 租户代理
        interceptor.addInnerInterceptor(new FixTenantLineInnerInterceptor(tenantLineInnerInterceptor));

        // 其他数据权限,根据用户配置进行数据权限控制
        DataPermissionInterceptor xxxPermissionInterceptor = new ResourceUserPermissionInterceptor();
        resourceUserPermissionInterceptor.setDataPermissionHandler(new XxxPermissionHandler());
        interceptor.addInnerInterceptor(xxxPermissionInterceptor);


        return interceptor;
    }

}

其他配置保持不变。

上述getTenantId和getTenantIdColumn即核心实现,根据不同的用户类型,赋不同的租户字段标识和租户id信息。相比于网上其他多字段租户方案,本方案不依赖额外配置,不需要复杂解析,应该是最简洁且拓展最为便捷的方式。


网站公告

今日签到

点亮在社区的每一天
去签到