代码生成

以下操作均为橙单代码生成器中的工程配置。下图所示的「部门支持」和「数据权限」均对应于「租户运营应用」中的相关配置,「租户管理应用」中的对应配置,可在创建工程后修改,具体修改方式下面会有详细介绍。

多租户工程创建后,可以继续修改「租户管理应用」的配置项。

由上图可见,多租户工程创建后,会默认生成两个应用,分别是「租户管理」和「租户运营」。租户管理主要服务于租户平台管理的相关业务,比如租户创建、租户角色和权限的分配、租户字典数据的统一管理,以及租户数据的统计分析等相关业务。而租户运营则是每个租户运营和管理其自身业务的应用,比如租户管理员创建租户用户、创建用户角色、配置用户菜单、配置租户流程、配置租户业务数据统计图表、修改租户自身使用的数据字典等。下面是多租户工程生成后的截图说明。

数据库架构

在开始搭建环境之前,我们需要先介绍一下多租户的数据库架构,下图是我们在生成器中配置的「租户管理」和「租户运营」两个数据库。更多技术详情可参考开发文档 租户多数据库架构章节

  • 租户管理数据库,存储租户管理应用自身所需的业务表数据,其中 zz_sys_tenant 表存储所有的租户注册数据。
  • 租户运营数据库,存储所有租户的业务数据。默认情况下我们可以将所有的租户业务数据,存储在同一个数据库中,并通过数据表中的 tenant_id 字段进行逻辑隔离。当然也可以横向扩展该类数据库,并将不同租户的数据,混合部署在不同的租户运营数据库中。

开发环境搭建

具体可参考 快速搭建多租户工程章节

租户管理

主要介绍创建租户和租户用户登录的过程。

  • 登录租户管理后台,创建租户。
  • 租户创建后,橙单会自动为该租户创建管理员用户。

  • 租户管理员用户登录租户运营业务后台,初始默认密码是 123456。
  • 所有租户的基础数据全部存在「租户管理数据库」的 zz_sys_tenant 表中,租户用户登录时会查询该表进行租户编码的验证,同时获取租户所在的数据库链接。由于该操作非常高频,因此我们提供了缓存机制,以提升登录接口的应答效率,同时降低对「租户管理数据库」的访问压力。

租户菜单权限管理

新租户创建后,因为没有分配任何菜单权限,因此租户管理员用户登录租户运营平台后,没有任何菜单可见。本小节将重点介绍租户、租户角色和租户菜单三者之间的页面操作和数据关联关系。更多技术细节可参考开发文档「租户权限详解章节」。

  • 创建租户角色。
  • 创建租户菜单。
  • 租户角色添加租户菜单。
  • 租户角色添加租户。租户和租户角色是多对多关系。如果一个租户同时属于多个租户角色,那么该租户的租户菜单权限,将是所有租户角色的权限合集。
  • 租户管理员登录「租户运营后台」,验证已分配的菜单权限。

租户菜单权限配置

菜单的前后端权限配置规则,多租户工程与单体和微服务完全一样,具体配置可参考用户权限管理章节的 菜单前端权限编码菜单后台权限字列表 两个小节中的配置说明,这里的所有配置都会被「tenant-sync」服务同步到每个租户运营数据库中的 zz_sys_menu 表中。

租户移动端入口管理

租户运营业务应用中的移动端入口「轮播图」和「九宫格」,均需在租户管理应用中进行统一的配置。这里需要重点说明的是下图所示的「显示图片」字段,不推荐存放在租户管理服务所在的本地磁盘,建议存放于分布式存储系统,如橙单目前已支持的 Minio、阿里云 OSS 等。

  • 添加租户运营的移动端入口「九宫格」。
  • 租户移动端入口的租户权限配置,具体配置思路与前面介绍的 租户菜单权限管理 完全一致,同样是为租户的移动端入口配置租户角色,每个租户的移动端入口是其所属租户角色的并集,配置页面如下图所示。

租户报表管理

本小节仅介绍报表打印模块与多租户集成后的相关操作,如需深入了解「报表打印」模块的具体功能,请参考开发文档 统计报表配置章节

租户管理配置

多租户报表打印功能的「数据库链接、数据集和报表字典」等与数据结构相关的底层操作,均需在租户管理系统中完成配置。

  • 数据库链接。

