基于rouyi框架的多租户改造

1 篇文章 0 订阅
1 篇文章 0 订阅

         基于rouyi框架的多租户改造,重点是实现权限管理数据隔离。权限管理相当于从原来的“顶级管理员admin-普通用户user”转变为“顶级管理员admin-租户管理员tanantAdmin-普通用户user”。数据隔离主要通过分库、分表、表内设置tenantId字段进行过滤三种方式。

       本文主要介绍了rouyi下(SpringBoot3+vue2)权限管理的改造方法思路以及数据隔离的分表、同表加字段过滤方法。同时介绍了:

多租户改造的重点:权限管理+数据隔离实现方法;

多租户优化功能:切换租户设置虚拟ID,实现免登录对应租户账号可查看下级租户数据;

前端请求头设置、后端请求头拦截器的使用;

Spring Security手动设置登录信息的方法;

子模块互相调用时避免相互依赖解决方法;

mybatisPlus的动态表名插件拦截器使用(手动在需要的sql过滤,非全自动);

手动tenantId过滤的方法和注意事项;

目录

一、权限管理

1、顶级租户用户

2、子集租户用户

3、菜单、角色分配、部门分配、租户实现角色控制

​二、数据隔离

三、实现方案

1、租户切换(请求头设置与拦截方法、Spring Security框架下手动更改登录信息方法)

(1)实现功能

(2)实现方式

(3)实现案例

(3.1)前端请求头设置及请求封装

         (3.2)请求拦截器获取tenantId,Spring Security框架下更新登录用户信息

            (3.3)拦截器配置文件:resourcesConfig.java

2、权限管理控制与租户管理

(1)权限管理

(2)租户管理

3、Mybatis拦截器实现动态表名

(3.1)Mybatis动态表名拦截器

(3.2)MyBatisConfig.java添加插件DynamicTableNameInnerInterceptor

(3.3)自定义使用

4、分表隔离(子模块之间相互调用且避免循环依赖方法)

5、过滤字段隔离

6、分表和过滤字段隔离 sql修改注意

7、定时任务


一、权限管理

1、顶级租户用户

用户admin不属于任何租户,唯一账号且最高权限,唯一可以管理租户的账号。

新建菜单==>新建租户并为租户分配菜单==>新建人员(带归属租户)

2、子集租户用户

租户管理员角色tenantAdmin,新建人员(租户归属只能是自己租户),新建角色并为之分配菜单,菜单最多分配到 本租户分配到的菜单。

3、菜单、角色分配、部门分配、租户实现角色控制

二、数据隔离

       Saas数据隔离主要通过:分库、分表、字段过滤三种方式。分库可以通过动态数据源实现,分表可以通过动态表名实现(手动设置或mybatisPlus的DynamicTableNameInnerInterceptor),字段过滤可以通过where条件手动或mybatisPlus的TenantLineInnerInterceptor实现。

三、实现方案

1、租户切换(请求头设置与拦截方法、Spring Security框架下手动更改登录信息方法)

(1)实现功能

       租户切换为优化功能,admin超级管理员可以不用创建和登录租户下用户的账号,就可以以该租户的最高权限操作该租户的数据,查看该租户的数据。

(2)实现方式

前端:切换租户时,将切换的租户id进行缓存,在request请求头加上tenantId信息;后续每个请求进行封装时请求头都会加上tenantId信息。

后端:请求头拦截器做改造,请求头拦截器可以拦截过滤每一个访问后端的请求。设置虚拟mockTenantId(切换租户id,区分实际登录用户的tenantId), 将虚拟mockTenantId存到登录用户信息里。登录用户信息采用Spring Security框架,人为手动更改登录用户信息后,需要调用tokenService更新;也可以将登录用户信息存储在redis,但在使用Spring Security框架时采用此种方法,容易造成登录信息不同步(框架操作了登录用户信息,但redis未人工加代码同步更新)。

(3)实现案例
  (3.1)前端请求头设置及请求封装

request.js文件:

config.headers['tenantId']请求头设置;encodeURIComponent要加,否则会乱码;localStore为本地缓存,也可通过其他方式。

