简介这是一套面向企业级应用场景的开源多租户云平台架构以微服务为底座采用主流框架进行构建并整合了数据库访问、持久层映射、统一认证授权等多种技术能力适合正在学习分布式系统、多租户隔离和企业级授权方案的Java后端开发者与架构师。压缩包共708个文件总大小10.22MB其中以581个Java源文件为核心包含46个XML配置与映射文件、13个Properties配置文件、7个YAML配置及10个FreeMarker模板配套提供初始化数据库脚本目录结构按微服务模块划分便于定位和二次开发。项目实现了完善的多租户数据隔离方案并内置OAuth2.1授权服务可支撑认证、令牌刷新、资源访问等完整流程同时附带的代码生成模板能够快速生成实体、控制器、服务、接口等基础代码大幅减少重复工作。目前已有六百五十六人浏览学习作者持续维护并承诺有Bug第一时间修复适合作为企业项目选型参考或学习样本。1. 开源 SaaS 多租户云平台架构骨架易得租户边界难守提到开源 SaaS 多租户云平台架构很多人第一反应是先拆一堆微服务。但真正拆过分布式服务的人都有体会注册中心、网关、熔断这些都能照着文档搭让项目翻车返工的往往是租户边界。这套基于 SpringCloud2023、Spring Cloud Alibaba2022、Mybatis-Plus 和 OAuth2.1 的多租户脚手架把租户上下文传递、数据隔离、代码生成串成了一条完整的线。它不是画一张架构图就完事而是能从数据库表直接生成 entity、controller、mapper、serviceImpl以及前端 index.vue、api.ts、crud.ts 的全套可运行代码。适合正在做 SaaS 产品、软件外包交付或者想搞明白微服务多租户到底怎么落地的人尤其是那些不想从零写权限和数据隔离的团队。2. 多租户与权限模型OAuth2.1 认证和租户上下文怎么串起来2.1 租户隔离的三种方案先选型再动手多租户的第一个决策点不是写代码而是选数据隔离级别。常见做法是下面三种隔离方案数据隔离强度成本与运维典型场景独立数据库最高每个租户一套数据库备份、迁移、版本升级都要按租户处理金融、政务、大客户私有化共享数据库、独立 Schema中高一个数据库多个 schema连接数低但跨租户统计麻烦中大型 SaaS租户量几百到上千共享数据库、共享表中所有租户在同一张表靠 tenant_id 区分扩容最容易创业型 SaaS、小程序、B 端工具这套架构默认走的是第三种共享表和 tenant_id 方案。原因很直接Mybatis-Plus 对多租户插件支持最成熟代码生成器生成的 mapper.xml 里只要把 tenant_id 条件拼进去数据隔离就完成了一大半。第一版先用共享表把业务跑通等出现真正需要独立库的大客户再按租户维度做路由切换这是最省成本的路子。选择共享表方案后需要在 Mybatis-Plus 配置里显式声明哪些表需要租户隔离、哪些表是全局表。全局表比如系统字典、区域表、租户套餐表这些不能加租户条件否则登录都进不去。mybatis-plus: configuration: map-underscore-to-camel-case: true global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 interceptor: tenant: enabled: true tenant-id-column: tenant_id ignore-tables: - sys_tenant - sys_dict - sys_area这里tenant-id-column指定了所有业务表里用于区分租户的字段名ignore-tables是白名单。加了logic-delete-field后删除变成逻辑删除多租户和数据删除同时生效。实际项目里最常踩的坑是忽略表名单配少了导致框架把 sys_user 这种系统表也拼上 tenant_id 条件登录接口直接查不到数据。后面避坑章会展开讲。2.2 租户上下文在 Gateway 与 Feign 之间传递隔离方案定了之后第二件要紧事是让租户 ID 在整个请求链路里不丢。前端登录时把租户编码传给认证服务认证服务签发 Token 时把 tenant_id 放进 claim 里网关解析 Token 后把租户 ID 放到 Header 中下游服务从 Header 里取。这个链路只要有一个环节没传业务表里的数据就会串。我一般会在每个服务里放一个 TenantContext用 ThreadLocal 保存当前租户 ID。public class TenantContext { private static final ThreadLocalString CURRENT_TENANT new ThreadLocal(); public static void setTenantId(String tenantId) { CURRENT_TENANT.set(tenantId); } public static String getTenantId() { return CURRENT_TENANT.get(); } public static void clear() { CURRENT_TENANT.remove(); } }写完后必须在一次请求结束时清理 ThreadLocal否则线程池复用会串租户。上述代码里的clear()一般放在 Filter 的 finally 里调用。跨服务调用时Feign 请求头里的租户 ID 也要原样传下去。常见做法是实现一个 Feign 的 RequestInterceptor把当前上下文中的租户 ID 塞进 Header。Configuration public class TenantFeignInterceptor implements RequestInterceptor { Override public void apply(RequestTemplate template) { String tenantId TenantContext.getTenantId(); if (StringUtils.hasText(tenantId)) { template.header(X-Tenant-Id, tenantId); } } }为什么非要手动传因为 Feign 默认不会把调用方线程里的变量自动带到 HTTP Header 里。你当前线程的 ThreadLocal 只在本地生效发出去的请求是全新的 HTTP 报文不显式塞就丢了。这个X-Tenant-Id的名字可以自己约定但网关、认证服务、业务服务必须统一。2.3 OAuth2.1 与 Token 里的租户信息OAuth2.1 相比 OAuth2.0最主要的变化是密码模式被移除授权码模式强制要求 PKCEToken 里的 scope 校验更严格。现实中大多数 SaaS 项目用的是客户端模式加刷新令牌。因为前端页面和移动端拿 Token 的流程已经和 OAuth2.0 时代不一样了直接基于 OAuth2.1 实现能少走老项目改造的弯路。生成 Token 时把租户 ID 作为额外 claim 放进去MapString, Object claims new HashMap(); claims.put(tenant_id, tenant.getTenantId()); claims.put(user_id, user.getId()); claims.put(scope, read write); OAuth2AccessToken token tokenService.createAccessToken( new OAuth2ClientAuthenticationToken(client, null, null), claims );资源服务拿到 Token 后解析出 tenant_id写入 TenantContext。这个动作应该在网关层统一做不要在业务 Controller 里解析 Token。网关 Filter 里解析一次把租户 ID 放进 Header下游服务只要信任网关即可。这样做的好处是业务服务不需要依赖认证服务器 SDK接口层只认 Header。注意一点OAuth2.1 的 Token 默认不再支持带 refresh_token 的隐式流程刷新令牌必须走受保护的端点。所以前端刷新 Token 时要带上 client_id 和 client_secret不能在浏览器里裸奔。后面避坑章会专门讲刷新时租户丢失的坑。3. 代码生成器实战把 entity.java.ftl 到 index.vue.ftl 变成可运行代码3.1 模板文件在生成器里扮演什么角色这套源码里让人一走神就上手的地方是集成了一个代码生成器核心逻辑靠一批 FreeMarker 模板文件驱动。.ftl是 FreeMarker 模板的后缀生成器读取数据库表结构把表名、字段名、注释渲染到模板里最终输出 Java、Vue、TypeScript、XML 等源码文件。模板和输出的对应关系按这套源码的命名基本能看出来模板文件生成物作用entity.java.ftl实体类映射数据库表字段包含逻辑删除、租户字段注解controller.java.ftl控制器提供增删改查 REST 接口支持分页serviceImpl.java.ftl服务实现业务逻辑层事务、租户校验写在里面mapper.xml.ftlMyBatis XML动态 SQL多租户条件在这里拼接crud.ts.ftl前端 CRUD 调用封装查询、新增、编辑、删除请求api.ts.ftl接口定义统一 API 路径和请求参数类型index.vue.ftl页面表格、搜索条件、弹窗表单的完整页面resource.sql.ftl数据库脚本菜单权限、字典、初始数据的 SQLstyle.css / signin.css登录页样式租户登录页的品牌化样式这套模板其实是把全栈 CRUD 的重复劳动自动化了。一个数据表从建表到前端页面能点通常要写十几类文件生成器一次完成。但模板永远只能生成通用逻辑真正有业务差异的部分还是需要人工介入这一点在下载使用前要有心理预期。3.2 生成一个完整 CRUD 的流程先把业务表建好比如一张租户内的订单表CREATE TABLE order_info ( id bigint NOT NULL AUTO_INCREMENT, tenant_id bigint NOT NULL COMMENT 租户ID, order_no varchar(64) NOT NULL COMMENT 订单号, amount decimal(10,2) NOT NULL COMMENT 金额, status tinyint NOT NULL DEFAULT 0 COMMENT 状态0待支付 1已支付, deleted tinyint NOT NULL DEFAULT 0 COMMENT 逻辑删除, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time datetime NOT NULL ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单表;这张表必须包含 tenant_id 和 deleted 字段否则生成出来的代码和租户插件配合不上。建完表后配置生成器generator: database: url: jdbc:mysql://localhost:3306/saas_cloud?useUnicodetruecharacterEncodingutf8mb4 username: root password: yourpassword tpl-dir: templates/ # 指定 .ftl 模板所在目录 package: parent: com.example.saas module: order table: name: order_info prefix: entity: OrderInfo执行生成器后代码会输出到对应的模块目录。我一般会先生成一个没有复杂关联的单表模块跑通链路再生成有外键关联的业务模块。首表生成成功后续无非是调整模板参数。生成器输出的 controller 里会有新增、删除、分页查询四个基础接口。分页查询的 Mybatis-Plus 写法一般是这样的Override public PageOrderInfo queryPage(OrderQuery query) { LambdaQueryWrapperOrderInfo wrapper Wrappers.lambdaQuery(); wrapper.eq(StringUtils.hasText(query.getOrderNo()), OrderInfo::getOrderNo, query.getOrderNo()); wrapper.eq(query.getStatus() ! null, OrderInfo::getStatus, query.getStatus()); return baseMapper.selectPage(new Page(query.getPageNo(), query.getPageSize()), wrapper); }这里的LambdaQueryWrapper会自动带上 Mybatis-Plus 租户插件生成的 tenant_id 条件不需要手动eq。query.getPageNo()和query.getPageSize()从前端分页组件传入Page 对象返回后由 controller 统一包装成{ records, total, current, size }。模板生成的代码里分页参数名必须是 pageNo/pageSize不能写成 pageNum 和 pagesize否则前端 crud.ts 的默认参数映射会错位。3.3 生成后需要手工调整的地方生成器不是银弹有几个位置必须人工过一遍。第一是实体类里的租户字段注解。Mybatis-Plus 的TableField要确认 tenant_id 没有被标记为fill FieldFill.INSERT否则自动填充只会写入一次更新时租户 ID 不会变。租户 ID 应该由拦截器在每次 insert 时自动写入update 时不要动它。第二是逻辑删除字段。模板默认生成TableLogic但如果你在 mapper.xml 里手写了自定义 SQL比如连表查询也要自己在 XML 里补deleted 0条件。Mybatis-Plus 的自动逻辑删除只对 Wrapper 和内置方法生效对 XML 里手写的 SQL 不生效这是代码生成后最容易漏的一处。第三是菜单权限 SQL。resource.sql.ftl 生成的是菜单初始化脚本其中会带出每个 controller 的权限标识。如果 controller 方法名改了权限标识也要同步改否则前端菜单渲染出来了点击接口却返回 403。第四是前端 index.vue 里常见的字典翻译。生成器只知道数据库字段注释不知道状态字段 0、1、2 分别代表什么所以模板里默认只做原始值展示。业务上如果要在列表页显示已支付待支付就需要自己在 index.vue 里补字典映射。每次生成完代码我建议先跑一遍编译和单测再看生成的 Vue 页面有没有把查询条件渲染成表单。比全手工写代码省力但绝不能生成完就直接上生产。4. 前端动态菜单与权限从 signin.css 到 index.vue 的权限闭环4.1 登录页与租户标识前端登录页用的是 signin.css 和 style.css 两个样式文件。很多 SaaS 项目把租户编码放在登录页上用户输入租户编码后前端拿到租户信息再跳转到对应的认证入口。这样做的好处是同一套前端可以服务多个租户不用每个租户单独部署一个域名。登录表单里的租户编码输入框和用户名密码一起提交export function login(data: LoginForm) { return request({ url: /auth/oauth2/token, method: post, data: { tenant_code: data.tenantCode, username: data.username, password: data.password, grant_type: client_credentials } }); }实际项目中 grant_type 应根据认证服务实际支持来填。这里用客户端模式做示例因为 OAuth2.1 下密码模式不可用了而授权码模式在前后端分离下要走重定向比较重。用客户端模式时前端必须配合刷新令牌否则 Token 过期后用户只能重新登录。登录成功后前端要把租户编码存到本地并且在整个请求头里带上租户标识。多数情况不是直接带 tenant_code而是带后端下发的 Token租户 ID 已经从 Token claim 里解析出来了。前端只需要保存租户编码用于展示和下次登录回填。4.2 动态路由与按钮级权限登录用户的菜单不是写在前端路由表里的而是登录后从后端接口拉取。后端返回的是菜单树每个节点包含路由地址、组件路径、权限标识。前端拿到后动态加到 Vue Router 上。function generateRoutes(menus: MenuItem[]) { const routes menus.map(menu { return { path: menu.path, name: menu.name, component: loadView(menu.component), meta: { title: menu.title, permission: menu.permission } }; }); router.addRoute(routes); }这里的loadView是根据后端返回的组件路径动态 import。生成器生成的 index.vue 会注册到组件列表里前端路由才能跳转成功。如果后端返回的 component 值写的是order/index前端就必须能匹配到views/order/index.vue。代码生成器生成完后要确认 index.vue 放到了对应视图目录下。按钮级权限一般用自定义指令。一个删除按钮如果当前用户没有order:delete权限v-permission 指令直接移除按钮。el-button v-permission[order:delete] typedanger clickhandleDelete(row) 删除 /el-buttonv-permission 指令内部会读取当前用户权限集合没有匹配项就把宿主 DOM 移除。生成器的 resource.sql.ftl 里预置了增删改查的按钮权限但只覆盖通用按钮。如果业务上还有导出、审核这类操作需要自己在菜单 SQL 里追加否则权限闭环始终缺角。4.3 接口对接与 crud.ts.ftl 的自动分层生成器生成的 crud.ts.ftl 和 api.ts.ftl 会把请求封装分离。api.ts 里定义每个接口的路径和参数类型crud.ts 里组合这些接口并导出成可复用的对象。// api.ts 生成内容 export const orderApi { page: (params: PageParams) request({ url: /order/page, method: get, params }), add: (data: OrderInfo) request({ url: /order, method: post, data }), update: (data: OrderInfo) request({ url: /order, method: put, data }), remove: (id: number) request({ url: /order/${id}, method: delete }) };// crud.ts 生成内容 export function useOrderCrud() { const page async (params: PageParams) { const res await orderApi.page(params); return res.data; }; return { page, add: orderApi.add, update: orderApi.update, remove: orderApi.remove }; }这个分层的好处是业务页面只要调用 useOrderCrud不直接接触 request 实例。如果后端接口路径调整只需要改 api.ts。生成的 index.vue 里搜索条件、分页事件、新增编辑弹窗全部复用 crud.ts理论上单表模块可以做到零手工代码。但我在实际接过的项目里发现模板生成的页面有一个通病搜索条件是硬编码的字段一旦多了表单布局会很丑。所以下载这份源码后第一件事不是跑生成器而是看 index.vue.ftl 模板里的搜索区写的是不是响应式布局。如果是挤在一行建议先改模板再生成。5. 避坑指南多租户项目最常见的 5 个翻车现场5.1 租户 ID 从认证到数据库之间丢失现象用户登录后能看到自己的数据但过一会儿报错说查不到数据或者看到别的租户的数据。典型场景是刷新 Token 后第一次请求就串数据。原因刷新 Token 时重新签发的 Access Token 里没有携带 tenant_id。这个算是最隐蔽的黑匣子问题。刷新接口和登录接口是两条链路很多实现里登录 Token 加入了租户 ID但刷新 Token 时只重新生成 access_token没有把原来的 claim 透传过来。解决刷新令牌接口必须拿到原 Token 的租户信息。常见做法是在刷新时解析旧 refresh_token 关联的认证记录把 tenant_id 放回新 Token。验证方式也很简单刷新 Token 后把 JWT 解码看 claim 里有没有 tenant_id。# JWT 解码示例仅用于本地验证 echo $ACCESS_TOKEN | cut -d . -f 2 | base64 -d如果输出 JSON 里没有 tenant_id就把认证服务器的 refreshTokenGrant 逻辑补上。我的血泪经验是只要发现串数据第一反应不要查业务代码先抓当前 Token 里的 claim。5.2 Mybatis-Plus 租户插件和手写 SQL 冲突现象代码生成器生成的 mapper.xml 里如果写的是连表查询部分租户的数据查不出来或者查出来了但会带上别的租户的数据。原因Mybatis-Plus 租户插件会对单表 SQL 自动拼 tenant_id 条件但手写多表 join 时插件只对主表拼条件副表如果也有 tenant_id条件没补上。反过来如果 SQL 里用了子查询插件解析 SQL 时还可能把租户条件拼错位置。解决最简单的方法是避免在 mapper.xml 里手写跨租户连表 SQL改用单表查询后内存组装数据。业务上必须连表时在 XML 里手工加上副表别名对应的租户条件。select idselectOrderWithUser resultTypecom.example.saas.vo.OrderVO select o.*, u.name as user_name from order_info o left join sys_user u on o.user_id u.id where o.tenant_id #{tenantId} and u.tenant_id #{tenantId} /select注意这里的 tenantId 必须从 TenantContext 里传入不要用前端传进来的参数。前端传参可以被篡改而 TenantContext 里的是经过网关解 Token 后写入的。把租户 ID 作为查询参数传进 XML还有一个好处是后续做分页时总条数统计也准确。5.3 代码生成器生成的 Vue 页面无法直接运行现象从模板生成的 index.vue 复制到前端项目后页面空白、接口 404、路由匹配不到组件。原因生成器只会把模板里的变量替换成表字段但不会自动注册路由也不会检查 views 目录路径与后端菜单返回值是否一致。如果后端返回的菜单 component 是/order/index而前端文件放在了views/sale/order/index.vue路由永远匹配不上。解决生成每条菜单前先约定好前端目录规则。我一般会在生成器配置里把 module 名称和前端 views 子目录保持一致并且在 resource.sql.ftl 的菜单 SQL 里写好 component 路径。生成后打开前端项目找到src/router/index.ts确认动态路由的 loadView 是否用常量对象映射组件。如果用的是动态 import 字符串Webpack 或 Vite 会无法解析这也是页面白屏的常见原因。5.4 刷新 Token 时把租户信息丢了现象用户登录后长时间停留Token 过期后调用刷新接口返回的新 Token 能请求到数据但租户切换后发现问题甚至 A 租户的 Token 能访问 B 租户的数据。原因刷新接口只校验 refresh_token 合法性没有校验 refresh_token 里的租户 ID 是否和当前请求头里的租户 ID 一致。攻击者如果拿到了 A 的 refresh_token理论上可以把租户 ID 改成 B 再刷新获得 B 的 Token。解决刷新时双重校验。第一层从 refresh_token 里解析出原租户 ID第二层比较请求头中的 X-Tenant-Id两者必须一致才签发新 Token。OAuth2.1 规范里虽然没有强制要求这一步但多租户场景下不做就是事故。这个校验适合放在认证服务里作为 refreshTokenGrant 的扩展逻辑。5.5 Nacos 配置共享导致多环境串配置现象开发环境联调正常部署到测试环境后所有租户的数据库连接指向了开发库甚至租户插件被关闭。原因多服务配置放在了 Nacos 共享配置中心spring.application.name 相同的服务拉到了同一份配置。租户插件开关、数据源地址这类关键配置如果放在共享 dataId 里修改一次全环境生效。解决数据源、租户插件开关、Redis、加密密钥这类配置必须按环境拆 dataId不要把共享配置和独立配置混在一起。每套环境用一个 group 或多个命名空间隔离。部署时检查 Nacos 上该服务实际拉取的配置列表curl -X GET http://nacos-server:8848/nacos/v1/cs/configs?dataIdsaas-order-test.ymlgroupDEFAULT_GROUP如果发现测试环境还挂着 dev 的 group马上改回来。租户插件开关如果被关了共享表的数据隔离就形同虚设这个坑最容易发生在环境迁移时因为日志不会有任何报错。6. 进阶验证用一条 SQL 和并发脚本验证租户隔离6.1 验证租户隔离是否生效拿到一份多租户源码后先不要急着加功能第一件事是验证隔离到底有没有生效。最简单的验证方式是直接查数据库模拟两个租户的数据-- 租户1插入一条测试数据 INSERT INTO order_info(tenant_id, order_no, amount, status) VALUES (1001, TEST-1001, 99.00, 1); -- 租户2插入一条测试数据 INSERT INTO order_info(tenant_id, order_no, amount, status) VALUES (1002, TEST-1002, 199.00, 1); -- 模拟服务端 TenantContexttid1001 时执行查询 SELECT * FROM order_info WHERE tenant_id 1001 AND deleted 0;如果只有第一条数据返回说明 SQL 层面的租户条件生效。但这只是静态验证真正需要压的是运行时上下文传递。6.2 用并发脚本压出上下文丢失问题租户上下文的 bug 最常出现在并发场景。用 curl 模拟 20 个并发请求分别带不同的租户 Header看响应数据是否串号for i in $(seq 1 20); do curl -s -H X-Tenant-Id: $((1000 i % 2)) \ http://localhost:8080/order/page?pageNo1pageSize10 | \ grep -o records:\[[^]]*\] done wait如果返回的数据里租户 1001 的请求混入了租户 1000 的数据基本可以断定 ThreadLocal 没有在线程复用前清理或者 Feign 拦截器没有把 Header 传下去。正常的多租户脚手架应该让每个请求都只看到自己租户的记录。这样的压测脚本能直接逼出很多只有在高并发下才会出现的串数据问题。6.3 我的强制验证习惯从那以后我每次拿到多租户源码都会强制走一遍这套验证流程先配两个租户账号登录拿 Token然后检查 Token claim 里有没有 tenant_id再刷新 Token确认刷新后的 claim 没丢接着插入两条不同租户的数据跑一遍分页查询和连表查询最后用并发脚本压一遍。四步全部通过才敢把代码生成器生成的业务模块往上面加。你拿到这套资源后也别一上来就改业务代码先按这个流程验证底层能力确认没问题再用代码生成器生成你的订单表、用户表、商品表这样能少走很多弯路。希望帮到你。本文还有配套的精品资源点击获取