租户管理详解
作者:唐亚峰 | 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>