Files
ruoyi-vue-pro/.cursor/rules/multi-tenant-architecture.mdc

709 lines
20 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 多租户架构详解
## SaaS 多租户模式
### 租户隔离方案
芋道框架采用**共享数据库、共享 Schema**的租户隔离模式:
- 所有租户共享同一个数据库实例
- 通过 `tenant_id` 字段进行数据隔离
- 在应用层面实现租户数据的自动过滤
### 核心表结构
```sql
-- 租户表
CREATE TABLE system_tenant (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '租户编号',
name VARCHAR(30) NOT NULL COMMENT '租户名',
contact_user_id BIGINT COMMENT '联系人用户编号',
contact_name VARCHAR(30) NOT NULL COMMENT '联系人',
contact_mobile VARCHAR(500) DEFAULT NULL COMMENT '联系手机',
status TINYINT NOT NULL DEFAULT 0 COMMENT '租户状态',
website VARCHAR(256) DEFAULT '' COMMENT '绑定域名',
package_id BIGINT NOT NULL COMMENT '租户套餐编号',
expire_time DATETIME NOT NULL COMMENT '过期时间',
account_count INT NOT NULL COMMENT '账号数量',
creator VARCHAR(64) DEFAULT '' COMMENT '创建者',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updater VARCHAR(64) DEFAULT '' COMMENT '更新者',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
deleted BIT NOT NULL DEFAULT 0 COMMENT '是否删除'
);
-- 租户套餐表
CREATE TABLE system_tenant_package (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '套餐编号',
name VARCHAR(30) NOT NULL COMMENT '套餐名',
status TINYINT NOT NULL DEFAULT 0 COMMENT '状态',
remark VARCHAR(256) DEFAULT '' COMMENT '备注',
menu_ids VARCHAR(4096) NOT NULL COMMENT '关联的菜单编号',
creator VARCHAR(64) DEFAULT '' COMMENT '创建者',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updater VARCHAR(64) DEFAULT '' COMMENT '更新者',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
deleted BIT NOT NULL DEFAULT 0 COMMENT '是否删除'
);
```
## 租户上下文管理
### 租户上下文类
```java
/**
* 租户上下文 Holder
*/
public class TenantContextHolder {
/**
* 当前租户编号
*/
private static final ThreadLocal<Long> TENANT_ID = new ThreadLocal<>();
/**
* 是否忽略租户
*/
private static final ThreadLocal<Boolean> IGNORE = new ThreadLocal<>();
public static void setTenantId(Long tenantId) {
TENANT_ID.set(tenantId);
}
public static Long getTenantId() {
return TENANT_ID.get();
}
public static void setIgnore(Boolean ignore) {
IGNORE.set(ignore);
}
public static Boolean getIgnore() {
return IGNORE.get();
}
public static void clear() {
TENANT_ID.remove();
IGNORE.remove();
}
}
```
### 租户拦截器
```java
@Component
public class TenantSecurityWebFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
// 获取租户 ID从请求头、域名、用户信息等
Long tenantId = getTenantId(httpRequest);
try {
// 设置租户上下文
TenantContextHolder.setTenantId(tenantId);
// 继续处理请求
chain.doFilter(request, response);
} finally {
// 清理租户上下文
TenantContextHolder.clear();
}
}
private Long getTenantId(HttpServletRequest request) {
// 1. 优先从请求头获取
String tenantIdHeader = request.getHeader("Tenant-Id");
if (StrUtil.isNotBlank(tenantIdHeader)) {
return Long.valueOf(tenantIdHeader);
}
// 2. 从域名获取
String domain = request.getServerName();
Long tenantId = getTenantIdByDomain(domain);
if (tenantId != null) {
return tenantId;
}
// 3. 从用户 Token 获取
String token = SecurityFrameworkUtils.obtainAuthorization(request);
if (StrUtil.isNotBlank(token)) {
return getTenantIdFromToken(token);
}
// 4. 默认租户
return TenantConstants.DEFAULT_TENANT_ID;
}
}
```
## 数据库租户隔离
### MyBatis Plus 租户插件
```java
@Configuration
public class TenantConfiguration {
@Bean
public TenantLineInnerInterceptor tenantLineInnerInterceptor() {
TenantLineInnerInterceptor interceptor = new TenantLineInnerInterceptor();
// 设置租户处理器
interceptor.setTenantLineHandler(new TenantLineHandler() {
@Override
public Expression getTenantId() {
Long tenantId = TenantContextHolder.getTenantId();
if (tenantId == null) {
return new LongValue(TenantConstants.DEFAULT_TENANT_ID);
}
return new LongValue(tenantId);
}
@Override
public String getTenantIdColumn() {
return "tenant_id";
}
@Override
public boolean ignoreTable(String tableName) {
// 系统表不需要租户隔离
return TenantConstants.IGNORE_TABLES.contains(tableName);
}
});
return interceptor;
}
}
```
### 租户注解
```java
/**
* 忽略租户
*/
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface TenantIgnore {
}
/**
* 租户信息
*/
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface TenantInfo {
}
```
### 租户 AOP 切面
```java
@Aspect
@Component
public class TenantAspect {
@Around("@annotation(tenantIgnore)")
public Object aroundTenantIgnore(ProceedingJoinPoint joinPoint, TenantIgnore tenantIgnore) throws Throwable {
Boolean oldIgnore = TenantContextHolder.getIgnore();
try {
TenantContextHolder.setIgnore(true);
return joinPoint.proceed();
} finally {
TenantContextHolder.setIgnore(oldIgnore);
}
}
}
```
## 租户管理服务
### 租户服务接口
```java
@Service
public class TenantServiceImpl implements TenantService {
@Resource
private TenantMapper tenantMapper;
@Override
@TenantIgnore // 系统级操作,忽略租户
public Long createTenant(TenantCreateReqVO reqVO) {
// 校验租户套餐
TenantPackageDO tenantPackage = validateTenantPackage(reqVO.getPackageId());
// 创建租户
TenantDO tenant = TenantConvert.INSTANCE.convert(reqVO);
tenant.setAccountCount(tenantPackage.getAccountCount());
tenant.setExpireTime(calculateExpireTime(reqVO.getExpireDays()));
tenantMapper.insert(tenant);
// 初始化租户数据
initTenantData(tenant.getId(), tenantPackage);
return tenant.getId();
}
@Override
@TenantIgnore
public void updateTenant(TenantUpdateReqVO reqVO) {
// 校验租户存在
validateTenantExists(reqVO.getId());
// 更新租户
TenantDO updateObj = TenantConvert.INSTANCE.convert(reqVO);
tenantMapper.updateById(updateObj);
}
@Override
@TenantIgnore
public void deleteTenant(Long tenantId) {
// 校验租户存在
validateTenantExists(tenantId);
// 删除租户(软删除)
tenantMapper.deleteById(tenantId);
// 清理租户相关数据
cleanTenantData(tenantId);
}
/**
* 初始化租户数据
*/
private void initTenantData(Long tenantId, TenantPackageDO tenantPackage) {
// 创建租户管理员账号
createTenantAdmin(tenantId);
// 初始化租户菜单权限
initTenantMenus(tenantId, tenantPackage.getMenuIds());
// 初始化租户角色
initTenantRoles(tenantId);
// 初始化其他基础数据
initTenantBasicData(tenantId);
}
}
```
### 租户套餐管理
```java
@Service
public class TenantPackageServiceImpl implements TenantPackageService {
@Override
@TenantIgnore // 套餐管理是系统级功能
public Long createTenantPackage(TenantPackageCreateReqVO reqVO) {
// 校验菜单编号合法性
validateMenuIds(reqVO.getMenuIds());
// 创建租户套餐
TenantPackageDO tenantPackage = TenantPackageConvert.INSTANCE.convert(reqVO);
tenantPackageMapper.insert(tenantPackage);
return tenantPackage.getId();
}
@Override
@TenantIgnore
public void updateTenantPackage(TenantPackageUpdateReqVO reqVO) {
// 校验套餐存在
validateTenantPackageExists(reqVO.getId());
// 校验菜单编号
validateMenuIds(reqVO.getMenuIds());
// 更新套餐
TenantPackageDO updateObj = TenantPackageConvert.INSTANCE.convert(reqVO);
tenantPackageMapper.updateById(updateObj);
// 同步更新使用该套餐的租户权限
syncTenantMenuPermissions(reqVO.getId(), reqVO.getMenuIds());
}
/**
* 同步租户菜单权限
*/
private void syncTenantMenuPermissions(Long packageId, List<Long> menuIds) {
// 查找使用该套餐的租户
List<TenantDO> tenants = tenantMapper.selectListByPackageId(packageId);
for (TenantDO tenant : tenants) {
// 更新租户菜单权限
updateTenantMenuPermissions(tenant.getId(), menuIds);
}
}
}
```
## 租户数据初始化
### 租户初始化服务
```java
@Service
public class TenantInitService {
/**
* 初始化租户基础数据
*/
@TenantIgnore
public void initTenantData(Long tenantId, Long packageId) {
// 切换到目标租户上下文
TenantContextHolder.setTenantId(tenantId);
try {
// 1. 创建默认角色
Long adminRoleId = createDefaultAdminRole(tenantId);
Long userRoleId = createDefaultUserRole(tenantId);
// 2. 创建默认部门
Long rootDeptId = createDefaultDepartment(tenantId);
// 3. 创建租户管理员
Long adminUserId = createTenantAdmin(tenantId, adminRoleId, rootDeptId);
// 4. 初始化系统配置
initSystemConfigs(tenantId);
// 5. 初始化字典数据
initDictData(tenantId);
} finally {
TenantContextHolder.clear();
}
}
private Long createDefaultAdminRole(Long tenantId) {
RoleCreateReqVO roleReq = new RoleCreateReqVO();
roleReq.setName("租户管理员");
roleReq.setCode("tenant_admin");
roleReq.setStatus(CommonStatusEnum.ENABLE.getStatus());
roleReq.setType(RoleTypeEnum.CUSTOM.getType());
roleReq.setRemark("租户默认管理员角色");
return roleService.createRole(roleReq, RoleTypeEnum.CUSTOM);
}
private Long createTenantAdmin(Long tenantId, Long roleId, Long deptId) {
UserCreateReqVO userReq = new UserCreateReqVO();
userReq.setUsername("admin");
userReq.setNickname("管理员");
userReq.setPassword("admin123");
userReq.setStatus(CommonStatusEnum.ENABLE.getStatus());
userReq.setDeptId(deptId);
userReq.setRoleIds(Collections.singleton(roleId));
return userService.createUser(userReq);
}
}
```
## 租户权限控制
### 菜单权限隔离
```java
@Service
public class TenantMenuService {
/**
* 获取租户可用菜单
*/
public List<MenuDO> getTenantMenus(Long tenantId) {
// 获取租户套餐
TenantDO tenant = tenantService.getTenant(tenantId);
TenantPackageDO tenantPackage = tenantPackageService.getTenantPackage(tenant.getPackageId());
// 解析套餐菜单编号
List<Long> menuIds = StrUtils.splitToLong(tenantPackage.getMenuIds(), ',');
// 查询菜单列表
return menuMapper.selectListByIds(menuIds);
}
/**
* 校验用户菜单权限
*/
public boolean hasMenuPermission(Long userId, Long menuId) {
// 获取用户租户
UserDO user = userService.getUser(userId);
Long tenantId = user.getTenantId();
// 获取租户可用菜单
List<MenuDO> tenantMenus = getTenantMenus(tenantId);
// 检查菜单是否在租户套餐中
boolean inTenantPackage = tenantMenus.stream()
.anyMatch(menu -> menu.getId().equals(menuId));
if (!inTenantPackage) {
return false;
}
// 检查用户角色权限
return permissionService.hasMenuPermission(userId, menuId);
}
}
```
### 数据权限隔离
```java
@Service
public class TenantDataPermissionService {
/**
* 校验数据归属权限
*/
public void validateDataPermission(Long dataId, String dataType) {
Long currentTenantId = TenantContextHolder.getTenantId();
if (currentTenantId == null) {
throw new ServiceException("未设置租户上下文");
}
// 查询数据所属租户
Long dataTenantId = getDataTenantId(dataId, dataType);
if (!Objects.equals(currentTenantId, dataTenantId)) {
throw new ServiceException("无权访问其他租户数据");
}
}
/**
* 过滤租户数据
*/
public <T> List<T> filterTenantData(List<T> dataList, Function<T, Long> tenantIdExtractor) {
Long currentTenantId = TenantContextHolder.getTenantId();
if (currentTenantId == null) {
return dataList;
}
return dataList.stream()
.filter(data -> Objects.equals(currentTenantId, tenantIdExtractor.apply(data)))
.collect(Collectors.toList());
}
}
```
## 租户计费和限制
### 租户使用量统计
```java
@Service
public class TenantUsageService {
/**
* 统计租户用户数量
*/
public long countTenantUsers(Long tenantId) {
return userMapper.selectCount(new LambdaQueryWrapper<UserDO>()
.eq(UserDO::getTenantId, tenantId)
.eq(UserDO::getDeleted, false));
}
/**
* 统计租户存储使用量
*/
public long calculateTenantStorage(Long tenantId) {
return fileMapper.selectStorageByTenantId(tenantId);
}
/**
* 检查租户账号数量限制
*/
public void checkAccountLimit(Long tenantId) {
TenantDO tenant = tenantService.getTenant(tenantId);
long currentUserCount = countTenantUsers(tenantId);
if (currentUserCount >= tenant.getAccountCount()) {
throw new ServiceException("账号数量已达上限");
}
}
/**
* 检查租户有效期
*/
public void checkTenantExpire(Long tenantId) {
TenantDO tenant = tenantService.getTenant(tenantId);
if (tenant.getExpireTime().before(new Date())) {
throw new ServiceException("租户已过期");
}
}
}
```
### 租户限流控制
```java
@Component
public class TenantRateLimitService {
@Resource
private RedisTemplate<String, Object> redisTemplate;
/**
* 检查 API 调用频率限制
*/
public boolean checkApiRateLimit(Long tenantId, String apiPath) {
String key = "tenant:rate_limit:" + tenantId + ":" + apiPath;
// 获取租户套餐限制
TenantPackageDO tenantPackage = getTenantPackage(tenantId);
int rateLimit = tenantPackage.getApiRateLimit();
// 使用滑动窗口限流
return checkRateLimit(key, rateLimit, Duration.ofMinutes(1));
}
/**
* 检查存储空间限制
*/
public void checkStorageLimit(Long tenantId, long fileSize) {
long currentStorage = tenantUsageService.calculateTenantStorage(tenantId);
TenantPackageDO tenantPackage = getTenantPackage(tenantId);
if (currentStorage + fileSize > tenantPackage.getStorageLimit()) {
throw new ServiceException("存储空间不足");
}
}
}
```
## 租户域名绑定
### 域名解析服务
```java
@Service
public class TenantDomainService {
/**
* 绑定租户域名
*/
@TenantIgnore
public void bindDomain(Long tenantId, String domain) {
// 校验域名格式
validateDomainFormat(domain);
// 校验域名未被使用
validateDomainNotUsed(domain);
// 更新租户域名
TenantDO updateObj = new TenantDO();
updateObj.setId(tenantId);
updateObj.setWebsite(domain);
tenantMapper.updateById(updateObj);
// 清理域名缓存
clearDomainCache(domain);
}
/**
* 根据域名获取租户ID
*/
public Long getTenantIdByDomain(String domain) {
String cacheKey = "tenant:domain:" + domain;
// 先从缓存获取
Long tenantId = (Long) redisTemplate.opsForValue().get(cacheKey);
if (tenantId != null) {
return tenantId;
}
// 从数据库查询
TenantDO tenant = tenantMapper.selectOneByWebsite(domain);
if (tenant != null) {
tenantId = tenant.getId();
// 缓存结果
redisTemplate.opsForValue().set(cacheKey, tenantId, Duration.ofHours(1));
}
return tenantId;
}
}
```
## 租户数据迁移
### 租户数据导出
```java
@Service
public class TenantDataExportService {
/**
* 导出租户数据
*/
public TenantDataExportVO exportTenantData(Long tenantId) {
TenantDataExportVO exportData = new TenantDataExportVO();
// 设置租户上下文
TenantContextHolder.setTenantId(tenantId);
try {
// 导出用户数据
exportData.setUsers(userService.getUserList(null));
// 导出角色数据
exportData.setRoles(roleService.getRoleList());
// 导出部门数据
exportData.setDepts(deptService.getDeptList(null));
// 导出菜单数据
exportData.setMenus(menuService.getMenuList());
// 导出其他业务数据
exportBusinessData(exportData, tenantId);
} finally {
TenantContextHolder.clear();
}
return exportData;
}
/**
* 导入租户数据
*/
public void importTenantData(Long tenantId, TenantDataExportVO importData) {
TenantContextHolder.setTenantId(tenantId);
try {
// 导入用户数据
importUsers(importData.getUsers());
// 导入角色数据
importRoles(importData.getRoles());
// 导入部门数据
importDepts(importData.getDepts());
// 导入菜单数据
importMenus(importData.getMenus());
} finally {
TenantContextHolder.clear();
}
}
}
```
## 最佳实践
### 性能优化
1. **租户缓存**: 缓存租户基础信息和套餐信息
2. **索引优化**: 为 tenant_id 字段添加复合索引
3. **连接池**: 考虑为大租户使用独立连接池
4. **数据分片**: 超大租户可考虑数据分片
### 安全考虑
1. **数据隔离**: 确保租户数据完全隔离
2. **权限校验**: 多层权限校验防止越权访问
3. **审计日志**: 记录跨租户操作的审计日志
4. **数据备份**: 定期备份租户重要数据
### 监控告警
1. **租户状态**: 监控租户是否正常运行
2. **使用量**: 监控租户资源使用情况
3. **性能指标**: 监控租户相关 API 性能
4. **异常访问**: 检测异常的跨租户访问