SpringBoot + OAuth2 + Redis + MongoDB:八年 Java 开发教你做 "安全不泄露、权限不越界" 的 SaaS 多租户平台
做 Java 开发八年,接过不少 SaaS 平台的活,但多租户数据隔离和 API 权限管控绝对是 "试金石"------ 早期用纯 MySQL 做 SaaS,没做租户隔离,导致 A 客户能查到 B 客户的数据,差点赔了违约金;后来用 Sharding-JDBC 分库分表,权限控制靠硬编码,新增角色时要改 N 处代码;再到对接第三方系统,OAuth2 配置不当,出现 "一次授权永久可用" 的安全漏洞......
直到用了 SpringBoot + OAuth2 + Redis + MongoDB 的组合,才彻底解决这些痛点。今天就从八年开发的视角,带大家拆解这套 "数据隔离严、权限管控细、安全系数高" 的 SaaS 架构,从痛点分析到代码实现,再到踩过的坑,全是实战干货,拒绝空谈理论。
一、先聊 SaaS 多租户的 "致命痛点":为什么这套技术组合能封神?
很多新手做 SaaS 会陷入一个误区:"不就是多了个租户 ID 吗?加个字段过滤就行"------ 这话在 demo 环境能跑,但到生产环境分分钟翻车。先列几个我踩过的 "血泪痛点",看看为什么纯 MySQL + 硬编码权限走不通:
痛点 | 纯 MySQL + 硬编码方案的坑 | 后果 |
---|---|---|
多租户数据隔离 | 仅靠 SQL 加where tenant_id = ? 过滤,开发忘写、漏写就会数据泄露;跨表关联时容易忽略租户 ID |
A 客户看到 B 客户的核心数据,触发合规风险,面临天价罚款 |
API 权限管控 | 用 if-else 判断角色权限,比如if (role != "ADMIN") return 403 ,新增角色、修改权限要改代码 |
迭代效率低,权限漏洞多(比如漏判 "只读角色" 的修改权限) |
认证授权安全 | 自定义 token 机制,过期时间固定,无法实时吊销;多端登录(PC/APP)权限同步不及时 | 员工离职后 token 仍有效,越权操作数据;多端登录状态混乱 |
高并发权限校验 | 每次 API 请求都查数据库校验权限,1 万并发下数据库连接池满了,接口超时 | 平台响应变慢,客户投诉量暴涨,流失率上升 |
而 SpringBoot + OAuth2 + Redis + MongoDB 的组合,正好精准 "对症下药":
- SpringBoot:作为基础框架,快速整合其他组件,减少 boilerplate 代码(比如用 Spring Security Starter 一键集成 OAuth2);
- OAuth2 + JWT:解决多端认证授权,支持 "授权码 / 客户端凭证" 等多种模式,JWT 存用户 + 租户 + 权限信息,无状态更灵活;
- Redis:缓存 JWT 黑名单(支持实时吊销)、高频权限数据,权限校验从 "查库 100ms" 降到 "查缓存 1ms";
- MongoDB:天生适合多租户场景 ------ 文档结构灵活(适配不同租户的定制化字段),支持 "租户级数据分片",隔离性比 MySQL 强,查询效率更高。
可能有人会问:"为什么用 MongoDB 不用 MySQL?"------MySQL 适合存储结构化的租户基础数据(比如租户信息、用户账号),但 SaaS 平台常面临 "不同租户字段不同" 的需求(比如租户 A 需要 "客户等级" 字段,租户 B 不需要),MongoDB 的动态文档结构能完美适配,且自带的多租户隔离方案(如租户级集合、分片)比 MySQL 更省心。
二、整体架构设计:从 "租户登录" 到 "API 访问" 的全链路安全管控
先放一张简化的架构图,让大家直观理解各组件的分工:

整个链路的核心逻辑是 "认证先行、权限中拦、数据后隔":
- 认证层:OAuth2 负责多端登录认证,生成含 "租户 ID + 用户角色 + 接口权限" 的 JWT,Redis 缓存 JWT 黑名单(支持实时吊销);
- 权限层:Spring Security 拦截 API 请求,解析 JWT 中的权限信息,结合 Redis 缓存的权限数据,实现精细化校验;
- 数据层:MongoDB 按租户 ID 隔离业务数据,开发无需手动加过滤条件,从根源避免数据泄露。
这种设计的好处很明显:数据隔离不依赖开发经验,权限管控无需硬编码,认证授权安全且灵活。
三、核心功能实现:代码 + 八年经验技巧
接下来分模块讲核心功能,每个部分都贴关键代码,并穿插我总结的避坑技巧 ------ 这些代码都是生产环境跑过的,踩过的坑都帮你填好了。
3.1 基础准备:组件整合(SpringBoot+OAuth2+Redis+MongoDB)
先搞定依赖和配置,这是后续开发的基础。
3.1.1 引入核心依赖(pom.xml)
xml
<!-- SpringBoot核心 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- OAuth2 + Spring Security(认证授权) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security.oauth</groupId>
<artifactId>spring-security-oauth2</artifactId>
<version>2.3.8.RELEASE</version>
</dependency>
<!-- Redis(缓存JWT/权限数据) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- MongoDB(多租户业务数据存储) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
<!-- JWT工具类 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<!-- 工具类(JSON/租户上下文) -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.32</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
3.1.2 核心配置(application.yml)
yaml
spring:
# 数据源配置(MySQL:存租户、用户、角色基础数据)
datasource:
url: jdbc:mysql://localhost:3306/saas_tenant?useSSL=false&serverTimezone=UTC
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
# Redis配置(缓存JWT、权限数据)
redis:
host: localhost
port: 6379
password:
database: 1
timeout: 5000ms
lettuce:
pool:
max-active: 50 # 高并发场景调大
# MongoDB配置(多租户业务数据)
data:
mongodb:
uri: mongodb://localhost:27017/saas_business?authSource=admin
# 租户隔离策略:按集合前缀区分(比如租户1的客户集合是t1_customer)
tenant-collection-prefix: t
# Spring Security配置
security:
user:
name: admin
password: admin
# OAuth2 JWT配置
oauth2:
jwt:
secret: saas_platform_secret_key_2025 # JWT签名密钥(生产环境用更复杂的,存在配置中心)
expiration: 3600 # JWT有效期(1小时,短期更安全)
refresh-expiration: 86400 # 刷新令牌有效期(24小时)
# 日志配置(方便调试认证授权流程)
logging:
level:
org.springframework.security: debug
org.springframework.data.mongodb: debug
八年经验技巧:
- JWT 密钥一定要复杂,且存到配置中心(比如 Nacos),别硬编码在代码里;有效期设 1 小时以内,避免泄露后风险扩大;
- MongoDB 的租户隔离用 "集合前缀"(比如
t1_customer
),比 "单集合加租户字段" 隔离性更强,查询时不用过滤,性能更高; - 开启 Spring Security 和 MongoDB 的 debug 日志,排错时能快速定位 "认证失败原因""数据隔离是否生效"。
3.2 核心功能 1:多租户数据隔离(MongoDB + 租户上下文)
这是 SaaS 平台的 "生命线"------ 必须确保租户数据绝对隔离。我们用 "租户上下文 + MongoDB 集合前缀" 的方案,从框架层面强制隔离,避免开发漏写租户 ID。
3.2.1 租户上下文(ThreadLocal 存储当前租户 ID)
csharp
/**
* 租户上下文:存储当前请求的租户ID(ThreadLocal保证线程安全)
*/
public class TenantContextHolder {
// ThreadLocal存储租户ID(每个请求一个线程,互不干扰)
private static final ThreadLocal<String> TENANT_ID = new ThreadLocal<>();
// 设置租户ID(从JWT中解析后调用)
public static void setTenantId(String tenantId) {
TENANT_ID.set(tenantId);
}
// 获取当前租户ID
public static String getTenantId() {
return TENANT_ID.get();
}
// 清除租户ID(请求结束后调用,避免内存泄露)
public static void clear() {
TENANT_ID.remove();
}
}
3.2.2 租户拦截器(自动解析 JWT 中的租户 ID,存入上下文)
java
/**
* 租户拦截器:API请求进来时,解析JWT中的租户ID,存入上下文
*/
@Component
public class TenantInterceptor implements HandlerInterceptor {
@Autowired
private JwtTokenProvider jwtTokenProvider;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1. 从请求头获取JWT令牌(前端传入Authorization: Bearer {token})
String token = request.getHeader("Authorization");
if (token != null && token.startsWith("Bearer ")) {
token = token.substring(7);
// 2. 解析JWT中的租户ID(JWT payload中存了tenantId)
String tenantId = jwtTokenProvider.getTenantIdFromToken(token);
// 3. 存入租户上下文
TenantContextHolder.setTenantId(tenantId);
} else {
// 无token请求(比如登录接口),直接放行
return true;
}
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 请求结束,清除租户上下文(避免ThreadLocal内存泄露)
TenantContextHolder.clear();
}
}
// 注册拦截器
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private TenantInterceptor tenantInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(tenantInterceptor)
.addPathPatterns("/api/**") // 所有API请求都经过租户拦截器
.excludePathPatterns("/api/auth/**"); // 登录接口放行
}
}
3.2.3 MongoDB 租户隔离(自定义 MongoTemplate,自动加集合前缀)
typescript
/**
* 自定义MongoTemplate:自动给集合名加租户前缀(比如customer→t1_customer)
*/
@Component
public class TenantMongoTemplate extends MongoTemplate {
@Value("${spring.data.mongodb.tenant-collection-prefix}")
private String tenantPrefix;
public TenantMongoTemplate(MongoDatabaseFactory mongoDbFactory, MongoConverter converter) {
super(mongoDbFactory, converter);
}
// 重写集合名获取方法,自动加租户前缀
@Override
protected String getCollectionName(Class<?> entityClass) {
String originalCollectionName = super.getCollectionName(entityClass);
String tenantId = TenantContextHolder.getTenantId();
if (tenantId == null) {
throw new BusinessException("租户ID为空,无法访问数据");
}
// 拼接租户前缀:t1_customer(tenantPrefix是t,租户ID是1,集合名是customer)
return tenantPrefix + tenantId + "_" + originalCollectionName;
}
}
// MongoDB Repository使用自定义MongoTemplate
@Repository
public interface CustomerRepository extends MongoRepository<Customer, String> {
// 无需写SQL,Spring Data MongoDB自动生成查询,集合名会自动加租户前缀
List<Customer> findByUserNameLike(String userName);
}
八年经验技巧:
- 租户上下文一定要在请求结束后清除:早期没写
afterCompletion
,导致线程池复用后,租户 ID 串了,A 租户查到 B 租户的数据; - 自定义 MongoTemplate 时,要重写所有涉及集合名的方法(比如
getCollectionName
),避免漏写导致隔离失效; - 无租户 ID 的请求直接抛异常,从根源杜绝 "匿名访问数据"。
3.3 核心功能 2:OAuth2 + JWT 认证授权(多端兼容 + 实时吊销)
解决 "谁能访问" 的问题 ------ 支持 PC/APP/ 第三方系统多端登录,员工离职后能实时吊销 token,避免越权。
3.3.1 JWT 工具类(生成 / 解析 token,获取租户 / 权限信息)
typescript
/**
* JWT工具类:生成token、解析token、验证token
*/
@Component
public class JwtTokenProvider {
@Value("${oauth2.jwt.secret}")
private String jwtSecret;
@Value("${oauth2.jwt.expiration}")
private long jwtExpiration;
/**
* 生成JWT token(含租户ID、用户名、角色、权限)
*/
public String generateToken(UserDetails userDetails, String tenantId) {
// 构建JWT payload(存储关键信息,别存敏感数据)
Map<String, Object> claims = new HashMap<>();
claims.put("tenantId", tenantId); // 租户ID
claims.put("username", userDetails.getUsername()); // 用户名
claims.put("roles", userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList())); // 角色
// 生成JWT
return Jwts.builder()
.setClaims(claims)
.setSubject(userDetails.getUsername())
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + jwtExpiration * 1000))
.signWith(SignatureAlgorithm.HS512, jwtSecret) // HS512算法签名
.compact();
}
/**
* 从token中解析租户ID
*/
public String getTenantIdFromToken(String token) {
Claims claims = Jwts.parser()
.setSigningKey(jwtSecret)
.parseClaimsJws(token)
.getBody();
return claims.get("tenantId", String.class);
}
/**
* 从token中解析权限信息
*/
public List<String> getRolesFromToken(String token) {
Claims claims = Jwts.parser()
.setSigningKey(jwtSecret)
.parseClaimsJws(token)
.getBody();
return claims.get("roles", List.class);
}
/**
* 验证token是否有效(未过期、签名正确)
*/
public boolean validateToken(String token) {
try {
Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(token);
return true;
} catch (Exception e) {
log.error("token无效:{}", e.getMessage());
return false;
}
}
}
3.3.2 OAuth2 配置(认证中心 + 授权模式)
scss
/**
* OAuth2配置:搭建认证中心,支持授权码模式(PC登录)、客户端凭证模式(第三方系统对接)
*/
@Configuration
@EnableAuthorizationServer
public class OAuth2AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private JwtTokenProvider jwtTokenProvider;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
// 配置token存储(JWT+Redis黑名单)
@Bean
public TokenStore tokenStore() {
return new JwtTokenStore(jwtTokenProvider.jwtEncoder());
}
// 配置客户端信息(比如PC端、APP端、第三方系统的client_id和secret)
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
// PC端客户端
.withClient("pc_client")
.secret(passwordEncoder.encode("pc_secret"))
.authorizedGrantTypes("authorization_code", "refresh_token") // 授权码模式+刷新token
.scopes("read", "write") // 权限范围
.accessTokenValiditySeconds(3600) // access_token有效期1小时
.refreshTokenValiditySeconds(86400) // refresh_token有效期24小时
// APP端客户端
.and()
.withClient("app_client")
.secret(passwordEncoder.encode("app_secret"))
.authorizedGrantTypes("password", "refresh_token") // 密码模式(APP端用)
.scopes("read", "write")
.accessTokenValiditySeconds(1800) // APP端token有效期30分钟,更安全
// 第三方系统客户端
.and()
.withClient("third_client")
.secret(passwordEncoder.encode("third_secret"))
.authorizedGrantTypes("client_credentials") // 客户端凭证模式(无用户,仅系统对接)
.scopes("read") // 仅只读权限
.accessTokenValiditySeconds(3600);
}
// 配置token生成规则(用自定义JWT工具类)
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints
.authenticationManager(authenticationManager)
.userDetailsService(userDetailsService)
.tokenStore(tokenStore())
.tokenEnhancer(jwtTokenProvider)
// 配置token吊销逻辑(存入Redis黑名单)
.revocationServices((token, authentication) -> {
String jti = Jwts.parser().setSigningKey(jwtTokenProvider.getJwtSecret()).parseClaimsJws(token.getValue()).getBody().getId();
redisTemplate.opsForValue().set("jwt:blacklist:" + jti, "1", jwtTokenProvider.getJwtExpiration(), TimeUnit.SECONDS);
});
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
八年经验技巧:
- 不同客户端用不同授权模式:PC 端用 "授权码模式"(最安全),APP 端用 "密码模式"(用户直接输入账号密码),第三方系统用 "客户端凭证模式"(无用户,仅系统级对接);
- token 吊销存入 Redis 黑名单:员工离职后,调用
tokenServices.revokeToken(token)
,将 token 加入黑名单,每次校验时先查黑名单,实现实时吊销; - 客户端 secret 要用 BCrypt 加密:避免明文存储,生产环境客户端信息要存到数据库(
JdbcClientDetailsService
),别存在内存里。
3.4 核心功能 3:API 权限精细化管控(Spring Security + 注解)
解决 "能访问什么" 的问题 ------ 比如 "租户管理员能修改客户,只读角色只能查看客户",用注解 + Redis 缓存权限,实现细粒度管控。
3.4.1 自定义权限注解(@RequiresPermission)
less
/**
* 自定义权限注解:标注API需要的权限,比如@RequiresPermission("customer:edit")
*/
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface RequiresPermission {
String value(); // 权限标识(比如"customer:view"查看客户,"customer:edit"修改客户)
}
3.4.2 权限拦截器(解析注解,校验用户权限)
typescript
/**
* 权限拦截器:拦截加了@RequiresPermission的API,校验用户是否有对应权限
*/
@Component
public class PermissionInterceptor implements HandlerInterceptor {
@Autowired
private JwtTokenProvider jwtTokenProvider;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1. 判断是否是方法请求,且加了@RequiresPermission注解
if (handler instanceof HandlerMethod) {
HandlerMethod handlerMethod = (HandlerMethod) handler;
RequiresPermission annotation = handlerMethod.getMethodAnnotation(RequiresPermission.class);
if (annotation == null) {
// 没加注解,直接放行(或默认需要登录权限,根据业务调整)
return true;
}
// 2. 获取需要的权限标识
String requiredPermission = annotation.value();
// 3. 从请求头获取JWT token,解析用户权限
String token = request.getHeader("Authorization").substring(7);
List<String> userRoles = jwtTokenProvider.getRolesFromToken(token);
String username = jwtTokenProvider.getUsernameFromToken(token);
// 4. 从Redis缓存获取用户的权限列表(避免每次查库)
String permissionCacheKey = "saas:permission:" + username;
List<String> userPermissions = (List<String>) redisTemplate.opsForValue().get(permissionCacheKey);
if (CollectionUtils.isEmpty(userPermissions)) {
// 缓存未命中,查数据库获取用户权限(比如从role_permission表查询)
userPermissions = permissionService.getUserPermissions(username, userRoles);
// 回填Redis缓存(有效期1小时)
redisTemplate.opsForValue().set(permissionCacheKey, userPermissions, 3600, TimeUnit.SECONDS);
}
// 5. 校验用户是否有需要的权限
if (!userPermissions.contains(requiredPermission)) {
response.setStatus(HttpStatus.FORBIDDEN.value());
response.getWriter().write("无权限访问:需要" + requiredPermission + "权限");
return false;
}
}
return true;
}
}
// 注册权限拦截器(在租户拦截器之后执行)
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private TenantInterceptor tenantInterceptor;
@Autowired
private PermissionInterceptor permissionInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(tenantInterceptor)
.addPathPatterns("/api/**")
.excludePathPatterns("/api/auth/**");
registry.addInterceptor(permissionInterceptor)
.addPathPatterns("/api/**")
.excludePathPatterns("/api/auth/**");
}
}
3.4.3 API 使用示例(加注解控制权限)
less
/**
* 客户管理API(带权限控制)
*/
@RestController
@RequestMapping("/api/customer")
public class CustomerController {
@Autowired
private CustomerService customerService;
// 查看客户:需要customer:view权限
@GetMapping("/{id}")
@RequiresPermission("customer:view")
public R<Customer> getCustomer(@PathVariable String id) {
return R.success(customerService.getById(id));
}
// 修改客户:需要customer:edit权限
@PutMapping("/{id}")
@RequiresPermission("customer:edit")
public R<Void> updateCustomer(@PathVariable String id, @RequestBody Customer customer) {
customerService.updateById(id, customer);
return R.success();
}
// 删除客户:需要customer:delete权限(仅管理员有)
@DeleteMapping("/{id}")
@RequiresPermission("customer:delete")
public R<Void> deleteCustomer(@PathVariable String id) {
customerService.deleteById(id);
return R.success();
}
}
八年经验技巧:
- 权限标识用 "资源:操作" 格式(比如
customer:edit
),清晰易懂,便于管理; - 用户权限缓存到 Redis,有效期 1 小时:权限修改后,要主动删除缓存(比如
redisTemplate.delete(permissionCacheKey)
),避免缓存脏读; - 拦截器执行顺序:先解析租户 ID(租户拦截器),再校验权限(权限拦截器),避免权限校验时租户 ID 为空。
四、踩坑实录:这些坑我替你踩过了
做 SaaS 多租户的八年里,踩过不少 "致命坑",分享 3 个最典型的,帮你少走弯路:
4.1 坑 1:租户 ID 串了,A 租户查到 B 租户数据
问题:高并发场景下,线程池复用 ThreadLocal 中的租户 ID,导致 A 租户的请求拿到 B 租户的 ID,查询到 B 租户的数据。
原因 :租户拦截器的afterCompletion
方法没写,请求结束后没清除 ThreadLocal 中的租户 ID,线程池复用线程时,租户 ID 残留。
解决方案:
- 必须在
afterCompletion
中调用TenantContextHolder.clear()
,清除租户 ID; - 用
InheritableThreadLocal
替代ThreadLocal
(如果有异步操作,子线程能继承租户 ID)。
java
// 修复后的租户拦截器
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
TenantContextHolder.clear(); // 关键:请求结束清除租户ID
}
4.2 坑 2:JWT 吊销无效,员工离职后仍能访问
问题:员工离职后,调用了 token 吊销接口,但员工的旧 token 仍能访问 API。
原因:只在 token 生成时存入 JWT,吊销时加入 Redis 黑名单,但权限校验时没查黑名单,导致黑名单失效。
解决方案:
- 在 JWT 工具类的
validateToken
方法中,增加黑名单校验; - 每次 API 请求都先校验 token 是否在黑名单中。
typescript
// 修复后的JWT验证方法
public boolean validateToken(String token) {
try {
// 1. 校验token签名和过期时间
Claims claims = Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(token).getBody();
// 2. 校验token是否在黑名单中
String jti = claims.getId();
Boolean isBlacklisted = redisTemplate.hasKey("jwt:blacklist:" + jti);
if (Boolean.TRUE.equals(isBlacklisted)) {
return false;
}
return true;
} catch (Exception e) {
return false;
}
}
4.3 坑 3:MongoDB 集合前缀漏加,租户数据混存
问题 :新增一个 Repository 时,忘记用自定义的TenantMongoTemplate
,导致集合名没加租户前缀,所有租户的数据都存在同一个集合中。
原因 :Spring Data MongoDB 默认使用自带的MongoTemplate
,没指定自定义的TenantMongoTemplate
。
解决方案:
- 在 Repository 中指定
@Qualifier
,明确使用自定义的TenantMongoTemplate
; - 全局配置 MongoTemplate,覆盖默认实现。
less
// 修复后的Repository
@Repository
public interface CustomerRepository extends MongoRepository<Customer, String> {
// 指定使用自定义的TenantMongoTemplate
@Qualifier("tenantMongoTemplate")
MongoTemplate getMongoTemplate();
}
// 全局配置MongoTemplate
@Configuration
public class MongoConfig {
@Bean
public MongoTemplate mongoTemplate(MongoDatabaseFactory mongoDbFactory, MongoConverter converter) {
return new TenantMongoTemplate(mongoDbFactory, converter);
}
}
五、总结:SaaS 多租户架构的 "三大核心原则"
做了八年 SaaS 开发,总结出 3 个核心设计原则,适用于所有多租户平台:
- 数据隔离前置化:从框架层面强制隔离(比如 MongoDB 集合前缀、ThreadLocal 租户上下文),别依赖开发手动过滤,减少人为失误;
- 权限管控精细化:用 OAuth2 + 注解替代硬编码,支持多端授权、实时吊销,权限粒度到 "资源:操作",满足复杂业务需求;
- 性能安全平衡化:Redis 缓存权限、JWT 黑名单,减少数据库压力;JWT 短期有效,搭配刷新令牌,兼顾性能和安全。
最后给同行一个建议:做 SaaS 别贪多求全,先把 "数据隔离" 和 "认证授权" 这两个基础打牢,再考虑定制化、高并发等进阶需求。如果一开始就堆复杂功能,很容易出现安全漏洞。