在租户管理后台为租户配置统计表单的数据库链接有以下几点非常非常重要,请仔细阅读!默认情况下,橙单已生成开箱即用的配置,因此直接启动运行即可。

  • 下图中的数据库链接是由初始化脚本 tenant-report-dblink-init.sql 插入的,当前页面只能修改,不能增删。
  • 该脚本所配置的数据库为默认生成的「租户运营业务数据库」,该库必须包含所有的租户业务数据表。
  • 这里的数据库链接,仅用于租户管理为租户业务应用进行在线统计表单的数据集配置,可以简单的理解为租户业务数据表的模板数据库。
  • 在租户业务应用的实际运行中,并不会根据该链接读取在线表单的配置数据,而是直接从租户业务所在的数据库中读取每个租户业务库中的副本配置。
  • 当新增租户业务数据库时,下图中的配置仍然无需修改,但是请务必确保新建的租户数据库中的数据表与其他租户业务库中的表结构保持一致。
  • 数据字典。

这里配置的报表字典,是所有租户可见的。而字典的数据,则会根据租户的不同而不同。比如说「数据表字典」,每个租户的表内数据不同,那么可用的字典值和字典项也均不同。再比如「全局编码字典」,对于非公有的租户全局编码字典而言,每个租户是可以自己维护租户字典数据的,这一点会在本小节的后面,有更详细的图示说明。

  • 数据集。
  • 数据集字段绑定字典。

如下图所示,数据集字段绑定字典的关系只能在租户管理中配置,每个租户不能在租户运营业务系统中修改这一配置。但是租户字典的数据,每个租户可以是不同的,这一点会在本小节的后面,有更详细的图示说明。

  • 租户角色配置与报表数据集的多对多关联关系。

在上一步中,我们为「租户角色三」配置了「测试租户三」,在下面的截图中,「租户租户三」的租户用户,将可以使用下图中,为当前租户角色添加的报表数据集。后面的小节会有详细的操作介绍。

租户运营配置

本小节将以前面配置的「测试租户三」的管理员用户登录,继续介绍租户用户在「租户运营业务系统」中,如何使用前一小节在「租户管理系统」中配置的报表数据集。

  • 租户报表字典详解。

在前面的小节,我们介绍了如何在「租户管理后台」配置租户的报表字典,以及为报表数据集字段设置报表字典,具体操作见下图。

在「租户管理后台」配置的报表字典对所有租户都是可用的,然而很多时候,我们都希望不同的租户可以有不同的租户字典数据,以便实现租户业务数据的个性化显示。对于上图所示的非公用编码字典「科目」而言,每个租户都可以修改该字典的初始化数据,并且不会对其他租户的该字典数据产生任何影响。

  • 配置报表页面。

这里需要重点说明的是,「租户运营业务系统」对于报表模块的「数据库链接、报表数据集和报表字典」等功能菜单没有访问权限,因为对他们的操作都是在「租户管理后台」统一配置的。

  • 报表页面绑定菜单。

在「租户运营业务后台」,租户可以将配置好的页面绑定到菜单。从而可以实现,每个租户配置适合于自身业务的统计页面。

租户在线表单管理

本小节仅介绍在线表单模块与多租户集成后的相关操作,如需深入了解「在线表单」模块的具体功能,请参考开发文档 在线表单配置章节

租户管理配置

多租户在线表单功能的「数据库链接、字典管理和表单管理」等与数据结构相关的底层操作,均需在租户管理系统中完成配置。

  • 数据库链接。

在租户管理后台为租户配置在线表单的数据库链接有以下几点非常非常重要,请仔细阅读!默认情况下,橙单已生成开箱即用的配置,因此直接启动运行即可。

  • 下图中的数据库链接是由初始化脚本 tenant-online-dblink-init.sql 插入的,当前页面只能修改,不能增删。
  • 该脚本所配置的数据库为默认生成的「租户运营业务数据库」,该库必须包含所有的租户业务数据表。
  • 这里的数据库链接,仅用于租户管理为租户业务应用进行在线表单的数据集配置,可以简单的理解为租户业务数据表的模板数据库。
  • 在租户业务应用的实际运行中,并不会根据该链接读取在线表单的配置数据,而是直接从租户业务所在的数据库中读取每个租户业务库中的副本配置。
  • 当新增租户业务数据库时,下图中的配置仍然无需修改,但是请务必确保新建的租户数据库中的数据表与其他租户业务库中的表结构保持一致。
  • 字典管理。

