Skip to content

租户管理详解

作者:唐亚峰 | battcn
字数统计:1.4k 字

学习目标

掌握 Wemirr Platform 租户管理的完整流程和配置方法

租户生命周期

创建租户 → 初始化数据 → 配置功能 → 正常运营 → 续费/升级 → 停用/删除

租户创建

创建流程

┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│  填写信息   │ ──▶ │  创建数据源  │ ──▶ │  初始化数据  │
└─────────────┘     └─────────────┘     └─────────────┘

┌─────────────┐     ┌─────────────┐           ▼
│  创建完成   │ ◀── │  创建管理员  │ ◀── ┌─────────────┐
└─────────────┘     └─────────────┘     │  分配套餐   │
                                        └─────────────┘

基础信息

字段说明示例
租户编码唯一标识,不可修改T202401001
租户名称企业/组织名称北京科技有限公司
联系人租户负责人张三
联系电话联系方式13800138000
有效期租户使用期限2024-01-01 ~ 2024-12-31
状态租户状态正常/禁用/过期

后台配置

java
// 创建租户
@PostMapping("/tenants")
@SysLog("创建租户")
public Result<Void> create(@RequestBody @Valid TenantDTO dto) {
    tenantService.create(dto);
    return Result.success();
}

// TenantServiceImpl
@Override
@Transactional(rollbackFor = Exception.class)
public void create(TenantDTO dto) {
    // 1. 保存租户基础信息
    Tenant tenant = BeanUtil.copyProperties(dto, Tenant.class);
    tenant.setStatus(TenantStatus.NORMAL);
    tenantMapper.insert(tenant);
    
    // 2. 创建租户数据源(数据源隔离模式)
    if (multiTenantType == TenantType.DATASOURCE) {
        createTenantDatabase(tenant);
    }
    
    // 3. 初始化租户数据
    initTenantData(tenant.getId());
    
    // 4. 创建租户管理员
    createTenantAdmin(tenant);
    
    // 5. 分配产品套餐
    if (dto.getProductId() != null) {
        assignProduct(tenant.getId(), dto.getProductId());
    }
}

数据源配置

字段隔离配置

yaml
# application.yml
extend:
  mybatis-plus:
    multi-tenant:
      type: column                    # 隔离类型:字段隔离
      tenant-column: tenant_id        # 租户字段名
      include-tables:                 # 需要租户过滤的表
        - t_order
        - t_customer
        - t_product
      ignore-tables:                  # 忽略租户过滤的表
        - sys_tenant
        - sys_dict
        - sys_config

数据源隔离配置

yaml
# application.yml
extend:
  mybatis-plus:
    multi-tenant:
      type: datasource              # 隔离类型:数据源隔离
      strategy: feign               # 数据源获取策略(非IAM模块使用feign)
      db-notify: redis              # 数据源变更通知方式

# 主数据源配置
spring:
  datasource:
    dynamic:
      primary: master
      strict: true
      datasource:
        master:
          driver-class-name: com.mysql.cj.jdbc.Driver
          url: jdbc:mysql://localhost:3306/wemirr_platform_master?...
          username: root
          password: 123456

动态数据源初始化

java
// 启动时加载所有租户数据源
@Component
@RequiredArgsConstructor
public class TenantDataSourceInitializer implements CommandLineRunner {
    
    private final TenantService tenantService;
    private final DynamicRoutingDataSource dataSource;
    
    @Override
    public void run(String... args) {
        // 获取所有租户数据源配置
        List<TenantDataSource> tenantDataSources = tenantService.getAllDataSources();
        
        // 添加到动态数据源
        for (TenantDataSource tds : tenantDataSources) {
            DataSourceProperty property = buildDataSourceProperty(tds);
            dataSource.addDataSource(tds.getTenantCode(), 
                dataSourceCreator.createDataSource(property));
        }
    }
}

数据初始化

初始化脚本

新租户创建时执行的初始化 SQL 位于:附件/mysql/tenant_schema.sql

sql
-- 基础角色
INSERT INTO sys_role (name, code, description) VALUES 
('管理员', 'ADMIN', '租户管理员,拥有全部权限'),
('普通用户', 'USER', '普通用户,基础权限');

-- 基础菜单权限(从主库复制)
INSERT INTO sys_menu (SELECT * FROM master.sys_menu WHERE tenant_id = 0);

-- 角色菜单关联
INSERT INTO sys_role_menu (role_id, menu_id)
SELECT r.id, m.id FROM sys_role r, sys_menu m WHERE r.code = 'ADMIN';

-- 系统字典
INSERT INTO sys_dict (SELECT * FROM master.sys_dict WHERE tenant_id = 0);
INSERT INTO sys_dict_item (SELECT * FROM master.sys_dict_item WHERE tenant_id = 0);

-- 系统配置
INSERT INTO sys_config (SELECT * FROM master.sys_config WHERE tenant_id = 0);

初始化代码

java
@Service
public class TenantInitService {
    
    /**
     * 初始化租户数据
     */
    public void initTenantData(Long tenantId) {
        // 切换到租户数据源
        TenantContextHolder.setTenantId(tenantId);
        
        try {
            // 初始化角色
            initRoles(tenantId);
            
            // 初始化菜单
            initMenus(tenantId);
            
            // 初始化字典
            initDicts(tenantId);
            
            // 初始化配置
            initConfigs(tenantId);
            
        } finally {
            TenantContextHolder.clear();
        }
    }
    
