基于RuoYi-Cloud的多租户SaaS开发框架改造与实践

发布时间:2026/9/17 17:04:00
基于RuoYi-Cloud的多租户SaaS开发框架改造与实践 简介基于 RuoYi-Cloud 二次开发的多租户 SaaS 开发框架定位于帮中小型企业快速搭建后台管理系统可在不纠结开源组件取舍的前提下直接获得带租户隔离、权限控制与微服务治理的工程底座。压缩包共 952 个文件涵盖 378 个 Java 源码、99 个 Vue 组件、156 个 JS 脚本对应后端业务逻辑、前端页面与交互控制另有 54 个 XML、11 个 YML 环境配置和 4 个 SQL 脚本用于服务配置、多环境切换及数据库初始化整体仅 6.19MB结构精炼。包内提供的启动类 BAT 脚本覆盖网关、认证、系统服务、文件服务、代码生成与监控模块并附有 Nginx 配置示例方便本地联调和前后端部署。已有 1623 人学习下载适合具备一定 Java 基础、想基于成熟方案实践多租户 SaaS 平台的开发者作为起点工程参考。1. 多租户SaaS开发框架从RuoYi-Cloud里拆出能直接交付的底座在这个开源项目里基于 RuoYi-Cloud 二次改造的多租户 SaaS 开发框架把原本面向企业内部系统的微服务骨架收窄成“一租户一数据域、按套餐交付功能”的快速开发底座。对中小企业或外包团队来说它解决的并不是某一个业务功能而是从零搭建多租户后台管理系统时必然遇到的租户表设计、数据隔离、网关路由和启动部署问题。项目整体保留 gateway、auth、modules-system、file、monitor、gen 这些微服务模块同时提供 copy.bat、run-gateway.bat、run-modules-system.bat 等一键启动脚本以及一套 nginx.conf。拿到手不需要再纠结哪些复杂功能该留、哪些不该留直接按 SaaS 模式把租户维度跑起来适合想快速做多租户后台管理系统、又不打算重新造轮子的团队。2. 租户数据模型与上下文传递改造RuoYi-Cloud的第一个核心技术点多租户改造的第一步不是急着写代码而是先把租户身份识别和数据隔离的链路理清楚。这里不是简单给每张业务表加一个 tenant_id 就完事还要考虑租户信息在调用链路上怎么传递以及在 SQL 执行前如何统一加入过滤条件。如果这一步设计不完整后面所有业务模块都会出现租户串数据的问题。2.1 租户表结构与 tenant_id 落法多租户场景下的核心表至少包含sys_tenant、sys_tenant_package、sys_user、sys_role等。sys_tenant用于注册租户实例sys_tenant_package用来描述当前租户购买的套餐容量。下面是常见的设计表格表名作用关键字段sys_tenant租户实例id、tenant_id、tenant_name、status、expire_timesys_tenant_package套餐模板package_id、package_name、user_limit、app_limit、data_limitsys_user用户user_id、tenant_id、user_name、dept_idsys_role角色role_id、tenant_id、role_name、data_scope这里的tenant_id不直接复用主键 id因为很多 SaaS 系统会允许租户通过独立域名或邀请码识别。独立字段可以让后续对接独立域名时不用改主键。所有业务表在创建时都要求带上tenant_id并且建议在tenant_id和业务主键上建联合唯一索引避免不同租户的数据被唯一约束相互干扰。比如订单表应该这样建索引alter table biz_order add unique index uk_tenant_order(tenant_id, order_no);这个索引的意义是同一个租户内部订单号唯一不同租户可以出现同样的订单号重点在于防止租户数据的唯一性被全局约束破坏。我的一般做法是不在每条 SQL 里手工加tenant_id而是通过 MyBatis 拦截器统一处理。这样一方面减少开发漏加条件另一方面也方便在测试环境临时放开租户维度做全量数据核对。改造时建议先给所有业务表补tenant_id字段再处理历史数据。2.2 基于 Token 的租户上下文传递若依云版本身用 JWT 登录用户信息放在LoginUser对象里再写入 Redis。多租户改造后登录返回的 token 里必须包含tenant_id。前端后续请求会带上Authorization网关解析 JWT 后从 claim 中读出tenant_id再以请求头X-Tenant-Id传给下游服务。Spring Cloud Gateway 的全局过滤器可以这样处理Component public class TenantHeaderFilter implements GlobalFilter, Ordered { Override public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { String tenantId exchange.getRequest().getHeaders().getFirst(X-Tenant-Id); if (StrUtil.isBlank(tenantId)) { // 从 JWT 中解析租户 id通常由 ruoyi-gateway 的 AuthFilter 完成 tenantId JwtUtils.getTenantIdFromToken(exchange.getRequest()); } ServerHttpRequest request exchange.getRequest().mutate() .header(X-Tenant-Id, tenantId) .build(); return chain.filter(exchange.mutate().request(request).build()); } Override public int getOrder() { return -200; } }这段代码的要点是getOrder()返回 -200保证在路由转发前执行header 的 key 必须与下游服务读取时一致。各业务服务在 Controller 或 Service 层通过RequestContextHolder取得当前请求的X-Tenant-Id再把它放入TenantContextHolder供 MyBatis 拦截器读取。与纯前端传参相比用 Token Header 传递的好处是租户不会因为前端改了个表单字段就被伪造。只要 JWT 签名没被破解租户维度就是可信的。2.3 MyBatis 拦截器实现数据自动隔离这里的关键是拦截器解析 SQL 后在 WHERE 条件前拼入tenant_id 当前租户。常见的做法是借助net.sf.jsqlparser解析 SQL将原始 SQL 解析成 Select 对象然后遍历 where 条件加入租户判断。Intercepts({ Signature(type StatementHandler.class, method prepare, args {Connection.class, Integer.class}) }) public class TenantLineHandler implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { StatementHandler statementHandler (StatementHandler) invocation.getTarget(); MetaObject metaObject SystemMetaObject.forObject(statementHandler); MappedStatement mappedStatement (MappedStatement) metaObject.getValue(delegate.mappedStatement); if (mappedStatement.getId().contains(TenantIgnore)) { return invocation.proceed(); } BoundSql boundSql statementHandler.getBoundSql(); String sql boundSql.getSql(); String newSql TenantSqlParser.addTenantCondition(sql, TenantContextHolder.getTenantId()); metaObject.setValue(delegate.boundSql.sql, newSql); return invocation.proceed(); } }这段代码不能直接照搬只是一个简化示例。实际项目中还要处理 INSERT 语句自动补tenant_id、DELETE 和 UPDATE 的 WHERE 条件拼接、以及子查询里的嵌套表。参数说明MappedStatement.getId()返回的是 Mapper 方法的全限定名如果你的 mapper 方法名里包含TenantIgnore关键字就被视为忽略租户过滤TenantContextHolder是自定义的 ThreadLocal 变量用于存放流程内的租户 id。用拦截器还有一个额外好处统一封装后如果将来要把租户维度升级成按团队隔离只需要拦截器里多解析一个team_id业务代码基本不动。我在做若依云版多租户项目时就是先跑通拦截器再回头改业务表改造效率会高很多。2.4 哪些地方不能依赖自动拦截需要特别说明的是拦截器的边界。它只负责在执行 SQL 前拼条件不负责业务逻辑的租户判断。下面几类场景需要手动处理异步任务Async方法里用的是新线程TenantContextHolder是线程变量拿不到租户 id。要在任务入队时把tenantId作为参数传进去在方法入口重新设置TenantContextHolder。跨租户统计报表平台方查看所有租户的使用量属于超管权限不应该走租户过滤器。这种 mapper 方法名需要包含TenantIgnore。定时任务定时任务没有前端请求需要在任务配置里显式指定租户 id不能依赖请求头。多数据源场景如果租户表和数据表分库每次切换数据源时也要重新判断租户。这个阶段最容易犯的错误是只给业务表加了tenant_id却没处理用户在 A 租户登录后通过修改参数访问 B 租户的接口。因为菜单和角色已经在接口层做了鉴权所以改造时要先把“租户上下文从哪来、到哪去”这条链路理清楚再谈数据过滤。3. copy.bat 与 run-*.bat本地一键启动的工程化设计拿到项目后最先接触的往往是根目录下那批.bat脚本。它们不是可有可无的快捷方式而是整个多租户 SaaS 环境的最小启动编排。平时开发中每次改动前端或后端都需要重新构建、替换、重启脚本将这套动作固化下来能减少大量重复操作。3.1 copy.bat解决本地工程资源同步一套微服务框架里前端构建产物、公共 jar 包、配置文件往往分散在不同目录。copy.bat的作用是把这些资源统一复制到运行目录避免每次手动 copy。一个常见的copy.bat内容如下echo off echo 1. copy frontend dist - nginx html xcopy /E /Y /Q ..\ruoyi-ui\dist .\nginx\html\ echo 2. copy auth jar xcopy /Y /Q ..\ruoyi-auth\target\ruoyi-auth.jar .\run\auth\ echo 3. copy system module jar xcopy /Y /Q ..\ruoyi-modules\ruoyi-modules-system\target\ruoyi-modules-system.jar .\run\system\ echo 4. copy file module jar xcopy /Y /Q ..\ruoyi-modules\ruoyi-modules-file\target\ruoyi-modules-file.jar .\run\file\ pause参数说明/E复制子目录包括空目录/Y覆盖时不询问/Q关闭回显/I自动创建目标目录。如果你的机器上装了 Git Bash也可以写成 shell 脚本但 Windows 团队用 bat 更直接。每次改完前端或模块代码编译成功后执行copy.bat运行目录就是最新的产物。3.2 启动顺序与依赖关系这个项目的运行脚本从命名上已经能看出模块分工。但不是所有服务都能同时启动需要一个合理的顺序先启动基础设施Nacos、Redis、MySQL再启动网关然后是认证服务最后是各业务模块。否则modules-system启动时注册不上 Nacos网关转发时会报找不到实例。下面是我建议的服务依赖表脚本对应服务默认端口依赖run-gateway.batruoyi-gateway8080Nacos、Redisrun-auth.batruoyi-auth9200Nacos、Redis、MySQLrun-modules-system.batsystem 模块9201Nacos、MySQLrun-modules-file.batfile 模块9300Nacos、MinIO/OSSrun-monitor.batmonitor 服务9100Nacos、Redisrun-modules-gen.bat代码生成9202Nacos、MySQL如果你的电脑配置不高可以先不启动 monitor 和 gen。run-monitor.bat主要是监控中心对业务数据库并没有强依赖run-modules-gen.bat只在需要生成代码时启动即可。注意端口不能冲突如果本机 9201 被占用要同时改 Nacos 配置、bootstrap.yml和启动脚本里的server.port。3.3 run-*.bat 的 JVM 参数调优这些 bat 最终执行的就是java -jar命令只是帮你把 profile 和 JVM 参数提前写好了。以run-modules-system.bat为例echo off set JAR_NAMEruoyi-modules-system.jar set JVM_OPTS-Xms512m -Xmx1024m -Xss256k -Dfile.encodingutf-8 set PROFILEdev java %JVM_OPTS% -jar %JAR_NAME% --spring.profiles.active%PROFILE% pause这里的-Xms和-Xmx分别是最小堆和最大堆-Xss是线程栈大小。如果你的电脑是 8G 内存我一般会把 gateway 设成 256m–512m业务模块设成 512m–1024m开发机总共需要 3–4G 内存。profile 用 dev对应 Nacos 里的ruoyi-dev.yml这决定了连接哪个数据库和注册中心。3.4 本地启动的常见报错用这些脚本启动时遇到最多的情况有三种8080 网关端口被本机 nginx 或 Apache 占用。可以先执行clean.bat或者改 nginx.conf 的listen端口。服务启动后立刻退出且日志提示 Nacos 连接超时。检查 Nacos 是否先启动再去nacos/conf/application.properties里确认端口是 8848。模块报 “Table xxx doesnt exist”。说明数据库初始化脚本没有执行或者执行的 SQL 是单租户版本。这个项目里多租户改造所需的新表通常在doc/sql目录下需要先导入主库再在sys_tenant里插入初始租户。提示如果在 Windows 上双击 bat 启动服务窗口如果关闭子服务也会一起结束。我习惯用start java ...方式或者把命令放进cmd /k容器里这样每个服务独立一个窗口关闭窗口只终止当前服务。4. Nginx 网关路由与前端联调多租户场景下的反向代理配置项目根目录提供了 nginx.conf这是多租户框架能同时服务前端页面、网关接口和静态资源的关键。直接修改此文件重载即可不用在应用层再单独处理跨域。4.1 整体路由设计前端文件会由copy.bat拷到 nginx 的 html 目录浏览器访问的是 RuoYi-Vue 的编译产物。前端请求地址通常带/prod-api前缀nginx 需要把这个前缀转发到网关 8080再由网关路由到 auth、system、file 等微服务。因此 nginx.conf 里至少要有两个 location一个负责静态页面一个负责 API 反向代理。4.2 关键 nginx.conf 配置一个可用的 server 块大致如下server { listen 80; server_name localhost; gzip on; gzip_types text/plain text/css application/javascript application/json; location / { root html; index index.html; try_files $uri $uri/ /index.html; } location /prod-api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Tenant-Id $http_x_tenant_id; } }注意这里proxy_pass http://127.0.0.1:8080/;末尾的斜杠。它会把/prod-api/login转发到网关的/login而不是/prod-api/login。如果你的网关里还配置了/prod-api前缀就必须把斜杠去掉否则路径会重复。多租户改造后X-Tenant-Id需要通过$http_x_tenant_id原样透传否则前端手动携带的租户标识会在 nginx 层丢失。这样一来网关全局过滤器即使不解析 JWT也能取到租户 id但安全上我建议仍以 JWT claim 为准nginx 传递的值只作为调试入口。4.3 独立域名与租户识别在 SaaS 场景下你可能希望每个租户使用独立域名。nginx 可以利用map指令直接根据域名匹配租户不用穿透整个链路map $http_host $tenant_id { default 0; demo.example.com 1001; acme.example.com 1002; } server { server_name demo.example.com acme.example.com; location / { proxy_set_header X-Tenant-Id $tenant_id; proxy_pass http://127.0.0.1:8080; } }此时后端网关仍然可以将请求头中的X-Tenant-Id转发给业务模块而业务模块里只需要在TenantContextHolder初始化时先读这个头再读 Redis 中的用户租户信息二者不一致时可以直接拒绝访问。4.4 前端联调时的跨域与 Cookie 问题如果前端开发时用 Vite 代理而后端是这套 nginx最容易踩的坑是 Cookie 的 Path 和 SameSite 设置。若依系统登录后 Cookie 可能被写在/prod-api或网关域下。跨域联调时建议让前端请求统一走/prod-api并在 nginx 里加一行proxy_cookie_path / /prod-api/;这个配置可以把后端返回的 Set-Cookie 的路径改写为/prod-api确保前端在根路径也能带上 Cookie。如果你用 JWT 无状态登录则不需要关心这个配置但要注意请求头中携带的 token 不能在网关层被清理掉。多租户场景下建议把租户 id 也写进 JWT claim这样即使 Cookie 丢失网关依然可以解析出正确租户。5. 租户套餐与独立域名的二次扩展基础多租户链路跑通之后SaaS 框架还需要具备“套餐”和“域名”这类业务属性。这里不是功能堆砌而是把租户从技术概念落地成可售卖的商业单位。5.1 基于套餐限制用户数多租户 SaaS 框架通常需要平台管理员为每个租户分配套餐套餐里配置最大用户数、最大应用数。实现时可以在租户保存、用户创建时校验数量public void checkTenantUserLimit(Long tenantId, int addCount) { TenantPackage pack tenantPackageMapper.selectByTenantId(tenantId); Long current userMapper.selectCountByTenantId(tenantId); if (current addCount pack.getUserLimit()) { throw new ServiceException(用户数超出套餐限制 pack.getUserLimit()); } }这里面的selectByTenantId和selectCountByTenantId只需要把tenant_id作为查询条件。租户表数据量通常不大索引命中后性能可以接受。要注意的是这类限制在网关里做不太合适因为网关不感知业务表放在业务服务入口配合 AOP 注解会更清晰。5.2 菜单权限与租户数据的叠加若依本身有菜单权限改造后要区分两种情况平台管理员可以看到全部菜单租户管理员只能看到套餐允许的菜单。我一般会在sys_tenant_package_menu表里维护套餐和菜单的关系在登录后构造LoginUser时先查租户的套餐菜单集合再与角色菜单集合做交集最后放入权限列表。代码上可以这么理解RuoYi 原本的逻辑是查用户角色 - 菜单 - 权限字符多租户版本则在“查菜单”前加一个“套餐菜单”过滤SetString packageMenus tenantPackageService.selectMenuKeysByTenantId(tenantId); SetString userMenus menuService.selectMenuKeysByUserId(userId); userMenus.retainAll(packageMenus);这样即使角色里的菜单配置很多超出套餐范围也无法访问。代码生成模块生成的前端页面可以做成按套餐可见但要注意 gen 模块本身不带租户数据如果让 gen 模块直接理解租户套餐需要把套餐查询做成独立 API。5.3 给租户分配独立域名第 4 章我们提到用 nginx map 实现域名对应租户这里补充一个后端校验。有的场景下域名不是通过 nginx 反代到同一个网关而是直接请求后端此时需要在网关里按 serverName 识别租户。String tenantId exchange.getRequest().getHeaders().getFirst(X-Tenant-Id); if (StrUtil.isBlank(tenantId)) { String host exchange.getRequest().getURI().getHost(); tenantId tenantService.getTenantIdByDomain(host); } if (StrUtil.isBlank(tenantId)) { throw new TenantException(无法识别当前租户); }注意这里的getTenantIdByDomain会查sys_tenant_domain表实际场景中这个表要加缓存否则网关的每次请求都会打一次数据库。缓存过期时间可以设成 5 分钟域名更新后就等缓存失效或者引入 Redis 发布订阅来主动失效。5.4 租户维度的初始化数据脚本当一个新租户注册时需要为他初始化角色、菜单、部门、字典等基础数据。通常的做法是使用一套模板 SQL复制数据时替换tenant_id。比如新建租户后平台管理员会在管理端手动点击“初始化租户”后台执行以下逻辑Transactional(rollbackFor Exception.class) public void initTenantData(Long tenantId) { // 1. 从模板租户复制角色 roleService.copyRolesFromTenant(TEMPLATE_TENANT_ID, tenantId); // 2. 从模板租户复制菜单 menuService.copyMenusFromTenant(TEMPLATE_TENANT_ID, tenantId); // 3. 从模板租户复制基础字典 dictService.copyDictsFromTenant(TEMPLATE_TENANT_ID, tenantId); // 4. 插入管理员用户 userService.createTenantAdmin(tenantId); }这里的TEMPLATE_TENANT_ID是一个约定好的模板租户不允许登录业务系统只作为复制源。复制时要注意主键冲突建议在 insert 语句中为新租户生成新的 id而不是直接沿用模板租户的主键。如果使用 MyBatis 拦截器复制操作务必要标注TenantIgnore否则从模板租户读取的数据会被过滤器改写导致复制失败。6. 验证租户隔离与 SQL 日志排查技巧到这里多租户框架已经可以跑起来但还需要验证隔离是否真正生效。下面是我在交付时最常用的一组技巧能快速定位租户条件是否被自动拼入 SQL。6.1 打开 MyBatis SQL 日志在application-dev.yml里把日志级别调到 debug重点观察 mapper 包下的 SQL 输出logging: level: com.ruoyi: debug com.ruoyi.system.mapper: debug重启后任意一次列表查询都会打印完整 SQL。如果日志中看不到tenant_id ?说明拦截器没有生效或者当前 mapper 方法被TenantIgnore标记了。6.2 用两个浏览器切换租户验证数据隔离分别用租户 A 和租户 B 的账号登录进入同一张业务列表打开浏览器开发者工具的 Network 面板找到列表接口的请求参数。正常情况下两个请求的响应数据不应出现交集。如果想更快验证可以在 A 租户创建一条带特殊名称的数据然后在 B 租户页面搜索该名称。如果出现在结果里说明租户条件没有拼进去需要检查拦截器的 SQL 解析逻辑。如果只在初次进入页面时出现该数据可能是前端缓存刷新后再验证。如果请求头带了X-Tenant-Id但 SQL 没有过滤排查点通常在TenantContextHolder的初始化位置。如果是异步线程里的查询出现串号请参考第 2.4 节把租户 id 显式传递到异步方法。6.3 自动化冒烟测试判断拦截器是否生效更稳妥的方式是写一段集成测试直接调用 mapper 接口并打印 SQL。下面是一个简单的 Spring Boot 测试片段Test void testTenantIsolation() { TenantContextHolder.setTenantId(1001L); ListBizOrder orders bizOrderMapper.selectList(null); Assertions.assertTrue(orders.stream().allMatch(o - o.getTenantId().equals(1001L))); }看到selectList返回的所有数据都对应 1001 号租户说明拦截器在工作如果返回了其他租户数据则说明 SQL 解析器漏掉了这种查询结构。此时可以继续断点调试TenantSqlParser.addTenantCondition查看原始 SQL 是否被正确改写。生产环境则建议开启 SQL 审计插件记录所有不带租户条件的查询作为质量红线。本文还有配套的精品资源点击获取