这里配置的在线表单字典,是所有租户可见的。而字典的数据,则会根据租户的不同而不同。比如说「数据表字典」,每个租户的表内数据不同,那么可用的字典值和字典项也均不同。再比如「全局编码字典」,对于非公有的租户全局编码字典而言,每个租户是可以自己维护租户字典数据的,这一点会在本小节的后面,有更详细的图示说明。

  • 页面管理。

敲黑板,这里是重点!在「租户管理后台」配置的在线表单页面,事实上相当于页面模板,当给租户角色授权时 (后面会有详细说明),系统会为该角色的每个租户克隆一份该在线表单配置数据,并用 tenant_id 字段加以区分。

另外需要说明的是,在「租户管理后台」配置的表单模板所关联的底层数据源配置发生变化时,也同样会影响到每一个已克隆的租户在线表单。而表单模板的页面配置变化,则不会影响到已克隆的租户表单页面,只有新授权的租户,才会使用更新后的页面配置。

  • 数据表字段绑定字典。

如下图所示,数据表字段绑定字典的关系只能在租户管理中配置,每个租户不能在租户运营业务系统中修改这一配置。但是租户字典的数据,每个租户可以是不同的,这一点会在本小节的后面,有更详细的图示说明。

  • 租户角色配置与在线表单页面的多对多关联关系。

在上图中,我们为「租户角色一」配置了「测试租户一」。而在下面的截图中,「租户租户一」的租户用户,将可以使用下图中,为当前租户角色添加的所有在线表单页面。授权后,系统会为该租户角色下的所有租户,克隆一份该页面模板的数据,以便每个租户可以修改自己的页面布局。后面的小节会有详细的操作介绍。

租户运营配置

本小节将以前面配置的「测试租户一」的管理员用户登录,继续介绍租户用户在「租户运营业务系统」中,如何使用前一小节在「租户管理系统」中配置的在线表单页面。

  • 租户在线表单字典详解。

在前面的小节,我们介绍了如何在「租户管理后台」配置租户的在线表单字典,以及为在线表单数据源字段设置字典,具体操作见下图。

在「租户管理后台」配置的在线表单字典对所有租户都是可用的,然而很多时候,我们都希望不同的租户可以有不同的租户字典数据,以便实现租户业务数据的个性化显示。对于上图所示的非公用编码字典「商品请假类型」而言,每个租户都可以修改该字典的初始化数据,并且不会对其他租户的该字典数据产生任何影响。

  • 配置在线表单页面。

重点说明的是「租户运营业务系统」对于在线表单模块的「数据库链接和字典管理」等功能菜单没有访问权限,因为对他们的操作都是在「租户管理后台」统一配置的。

  • 在线表单页面绑定菜单。

在「租户运营业务后台」,租户可以将配置好的页面绑定到菜单。从而可以实现,每个租户配置适合于自身业务的在线表单页面。

租户流程管理

本小节仅介绍工作流模块与多租户集成后的相关操作,如需深入了解「工作流」模块的具体功能,请参考开发文档 流程管理章节

工作流在「租户管理后台」无需做任何配置,仅在「租户运营业务后台」,基于租户有权访问的在线表单页面配置即可。

  • 创建流程分类,这里创建的流程分类,都是仅对租户自身可见的。

  • 创建流程,这里创建的流程分类,都是仅对租户自身可见的。
  • 流程实例列表和工单列表,均仅能查看当前租户的流程列表。

租户编码字典管理

在阅读完本小节内容后,如需更多了解可参考开发文档 租户编码字典详解章节

公用字典

顾名思义,所有租户使用的字典数据都是一致的。因此该类字典数据的维护,是由租户平台管理人员在「租户管理后台」进行操作的,每个租户只能使用该字典,而无权修改字典数据。具体操作如下。

  • 在「租户管理后台」配置全局编码公用字典。
  • 租户登录「租户运营业务后台」,查看上一步配置的全局编码公用字典。

非公用字典

相比于租户公用编码字典,非公用编码字典会为平台中的每个租户克隆一份字典数据。这样,每个租户的管理员可以根据自身的业务数据显示需求,增删改租户自己的非公用编码字典数据,修改后对于其他租户则没有任何影响。

  • 在「租户管理后台」配置全局编码非公用字典。

