若依框架 RuoYi-Vue 3.8.9 多租户隔离实战:从零到生产的完整改造指南
前言
在企业级应用开发中,多租户隔离是一个绕不开的话题。当我们基于若依框架开发SaaS化产品时,原版若依并没有提供多租户支持,需要开发者从零实现。本文将分享一次完整的生产级多租户隔离改造经验,基于 RuoYi-Vue 3.8.9 原版(MyBatis + Spring Security + JWT,Spring Boot 2.x),覆盖数据库设计、MyBatis拦截器、前后端联动、Redis缓存隔离、应用配置管理等全套改造点。
前置说明:本文讲解的是原版若依的改造,不涉及若依Plus版本。Plus版本已有内置多租户支持,但原版需要自行实现,这套方案同样适用于其他基于MyBatis的项目。
一、项目背景与需求分析
1.1 为什么要做多租户隔离
在SaaS系统中,多个租户共享同一套系统实例,但数据必须相互隔离。常见的隔离方案有三种:
|
隔离方案 |
实现方式 |
适用场景 |
|
数据库隔离 |
每个租户独立数据库 |
数据安全性要求极高、租户量少 |
|
Schema隔离 |
Oracle/PostgreSQL的Schema |
中等规模租户 |
|
行级隔离 |
共享数据库,通过tenant_id字段区分 |
租户量大、成本敏感场景 |
我们选择行级隔离方案,原因很直接:
• 运维成本低:一份代码、一个数据库,部署简单
• 成本可控:不需要为每个租户维护独立数据库
• 扩展性强:单库可支撑数千租户
1.2 核心需求拆解
经过业务梳理,我们确定了以下核心需求:
┌─────────────────────────────────────────────────────────────┐
│ 需求矩阵 │
├─────────────────────────────────────────────────────────────┤
│ 1. 数据隔离 │ 不同租户数据完全隔离,行级隔离共享数据库 │
│ 2. 超管能力 │ tenant_id=0 可切换租户查看任意租户数据 │
│ 3. 租户限制 │ 普通用户只能看自己租户数据,无法跨租户访问 │
│ 4. 前端切换 │ Navbar有租户切换下拉框,切换后实时生效 │
│ 5. 配置隔离 │ 每个租户有独立的业务配置,支持全局默认配置 │
└─────────────────────────────────────────────────────────────┘
1.3 技术选型
|
组件 |
选型 |
理由 |
|
隔离粒度 |
行级隔离 |
平衡成本与隔离需求 |
|
过滤层 |
MyBatis拦截器 |
自动追加WHERE条件,对业务代码无侵入 |
|
上下文传递 |
ThreadLocal |
请求线程内共享租户ID |
|
前端状态 |
Vuex + sessionStorage |
切换后持久化,刷新页面不丢失 |
二、整体架构设计
2.1 数据流全景图
┌─────────────────────────────────────────────────────────────────────┐
│ 请求生命周期 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌──────────────┐ ┌─────────────┐ ┌──────────┐ │
│ │ 前端 │───▶│ Vuex Store │───▶│ request.js │───▶│ HTTP │ │
│ │ 切换 │ │ currentTenant│ │ Header注入 │ │ Request │ │
│ │ 租户 │ │ Id │ │ Tenant-Id │ │ │ │
│ └──────────┘ └──────────────┘ └─────────────┘ └────┬─────┘ │
│ │ │
│ ┌──────────┐ ┌──────────────┐ ┌─────────────┐ │ │
│ │ 业务代码 │◀───│ @Param注解 │◀───│ ThreadLocal │◀────────┘ │
│ │ 无感知 │ │ 可选获取 │ │ TenantContext│ │
│ └──────────┘ └──────────────┘ └─────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ MyBatis 执行层 │ │
│ │ ┌─────────────────────┐ ┌──────────────────────────┐ │ │
│ │ │ TenantInterceptor │ │ TenantInsertInterceptor │ │ │
│ │ │ SELECT → 自动追加 │ │ INSERT → 自动填充 │ │ │
│ │ │ WHERE tenant_id=? │ │ tenant_id字段 │ │ │
│ │ └─────────────────────┘ └──────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
2.2 两条拦截器的核心区别
这是最容易踩坑的点,必须深刻理解:
|
拦截器 |
SQL类型 |
读取来源 |
跟随谁走 |
场景举例 |
|
TenantInterceptor |
SELECT |
TenantContext.getCurrentTenantId() |
前端租户切换 |
超管切换到租户A后,看到租户A的数据 |
|
TenantInsertInterceptor |
INSERT |
SecurityUtils.getTenantId() |
用户真实身份 |
用户属于租户B,写入数据时tenant_id永远是B |
为什么这么设计?
假设超管切换到租户A查看数据,此时:
• TenantContext = 租户A的ID(跟随切换)
• SecurityUtils = 超管自己的租户ID = 0(固定不变)
如果INSERT也用TenantContext,超管切换到租户A后创建的所有数据都归属到租户A——这显然是错误的。
2.3 核心类关系图
┌──────────────────────────────────────────────────────────────────┐
│ 核心类依赖关系 │
├──────────────────────────────────────────────────────────────────┤
│ │
│ TenantFilter (Servlet Filter) │
│ │ │
│ ├── 读取请求头 "Tenant-Id" │
│ │ │
│ ▼ │
│ TenantContext (ThreadLocal) │
│ │ │
│ ├── TenantInterceptor ──────▶ 读取用于 SELECT过滤 │
│ │ │
│ └── (INSERT用SecurityUtils.getTenantId()) │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ AppConfigHelper │◀──────│ TenantContext │ │
│ │ 配置读取工具 │ │ (可作为兜底) │ │
│ └─────────────────┘ └─────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────┘
三、数据库层改造
3.1 租户主表设计
首先需要一张租户表来管理租户信息:
// sql
-- 租户管理表
CREATE TABLE sys_tenant (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '租户编号',
tenant_name VARCHAR(100) NOT NULL COMMENT '租户名称',
tenant_code VARCHAR(100) NOT NULL COMMENT '租户编码',
contract_start DATETIME DEFAULT NULL COMMENT '合同开始日期',
contract_end DATETIME DEFAULT NULL COMMENT '合同结束日期',
user_count INT DEFAULT 0 COMMENT '用户数量限制',
storage_quota BIGINT DEFAULT 10737418240 COMMENT '存储配额(字节)',
status CHAR(1) DEFAULT '0' COMMENT '状态(0正常 1停用)',
del_flag CHAR(1) DEFAULT '0' COMMENT '删除标志(0存在 2删除)',
remark VARCHAR(500) DEFAULT NULL COMMENT '备注',
create_by VARCHAR(64) DEFAULT '' COMMENT '创建者',
create_time DATETIME DEFAULT NULL COMMENT '创建时间',
update_by VARCHAR(64) DEFAULT '' COMMENT '更新者',
update_time DATETIME DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_tenant_code (tenant_code)
) ENGINE=InnoDB COMMENT='租户表';
提示:在实际项目中,若依的菜单管理本身就支持租户管理功能,可以在此基础上扩展。
3.2 业务表加tenant_id字段
这是最繁琐的部分。我们对项目中的 97张表 进行了逐一分析,确定隔离粒度:
|
分类 |
数量 |
处理方式 |
示例表 |
|
纯公共框架表 |
25张 |
不加tenant_id |
sysmenu, sysdicttype, sysconfig |
|
已有tenant_id |
6张 |
保持原样 |
- |
|
已执行加字段 |
22张 |
ALTER TABLE |
sysuser, tbarticles |
|
新增业务表 |
4张 |
CREATE TABLE |
tbappversion |
|
数据同步表 |
40张 |
暂保留在EXCLUDE_TABLES |
appvwjbqk, fzmb_* |
3.2.1 纯公共框架表清单(不加tenant_id)
这些表存放的是系统级公共数据,所有租户共享:
// sql
-- 这些表不需要租户隔离
-- sys_menu 菜单配置
-- sys_dict_type 字典类型
-- sys_dict_data 字典数据
-- sys_config 系统配置
-- sys_role 角色信息(注意:如果角色需要按租户隔离则需加tenant_id)
-- sys_user_role 用户角色关联
-- sys_role_menu 角色菜单关联
-- sys_role_dept 角色部门关联
-- sys_user_post 用户岗位关联
-- sys_post 岗位信息
-- sys_dept 部门信息
-- sys_job 定时任务
-- sys_job_log 定时任务日志
-- sys_logininfor 登录日志
-- sys_oper_log 操作日志
-- sys_notice 通知公告
-- sys_oss_config OSS配置
-- gen_table 代码生成表
-- gen_table_column 代码生成列
3.2.2 SQL执行示例
// sql
-- 【已有表】修改默认值(适用于原有tenant_id字段的表)
ALTER TABLE tb_articles MODIFY COLUMN tenant_id bigint DEFAULT 1 COMMENT '租户ID';
-- 【新增字段】给已有表添加tenant_id列
ALTER TABLE tb_app_version ADD COLUMN tenant_id bigint DEFAULT 1 COMMENT '租户ID' AFTER id;
-- 【新表】创建时直接包含tenant_id
CREATE TABLE tb_product (
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL DEFAULT 1 COMMENT '租户ID',
product_name VARCHAR(100) NOT NULL COMMENT '产品名称',
...
PRIMARY KEY (id),
KEY idx_tenant_id (tenant_id)
) ENGINE=InnoDB COMMENT='产品表';
3.3 隔离粒度决策原则
┌────────────────────────────────────────────────────────────────┐
│ 隔离粒度决策流程 │
├────────────────────────────────────────────────────────────────┤
│ │
│ 问:这个表需要租户隔离吗? │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ 是系统级公共表? │ ──YES──▶ 不加tenant_id(共享) │
│ └────────┬────────┘ │
│ │ NO │
│ ▼ │
│ ┌─────────────────┐ │
│ │ 是业务数据表? │ ──YES──▶ 必须加tenant_id │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ 是数据同步表? │ ──YES──▶ 先加字段,暂放EXCLUDE_TABLES │
│ └─────────────────┘ 等同步逻辑改造后再移出 │
│ │
└────────────────────────────────────────────────────────────────┘
四、后端核心实现
4.1 TenantContext - 租户上下文
这是整个多租户体系的基础,通过ThreadLocal在线程内传递当前租户ID。
// java
package com.ruoyi.framework.security.context;
/**
* 租户上下文 - ThreadLocal实现
* 用于在当前请求线程中存储和获取租户ID
*
* @author ruoyi
*/
public class TenantContext {
private static final ThreadLocal<Long> CURRENT_TENANT = new ThreadLocal<>();
/**
* 设置当前租户ID
*
* @param tenantId 租户ID,0表示超管(全部租户)
*/
public static void setCurrentTenantId(Long tenantId) {
CURRENT_TENANT.set(tenantId);
}
/**
* 获取当前租户ID
*
* @return 租户ID,可能为null(无HTTP上下文时)
*/
public static Long getCurrentTenantId() {
return CURRENT_TENANT.get();
}
/**
* 检查是否为超管模式(tenantId == 0)
*/
public static boolean isSuperAdmin() {
Long tenantId = CURRENT_TENANT.get();
return tenantId == null || tenantId == 0L;
}
/**
* 清除租户上下文(必须在线程结束时调用,防止内存泄漏)
*/
public static void clear() {
CURRENT_TENANT.remove();
}
}
4.2 TenantFilter - HTTP过滤器
负责从请求头读取租户ID并写入ThreadLocal。
// java
package com.ruoyi.framework.security.filter;
import com.ruoyi.framework.security.context.TenantContext;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
/**
* 租户过滤器 - 从请求头读取租户ID
*
* @Order(1) 确保此过滤器在其他业务过滤器之前执行
*/
@Component
@Order(1)
public class TenantFilter implements Filter {
private static final String TENANT_HEADER = "Tenant-Id";
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String tenantIdHeader = httpRequest.getHeader(TENANT_HEADER);
try {
// 解析租户ID
Long tenantId = parseTenantId(tenantIdHeader);
TenantContext.setCurrentTenantId(tenantId);
// 继续执行后续过滤器
chain.doFilter(request, response);
} finally {
// 务必清理ThreadLocal,防止内存泄漏
TenantContext.clear();
}
}
/**
* 解析租户ID
* - null或空字符串 → 0(超管模式)
* - 有效数字字符串 → 对应数字
* - 无效格式 → 0
*/
private Long parseTenantId(String tenantIdHeader) {
if (tenantIdHeader == null || tenantIdHeader.trim().isEmpty()) {
return 0L;
}
try {
return Long.parseLong(tenantIdHeader.trim());
} catch (NumberFormatException e) {
// 无效格式,视为超管
return 0L;
}
}
@Override
public void init(FilterConfig filterConfig) throws ServletException {
// 无需初始化
}
@Override
public void destroy() {
// 无需清理
}
}
4.3 TenantInterceptor - SELECT拦截器
核心拦截器,自动在SELECT语句中追加 WHERE tenant_id=? 条件。
// java
package com.ruoyi.framework.interceptor;
import com.ruoyi.framework.security.context.TenantContext;
import net.sf.jsqlparser.JSQLParserException;
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
import net.sf.jsqlparser.statement.Statement;
import net.sf.jsqlparser.statement.select.*;
import net.sf.jsqlparser.expression.LongValue;
import net.sf.jsqlparser.expression.operators.relational.EqualsTo;
import net.sf.jsqlparser.expression.operators.logical.AndExpression;
import net.sf.jsqlparser.schema.Column;
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.plugin.*;
import org.apache.ibatis.reflection.MetaObject;
import org.apache.ibatis.reflection.SystemMetaObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.lang.reflect.Field;
import java.sql.Connection;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 租户拦截器 - 自动追加租户条件
*
* 拦截所有SELECT语句,自动添加 tenant_id 过滤条件
* tenantId == 0 时不过滤(超管查看全部)
*/
@Intercepts({
@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})
})
public class TenantInterceptor implements Interceptor {
private static final Logger log = LoggerFactory.getLogger(TenantInterceptor.class);
/**
* 不需要租户过滤的表(系统公共表)
*/
private static final Set<String> EXCLUDE_TABLES = new HashSet<>(Arrays.asList(
// 公共框架表
"sys_menu", "sys_dict_type", "sys_dict_data", "sys_config",
"sys_user_role", "sys_role_menu", "sys_role_dept", "sys_user_post",
"sys_post", "sys_dept",
// 日志表
"sys_job", "sys_job_log", "sys_logininfor", "sys_oper_log",
"sys_notice", "sys_oss_config", "sys_oss",
// 代码生成表
"gen_table", "gen_table_column",
// 数据同步表(暂不拦截,等同步逻辑改造后移出)
"app_vw_jbqk", "app_vw_zrr", "app_vw_fzmb",
"fzmb_jbqk", "fzmb_zrr", "fzmb_fzmb",
"szzf_jbqk", "szzf_zrr", "szzf_fzmb"
));
/**
* SQL表名提取正则(处理别名)
*/
private static final Pattern TABLE_NAME_PATTERN = Pattern.compile(
"(?i)\\bfrom\\s+([\\w`\\[\\]]+)(?:\\s+(?:as\\s+)?\\w+)?\\b"
);
@Override
public Object intercept(Invocation invocation) throws Throwable {
StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
String originalSql = statementHandler.getBoundSql().getSql();
// 获取当前租户ID
Long tenantId = TenantContext.getCurrentTenantId();
// 租户ID为0或null时,不过滤(超管查看全部)
if (tenantId == null || tenantId == 0L) {
return invocation.proceed();
}
// 提取表名
String tableName = extractMainTableName(originalSql);
if (tableName == null) {
return invocation.proceed();
}
// 排除公共表
if (EXCLUDE_TABLES.contains(tableName.toLowerCase())) {
return invocation.proceed();
}
// 检查SQL中是否已有tenant_id条件,避免重复添加
if (hasTenantCondition(originalSql)) {
return invocation.proceed();
}
// 解析并改写SQL
String modifiedSql = addTenantCondition(originalSql, tenantId);
if (modifiedSql == null) {
return invocation.proceed();
}
// 通过反射修改BoundSql中的SQL(MyBatis不提供直接修改方法)
setModifiedSql(statementHandler.getBoundSql(), modifiedSql);
log.debug("租户过滤 - SQL改写: \n原始: {}\n改写: {}", originalSql, modifiedSql);
return invocation.proceed();
}
/**
* 提取主表名(from后的第一个表)
*/
private String extractMainTableName(String sql) {
Matcher matcher = TABLE_NAME_PATTERN.matcher(sql);
if (matcher.find()) {
String tableName = matcher.group(1);
// 去掉反引号和方括号
return tableName.replace("`", "").replace("[", "").replace("]", "");
}
return null;
}
/**
* 检查SQL中是否已有tenant_id条件
*/
private boolean hasTenantCondition(String sql) {
String lowerSql = sql.toLowerCase();
return lowerSql.contains("tenant_id") ||
lowerSql.contains("tenant\\id") || // 处理可能的别名
lowerSql.contains("t.tenant_id");
}
/**
* 添加租户条件
*/
private String addTenantCondition(String originalSql, Long tenantId) {
try {
Statement statement = CCJSqlParserUtil.parse(originalSql);
if (!(statement instanceof Select)) {
return null;
}
Select select = (Select) statement;
SelectBody selectBody = select.getSelectBody();
if (!(selectBody instanceof PlainSelect)) {
return null;
}
PlainSelect plainSelect = (PlainSelect) selectBody;
// 创建租户条件
EqualsTo tenantCondition = new EqualsTo();
tenantCondition.setLeftExpression(new Column("tenant_id"));
tenantCondition.setRightExpression(new LongValue(tenantId));
// 追加条件到WHERE
if (plainSelect.getWhere() != null) {
// 已有WHERE条件,用AND连接
AndExpression newWhere = new AndExpression(plainSelect.getWhere(), tenantCondition);
plainSelect.setWhere(newWhere);
} else {
// 没有WHERE条件,直接设置
plainSelect.setWhere(tenantCondition);
}
return select.toString();
} catch (JSQLParserException e) {
log.warn("SQL解析失败,无法添加租户条件: {}", e.getMessage());
return null;
}
}
/**
* 通过反射设置修改后的SQL
*/
private void setModifiedSql(BoundSql boundSql, String modifiedSql) {
try {
Field field = BoundSql.class.getDeclaredField("sql");
field.setAccessible(true);
field.set(boundSql, modifiedSql);
} catch (Exception e) {
log.error("反射设置SQL失败", e);
}
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties properties) {
// 无需配置
}
}
4.4 TenantInsertInterceptor - INSERT拦截器
负责自动填充INSERT语句中的 tenant_id 字段。
// java
package com.ruoyi.framework.interceptor;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.framework.security.context.TenantContext;
import net.sf.jsqlparser.JSQLParserException;
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
import net.sf.jsqlparser.statement.Statement;
import net.sf.jsqlparser.statement.insert.Insert;
import net.sf.jsqlparser.statement.insert.InsertValue;
import net.sf.jsqlparser.expression.LongValue;
import net.sf.jsqlparser.schema.Column;
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.plugin.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.lang.reflect.Field;
import java.sql.Connection;
import java.util.List;
import java.util.regex.Pattern;
/**
* 租户INSERT拦截器 - 自动填充tenant_id
*
* 【关键】使用SecurityUtils.getTenantId()而非TenantContext
* 保证数据归属跟着用户真实身份走,不受前端租户切换影响
*/
@Intercepts({
@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})
})
public class TenantInsertInterceptor implements Interceptor {
private static final Logger log = LoggerFactory.getLogger(TenantInsertInterceptor.class);
/**
* 不需要自动填充tenant_id的表
*/
private static final String[] EXCLUDE_TABLES = {
"sys_user", // 用户表已有自己的tenant_id处理逻辑
};
/**
* INSERT语句正则(处理多行格式)
*/
private static final Pattern INSERT_PATTERN = Pattern.compile(
"(?i)^\\s*INSERT\\s+INTO\\s+([\\w`\\[\\]]+)"
);
@Override
public Object intercept(Invocation invocation) throws Throwable {
StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
String originalSql = statementHandler.getBoundSql().getSql();
// 【关键】使用SecurityUtils获取真实租户ID,而非TenantContext
Long tenantId = SecurityUtils.getTenantId();
// 超管(tenantId=0或null)不自动填充,使用用户原始SQL
if (tenantId == null || tenantId == 0L) {
return invocation.proceed();
}
// 提取表名
String tableName = extractTableName(originalSql);
if (tableName == null) {
return invocation.proceed();
}
// 排除不需要自动填充的表
if (isExcludeTable(tableName)) {
return invocation.proceed();
}
// 改写SQL
String modifiedSql = addTenantIdToInsert(originalSql, tenantId);
if (modifiedSql != null && !modifiedSql.equals(originalSql)) {
setModifiedSql(statementHandler.getBoundSql(), modifiedSql);
log.debug("INSERT租户填充 - SQL改写: \n原始: {}\n改写: {}", originalSql, modifiedSql);
}
return invocation.proceed();
}
/**
* 提取表名
*/
private String extractTableName(String sql) {
java.util.regex.Matcher matcher = INSERT_PATTERN.matcher(sql);
if (matcher.find()) {
String tableName = matcher.group(1);
return tableName.replace("`", "").replace("[", "").replace("]", "");
}
return null;
}
/**
* 检查是否在排除列表中
*/
private boolean isExcludeTable(String tableName) {
for (String exclude : EXCLUDE_TABLES) {
if (exclude.equalsIgnoreCase(tableName)) {
return true;
}
}
return false;
}
/**
* 改写INSERT语句,添加tenant_id
*
* 支持两种格式:
* 1. INSERT INTO table (col1, col2) VALUES (val1, val2)
* 2. INSERT INTO table VALUES (val1, val2)
*/
private String addTenantIdToInsert(String originalSql, Long tenantId) {
try {
Statement statement = CCJSqlParserUtil.parse(originalSql);
if (!(statement instanceof Insert)) {
return originalSql;
}
Insert insert = (Insert) statement;
String tableName = insert.getTable().getName().toLowerCase();
// 检查INSERT语句是否已包含tenant_id列
List<Column> columns = insert.getColumns();
boolean hasTenantId = columns.stream()
.anyMatch(col -> col.getColumnName().equalsIgnoreCase("tenant_id"));
if (hasTenantId) {
// 已有tenant_id列,保持原样(由业务代码或SQL指定值)
return originalSql;
}
// 追加tenant_id列
columns.add(new Column("tenant_id"));
// 处理VALUES
if (insert.getInsertValues() != null) {
InsertValue insertValue = insert.getInsertValues();
insertValue.getExpressionList().addExpressions(new LongValue(tenantId));
}
return insert.toString();
} catch (JSQLParserException e) {
log.warn("INSERT语句解析失败: {}", e.getMessage());
return originalSql;
}
}
/**
* 通过反射设置修改后的SQL
*/
private void setModifiedSql(BoundSql boundSql, String modifiedSql) {
try {
Field field = BoundSql.class.getDeclaredField("sql");
field.setAccessible(true);
field.set(boundSql, modifiedSql);
} catch (Exception e) {
log.error("反射设置SQL失败", e);
}
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties properties) {
// 无需配置
}
}
4.5 拦截器注册 - TenantConfig
这是另一个容易踩坑的点。由于若依的 RuoYiApplication 排除了 DataSourceAutoConfiguration,自定义了 SqlSessionFactory,不能用常规的 @Configuration 方式注册拦截器。
// java
package com.ruoyi.framework.config;
import com.ruoyi.framework.interceptor.TenantInterceptor;
import com.ruoyi.framework.interceptor.TenantInsertInterceptor;
import org.apache.ibatis.session.SqlSessionFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.List;
/**
* 租户拦截器配置
*
* 【关键】使用ApplicationReadyEvent方式注册拦截器
* 原因:RuoYiApplication排除了DataSourceAutoConfiguration,
* 自定义了SqlSessionFactory,常规@Configuration方式不生效
*/
@Component
public class TenantConfig implements ApplicationListener<ApplicationReadyEvent> {
@Resource
private List<SqlSessionFactory> sqlSessionFactoryList;
@Override
public void onApplicationEvent(ApplicationReadyEvent event) {
// 创建拦截器实例
TenantInterceptor tenantInterceptor = new TenantInterceptor();
TenantInsertInterceptor tenantInsertInterceptor = new TenantInsertInterceptor();
// 注册到所有SqlSessionFactory
for (SqlSessionFactory sqlSessionFactory : sqlSessionFactoryList) {
sqlSessionFactory.getConfiguration().addInterceptor(tenantInterceptor);
sqlSessionFactory.getConfiguration().addInterceptor(tenantInsertInterceptor);
}
}
}
4.6 SecurityUtils增强
确保 SecurityUtils 中有获取租户ID的方法:
// java
package com.ruoyi.common.utils;
import com.ruoyi.framework.web.service.TokenService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import java.util.concurrent.Callable;
/**
* 安全工具类 - 增强版
*/
@Component
public class SecurityUtils {
/**
* 获取当前登录用户
*/
public static LoginUser getLoginUser() {
return TokenService.getLoginUser();
}
/**
* 获取当前用户ID
*/
public static Long getUserId() {
try {
LoginUser user = getLoginUser();
return user != null ? user.getUserId() : null;
} catch (Exception e) {
return null;
}
}
/**
* 获取当前用户租户ID
*
* 【关键】这个方法用于INSERT拦截器,保证数据归属正确
*/
public static Long getTenantId() {
try {
LoginUser user = getLoginUser();
return user != null ? user.getTenantId() : 0L;
} catch (Exception e) {
return 0L;
}
}
// ... 其他方法
}
确保 LoginUser 实体类有 tenantId 字段:
// java
public class LoginUser implements Serializable {
// ... 其他字段
/** 租户ID */
private Long tenantId;
public Long getTenantId() {
return tenantId;
}
public void setTenantId(Long tenantId) {
this.tenantId = tenantId;
}
}
五、前端实现
5.1 Vuex Store改造
在 store/modules/user.js 中添加租户状态:
// javascript
import { login, logout, getInfo } from '@/api/login'
import { getTenantList } from '@/api/system/tenant'
export default {
namespaced: true,
state: {
// ... 原有字段
token: localStorage.getItem('token') || '',
userInfo: {},
roles: [],
permissions: [],
// 【新增】租户相关
currentTenantId: parseInt(sessionStorage.getItem('currentTenantId')) || 0,
tenantList: [],
currentTenantName: sessionStorage.getItem('currentTenantName') || '全部租户'
},
mutations: {
// ... 原有mutations
// 【新增】设置当前租户ID
SET_CURRENT_TENANT_ID: (state, { tenantId, tenantName }) => {
state.currentTenantId = tenantId
state.currentTenantName = tenantName || '全部租户'
sessionStorage.setItem('currentTenantId', tenantId)
sessionStorage.setItem('currentTenantName', tenantName || '全部租户')
},
// 【新增】设置租户列表
SET_TENANT_LIST: (state, list) => {
state.tenantList = list
}
},
actions: {
// 登录
Login({ commit }, userInfo) {
return new Promise((resolve, reject) => {
login(userInfo).then(res => {
localStorage.setItem('token', res.token)
commit('SET_TOKEN', res.token)
resolve()
}).catch(error => {
reject(error)
})
})
},
// 获取用户信息(包含租户列表)
GetInfo({ commit, dispatch }) {
return new Promise((resolve, reject) => {
getInfo().then(res => {
commit('SET_USER_INFO', res.user)
commit('SET_ROLES', res.roles)
commit('SET_PERMISSIONS', res.permissions)
// 【关键】登录后加载租户列表
if (res.user && res.user.tenantId === 0) {
// 超管:加载租户列表
dispatch('LoadTenantList', null, { root: true })
} else {
// 普通用户:设置自己租户
commit('SET_CURRENT_TENANT_ID', {
tenantId: res.user.tenantId,
tenantName: res.user.tenantName
})
}
resolve(res)
}).catch(error => {
reject(error)
})
})
},
// 加载租户列表
LoadTenantList({ commit }) {
return new Promise((resolve, reject) => {
getTenantList().then(res => {
commit('SET_TENANT_LIST', res.rows || [])
resolve(res)
}).catch(error => {
reject(error)
})
})
},
// 切换租户
SwitchTenant({ commit }, { tenantId, tenantName }) {
commit('SET_CURRENT_TENANT_ID', { tenantId, tenantName })
},
// 退出登录
LogOut({ commit }) {
return new Promise((resolve, reject) => {
logout().then(() => {
localStorage.removeItem('token')
sessionStorage.removeItem('currentTenantId')
sessionStorage.removeItem('currentTenantName')
commit('SET_TOKEN', '')
commit('SET_USER_INFO', {})
commit('SET_ROLES', [])
commit('SET_PERMISSIONS', [])
commit('SET_CURRENT_TENANT_ID', { tenantId: 0, tenantName: '全部租户' })
commit('SET_TENANT_LIST', [])
resolve()
}).catch(error => {
reject(error)
})
})
}
},
getters: {
// ... 原有getters
token: state => state.token,
userInfo: state => state.userInfo,
roles: state => state.roles,
permissions: state => state.permissions,
// 【新增】租户相关getters
currentTenantId: state => state.currentTenantId,
currentTenantName: state => state.currentTenantName,
tenantList: state => state.tenantList,
isSuperAdmin: state => state.currentTenantId === 0
}
}
5.2 request.js改造
在每个请求中自动带上 Tenant-Id 请求头:
// javascript
import axios from 'axios'
import store from '@/store'
import { getToken } from '@/utils/auth'
// 创建axios实例
const service = axios.create({
baseURL: process.env.VUE_APP_BASE_API,
timeout: 10000
})
// 请求拦截器
service.interceptors.request.use(
config => {
// 是否已登录
if (getToken()) {
config.headers['Authorization'] = 'Bearer ' + getToken()
}
// 【新增】带上当前租户ID
const currentTenantId = store.getters.currentTenantId
if (currentTenantId !== undefined && currentTenantId !== null) {
config.headers['Tenant-Id'] = currentTenantId
}
return config
},
error => {
return Promise.reject(error)
}
)
// 响应拦截器
service.interceptors.response.use(
// ... 原有逻辑
)
export default service
5.3 Navbar租户切换组件
在顶部导航栏添加租户切换下拉框:
// vue
<template>
<!-- 超管显示切换下拉框 -->
<el-dropdown @command="handleSwitchTenant" v-if="isSuperAdmin">
<span class="el-dropdown-link tenant-switch">
<i class="el-icon-office-building"></i>
{{ currentTenantName }}
<i class="el-icon-arrow-down el-icon--right"></i>
</span>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="0" :class="{ 'active': currentTenantId === 0 }">
全部租户
</el-dropdown-item>
<el-dropdown-item
v-for="tenant in tenantList"
:key="tenant.tenantId"
:command="tenant.tenantId"
:class="{ 'active': currentTenantId === tenant.tenantId }">
{{ tenant.tenantName }}
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
<!-- 普通用户显示租户标签 -->
<span v-else class="tenant-tag">
<i class="el-icon-office-building"></i>
{{ currentTenantName }}
</span>
</template>
<script>
import { mapGetters, mapActions } from 'vuex'
export default {
name: 'TenantSwitch',
computed: {
...mapGetters([
'currentTenantId',
'currentTenantName',
'tenantList',
'userInfo'
]),
// 是否是超管(可以切换租户)
isSuperAdmin() {
return this.userInfo && this.userInfo.tenantId === 0
}
},
methods: {
...mapActions(['SwitchTenant', 'LoadTenantList']),
handleSwitchTenant(tenantId) {
const tenantIdNum = parseInt(tenantId)
const tenant = this.tenantList.find(t => t.tenantId === tenantIdNum)
const tenantName = tenant ? tenant.tenantName : '全部租户'
this.SwitchTenant({
tenantId: tenantIdNum,
tenantName: tenantName
})
// 提示用户刷新页面以加载新租户数据
this.$msg.success(`已切换至${tenantName},部分数据将实时更新`)
// 可选:强制刷新页面以确保数据一致性
// this.$router.go(0)
}
}
}
</script>
<style lang="scss" scoped>
.tenant-switch {
cursor: pointer;
padding: 0 10px;
display: inline-flex;
align-items: center;
color: #fff;
&:hover {
opacity: 0.8;
}
}
.tenant-tag {
padding: 0 10px;
color: #fff;
display: inline-flex;
align-items: center;
i {
margin-right: 5px;
}
}
.active {
background-color: #ecf5ff;
color: #409eff;
}
</style>
5.4 租户列表API
// javascript
// api/system/tenant.js
import request from '@/utils/request'
// 获取租户列表
export function getTenantList(params) {
return request({
url: '/system/tenant/list',
method: 'get',
params
})
}
// 获取租户详情
export function getTenant(tenantId) {
return request({
url: '/system/tenant/' + tenantId,
method: 'get'
})
}
// 新增租户
export function addTenant(data) {
return request({
url: '/system/tenant',
method: 'post',
data
})
}
// 修改租户
export function updateTenant(data) {
return request({
url: '/system/tenant',
method: 'put',
data
})
}
// 删除租户
export function delTenant(tenantId) {
return request({
url: '/system/tenant/' + tenantId,
method: 'delete'
})
}
六、Redis缓存租户隔离
MyBatis拦截器只能处理SQL层面的隔离,但Redis缓存不走SQL,需要单独处理。
6.1 缓存Key加租户前缀
原则:所有与业务数据相关的缓存Key,都应该包含租户ID前缀。
// java
/**
* 缓存Key常量定义
*/
public class CacheConstants {
// ... 原有常量
// 【新增】应用配置缓存(带租户前缀)
public static final String APP_CONFIG_KEY = "app_config:";
/**
* 构建带租户的缓存Key
*
* @param prefix 缓存前缀
* @param tenantId 租户ID
* @param suffix 缓存后缀
* @return 完整的缓存Key
*/
public static String buildTenantKey(String prefix, Long tenantId, String suffix) {
return prefix + tenantId + ":" + suffix;
}
/**
* 构建带租户的缓存Key(无后缀)
*/
public static String buildTenantKey(String prefix, Long tenantId) {
return prefix + tenantId + ":";
}
}
使用示例:
// java
// 原:user_browse_log:123
// 改:user_browse_log:1:123 (租户1的用户123浏览记录)
String cacheKey = CacheConstants.buildTenantKey("user_browse_log:", tenantId, userId);
6.2 在线用户过滤
在线用户数据存在Redis中,查询时需要按租户过滤:
// java
/**
* 查询当前在线用户列表
*
* @param ipaddr IP地址
* @param userName 用户名
* @return 在线用户列表
*/
@Override
public List<SysUserOnline> selectOnlineUserList(String ipaddr, String userName) {
// 获取所有在线用户
Collection<String> keys = redisTemplate.keys(CacheConstants.LOGIN_TOKEN_KEY + "*");
List<SysUserOnline> onlineList = new ArrayList<>();
if (CollectionUtils.isNotEmpty(keys)) {
for (String key : keys) {
LoginUser loginUser = redisTemplate.opsForValue().get(key);
if (loginUser != null) {
SysUserOnline online = new SysUserOnline();
// ... 复制属性
// 【关键】根据当前查看者的租户上下文过滤
Long viewTenantId = TenantContext.getCurrentTenantId();
if (viewTenantId == null) {
viewTenantId = 0L;
}
// viewTenantId==0(超管)看全部,否则只看当前租户
if (viewTenantId == 0 || Objects.equals(loginUser.getTenantId(), viewTenantId)) {
// 用户名/IP过滤
if (StringUtils.isBlank(ipaddr) || online.getIpaddr().contains(ipaddr)) {
if (StringUtils.isBlank(userName) || online.getUserName().contains(userName)) {
onlineList.add(online);
}
}
}
}
}
}
// 排序
onlineList.sort((a, b) -> b.getLoginTime().compareTo(a.getLoginTime()));
return onlineList;
}
七、应用配置管理(sys_app_config)
7.1 为什么需要独立配置表
在SaaS系统中,不同租户可能有不同的业务配置(如站点名称、开关配置等)。传统做法是把配置写在 application.yml 里,但这样所有租户共享同一份配置,无法实现租户级别的个性化。
解决方案:创建 sys_app_config 配置表,支持:
• 按租户分组配置
• 配置继承(租户级 → 全局级)
• Redis缓存
• 后端工具类读取
7.2 表结构设计
// sql
CREATE TABLE sys_app_config (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
config_group VARCHAR(100) NOT NULL DEFAULT '' COMMENT '配置分组',
config_key VARCHAR(100) NOT NULL DEFAULT '' COMMENT '配置键',
config_value TEXT COMMENT COMMENT '配置值',
value_type VARCHAR(20) NOT NULL DEFAULT 'string' COMMENT '值类型(string/number/boolean/json)',
config_label VARCHAR(200) DEFAULT '' COMMENT '显示名称',
config_desc VARCHAR(500) DEFAULT '' COMMENT '配置说明',
sort_order INT DEFAULT 0 COMMENT '排序',
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID(0=全局)',
status CHAR(1) DEFAULT '0' COMMENT '状态(0正常 1停用)',
del_flag CHAR(1) DEFAULT '0' COMMENT '删除标志(0存在 2删除)',
create_by VARCHAR(64) DEFAULT '' COMMENT '创建者',
create_time DATETIME DEFAULT NULL COMMENT '创建时间',
update_by VARCHAR(64) DEFAULT '' COMMENT '更新者',
update_time DATETIME DEFAULT NULL COMMENT '更新时间',
remark VARCHAR(500) DEFAULT '' COMMENT '备注',
PRIMARY KEY (id),
UNIQUE KEY uk_tenant_group_key (tenant_id, config_group, config_key, del_flag)
) ENGINE=InnoDB COMMENT='应用配置表';
7.3 AppConfigHelper工具类
核心配置读取工具,支持缓存和继承:
// java
package com.ruoyi.common.helper;
import com.alibaba.fastjson2.JSONObject;
import com.ruoyi.common.utils.RedisUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.framework.security.context.TenantContext;
import com.ruoyi.system.domain.SysAppConfig;
import com.ruoyi.system.mapper.SysAppConfigMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 应用配置读取工具
*
* 读取链路:Redis缓存 → 当前租户配置 → 全局配置(tenant_id=0) → 默认值
*/
@Component
public class AppConfigHelper {
private static final String CACHE_PREFIX = "app_config:";
private static final long CACHE_TTL_HOURS = 24;
@Autowired
private SysAppConfigMapper appConfigMapper;
private static AppConfigHelper instance;
@PostConstruct
public void init() {
instance = this;
}
// ==================== 公共读取方法 ====================
/**
* 获取字符串配置
*/
public static String getString(String group, String key, String defaultValue) {
SysAppConfig config = getConfig(group, key);
return config != null ? config.getConfigValue() : defaultValue;
}
/**
* 获取整数配置
*/
public static Integer getInt(String group, String key, Integer defaultValue) {
String value = getString(group, key, null);
if (value == null) {
return defaultValue;
}
try {
return Integer.parseInt(value);
} catch (NumberFormatException e) {
return defaultValue;
}
}
/**
* 获取布尔配置
*/
public static Boolean getBoolean(String group, String key, Boolean defaultValue) {
String value = getString(group, key, null);
if (value == null) {
return defaultValue;
}
return "true".equalsIgnoreCase(value) || "1".equals(value);
}
/**
* 获取JSON配置
*/
public static JSONObject getJson(String group, String key) {
String value = getString(group, key, null);
if (value == null) {
return new JSONObject();
}
try {
return JSONObject.parseObject(value);
} catch (Exception e) {
return new JSONObject();
}
}
/**
* 获取分组下所有配置
*/
public static Map<String, String> getGroup(String group) {
Map<String, String> result = new HashMap<>();
Long tenantId = getTenantId();
// 先加载全局配置
List<SysAppConfig> globalConfigs = loadConfigsFromDb(0L, group);
for (SysAppConfig config : globalConfigs) {
result.put(config.getConfigKey(), config.getConfigValue());
}
// 再用租户配置覆盖
if (tenantId != 0L) {
List<SysAppConfig> tenantConfigs = loadConfigsFromDb(tenantId, group);
for (SysAppConfig config : tenantConfigs) {
result.put(config.getConfigKey(), config.getConfigValue());
}
}
return result;
}
// ==================== 核心实现 ====================
/**
* 获取单个配置
*
* 读取链路:Redis缓存 → 当前租户配置 → 全局配置(tenant_id=0)
*/
private static SysAppConfig getConfig(String group, String key) {
Long tenantId = getTenantId();
// 1. 先查Redis缓存
String cacheKey = buildCacheKey(tenantId, group, key);
SysAppConfig cached = RedisUtils.getCacheObject(cacheKey);
if (cached != null) {
return cached;
}
// 2. 查当前租户配置
if (tenantId != 0L) {
SysAppConfig tenantConfig = loadConfigFromDb(tenantId, group, key);
if (tenantConfig != null) {
RedisUtils.setCacheObject(cacheKey, tenantConfig, CACHE_TTL_HOURS);
return tenantConfig;
}
}
// 3. 查全局配置(tenant_id=0)
SysAppConfig globalConfig = loadConfigFromDb(0L, group, key);
if (globalConfig != null) {
// 全局配置也缓存,Key格式:0:group:key
String globalCacheKey = buildCacheKey(0L, group, key);
RedisUtils.setCacheObject(globalCacheKey, globalConfig, CACHE_TTL_HOURS);
// 同时缓存一份到租户Key下,便于统一管理TTL
if (tenantId != 0L) {
RedisUtils.setCacheObject(cacheKey, globalConfig, CACHE_TTL_HOURS);
}
}
return globalConfig;
}
/**
* 从数据库加载配置
*/
private static SysAppConfig loadConfigFromDb(Long tenantId, String group, String key) {
try {
return instance.appConfigMapper.selectConfig(tenantId, group, key);
} catch (Exception e) {
return null;
}
}
/**
* 从数据库加载分组配置
*/
private static List<SysAppConfig> loadConfigsFromDb(Long tenantId, String group) {
try {
return instance.appConfigMapper.selectConfigList(tenantId, group);
} catch (Exception e) {
return null;
}
}
/**
* 获取当前租户ID(带兜底)
*/
private static Long getTenantId() {
Long tenantId = TenantContext.getCurrentTenantId();
// 兜底处理:定时任务等无HTTP上下文时
if (tenantId == null) {
tenantId = 0L;
}
return tenantId;
}
/**
* 构建缓存Key
*/
private static String buildCacheKey(Long tenantId, String group, String key) {
return CACHE_PREFIX + tenantId + ":" + group + ":" + key;
}
// ==================== 缓存维护 ====================
/**
* 清除配置缓存
*
* 在配置Service的增删改方法中调用
*/
public static void clearCache(Long tenantId, String group, String key) {
// 清除租户缓存
String tenantCacheKey = buildCacheKey(tenantId, group, key);
RedisUtils.deleteObject(tenantCacheKey);
// 如果不是全局配置,同时清除全局缓存
if (tenantId != 0L) {
String globalCacheKey = buildCacheKey(0L, group, key);
RedisUtils.deleteObject(globalCacheKey);
}
}
/**
* 预热全局配置到缓存(可选,启动时调用)
*/
public static void warmupGlobalConfigs() {
try {
List<SysAppConfig> globalConfigs = instance.appConfigMapper.selectConfigList(0L, null);
for (SysAppConfig config : globalConfigs) {
String cacheKey = buildCacheKey(0L, config.getConfigGroup(), config.getConfigKey());
RedisUtils.setCacheObject(cacheKey, config, CACHE_TTL_HOURS);
}
} catch (Exception e) {
// 忽略预热失败
}
}
}
7.4 配置继承机制详解
┌────────────────────────────────────────────────────────────────┐
│ 配置读取优先级 │
├────────────────────────────────────────────────────────────────┤
│ │
│ 租户1读取 "site.siteName" │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Step 1: 查Redis缓存 (app_config:1:site:siteName) │ │
│ │ └── 命中 → 返回缓存值 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ 未命中 │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Step 2: 查租户1配置 (tenant_id=1) │ │
│ │ └── 命中 → 缓存并返回 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ 未命中 │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Step 3: 查全局配置 (tenant_id=0) │ │
│ │ └── 命中 → 缓存到 app_config:1: 和 app_config:0│ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ 未命中 │
│ ▼ │
│ 返回代码中定义的默认值 │
│ │
└────────────────────────────────────────────────────────────────┘
7.5 后端使用示例
// java
/**
* 系统设置Controller
*/
@RestController
@RequestMapping("/site/settings")
public class SiteSettingsController {
/**
* 获取站点信息
*/
@GetMapping("/info")
public AjaxResult getSiteInfo() {
Map<String, Object> siteInfo = new HashMap<>();
// 使用AppConfigHelper读取配置
siteInfo.put("siteName", AppConfigHelper.getString("site", "siteName", "默认站点"));
siteInfo.put("siteLogo", AppConfigHelper.getString("site", "siteLogo", "/logo.png"));
siteInfo.put("maxUploadSize", AppConfigHelper.getInt("site", "maxUploadSize", 10));
siteInfo.put("openRegister", AppConfigHelper.getBoolean("feature", "openRegister", false));
return AjaxResult.success(siteInfo);
}
/**
* 获取功能开关
*/
@GetMapping("/features")
public AjaxResult getFeatures() {
JSONObject features = AppConfigHelper.getJson("feature", "switches", new JSONObject());
return AjaxResult.success(features);
}
}
八、踩坑与解决方案
8.1 SELECT用TenantContext,INSERT用SecurityUtils
坑:如果INSERT也用 TenantContext.getCurrentTenantId(),超管切换到租户A后创建的数据全部归属到租户A,超管自己的数据反而查不到。
解:严格区分两个场景:
• 查询过滤 → 用 TenantContext(跟随前端切换)
• 数据写入 → 用 SecurityUtils.getTenantId()(跟随用户真实身份)
8.2 tenantId==0判断必须保留
坑:超管"全部租户"模式时 TenantContext 为0,如果不加判断,SQL会变成 WHERE tenant_id=0,导致什么都查不到。
解:
// java
Long tenantId = TenantContext.getCurrentTenantId();
if (tenantId == null || tenantId == 0L) {
return invocation.proceed(); // 不过滤
}
8.3 RuoYiApplication排除了DataSourceAutoConfiguration
坑:常规的 @Configuration + @Bean 注册MyBatis拦截器方式不生效,因为若依自定义了 SqlSessionFactory。
解:改用 ApplicationReadyEvent 事件监听:
// java
@Component
public class TenantConfig implements ApplicationListener<ApplicationReadyEvent> {
@Override
public void onApplicationEvent(ApplicationReadyEvent event) {
// 在应用启动完成后注册拦截器
for (SqlSessionFactory factory : sqlSessionFactoryList) {
factory.getConfiguration().addInterceptor(new TenantInterceptor());
}
}
}
8.4 el-select的required校验问题
坑:tenant_id=0 时被JavaScript判为falsy值,导致 required 校验失败。
解:在表单校验规则中指定 type: 'number':
// javascript
rules: {
tenantId: [
{ required: true, message: '请选择租户', trigger: 'change' },
{ type: 'number', message: '租户ID必须为数字' }
]
}
8.5 store里租户列表字段名
坑:写 v-for="tenant in tenantList" 时,item的字段名是 tenantId 不是 id。
解:在API返回和Vuex mutation中保持一致:
// javascript
// 后端返回
{ "tenantId": 1, "tenantName": "租户A" }
// 前端遍历
<el-option
v-for="tenant in tenantList"
:key="tenant.tenantId"
:label="tenant.tenantName"
:value="tenant.tenantId">
</el-option>
8.6 Vue2深度选择器
坑:在 <style scoped> 中使用 /deep/ 无效。
解:改用 ::v-deep 或 :deep():
// scss
// ❌ 错误
/deep/ .el-input { ... }
// ✅ 正确
::v-deep .el-input { ... }
// 或
:deep(.el-input) { ... }
8.7 sessionStorage持久化
坑:切换租户后刷新页面,Vuex状态丢失,切换效果消失。
解:state初始化时从 sessionStorage 读取:
// javascript
state: {
currentTenantId: parseInt(sessionStorage.getItem('currentTenantId')) || 0
}
mutations: {
SET_CURRENT_TENANT_ID(state, tenantId) {
state.currentTenantId = tenantId
sessionStorage.setItem('currentTenantId', tenantId)
}
}
8.8 定时任务无HTTP上下文
坑:TenantContext.getCurrentTenantId() 在定时任务中返回 null,导致配置读取异常。
解:在工具类中加兜底处理:
// java
private static Long getTenantId() {
Long tenantId = TenantContext.getCurrentTenantId();
if (tenantId == null) {
tenantId = 0L; // 兜底:默认读全局配置
}
return tenantId;
}
九、总结与展望
9.1 核心要点回顾
┌────────────────────────────────────────────────────────────────┐
│ 多租户隔离核心要点 │
├────────────────────────────────────────────────────────────────┤
│ │
│ 1. 【架构】 │
│ ThreadLocal传递租户ID → MyBatis拦截器改写SQL │
│ │
│ 2. 【SELECT拦截】 │
│ TenantContext.getCurrentTenantId() → 跟随前端切换 │
│ tenantId==0 时不过滤(超管模式) │
│ │
│ 3. 【INSERT拦截】 │
│ SecurityUtils.getTenantId() → 跟随用户真实身份 │
│ 保证数据归属正确 │
│ │
│ 4. 【EXCLUDE_TABLES】 │
│ 公共框架表不加tenant_id隔离 │
│ 数据同步表先加字段、暂放排除列表 │
│ │
│ 5. 【前端】 │
│ Vuex存currentTenantId → request.js Header注入 │
│ 超管下拉切换 → 普通用户标签显示 │
│ │
│ 6. 【缓存】 │
│ Redis Key加租户前缀 → 在线用户按租户过滤 │
│ │
│ 7. 【配置】 │
│ sys_app_config → 租户级+全局级继承 → Redis缓存 │
│ │
└────────────────────────────────────────────────────────────────┘
9.2 后续优化方向
1. 数据同步表改造:逐步将同步表移出 EXCLUDE_TABLES,实现完整租户隔离
2. 租户数据迁移:开发数据迁移工具,支持按租户导出/导入数据
3. 租户用量监控:统计各租户的资源使用量(用户数、存储量、API调用量)
4. 缓存分级策略:热数据按租户隔离,温数据全局共享
9.3 参考价值
本文方案同样适用于:
• 其他基于 MyBatis 的项目(如人人开源、JeecgBoot等)
• Spring Boot 2.x + MyBatis 的自定义框架
• 需要快速实现多租户隔离的业务系统
只要掌握了「ThreadLocal + MyBatis拦截器」的核心思路,就可以灵活适配各种场景。
十、完整文件清单
后端新增文件
|
文件路径 |
作用 |
核心功能 |
|
framework/src/main/java/com/ruoyi/framework/security/context/TenantContext.java |
租户上下文 |
ThreadLocal存取当前租户ID |
|
framework/src/main/java/com/ruoyi/framework/security/filter/TenantFilter.java |
租户过滤器 |
请求头读取 → ThreadLocal写入 |
|
framework/src/main/java/com/ruoyi/framework/interceptor/TenantInterceptor.java |
SELECT拦截器 |
自动追加WHERE tenant_id=? |
|
framework/src/main/java/com/ruoyi/framework/interceptor/TenantInsertInterceptor.java |
INSERT拦截器 |
自动填充tenant_id字段 |
|
framework/src/main/java/com/ruoyi/framework/config/TenantConfig.java |
拦截器注册 |
ApplicationReadyEvent方式注册 |
|
common/src/main/java/com/ruoyi/common/helper/AppConfigHelper.java |
配置读取工具 |
缓存+继承+多类型读取 |
|
system/src/main/java/com/ruoyi/system/domain/SysAppConfig.java |
配置实体 |
应用配置表映射 |
|
system/src/main/java/com/ruoyi/system/mapper/SysAppConfigMapper.java |
配置Mapper |
配置CRUD |
|
system/src/main/java/com/ruoyi/system/service/ISysAppConfigService.java |
配置Service接口 |
服务层定义 |
|
system/src/main/java/com/ruoyi/system/service/impl/SysAppConfigServiceImpl.java |
配置Service实现 |
含缓存维护逻辑 |
|
system/src/main/java/com/ruoyi/system/controller/SysAppConfigController.java |
配置Controller |
管理API+运行时API |
|
system/src/main/java/com/ruoyi/system/controller/SysTenantController.java |
租户Controller |
租户CRUD |
|
common/src/main/java/com/ruoyi/common/utils/RedisUtils.java |
增强RedisUtils |
补充缓存操作方法 |
|
common/src/main/java/com/ruoyi/common/constant/CacheConstants.java |
缓存常量 |
新增APPCONFIGKEY |
后端修改文件
|
文件路径 |
修改内容 |
|
common/src/main/java/com/ruoyi/common/utils/SecurityUtils.java |
增加getTenantId()方法 |
|
framework/src/main/java/com/ruoyi/framework/web/service/TokenService.java |
登录用户信息包含tenantId |
|
system/src/main/java/com/ruoyi/system/domain/SysUser.java |
LoginUser增加tenantId字段 |
|
common/src/main/java/com/ruoyi/framework/web/domain/OnlineUser.java |
增加租户相关字段 |
|
SqlSessionFactoryConfig 或 DruidConfig |
如有自定义SqlSessionFactory |
前端新增文件
|
文件路径 |
作用 |
|
src/api/system/tenant.js |
租户管理API |
|
src/api/system/appConfig.js |
配置管理API |
|
src/components/TenantSwitch/index.vue |
租户切换组件 |
|
src/views/system/tenant/index.vue |
租户管理页面 |
|
src/views/system/appConfig/index.vue |
配置管理页面 |
前端修改文件
|
文件路径 |
修改内容 |
|
src/store/modules/user.js |
增加租户状态、切换逻辑 |
|
src/utils/request.js |
请求头注入Tenant-Id |
|
src/layout/components/Navbar.vue |
引入租户切换组件 |
|
src/api/login.js |
登录响应包含tenantId |
数据库SQL
// sql
-- 1. 租户表
CREATE TABLE sys_tenant (...);
-- 2. 应用配置表
CREATE TABLE sys_app_config (...);
-- 3. 业务表加tenant_id
ALTER TABLE sys_user ADD COLUMN tenant_id bigint DEFAULT 0 COMMENT '租户ID';
-- ... 其他表类似
-- 4. 初始化全局配置
INSERT INTO sys_app_config (tenant_id, config_group, config_key, config_value, value_type, config_label) VALUES
(0, 'site', 'siteName', '默认站点', 'string', '站点名称'),
(0, 'site', 'siteLogo', '/logo.png', 'string', '站点Logo'),
(0, 'feature', 'openRegister', 'true', 'boolean', '开放注册');
写在最后:多租户隔离是企业级SaaS系统的基础能力,本文提供的方案在若依框架上实现了生产级别的多租户支持。核心思路是「ThreadLocal传递 + MyBatis拦截器改写」,对业务代码几乎无侵入。如果你在实施过程中遇到问题,欢迎在评论区交流。
更多推荐
所有评论(0)