基于 Spring Boot 3.5 MyBatis-Plus从零搭建共享数据库的 多租户系统覆盖 SQL 自动过滤、登录租户识别、套餐菜单权限、缓存隔离四大核心环节。一、什么是多租户多租户Multi-Tenancy是 SaaSSoftware as a Service软件即服务系统的核心架构模式。一套应用实例服务多个租户客户/组织各租户的数据相互隔离互不可见。常见的多租户隔离方案有三种方案一独立数据库 每个租户一个库隔离最强成本最高方案二共享库 独立 Schema 每个租户一个 Schema折中方案✅ 方案三共享库 行级隔离 所有租户共用一张表通过 tenant_id 字段区分本文采用方案三共享数据库 行级隔离利用 MyBatis-Plus 的TenantLineInnerInterceptor租户行拦截器在 SQL 层面自动追加租户条件对业务代码零侵入。二、整体架构多租户的核心链路如下用户登录携带租户编码LoginController校验租户设置TenantContextUserDetailsService加载用户SQL 自动追加tenant_id条件JWT 生成携带tenantIdpackageId后续请求经JwtAuthenticationFilter解析 token设置TenantContextMyBatis-PlusTenantLineInnerInterceptor自动过滤数据缓存层通过TenantAwareCacheManager按租户隔离项目结构lanjii-framework/ ├── framework-context/ # 上下文模块 │ └── TenantContext.java # 租户上下文ThreadLocal ├── framework-mp/ # MyBatis-Plus 增强模块 │ ├── config/ │ │ ├── TenantConfiguration.java # 多租户配置注册拦截器 │ │ ├── InterceptorConfiguration.java # 拦截器组装 │ │ └── MetaObjectHandlerConfiguration.java # 自动填充 tenant_id │ ├── tenant/ │ │ ├── TenantHandler.java # 租户行处理器 │ │ └── TenantProperties.java # 配置属性 │ └── base/ │ └── TenantBaseEntity.java # 租户实体基类 ├── framework-security/ # 安全模块 │ ├── filter/JwtAuthenticationFilter.java # JWT 过滤器设置租户上下文 │ ├── util/JwtUtils.java # JWT 工具读写 tenantId │ └── model/AuthUser.java # 认证用户含 tenantId └── framework-cache/ # 缓存模块 ├── config/TenantAwareCaffeineCacheManager.java # 本地缓存租户隔离 └── config/RedisCacheConfiguration.java # Redis 缓存租户隔离 lanjii-modules/module-tenant/ ├── tenant-api/ # 对外接口 │ └── TenantApi.java └── tenant-biz/ # 业务实现 ├── entity/SysTenant.java # 租户实体 ├── entity/SysTenantPackage.java # 套餐实体 ├── service/TenantService.java # 租户服务 ├── service/TenantPackageService.java # 套餐服务 └── controller/TenantController.java # 租户管理API三、数据库设计3.1 租户表 sys_tenantCREATE TABLE sys_tenant ( id bigint NOT NULL AUTO_INCREMENT COMMENT 租户ID, tenant_code varchar(50) NOT NULL COMMENT 租户编码唯一标识用于登录, tenant_name varchar(100) NOT NULL COMMENT 租户名称, package_id bigint NULL COMMENT 套餐ID关联 sys_tenant_package, contact_name varchar(50) NULL COMMENT 联系人, contact_phone varchar(20) NULL COMMENT 联系电话, status tinyint NOT NULL DEFAULT 1 COMMENT 状态1-正常0-停用, expire_time datetime NULL COMMENT 过期时间NULL 表示永不过期, create_time datetime NULL DEFAULT CURRENT_TIMESTAMP, update_time datetime NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, create_by varchar(50) NULL, update_by varchar(50) NULL, deleted tinyint NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE INDEX uk_tenant_code (tenant_code) ) COMMENT 租户表;字段说明tenant_code租户唯一编码用户登录时输入此编码标识所属租户不填则以平台管理员身份登录package_id关联的套餐决定该租户可使用哪些菜单和功能status用于平台管理员停用/启用租户停用后该租户下所有用户无法登录expire_time租户到期时间到期后同样无法登录设为 NULL 表示永久有效3.2 套餐表 sys_tenant_packageCREATE TABLE sys_tenant_package ( id bigint NOT NULL AUTO_INCREMENT COMMENT 套餐ID, package_name varchar(50) NOT NULL COMMENT 套餐名称, menu_ids text NULL COMMENT 关联的菜单ID逗号分隔, status tinyint NOT NULL DEFAULT 1 COMMENT 状态1-正常0-停用, remark varchar(500) NULL COMMENT 备注, create_time datetime NULL DEFAULT CURRENT_TIMESTAMP, update_time datetime NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted tinyint NOT NULL DEFAULT 0, PRIMARY KEY (id) ) COMMENT 租户套餐表;字段说明menu_ids逗号分隔的菜单 ID 列表如1,3,49,50,52。平台管理员在创建套餐时勾选菜单系统自动拼接存储套餐是功能权限的边界租户管理员只能在套餐范围内分配角色权限3.3 业务表的 tenant_id 字段⚠️约定tenant_id 0表示平台管理员的数据平台管理员可以管理所有租户。所有需要租户隔离的表都添加tenant_id字段-- 用户表 tenant_id bigint NOT NULL DEFAULT 0 COMMENT 租户ID0-平台0-租户 -- 角色表、岗位表、部门表、操作日志表... 同理四、核心实现4.1 租户上下文TenantContext使用ThreadLocal在请求线程内传递当前租户 IDpublic final class TenantContext { private static final ThreadLocalLong TENANT_ID new ThreadLocal(); private TenantContext() { } /** 设置租户ID */ public static void setTenantId(Long tenantId) { TENANT_ID.set(tenantId); } /** 获取租户ID */ public static Long getTenantId() { return TENANT_ID.get(); } /** 清除上下文必须在 finally 中调用防止线程复用导致数据串扰 */ public static void clear() { TENANT_ID.remove(); } }核心原理每个 HTTP 请求由一个线程处理ThreadLocal让我们在请求入口Filter/Controller设置tenantId后续所有代码Service、Dao、SQL 拦截器都能通过TenantContext.getTenantId()获取无需层层传参。⚠️务必在 finally 中调用 TenantContext.clear()。Tomcat 使用线程池如果不清理下一个请求可能复用到上一个请求的tenantId造成数据泄漏。4.2 MyBatis-Plus 租户拦截器4.2.1 配置属性通过application.yml配置多租户行为lanjii: tenant: # 是否启用多租户 enabled: true # 租户字段名 column: tenant_id # 忽略租户过滤的表全局共享数据不按租户隔离 ignore-tables: - sys_tenant # 租户表本身 - sys_tenant_package # 套餐表 - sys_menu # 菜单表所有租户共享菜单定义 - sys_dict_type # 字典类型 - sys_dict_data # 字典数据 - sys_config # 系统配置对应 Java 配置类Data ConfigurationProperties(prefix lanjii.tenant) public class TenantProperties { /** 是否启用多租户 */ private boolean enabled false; /** 租户字段名 */ private String column tenant_id; /** 忽略租户过滤的表 */ private ListString ignoreTables new ArrayList(); }字段说明enabled总开关设为false可完全关闭多租户功能适用于单租户部署场景column数据库中租户字段的列名默认tenant_idignore-tables这些表的 SQL 不会追加tenant_id条件。比如sys_menu菜单定义是全局共享的所有租户看到的是同一份菜单4.2.2 租户行处理器 TenantHandlerRequiredArgsConstructor public class TenantHandler implements TenantLineHandler { /** 平台管理员租户ID */ public static final Long PLATFORM_TENANT_ID 0L; private final TenantProperties tenantProperties; Override public Expression getTenantId() { // 从 ThreadLocal 获取当前租户ID Long tenantId TenantContext.getTenantId(); if (tenantId null) { return new NullValue(); } return new LongValue(tenantId); } Override public String getTenantIdColumn() { // 返回配置的租户字段名 return tenantProperties.getColumn(); } Override public boolean ignoreTable(String tableName) { // 判断当前表是否跳过租户过滤 return tenantProperties.getIgnoreTables().stream() .anyMatch(t - t.equalsIgnoreCase(tableName)); } }工作原理MyBatis-Plus 在执行 SQL 前会调用TenantHandler的方法getTenantId()返回当前租户 ID拦截器将其拼入 SQL 的 WHERE 条件getTenantIdColumn()告诉拦截器字段名是什么ignoreTable()返回true则跳过该表例如一条简单的查询-- 原始 SQL SELECT * FROM sys_user WHERE username admin -- 经过拦截器后假设当前 tenantId 1 SELECT * FROM sys_user WHERE username admin AND tenant_id 14.2.3 注册拦截器Configuration EnableConfigurationProperties(TenantProperties.class) public class TenantConfiguration { Bean ConditionalOnProperty(prefix lanjii.tenant, name enabled, havingValue true) public TenantLineInnerInterceptor tenantLineInnerInterceptor(TenantProperties tenantProperties) { TenantHandler tenantHandler new TenantHandler(tenantProperties); return new TenantLineInnerInterceptor(tenantHandler); } }代码说明ConditionalOnProperty只有配置lanjii.tenant.enabledtrue时才创建 Bean保证开关灵活创建的TenantLineInnerInterceptor会被注入到MybatisPlusInterceptor中拦截器组装注意顺序Configuration public class InterceptorConfiguration { Bean public MybatisPlusInterceptor mybatisPlusInterceptor( ObjectProviderTenantLineInnerInterceptor tenantInterceptorProvider, BlockAttackInnerInterceptor blockAttack) { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); // 多租户插件必须在第一个位置 tenantInterceptorProvider.ifAvailable(interceptor::addInnerInterceptor); // 防止全表更新与删除插件 interceptor.addInnerInterceptor(blockAttack); return interceptor; } }⚠️多租户拦截器必须放在所有拦截器的最前面确保 SQL 先被加上租户条件再进行其他处理。4.3 自动填充 tenant_id新增数据时自动填充tenant_id业务代码无需手动setTenantId()租户基类Data EqualsAndHashCode(callSuper true) public class TenantBaseEntity extends BaseEntity { /** 租户IDINSERT 时自动填充 */ TableField(fill FieldFill.INSERT) private Long tenantId; }MetaObjectHandler 配置Bean public MetaObjectHandler metaObjectHandler(TenantProperties tenantProperties) { return new MetaObjectHandler() { Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, createTime, LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); // 自动填充租户ID if (tenantProperties.isEnabled()) { Long tenantId TenantContext.getTenantId(); this.strictInsertFill(metaObject, tenantId, Long.class, tenantId ! null ? tenantId : 0L); } } Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); } }; }流程save()调用 MyBatis-Plus 触发insertFill从TenantContext获取tenantId写入最终 SQL 执行五、登录与认证集成5.1 登录流程登录时需要识别用户属于哪个租户。前端登录表单包含一个可选的租户编码字段Data public class LoginBody { /** 租户编码不填则以平台管理员身份登录 */ private String tenantCode; NotEmpty(message 用户名不能为空) private String username; NotEmpty(message 密码不能为空) private String password; }登录接口核心逻辑PostMapping(/login) public RLoginInfo login(Validated RequestBody LoginBody loginBody) { // 校验租户并确定 tenantId Long tenantId TenantHandler.PLATFORM_TENANT_ID; // 默认为平台0 String tenantCode loginBody.getTenantCode(); if (StringUtils.hasText(tenantCode)) { SysTenantVO tenant tenantApi.getTenantByCode(tenantCode); if (tenant null) { throw new BizException(ResultCode.BAD_REQUEST, 租户不存在); } if (tenant.getStatus() ! 1) { throw new BizException(ResultCode.BAD_REQUEST, 租户已被禁用); } tenantId tenant.getId(); } // 设置租户上下文后续 UserDetailsService 查用户时会自动过滤 tenant_id TenantContext.setTenantId(tenantId); try { Authentication authentication authenticationManager.authenticate( new UsernamePasswordAuthenticationToken( loginBody.getUsername(), loginBody.getPassword())); AuthUser userDetails (AuthUser) authentication.getPrincipal(); LoginInfo loginInfo loginService.generateLoginInfo(userDetails); return R.success(loginInfo); } finally { TenantContext.clear(); } }关键点不传tenantCode则tenantId 0以平台管理员身份登录传了tenantCode查询sys_tenant获取tenantId设置到TenantContext调用authenticationManager.authenticate()时内部会走到UserDetailsService此时 SQL 已自动追加tenant_id条件确保只查到该租户的用户5.2 JWT 携带租户信息登录成功后将tenantId和packageId写入 JWT Tokenpublic String generateToken(String username, Long tenantId, Long packageId) { var builder Jwts.builder() .subject(username) .issuedAt(new Date()) .expiration(new Date(System.currentTimeMillis() getExpiration())); if (tenantId ! null) { builder.claim(tenantId, tenantId); } if (packageId ! null) { builder.claim(packageId, packageId); } return builder.signWith(Keys.hmacShaKeyFor(getSecret().getBytes())).compact(); }代码说明tenantId和packageId以自定义 claim 写入 Token后续每次请求前端携带此 Token后端解析出租户信息5.3 JWT 过滤器恢复租户上下文每个请求到达时JwtAuthenticationFilter从 Token 中解析tenantId并设置到TenantContextOverride protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token ServletUtils.getBearerToken(); if (token ! null tokenService.validate(token)) { String username jwtUtils.getUsernameFromToken(token); try { // 从 JWT 中提取 tenantId 并设置到 ThreadLocal Long tenantId jwtUtils.getTenantIdFromToken(token); TenantContext.setTenantId(tenantId); UserDetails userDetails userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(authentication); } catch (Exception e) { SecurityContextHolder.clearContext(); TenantContext.clear(); } } filterChain.doFilter(request, response); }这样后续所有 Service、Dao 的 SQL 都会自动带上正确的 tenant_id条件。5.4 UserDetailsService 中的租户感知加载用户详情时同时获取租户的套餐信息Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { Long tenantId TenantContext.getTenantId(); // 获取套餐ID非平台租户需要关联套餐 Long packageId null; if (tenantId ! null tenantId 0) { SysTenantVO tenant tenantApi.getById(tenantId); if (tenant ! null) { packageId tenant.getPackageId(); } } // 查询用户SQL 自动追加 tenant_id 条件 SysUser user sysUserService.getOne(new LambdaQueryWrapperSysUser() .eq(SysUser::getUsername, username)); if (user null) { throw new BadCredentialsException(用户名或密码错误); } // 获取用户权限考虑套餐范围 ListString permissions sysMenuService.getUserPermissions( user.getId(), user.getIsAdmin(), tenantId, packageId); // 构建认证用户对象 return new AuthUser( user.getId(), user.getUsername(), user.getPassword(), user.getIsAdmin(), tenantId, packageId, authorities, user.getIsEnabled().equals(IsEnabledEnum.ENABLED.getCode())); }六、套餐权限控制套餐是控制租户功能范围的核心机制。6.1 套餐如何限制菜单当获取用户菜单树时会根据平台/租户身份走不同的逻辑Override public ListSysMenuVO getUserMenuTree(Long userId, Integer isAdmin, Long tenantId, Long packageId) { boolean isPlatformTenant tenantId null || tenantId 0; ListSysMenu menuList; if (isPlatformTenant) { // 平台管理员看到所有菜单 if (IsAdminEnum.isAdmin(isAdmin)) { menuList baseMapper.selectList(new LambdaQueryWrapperSysMenu() .ne(SysMenu::getType, 3) // 排除按钮类型 .eq(SysMenu::getIsEnabled, 1)); } else { menuList baseMapper.selectMenusByUserId(userId); } } else { // 租户用户先获取套餐允许的菜单ID ListLong packageMenuIds tenantApi.getMenuIdsByPackageId(packageId); if (packageMenuIds.isEmpty()) { return Collections.emptyList(); } if (IsAdminEnum.isAdmin(isAdmin)) { // 租户管理员只看套餐范围内的菜单 menuList baseMapper.selectList(new LambdaQueryWrapperSysMenu() .in(SysMenu::getId, packageMenuIds) .ne(SysMenu::getType, 3) .eq(SysMenu::getIsEnabled, 1)); } else { // 租户普通用户角色权限与套餐范围取交集 menuList baseMapper.selectMenusByUserId(userId); SetLong packageMenuIdSet new HashSet(packageMenuIds); menuList menuList.stream() .filter(menu - packageMenuIdSet.contains(menu.getId())) .collect(Collectors.toList()); } } return TreeUtils.buildTree(SysMenu.INSTANCE.toVo(menuList)); }权限控制层次平台管理员 ── 所有菜单租户管理员 ── 套餐范围内的所有菜单租户普通用户 ── 角色权限 和 套餐范围 交集6.2 套餐中菜单 ID 的存取套餐的menu_ids以逗号分隔的字符串存储解析逻辑如下Override public ListLong getMenuIdsByPackageId(Long packageId) { if (packageId null) { return Collections.emptyList(); } SysTenantPackage pkg this.getById(packageId); if (pkg null || pkg.getMenuIds() null || pkg.getMenuIds().isEmpty()) { return Collections.emptyList(); } return Arrays.stream(pkg.getMenuIds().split(,)) .map(String::trim) .filter(s - !s.isEmpty()) .map(Long::parseLong) .collect(Collectors.toList()); }七、租户管理7.1 创建租户创建租户时需要同时初始化默认部门和管理员用户Override Transactional(rollbackFor Exception.class) public void saveNew(SysTenantDTO dto) { // 校验租户编码唯一性 SysTenant exists this.getByTenantCode(dto.getTenantCode()); if (exists ! null) { throw new BizException(租户编码已存在); } SysTenant tenant new SysTenant(); BeanUtils.copyProperties(dto, tenant); this.save(tenant); // 调用系统模块API创建租户默认管理员 systemApi.createTenantAdmin(tenant.getId(), tenant.getTenantCode()); }createTenantAdmin的实现Override Transactional(rollbackFor Exception.class) public void createTenantAdmin(Long tenantId, String tenantCode) { Long previousTenantId TenantContext.getTenantId(); try { // 临时切换到新租户上下文 TenantContext.setTenantId(tenantId); // 创建默认部门 SysDept dept new SysDept(); dept.setTenantId(tenantId); dept.setParentId(0L); dept.setAncestors(0); dept.setDeptName(tenantCode 总部); dept.setSortOrder(1); dept.setIsEnabled(1); dept.setLeader(admin); sysDeptService.save(dept); // 创建管理员用户 String defaultPwd sysConfigService.getConfigValue(SysConfigKeys.DEFAULT_USER_PWD); SysUser admin new SysUser(); admin.setTenantId(tenantId); admin.setUsername(admin); admin.setPassword(passwordEncoder.encode(defaultPwd)); admin.setNickname(tenantCode -管理员); admin.setIsEnabled(1); admin.setIsAdmin(1); admin.setDeptId(dept.getId()); sysUserService.save(admin); } finally { // 恢复原租户上下文 TenantContext.setTenantId(previousTenantId); } }⚠️临时切换 TenantContext创建租户数据时需要将上下文切到新租户否则数据会被写入平台管理员的tenant_id0下。操作完成后必须恢复原上下文。7.2 校验租户有效性登录前校验租户状态和过期时间Override public boolean checkTenantValid(Long tenantId) { SysTenant tenant this.getById(tenantId); if (tenant null) { return false; } // 检查状态 if (tenant.getStatus() ! 1) { return false; } // 检查过期时间 if (tenant.getExpireTime() ! null tenant.getExpireTime().isBefore(LocalDateTime.now())) { return false; } return true; }7.3 RESTful API租户管理接口支持标准 CRUD使用 Spring SecurityPreAuthorize控制权限RestController RequestMapping(/admin/tenant) RequiredArgsConstructor public class TenantController { private final TenantService tenantService; PreAuthorize(hasAuthority(tenant:list)) GetMapping public RListSysTenantVO list(SysTenantDTO dto) { return R.success(tenantService.listTenants(dto)); } PreAuthorize(hasAuthority(tenant:add)) PostMapping public RVoid add(Valid RequestBody SysTenantDTO dto) { tenantService.saveNew(dto); return R.success(); } PreAuthorize(hasAuthority(tenant:edit)) PutMapping(/{id}) public RVoid update(PathVariable Long id, Valid RequestBody SysTenantDTO dto) { tenantService.updateByIdNew(id, dto); return R.success(); } PreAuthorize(hasAuthority(tenant:remove)) DeleteMapping(/{id}) public RVoid remove(PathVariable Long id) { tenantService.removeById(id); return R.success(); } }八、缓存租户隔离缓存层也需要按租户隔离否则不同租户会读到对方的缓存数据。8.1 本地缓存Caffeine通过装饰器模式给所有缓存 Key 加上租户前缀public class TenantAwareCaffeineCacheManager extends CaffeineCacheManager { Override public Cache getCache(String name) { Cache delegate super.getCache(name); // ... // 检查缓存定义是否需要租户隔离 CacheDef cacheDef cacheRegistry.get(name).orElse(null); boolean tenantIsolated cacheDef.isTenantIsolated(); if (tenantIsolated) { return new TenantAwareCache(delegate); // 装饰器 } return delegate; } /** 租户感知的 Cache 装饰器 */ static class TenantAwareCache implements Cache { private final Cache delegate; /** 对所有 Key 自动添加租户前缀 */ private Object createTenantKey(Object key) { Long tenantId TenantContext.getTenantId(); String prefix (tenantId ! null) ? tenantId.toString() : 0; return prefix : key; } Override public ValueWrapper get(Object key) { return delegate.get(createTenantKey(key)); } Override public void put(Object key, Object value) { delegate.put(createTenantKey(key), value); } // ... } }工作原理假设缓存 key 为admin租户 1 存的实际 key 是1:admin租户 2 是2:admin自然隔离。8.2 缓存定义中的隔离开关通过CacheDef的tenantIsolated属性控制每个缓存是否需要隔离public class CacheDef { private final String name; private final Duration ttl; private final long maxSize; /** 是否按租户隔离默认true */ private final boolean tenantIsolated; // 默认创建时 tenantIsolated true public static CacheDef of(String name, Duration ttl) { return new CacheDef(name, ttl, 1000L, true); } // 指定是否隔离 public static CacheDef of(String name, Duration ttl, boolean tenantIsolated) { return new CacheDef(name, ttl, 1000L, tenantIsolated); } }使用示例大部分缓存默认隔离。对于验证码等全局缓存可以设为tenantIsolated false。九、注意事项9.1 忽略表配置以下表不应加入租户过滤sys_tenant租户表本身不属于任何租户sys_tenant_package套餐是全局管理的sys_menu菜单定义全局共享通过套餐控制可见范围sys_dict_type/ sys_dict_data字典数据全局共享9.2 跨租户操作平台管理员管理租户时需要临时切换上下文Long previousTenantId TenantContext.getTenantId(); try { TenantContext.setTenantId(targetTenantId); // 执行操作... } finally { TenantContext.setTenantId(previousTenantId); // 恢复 }9.3 异步任务如果使用Async或线程池子线程不会继承父线程的ThreadLocal需要手动传递Long tenantId TenantContext.getTenantId(); executor.submit(() - { TenantContext.setTenantId(tenantId); try { // 执行异步任务... } finally { TenantContext.clear(); } });⚠️ 或者使用TransmittableThreadLocal阿里巴巴开源替代ThreadLocal可自动透传到线程池。9.4 数据库索引所有tenant_id字段建议添加索引避免全表扫描CREATE INDEX idx_tenant_id ON sys_user(tenant_id); CREATE INDEX idx_tenant_id ON sys_role(tenant_id); -- 所有含 tenant_id 的表均需添加十、总结本文实现了一个完整的多租户方案核心要点回顾环节实现方式数据隔离MyBatis-Plus TenantLineInnerInterceptor 自动追加 SQL 条件上下文传递ThreadLocalTenantContext登录识别前端传 tenantCode后端查表获取 tenantIdToken 携带JWT 自定义 claim 存储 tenantIdpackageId请求还原JwtAuthenticationFilter 解析 Token 设置上下文权限控制套餐菜单与角色权限交集缓存隔离Key 前缀 tenantId:cacheName::key自动填充MetaObjectHandler 在 INSERT 时填充 tenant_id这套方案对业务代码几乎零侵入只需要让实体类继承TenantBaseEntity配置好忽略表列表就可以透明地支持多租户。