// request拦截器
service.interceptors.request.use(config => {
  // 是否需要设置 token
  const isToken = (config.headers || {}).isToken === false
  if (getToken() && !isToken) {
    config.headers['Authorization'] = 'Bearer ' + getToken() // 让每个请求携带自定义token 请根据实际情况自行修改
    config.headers['tenantId'] = localStore.get('tenantId') === undefined ? '': encodeURIComponent(localStore.get('tenantId')) // 让每个请求携带自定义token 请根据实际情况自行修改

  }
(3.2)请求拦截器获取tenantId,Spring Security框架下更新登录用户信息

headerInterceptor.java文件:

Spring Security框架鉴权方式下,登录信息由框架代码处理,获取方法为

LoginUser loginUser = tokenService.getLoginUser(token);

或者

LoginUser loginUser =  SecurityUtils.getLoginUser();

要想手动修改登录信息需要调用:

tokenService.refreshToken(loginUser)。

也可以通过redis存储同步。

package com.inspur.framework.interceptor;

import com.inspur.common.constant.SecurityConstants;

import com.inspur.common.core.domain.model.LoginUser;

import com.inspur.common.service.TokenService;
import com.inspur.common.utils.RSAUtils;
import com.inspur.common.utils.ServletUtils;
import com.inspur.common.utils.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;

import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

/**
 * 自定义请求头拦截器,将Header数据封装到线程变量中方便获取
 * 注意:此拦截器会同时验证当前用户有效期自动刷新有效期
 *
 * @author inspur
 */
@Component
public class HeaderInterceptor implements HandlerInterceptor
{
    private final Logger logger = LoggerFactory.getLogger(HeaderInterceptor.class);
    @Autowired
    private TokenService tokenService;
    @Override
    public boolean preHandle(HttpServletRequest request,HttpServletResponse response, Object handler) throws Exception
    {
        if (!(handler instanceof HandlerMethod))
        {
            return true;
        }
        String token = request.getHeader(SecurityConstants.AUTHORIZATION_HEADER).substring(7);
        if (StringUtils.isNotEmpty(token)) {
            LoginUser loginUser = tokenService.getLoginUser(token);
            if (StringUtils.isNotNull(loginUser)) {

                // 顶级租户管理员可以切换租户
                if (loginUser.isSuperAdmin()) {
                    String mockTenantId = request.getHeader(SecurityConstants.MOCK_TENANT_ID);
                    if (StringUtils.isBlank(mockTenantId)) {
                        loginUser.setMockTenantId(null);
                    } else {



                        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
                        loginUser = (LoginUser) authentication.getPrincipal();
                        loginUser.setMockTenantId(Long.valueOf(mockTenantId));
                    }
                }
                    tokenService.refreshToken(loginUser);
//        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());

//        SecurityContextHolder.getContext().setAuthentication(authentication);

            }
        }
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex)
            throws Exception
    {
        String mockTenantId = ServletUtils.getRequest().getHeader(SecurityConstants.MOCK_TENANT_ID);

        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        LoginUser loginUser = (LoginUser) authentication.getPrincipal();
        loginUser.setMockTenantId(null);
        tokenService.refreshToken(loginUser);
//        SecurityContextHolder.remove();
    }


        public static boolean isStringNumeric(String str) {
            // 获取待验证的字符串
            if (str == null || str.length() == 0) {
                // 判断字符串是否为空
                return false;
            }
            for (char c : str.toCharArray()) {
                // 遍历字符串的每个字符
                if (!Character.isDigit(c)) {
                    // 检查每个字符是否是数字
                    return false;
                }
            }
            return true;
        }


}

(3.3)拦截器配置文件:resourcesConfig.java

在带有@Configuration的拦截器配置文件,添加tenantId拦截器。

在addInterceptors方法添加excludePathPatterns为不进行拦截过滤的,由于tenantId从登录信息获取,对于不需要登录的网址,如登录、验证码等需要排除。

registry.addInterceptor(headerInterceptor)
            .addPathPatterns("/**")
            .excludePathPatterns("/login","/loginApp", "/appLogin","/register", "/captchaImage","/factory/getPublicKey")
            .excludePathPatterns("/system/sysAppManagement/getActiveAppInfo");

/**
 * 自定义拦截规则
 */
@Override
public void addInterceptors(InterceptorRegistry registry)
{
    registry.addInterceptor(repeatSubmitInterceptor).addPathPatterns("/**");
    registry.addInterceptor(headerInterceptor)
            .addPathPatterns("/**")
            .excludePathPatterns("/login","/loginApp", "/appLogin","/register", "/captchaImage","/factory/getPublicKey")
            .excludePathPatterns("/system/sysAppManagement/getActiveAppInfo");
}

附:

package com.inspur.framework.config;

import com.inspur.framework.interceptor.HeaderInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import com.inspur.common.config.InspurConfig;
import com.inspur.common.constant.Constants;
import com.inspur.framework.interceptor.RepeatSubmitInterceptor;

/**
 * 通用配置
 *
 * @author Inspur
 */
@Configuration
public class ResourcesConfig implements WebMvcConfigurer
{
    @Autowired
    private RepeatSubmitInterceptor repeatSubmitInterceptor;
    @Autowired
    private HeaderInterceptor headerInterceptor;

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry)
    {
        /** 本地文件上传路径 */
        registry.addResourceHandler(Constants.RESOURCE_PREFIX + "/**").addResourceLocations("file:" + InspurConfig.getProfile() + "/");

        /** swagger配置 */
        registry.addResourceHandler("/swagger-ui/**").addResourceLocations("classpath:/META-INF/resources/webjars/springfox-swagger-ui/");
    }

    /**
     * 自定义拦截规则
     */
    @Override
    public void addInterceptors(InterceptorRegistry registry)
    {
        registry.addInterceptor(repeatSubmitInterceptor).addPathPatterns("/**");
        registry.addInterceptor(headerInterceptor)
                .addPathPatterns("/**")
                .excludePathPatterns("/login","/loginApp", "/appLogin","/register", "/captchaImage","/factory/getPublicKey")
                .excludePathPatterns("/system/sysAppManagement/getActiveAppInfo");
    }

    /**
     * 跨域配置
     */
    @Bean
    public CorsFilter corsFilter()
    {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowCredentials(true);
        // 设置访问源地址

        config.addAllowedOriginPattern("*");
        // 设置访问源请求头
        config.addAllowedHeader("*");
        // 设置访问源请求方法
        config.addAllowedMethod("*");
        // 对接口配置跨域设置
        source.registerCorsConfiguration("/**", config);
        return new CorsFilter(source);
    }
}

2、权限管理控制与租户管理

(1)权限管理

     主要在原有基础上添加sys_tenant、sys_tenant_menu(tenant_id, menu_id)表,进行控制。并对菜单、部门、角色进行tenant_id过滤。

        引入虚拟mockTenantId,用于切换租户功能,优先获取mockTenantId作为tenantId,表明用户当前进行切换租户操作;如果mockTenantId为空,表名未进行切换租户操作,获取实际登录用户的所属租户tenantId。

        租户管理员角色为全局固定“tenantAdmin”,顶级管理员admin只能有一个用户,不能重名;租户管理员可以有多个,赋予角色tenantAdmin,菜单权限为“*:*:*”

public static Long getTenantId() {
    try {

        LoginUser loginUser = SecurityUtils.getLoginUser();

        Long tenantId1 = loginUser.getTenantId();

        if (loginUser.getMockTenantId() != null && loginUser.isSuperAdmin()) {
            tenantId1 = loginUser.getMockTenantId();
        }
        return tenantId1;

    } catch (Exception e) {
        logger.error("获取租户ID异常",e);
        return -1L;
    }

}

public static boolean isSuperAdmin(LoginUser loginUser)
{
    if (loginUser.getUsername() != null && loginUser.getUsername().equals("admin")) {
        return true;
    }
    return false;
}

// 子集租户 - 租户管理员角色tenantAdmin
public static boolean hasTenantAdminRole(SysUser user) {
    if (user == null) {
        return false;
    }
    List<SysRole> roles = user.getRoles();
    if (roles == null || roles.isEmpty()) {
        return false;
    }
    for (SysRole sysRole : roles) {
        if (sysRole.getRoleKey().equals("tenantAdmin")) {
            return true;
        }
    }
    return false;
}
/**
 * 获取菜单数据权限
 *
 * @param user 用户信息
 * @return 菜单权限信息
 */
public Set<String> getMenuPermission(SysUser user)
{
    Set<String> perms = new HashSet<String>();
    // 管理员拥有所有权限
    if (user.isAdmin() || LoginUser.hasTenantAdminRole(user))
    {
        perms.add("*:*:*");
    }
    else
    {
        perms.addAll(menuService.selectMenuPermsByUserId(user.getUserId()));
    }
    return perms;
}
(2)租户管理

租户管理:admin唯一账号能管理租户、切换租户,为租户分配菜单。

用户管理:admin可以创建用户并选择所属租户,通常用于创建租户管理员。租户管理员角色只有admin能赋予。

其他用户(所属角色有”用户管理“菜单)可以创建用户,用户的租户默认为创建人所属租户。

3、Mybatis拦截器实现动态表名

          主要介绍动态表名拦截器的使用DynamicTableNameInnerInterceptor,且非全自动给所有语句添加,在需要使用的查询中设置使用,更加灵活。

Mybatis拦截器的应用有

·  自动分页: PaginationInnerInterceptor

·  多租户: TenantLineInnerInterceptor

·  动态表名: DynamicTableNameInnerInterceptor

·  乐观锁: OptimisticLockerInnerInterceptor

·  sql 性能规范: IllegalSQLInnerInterceptor

·  防止全表更新与删除: BlockAttackInnerInterceptor

    在多租户中,DynamicTableNameInnerInterceptor可用于分表隔离使用,TenantLineInnerInterceptor可用于同一表中tenant_id过滤隔离。

(3.1)Mybatis动态表名拦截器

TenantTableNameHandler.java

package com.inspur.framework.datasource;

import com.baomidou.mybatisplus.extension.plugins.handler.TableNameHandler;

import java.util.Arrays;
import java.util.List;

/**
 * 按天参数,组成动态表名
 */
public class TenantTableNameHandler implements TableNameHandler {
    //用于记录哪些表可以使用该动态表名处理器(即哪些表需要分表)
    private List<String> tableNames;
    //构造函数,构造动态表名处理器的时候,传递tableNames参数
    public TenantTableNameHandler(String ...tableNames) {
        this.tableNames = Arrays.asList(tableNames);
    }
    //每个请求线程维护一个tenantId数据,避免多线程数据冲突。所以使用ThreadLocal
    private static final ThreadLocal<String> TENANT_ID = new ThreadLocal<>();
    //设置请求线程的TenantId数据
    public static void setTenantId(String day) {
        TENANT_ID.set(day);
    }
    //删除当前请求线程的数据
    public static void removeTenantId() {
        TENANT_ID.remove();
    }
    //动态表名接口实现方法
    @Override
    public String dynamicTableName(String sql, String tableName) {
        if (this.tableNames.contains(tableName)){
            return tableName + "_" + TENANT_ID.get();  //表名增加后缀
        }else{
            return tableName;   //表名原样返回
        }
    }
}

(3.2)MyBatisConfig.java添加插件DynamicTableNameInnerInterceptor

MyBatisConfig.java文件:

@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor(){
    MybatisPlusInterceptor mybatisPlusInterceptor = new MybatisPlusInterceptor();
    //动态表名
    DynamicTableNameInnerInterceptor dynamicTableNameInnerInterceptor = new DynamicTableNameInnerInterceptor();
    //可以传多个表名参数,指定哪些表使用DayTableNameHandler处理表名称
    dynamicTableNameInnerInterceptor.setTableNameHandler(new TenantTableNameHandler(
            "factory_check_plan","factory_check_plan_item","factory_check_item","factory_check_task","factory_check_task_item","factory_check_task_user","factory_check_task_approve",
            "factory_inspection_plan","factory_inspection_plan_item","factory_inspection_item","factory_inspection_task","factory_inspection_task_item","factory_inspection_task_user"
            ));
    //以拦截器的方式处理表名称
    //可以传递多个拦截器,即:可以传递多个表名处理器TableNameHandler
    mybatisPlusInterceptor.addInnerInterceptor(dynamicTableNameInnerInterceptor);
    return mybatisPlusInterceptor;
}

(3.3)自定义使用

拦截器采取手动设置方法,在需要的地方使用动态表名,而不是所有语句,此种方式更为灵活。

//查询时手动设置,更为灵活,不会自动设置

Long tenantId = SecurityUtils.getTenantId();
TenantTableNameHandler.setTenantId(tenantId.toString());



//  sql语句

// getById、list、updateById、removeByIds等

//例如: FactoryCheckItem factoryCheckItem = factoryCheckItemService.getById(id);


// 移除不影响其他语句
TenantTableNameHandler.removeTenantId();

效果:

分表直接使用selectById

设置拦截器后,再使用selectById

4、分表隔离(子模块之间相互调用且避免循环依赖方法)

        分表隔离,表名设置通常为table_name_${tenantId},一个租户一张表,在创建租户时就要创建分表。

        租户管理在system_module模块,业务表在其他子模块(eg:factory_module), 业务模块factory_module一般会依赖于系统模块system_module。如果创建租户(system_module模块中)直接调用业务表创建方法(factory_module模块中),会造成循环依赖。

        子模块之间互相调用的解决方案主要有以下两种方法:

一是新建公共api模块,类似于common_module,此时实体类需要重命名防止类名冲突;

二是调用接口地址,在微服务Springcloud中建立公共module采用@Fegin方式,实现子模块调用解决循环依赖;在springBoot也可以采用类似思路,HttpUtil、restTemplate等工具,此时需要封装请求头,较为繁琐。详见:

Java调用第三方http接口的4种方式:restTemplate,HttpURLConnection,HttpClient,hutool的HttpUtil,实例直接干,以防忘记_resttemplate hutool-CSDN博客

        本文主要介绍第一种,提取公共模块并修改类名。

分表设置时,实体类加上tablePrefix(默认前缀)、 stableName(最终实际表名)、tenantId。查询时mapper映射xml文件的表名可以用 ${stableName}或者table_name_${tenantId}取代。

@TableField(exist = false)
private final String tablePrefix = "factory_check_task_item";


public String getStableName() {
    return tablePrefix + "_" + getTenantId();
}


@TableField(exist = false)
private String stableName;
@TableField(exist = false)
private Long tenantId;

public String getTablePrefix() {
    return tablePrefix;
}

public Long getTenantId() {
    if(tenantId!=null){
        return tenantId;
    }
    return SecurityUtils.getTenantId();
}

# 创建分表语句

create Table IF NOT EXISTS ${stableName} like ${tablePrefix};

# 查询语句

Select * from ${stableName};

5、过滤字段隔离

实体类加tenantId, 查询时mapper映射xml文件在where后加上tenantId过滤。

@TableField(exist = false)
private Long tenantId;



public Long getTenantId() {
    if(tenantId!=null){
        return tenantId;
    }
    return SecurityUtils.getTenantId();
}

#查询语句

Select * from table_name where

<if test="tenantId != null"> and tenant_id = #{tenantId}</if>

6、分表和过滤字段隔离 sql修改注意

为实现同分库级别同样效果的隔离:

(1)在涉及多表连接查询(left join等)时,每一张表都需要进行tenant_id过滤;

(2)在涉及多层嵌套子查询时,每一层都需要进行过滤

(3)在涉及传参为非实体类时,需要增加参数个数,即tenantId

7、定时任务

        定时任务无法获取登录用户,故无法获取tenant_id。

        此时可以在原有业务逻辑最外层嵌套for循环遍历所有租户,在查询语句前设置tenantId。

        此时实体类的getTenantId优先判断是否人工设置,如果已经设置就优先按照人工设置的tenantId,如果没有再自动获取登录用户tenantId.

定时任务:

List<SysTenant> tenantList = sysTenantService.selectSysTenantList(sysTenant);

for(SysTenant tenant : tenantList) {
    Long tenantId = tenant.getId();



//查询前:

QueryDao.setTenantId(tenantId);

Mapper.select***(QueryDao);

}

实体类:

public Long getTenantId() {
    if(tenantId!=null){
        return tenantId;
    }
    return SecurityUtils.getTenantId();
}

  • 16
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Rouyi框架中,当出现空指针异常时,可能是由于以下几个原因引起的: 1. 方法参数为空:检查你调用的方法是否传递了空值作为参数。空指针异常通常发生在试图对空对象调用方法时。 2. 对象引用为空:如果你在代码中使用了一个对象,但该对象为空,那么在尝试访问该对象的属性或方法时就会抛出空指针异常。 3. 未正确初始化对象:当你创建一个对象后,需要确保对该对象进行正确的初始化。如果你没有对对象进行初始化,就尝试访问该对象的属性或方法,就会导致空指针异常。 为了解决空指针异常,你可以采取以下措施: 1. 检查方法参数是否为空,并确保传递正确的参数。 2. 在使用对象之前,先检查对象是否为空,可以使用条件语句(如if语句)对对象进行判空。 3. 确保对象在使用之前被正确地初始化,例如通过构造函数或其他适当的方式。 另外,如果你遇到了具体的Rouyi框架的空指针异常问题,请提供更多的错误信息和相关代码,以便我能够更准确地帮助你解决问题。<span class="em">1</span><span class="em">2</span> #### 引用[.reference_title] - *1* *2* [Ruoyi实现单文件上传和多文件打包压缩包下载](https://blog.csdn.net/qq_39367410/article/details/127646636)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_1"}}] [.reference_item style="max-width: 100%"] [ .reference_list ]

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值