下图配置的非公用编码字典数据,会为平台中的每个租户克隆一份,并存储于「租户运营通用数据库」中。如在「租户管理后台」修改非公用编码字典数据,也不会影响到已存在租户中的该字典数据。此后新创建的租户,将使用最新的初始化数据。

  • 租户登录「租户运营业务后台」,查看上一步配置的全局编码非公用字典。租户对该字典数据的任何修改,均不会影响到其他租户的字典数据。

技术详解

  • 在「租户管理后台」新建租户全局编码字典的代码逻辑。下述代码位于 tenant-admin 服务的 TenantGlobalDictServiceImpl 文件中,请仔细阅读代码注释。
// 多数据源的动态切换注解。从参数中的常量值可以看出,是切换到了“租户运营通用数据库”了。
// 因此该类的所有方法,均会直接操作“租户运营通用数据库”。
@MyDataSource(ApplicationConstant.TENANT_COMMON_DATASOURCE_TYPE)
@Slf4j
@Service("tenantGlobalDictService")
public class TenantGlobalDictServiceImpl
       extends BaseService<TenantGlobalDict, Long> implements TenantGlobalDictService {
   @Transactional(rollbackFor = Exception.class)
   @Override
   public TenantGlobalDict saveNew(TenantGlobalDict dict, Set<Long> tenantIdSet) {
       String initialData = dict.getInitialData();
       dict.setDictId(idGenerator.nextLongId());
       dict.setDeletedFlag(GlobalDeletedFlag.NORMAL);
       dict.setCreateUserId(TokenData.takeFromRequest().getUserId());
       dict.setUpdateUserId(dict.getCreateUserId());
       dict.setCreateTime(new Date());
       dict.setUpdateTime(dict.getCreateTime());        
       if (BooleanUtil.isTrue(dict.getTenantCommon())) {
           dict.setInitialData(null);
       }
       // 存储全局编码字典对象。
       tenantGlobalDictMapper.insert(dict);
       List<TenantGlobalDictItem> dictItemList = null;
       if (StrUtil.isNotBlank(initialData)) {
           dictItemList = JSONArray.parseArray(initialData, TenantGlobalDictItem.class);
           dictItemList.forEach(dictItem -> {
               dictItem.setDictCode(dict.getDictCode());
               dictItem.setCreateUserId(dict.getCreateUserId());
           });
       }
       // 这里会判断是否为租户公用字典,对于租户公用字典而言,其字典数据仅保存一份,
       // 并且tenant_id的字段值为NULL。
       if (BooleanUtil.isTrue(dict.getTenantCommon())) {
           tenantGlobalDictItemService.saveNewBatch(dictItemList);
       } else {
           if (CollUtil.isEmpty(tenantIdSet) || dictItemList == null) {
               return dict;
           }
           // 而对于非公用编码字典数据,则会为当前平台中每个租户都克隆一份字典数据,
           // 字典数据的tenant_id字段值为每个租户的tenantId值。
           for (Long tenantId : tenantIdSet) {
               dictItemList.forEach(dictItem -> {
                   dictItem.setId(idGenerator.nextLongId());
                   dictItem.setTenantId(tenantId);
               });
               tenantGlobalDictItemService.saveNewBatch(dictItemList);
           }
       }
       return dict;
   }
   
   // 这里省略了该类其他方法的实现 ... ...
}
  • 添加新租户时,会将平台中所有「非公用编码字典」的最新初始化数据,为该租户克隆一份,并存储到「租户运营通用数据库」的 zz_tenant_global_dict_item 表中,字典数据的 tenant_id 字段值为当前新增租户的 tenantId。以下代码是新增租户的处理逻辑,位于 tenant-admin 服务的 SysTenantServiceImpl 文件中,请仔细阅读代码中的关键性注释说明。
