基于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();
}