基本方案
- 将
ruoyi-vue
扩展为多租户,查看了下其生态中也有一些多租户的扩展,感觉都有些简单,不太完善,所以并没有采用。 - 多租户实现方式只用了最简单的表中添加字段标识
tenant_id
的方式来实现多租户,其他单独数据库、独立表等方式未涉及。 - 采用的
mybatis-plus
提供的多租户方案,也测试过最近新的mybatis-flex
类库,但是它不支持原生xml语句自动拼接租户字段,而mybatis-plus
则支持。相关文档:mybatis文档、RuoYi集成mybatisplus文档、冲突解决 - 是否有管理后台,看到有一些案例并没有一个总的管理后台,而是其中一个租户有这些管理权限,但这样感觉权限不太好划分,目前采用区分管理后台和业务后台,用户登录时可切换选择哪个后台来登录。
- 登录时一般有两种方式::
- 一种是登录界面就选择租户,此时用户表一般就要增加租户标识。
- 一种是登录后自动登录上次的租户,然后可以在后台中切换不同租户,这样的话用户就是共用的,然后有一个用户和租户关系表来维护他们之间的关系。
整体框架
后台登录分为管理后台登录
和业务后台登录
,两者的功能及菜单规划如下图,红色为管理后台登录
后展示的功能,绿色为业务后台登录
后展示的功能,其中组织机构就是租户的概念,下面介绍下于原有系统大概的变更:
-
菜单表
- 不需要增加租户标识字段,而是依赖
sys_role_menu
角色菜单表来展示不同的菜单。 - 增加
is_sys
标识,只用于最顶层的菜单,为1
标识这个菜单只展示在管理后台中,为0
标识这个菜单只展示在租户业务后台中。 - 菜单列表展示所有的菜单,包含后台管理和业务的,且可以修改。
- 后台管理设置角色权限时只展示
is_sys
为1
的菜单。 - 后台管理设置租户菜单套餐时只展示
is_sys
为0
的菜单
- 不需要增加租户标识字段,而是依赖
-
用户表
- 增加
is_sys
标识,只有为1
的用户才能登录管理后台,管理后台中创建新用户时可以选择。 - 增加
user_org
关联表,用于标识用户和租户的关系。 - 管理后台的用户管理展示全部的用户,用户可以选择分配到哪个租户中,无部门选择,可以选择后台管理的角色。
- 业务后台的用户管理展示此租户下的用户,有部门,且可以选择租户下的角色。
- 增加
-
部门表
- 增加租户标识字段
- 管理后台取消部门相关功能(目前我的项目是这样,如果管理后台也想要部门的话可以用租户ID为
0
的来标识,但相关逻辑也要处理) - 部门管理只在租户内才存在。
-
角色表
- 增加租户标识字段
- 增加
is_admin
字段标识是否为管理员,而不是用是否为admin
来标识,而且此管理员既可以标识管理后台的管理员也可以标识租户后台的管理员。 - 管理后台的角色管理展示管理后台的角色,内部实现是用租户ID为0的代表管理后台的角色。
- 业务后台的角色管理展示的是业务后台的角色。
-
用户登录
- 前端页面
login.vue
复制一个login-admin.vue
,用来区分管理后台登录和租户业务后台登录,两个页面增加个按钮可以互相切换。 - 两个页面传递一个参数来区分
isAdminLogin
,管理登录还是业务登录,然后在loadUserByUsername
方法中判定逻辑- 如果是后台管理登录则判定用户的
is_sys
标识是否为1
,只有此标识才能登录管理后台 - 如果是后台业务登录,则从人员租户表中判定是否属于某个租户,如果不属于则报错,如果属于,则优先取上次登录的机构,无上次登录机构则取第一个机构,登录后可自行切换不同的租户。
- 上述两个登录区分不同的部门、角色信息,存入
LoginUser
信息中。
- 如果是后台管理登录则判定用户的
- 前端不需要在所有请求的
header
中增加类似TENANT-ID
的字段,只需要在登录后将租户ID放到LoginUser
中即可,然后请求用户信息时可以将当前登录的租户返回,方便展示当前的租户,而mybatis-plus
多租户中配置多租户的方法getTenantId
中直接从SecurityUtils
中获取当前租户的ID即可。
- 前端页面
-
组织机构
- 也就是租户管理,只存在管理后台中。
- 菜单套餐,可以设置不同的菜单套餐。
- 租户管理,可以创建租户,然后还有两个主要操作
- 可以分配不同的菜单套餐,同时系统会设置默认的租户管理员角色,管理员角色管理的关联菜单就是分配的菜单套餐。
- 可以设置管理员,上方分配菜单同时创建默认的管理员角色,此时分配管理员就是将此角色可此租户内的某个人员关联,此人就有了租户管理员角色。
-
缓存处理
- 主要要处理
redis
中用户信息的缓存。 - 总菜单
- 新增无影响,需要手动将相关角色、或套餐赋值新菜单。
- 中修改无影响,因为是通过ID来展示菜单。
- 删除需要先判定是否有分配角色,有则不能删除。新增无影响
- 菜单套餐
- 新增,无影响
- 修改,又分为菜单的新增和删除:
- 菜单新增,只增加关联此套餐下的租户管理员角色下的新增菜单,且更新在线的租户管理员的缓存,租户下其他人员再由租户管理员手动分配
- 菜单删除,删除关联此套餐下的所有角色下的此菜单,且更新在线的所有用到上方角色的人员的缓存。
- 删除,判定此套餐是否有分配,有则不允许删除
- 租户套餐分配
- 新增,只增加此租户下的管理员角色,将新增套餐的菜单关联写入,且更新在线的此租户管理员缓存。
- 删除,删除关联此套餐下的所有角色下的此菜单,且更新在线的所有用到上方角色的人员的缓存。
- 管理后台中的人员更新,则需要更新包含管理和业务此在线人员的缓存
- 业务后台中的人员更新,则只需要更新此租户下的在线人员的缓存。
- 主要要处理
示意图
-
登录,可切换管理和业务两种登录方式:
-
管理后台用户,可以分配所属租户,可以区分是否有管理后台登录权限,系统用户还可以选择系统角色:
-
管理后台菜单和权限,菜单列表展示的是全部菜单,然后区分哪些是属于管理后台哪些是属于租户后台,然后角色这里是指管理后台的角色,所以只展示了管理后台的菜单:
-
管理后台租户管理,可以配置菜单套餐,此套餐只取
is_sys
为0的菜单,然后机构可以分配套餐和指定管理员: -
租户业务后台用户登录,登录后只展示租户菜单,且可以切换不同租户,不同租户的权限及菜单也不相同:
-
租户业务后台的租户用户,只展示此租户内的用户且会关联部门,关联的角色也为此租户内的角色,如果创建,实际上是既创建一个总的用户,然后又创建了一个用户和本租户的关系,而删除则只移除与此租户关系,而不能删除用户:
-
租户业务后台的租户角色,只用于此租户内的角色,用管理员所具有的菜单做为此租户总的菜单:
-
部门和岗位,就是用原有的部门和岗位,只不过是放到租户中了且支持多租户,就不演示了。
代码示例
-
mybatis-plus
多租户配置:-
参数配置
package com.ruoyi.framework.config.properties; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Configuration; import java.util.List; /** * 多租户配置 * * @author vishun */ @Configuration @ConfigurationProperties(prefix = "tenant") @Data public class TenantProperties { /** * 是否开启多租户 */ private Boolean enable; /** * 租户字段名 */ private String column; /** * 需要忽略的租户表名 */ private List<String> excludes; }
tenant: # 是否启用,仅测试时可以临时关闭整个租户机制,但因为整个框架都是多租户架构,所以正式环境必须要启用 enable: true # 租户字段名 column: org_id # 排除表 excludes: - base_organization - base_menu_pack - base_menu_pack_detail - base_org_menu_pack - gen_table - gen_table_column - qrtz_blob_triggers - qrtz_calendars - qrtz_cron_triggers - qrtz_fired_triggers - qrtz_job_details - qrtz_locks - qrtz_paused_trigger_grps - qrtz_scheduler_state - qrtz_simple_triggers - qrtz_simprop_triggers - qrtz_triggers - sys_config - sys_dict_data - sys_dict_type - sys_job - sys_job_log - sys_logininfor - sys_menu - sys_notice - sys_oper_log - sys_role_dept - sys_role_menu - sys_user - sys_user_post - sys_user_role
-
拦截器配置
MybatisPlusConfig
类种@Autowired private TenantProperties tenantProperties; /** * mybatis拦截器 * * @return */ @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); //多租户插件 if (tenantProperties.getEnable()) { interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() { /** * 获取租户ID * * @return */ @Override public Expression getTenantId() { //从登录信息中获取当前组织ID Long orgId = SecurityUtils.getCurrentOrgIdWithoutException(); if (orgId == null) { return new NullValue(); } return new LongValue(orgId); } /** * 获取租户字段的名称 * * @return */ @Override public String getTenantIdColumn() { return tenantProperties.getColumn(); } /** * 哪些表忽略租户 * 这里通过反向来配置哪些表开启租户 * * @param tableName 表名 * @return true忽略,false开启 */ @Override public boolean ignoreTable(String tableName) { List<String> excludeTables = tenantProperties.getExcludes(); if (excludeTables != null && !excludeTables.isEmpty() && excludeTables.contains(tableName)) { return true; } return false; } })); } // 分页插件 interceptor.addInnerInterceptor(paginationInnerInterceptor()); // 乐观锁插件 interceptor.addInnerInterceptor(optimisticLockerInnerInterceptor()); // 阻断插件 interceptor.addInnerInterceptor(blockAttackInnerInterceptor()); return interceptor; }
-
-
登录处理,
UserDetailsServiceImpl
类中的登录方法:@Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { try { //此时还没有租户,所以全部关闭租户功能 InterceptorIgnoreHelper.handle(IgnoreStrategy.builder().tenantLine(true).build()); //后续逻辑 SysUser user = userService.selectUserByUserName(username); if (StringUtils.isNull(user)) { log.info("登录用户:{} 不存在.", username); throw new ServiceException("登录用户:" + username + " 不存在"); } else if (UserStatus.DELETED.getCode().equals(user.getDelFlag())) { log.info("登录用户:{} 已被删除.", username); throw new ServiceException("对不起,您的账号:" + username + " 已被删除"); } else if (UserStatus.DISABLE.getCode().equals(user.getStatus())) { log.info("登录用户:{} 已被停用.", username); throw new ServiceException("对不起,您的账号:" + username + " 已停用"); } //修改只有后台登录才验证,其它微信登录等没有密码不需要验证 Authentication usernamePasswordAuthenticationToken = AuthenticationContextHolder.getContext(); String loginUserType = AuthenticationContextHolder.getLoginUserType(); if (usernamePasswordAuthenticationToken != null && LoginUserType.SYS_USER.getCode().equals(loginUserType)) { passwordService.validate(user); } Long userId = user.getUserId(); Long currentOrgId = Constants.ADMIN_DEFAULT_ORG_ID;//后台管理登录时,默认为0 //区分管理登录和业务登录 Boolean isAdminLogin = AuthenticationContextHolder.getIsAdminLogin(); isAdminLogin = isAdminLogin != null && isAdminLogin; if (isAdminLogin) { //如果是管理后台登录,额外判定 if (SysYesNo2.NO.equals(user.getIsSys())) { log.info("登录用户:{} 非管理用户.", username); throw new ServiceException("对不起,您的账号:" + username + " 并非管理账号"); } } else { //判定是否存在租户,且设置默认租户 BaseUserOrg queryUserOrg = new BaseUserOrg(); queryUserOrg.setUserId(userId); queryUserOrg.setDisabled(SysNormalDisable.NORMAL); List<BaseUserOrg> list = baseUserOrgService.selectBaseUserOrgList(queryUserOrg); if (list == null || list.isEmpty()) { log.info("登录用户:{} 无归属机构.", username); throw new ServiceException("对不起,您的账号:" + username + " 无归属机构"); } //优先使用上次的租户,如果不存在,则取第一个 List<Long> orgIds = list.stream().map(BaseUserOrg::getOrgId).collect(Collectors.toList()); Long lastOrgId; if (LoginUserType.SYS_USER.getCode().equals(loginUserType)) { //如果是后端 lastOrgId = user.getBackendLoginOrgId(); } else { //如果是前端,我们系统有移动端,所以额外处理了下 lastOrgId = user.getFrontendLoginOrgId(); } if (lastOrgId > 0 && orgIds.contains(lastOrgId)) { currentOrgId = lastOrgId; } else { currentOrgId = orgIds.get(0); } } //组装部门、角色等信息 user = userService.getLatestUser(user, isAdminLogin, userId, currentOrgId); //返回 return new LoginUser(user, loginUserType, currentOrgId, isAdminLogin, permissionService.getMenuPermission(user)); } finally { //恢复租户功能 InterceptorIgnoreHelper.clearIgnoreStrategy(); } }