// 从下面的多数据源切换注解的常量参数可以看出,租户的基础数据存储于“租户管理数据库”中。
@MyDataSource(DataSourceType.TENANT_ADMIN)
@Slf4j
@Service("sysTenantService")
public class SysTenantServiceImpl extends BaseService<SysTenant, Long> implements SysTenantService {
   @Transactional(rollbackFor = Exception.class)
   @Override
   public SysTenant saveNew(SysTenant sysTenant) {
       sysTenant.setTenantId(idGenerator.nextLongId());
       sysTenant.setAvailable(true);
       sysTenant.setDeletedFlag(GlobalDeletedFlag.NORMAL);
       MyModelUtil.fillCommonsForInsert(sysTenant);
       sysTenantMapper.insert(sysTenant);
       SysTenantExt tenantExt = new SysTenantExt();
       tenantExt.setTenantId(sysTenant.getTenantId());
       sysTenantExtMapper.insert(tenantExt);
       List<SysTenantMenu> menuList = sysTenantMenuService.getAllList();
       JSONObject messageJsonData = this.makeMessageData(sysTenant);
       if (CollUtil.isNotEmpty(menuList)) {
           messageJsonData.put("menuList", menuList);
       }
       //在租户创建成功后,会同时给 RocketMQ 发送一条新租户同步消息数据,
       //并由随后介绍的 tenant-sync 同步服务中的消息消费者处理类进行数据
       //同步,该租户的非公用编码字典数据的克隆和批量插入,也会在同步的过程
       //中计算并存入租户运营通用数据库中。
       dataSyncProducer.sendOrderly(
               sysTenant.getTenantId(),
               applicationConfig.getTenantSyncTopic(),
               modelClass.getSimpleName(),
               DataSyncCommandType.INSERT.name(),
               messageJsonData.toJSONString(),
               TenantConstant.MESSAGE_QUEUE_SELECTOR_KEY);
       return sysTenant;
   }
   
   // 这里省略了该类其他方法的实现 ... ...
}
  • 下面的代码位于 tenant-sync 服务的 SysTenantServiceImpl 文件中。消息消费者在收到数据后,会调用该类的 doHandle 方法处理新增租户的数据同步逻辑,请仔细阅读代码中的关键性注释说明。
@Slf4j
@Service
public class SysTenantServiceImpl implements BaseDataSyncConsumerService {
 
   //消息消费者类会根据消息的类型,调用不同业务处理类的doHandle方法。新增租户的消息,是由该类实现具体的同步处理逻辑的。
   @Override
   public void doHandle(String transId, String messageCommand, JSONObject messageJsonData) {
       SysTenant sysTenant = messageJsonData.getJSONObject("sysTenant").toJavaObject(SysTenant.class);
       sysTenantService.doHandleWithinTenantBusinessDatabase(
               sysTenant.getDatasourceType(), transId, messageCommand, messageJsonData);
       // 该方法处理租户全局编码非公用字典的克隆和批量插入。
       sysTenantService.doHandleWithinTenantDictDatabase(transId, messageCommand, messageJsonData);
   }
   //从下面注解的常量参数中可以看出,操作的目标数据库是“租户运营通用数据库”。
   @SwitchTenantDatasource(datasourceType = DataSourceType.TENANT_COMMON)
   public void doHandleWithinTenantDictDatabase(String transId, String messageCommand, JSONObject messageJsonData) {
       Long tenantId = messageJsonData.getJSONObject("sysTenant").toJavaObject(SysTenant.class).getTenantId();
       if (messageCommand.equals(DataSyncCommandType.INSERT.name())) {
           TenantGlobalDict filter = new TenantGlobalDict();
           // 仅仅获取平台中已有的租户全局编码非公用字典数据列表。
           filter.setTenantCommon(false);
           List<TenantGlobalDict> dictList = tenantGlobalDictService.getGlobalDictList(filter, null);
           if (CollUtil.isEmpty(dictList)) {
               return;
           }
           //获取每个非公用字典的最新初始化数据,同时基于该数据,为当前新增的租户克隆出属于该租户的非公用编码字典数据。
           //将批量计算后的结果,批量插入到数据库中,以尽可能的提升同步效率。
           dictList.stream().filter(dict -> StrUtil.isNotBlank(dict.getInitialData())).forEach(dict -> {
               List<TenantGlobalDictItem> dictItemList =
                       JSON.parseArray(dict.getInitialData(), TenantGlobalDictItem.class);
               dictItemList.forEach(dictItem -> {
                   dictItem.setDictCode(dict.getDictCode());
                   dictItem.setTenantId(tenantId);
                   dictItem.setCreateUserId(dict.getCreateUserId());
               });
               tenantGlobalDictItemService.saveNewBatch(dictItemList);
           });
       } else if (messageCommand.equals(DataSyncCommandType.DELETE.name())) {
           TenantGlobalDictItem filter = new TenantGlobalDictItem();
           filter.setTenantId(tenantId);
           tenantGlobalDictItemService.removeBy(filter);
       }
   }
   // 这里省略了该类其他方法的实现 ... ...
}

结语

赠人玫瑰,手有余香,感谢您的支持和关注,选择橙单,效率乘三,收入翻番。