    private void initRoles(Long tenantId) {
        List<Role> roles = Arrays.asList(
            Role.builder().name("管理员").code("ADMIN").build(),
            Role.builder().name("普通用户").code("USER").build()
        );
        roleMapper.insertBatch(roles);
    }
}

产品套餐

套餐配置

java
@Data
public class Product {
    private Long id;
    private String name;          // 套餐名称
    private String code;          // 套餐编码
    private Integer maxUsers;     // 最大用户数
    private Long maxStorage;      // 最大存储空间(字节)
    private String features;      // 功能列表(JSON)
    private BigDecimal price;     // 价格
    private Integer period;       // 有效期(天)
}

套餐功能控制

java
@Service
public class FeatureService {
    
    /**
     * 检查功能是否可用
     */
    public boolean checkFeature(String featureCode) {
        Long tenantId = context.tenantId();
        TenantProduct tp = tenantProductMapper.selectByTenantId(tenantId);
        
        if (tp == null || tp.getExpireTime().isBefore(LocalDateTime.now())) {
            return false;
        }
        
        List<String> features = JsonUtil.toList(tp.getFeatures(), String.class);
        return features.contains(featureCode);
    }
    
    /**
     * 检查用户数是否超限
     */
    public void checkUserLimit() {
        Long tenantId = context.tenantId();
        TenantProduct tp = tenantProductMapper.selectByTenantId(tenantId);
        
        int currentUsers = userMapper.countByTenantId(tenantId);
        if (currentUsers >= tp.getMaxUsers()) {
            throw new BizException("用户数已达上限,请升级套餐");
        }
    }
}

租户切换

用户多租户

一个用户可以属于多个租户,支持在租户间切换:

java
// 获取用户所属租户列表
@GetMapping("/tenants")
public Result<List<TenantVO>> getUserTenants() {
    Long userId = context.userId();
    List<TenantVO> tenants = userTenantService.getTenantsByUserId(userId);
    return Result.success(tenants);
}

// 切换租户
@PostMapping("/tenants/{tenantId}/switch")
public Result<LoginVO> switchTenant(@PathVariable Long tenantId) {
    Long userId = context.userId();
    
    // 验证用户是否属于该租户
    boolean belongs = userTenantService.checkUserBelongs(userId, tenantId);
    if (!belongs) {
        throw new BizException("您不属于该租户");
    }
    
    // 生成新的 Token
    LoginVO loginVO = authService.switchTenant(userId, tenantId);
    return Result.success(loginVO);
}

租户状态管理

状态说明

状态说明可用性
NORMAL正常可正常访问
DISABLED禁用不可登录,数据保留
EXPIRED过期提示续费,限制功能
DELETED删除数据已清除

状态流转

NORMAL ──▶ DISABLED ──▶ DELETED
   │            │
   ▼            ▼
EXPIRED ──▶ NORMAL(续费后)

过期处理

java
@Scheduled(cron = "0 0 1 * * ?")  // 每天凌晨1点执行
public void checkTenantExpiry() {
    // 查找即将过期的租户(7天内)
    List<Tenant> expiring = tenantMapper.selectExpiringSoon(7);
    for (Tenant tenant : expiring) {
        // 发送续费提醒
        notifyService.sendExpiryReminder(tenant);
    }
    
    // 处理已过期租户
    List<Tenant> expired = tenantMapper.selectExpired();
    for (Tenant tenant : expired) {
        tenant.setStatus(TenantStatus.EXPIRED);
        tenantMapper.updateById(tenant);
        
        // 发送过期通知
        notifyService.sendExpiredNotice(tenant);
    }
}

前端租户管理

vue
<script setup lang="ts">
import { useFs } from '@fast-crud/fast-crud';
import { getTenantList, createTenant, updateTenant, deleteTenant } from './api';

const { crudBinding, crudRef } = useFs({
  crudOptions: {
    request: {
      pageRequest: async (query) => await getTenantList(query),
      addRequest: async ({ form }) => await createTenant(form),
      editRequest: async ({ form }) => await updateTenant(form.id, form),
      delRequest: async ({ row }) => await deleteTenant(row.id),
    },
    columns: {
      code: {
        title: '租户编码',
        type: 'text',
        search: { show: true },
        addForm: { show: true },
        editForm: { disabled: true },
      },
      name: {
        title: '租户名称',
        type: 'text',
        search: { show: true },
      },
      contact: {
        title: '联系人',
        type: 'text',
      },
      phone: {
        title: '联系电话',
        type: 'text',
      },
      expireTime: {
        title: '到期时间',
        type: 'datetime',
      },
      status: {
        title: '状态',
        type: 'dict-select',
        dict: {
          data: [
            { label: '正常', value: 'NORMAL', color: 'success' },
            { label: '禁用', value: 'DISABLED', color: 'warning' },
            { label: '过期', value: 'EXPIRED', color: 'error' },
          ],
        },
      },
    },
  },
});
</script>

<template>
  <fs-crud ref="crudRef" v-bind="crudBinding" />
</template>

下一步