前言

在企业级应用开发中,多租户隔离是一个绕不开的话题。当我们基于若依框架开发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

增加租户相关字段

SqlSessionFactoryConfigDruidConfig

如有自定义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拦截器改写」,对业务代码几乎无侵入。如果你在实施过程中遇到问题,欢迎在评论区交流。

更多推荐