ValidX 多租户系统的数据验证方案
📋 目录
- 概述
- 一、多租户架构:从共享到隔离的三种模式
- 二、租户ID的校验:谁有权访问谁的数据
- 三、数据隔离层验证:Service 层的"守门"职责
- 四、多租户场景下的字段校验策略
- 五、跨租户操作的校验边界
- 六、完整实战:SaaS 用户管理模块
- 七、多租户验证的常见坑与最佳实践
- 总结
- 项目地址
概述
如果你在做 SaaS 平台、企业微信应用、云 ERP 或任何 B2B 系统,"多租户"是一个绕不开的话题。多租户的核心挑战是:如何在同一个系统中确保 A 租户的用户永远看不到 B 租户的数据——这不仅是一个架构问题,更是一个数据验证问题。
传统的验证框架(包括 ValidX 的注解和链式 API)通常只关注"格式对不对",但多租户场景下,你需要额外关注:
- 租户ID是否合法:用户传过来的
tenantId是不是系统内真实存在的租户? - 数据归属校验:当前用户是否有权访问这条数据?
- 跨租户操作隔离:批量操作时,是否所有数据都属于同一租户?
- 租户级规则差异:不同租户可能有不同的校验规则(如密码复杂度、字段长度限制)。
本文从多租户的三种架构模式讲起,到 ValidX 在各层的验证策略,再到一个完整的 SaaS 用户管理实战,帮你建立一套完整的多租户数据验证体系。
一、多租户架构:从共享到隔离的三种模式
1.1 三种多租户架构模式
| 模式 | 描述 | 优劣势 | 验证重点 |
|---|---|---|---|
| 共享数据库 + 共享 Schema | 所有租户共用一套表,通过 tenant_id 字段区分 | 成本低、运维简单;数据隔离靠代码保证 | 每条查询必须带 tenant_id 过滤 |
| 共享数据库 + 独立 Schema | 每个租户独立 Schema,共用数据库实例 | 隔离性好;Schema 管理复杂 | Schema 级别的权限校验 |
| 独立数据库 | 每个租户独立数据库实例 | 隔离性最好、成本高;运维复杂 | 数据库连接级别的校验 |
1.2 大多数 SaaS 系统的选择
国内绝大多数 SaaS 平台采用共享数据库 + 共享 Schema模式——用 tenant_id 字段区分不同租户的数据。这种模式成本低、扩展性好,但最大的风险是:如果代码层不做好验证,就可能出现数据泄露。
-- 共享 Schema 的典型表结构
CREATE TABLE users (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
tenant_id BIGINT NOT NULL, -- 租户ID,核心隔离字段
username VARCHAR(50) NOT NULL,
email VARCHAR(100) NOT NULL,
phone VARCHAR(20),
UNIQUE KEY uk_tenant_username (tenant_id, username), -- 联合唯一键
UNIQUE KEY uk_tenant_email (tenant_id, email),
UNIQUE KEY uk_tenant_phone (tenant_id, phone)
);
1.3 多租户验证的三个层次
多租户验证不是"多一个字段校验"那么简单,它贯穿整个系统:
| 层次 | 验证内容 | 工具 |
|---|---|---|
| 入口层 | tenantId 格式、是否为空 | ValidX 注解(@NotNull、@Min(1)) |
| 权限层 | 当前用户是否有权访问目标租户的数据 | 自定义校验逻辑 |
| 数据层 | 查询结果是否被 tenant_id 正确过滤 | SQL 审计、数据库约束 |
二、租户ID的校验:谁有权访问谁的数据
2.1 租户ID的基础校验
在多租户系统中,租户ID通常从以下渠道获取:
- 请求头(Header):
X-Tenant-Id: 10086 - 请求参数(Query/Body):
?tenantId=10086 - JWT Token 解析:从登录用户的 Token 中提取
- 域名/子域名:
tenant1.saas.com→tenant1
无论哪种渠道,第一步都是格式校验:
public class TenantContext {
private static final ThreadLocal<Long> CURRENT_TENANT = new ThreadLocal<>();
public static void setCurrentTenant(Long tenantId) {
// 格式校验
ValidX chain = ValidX.init()
.withLocale(Locale.SIMPLIFIED_CHINESE)
.field("租户ID")
.isNotNull(tenantId)
.isGreaterThan(tenantId, 0L);
if (!chain.passed()) {
throw new TenantNotFoundException("无效的租户ID");
}
CURRENT_TENANT.set(tenantId);
}
public static Long getCurrentTenant() {
Long tenantId = CURRENT_TENANT.get();
if (tenantId == null) {
throw new TenantNotFoundException("租户ID未设置");
}
return tenantId;
}
public static void clear() {
CURRENT_TENANT.remove();
}
}
2.2 租户存在性校验
格式校验通过后,还需要校验租户是否真实存在:
@Service
public class TenantValidationService {
private final TenantRepository tenantRepository;
private final CacheManager cacheManager;
/**
* 校验租户是否存在且有效
*/
public void validateTenantExists(Long tenantId) {
// 从缓存中获取,避免频繁查库
Tenant tenant = cacheManager.getTenant(tenantId);
if (tenant == null) {
tenant = tenantRepository.findById(tenantId)
.orElse(null);
if (tenant != null) {
cacheManager.putTenant(tenant);
}
}
ValidX chain = ValidX.init()
.withLocale(Locale.SIMPLIFIED_CHINESE);
if (tenant == null) {
chain.field("租户ID").fail("租户不存在");
} else if (tenant.getStatus() == TenantStatus.DISABLED) {
chain.field("租户状态").fail("租户已停用");
} else if (tenant.getStatus() == TenantStatus.EXPIRED) {
chain.field("租户状态").fail("租户已过期");
}
if (!chain.passed()) {
throw new TenantNotFoundException(chain.getErrors().get(0));
}
}
}
2.3 统一拦截器:在请求入口统一校验
@Component
public class TenantInterceptor implements HandlerInterceptor {
@Autowired
private TenantValidationService tenantValidationService;
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
// 从请求头中提取租户ID
String tenantIdStr = request.getHeader("X-Tenant-Id");
if (tenantIdStr == null) {
throw new TenantNotFoundException("请求头缺少 X-Tenant-Id");
}
Long tenantId;
try {
tenantId = Long.parseLong(tenantIdStr);
} catch (NumberFormatException e) {
throw new TenantNotFoundException("租户ID格式不正确");
}
// 校验租户存在性
tenantValidationService.validateTenantExists(tenantId);
// 设置到上下文
TenantContext.setCurrentTenant(tenantId);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) {
TenantContext.clear();
}
}
2.4 注解化:为 DTO 添加租户ID校验
public class CreateUserRequest {
@NotNull(message = "租户ID不能为空")
@Min(value = 1, message = "租户ID必须大于 0")
private Long tenantId;
@NotBlank(message = "用户名不能为空")
@Size(min = 4, max = 20, message = "用户名长度需在 4-20 位之间")
private String username;
@NotBlank(message = "邮箱不能为空")
@Email(message = "邮箱格式不正确")
private String email;
@NotBlank(message = "手机号不能为空")
@ChinesePhone(message = "手机号格式不正确")
private String phone;
}
三、数据隔离层验证:Service 层的"守门"职责
3.1 数据归属校验
多租户系统中最危险的 bug 之一是:查询时忘记加 tenant_id 条件,导致 A 租户能看到 B 租户的数据。
ValidX 链式 API 可以在 Service 层做"数据归属校验"——在返回数据前,校验数据是否属于当前租户:
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
public UserDTO getUserById(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new EntityNotFoundException("用户不存在"));
// 数据隔离校验:当前用户只能访问自己租户的数据
Long currentTenantId = TenantContext.getCurrentTenant();
ValidX chain = ValidX.init()
.withLocale(Locale.SIMPLIFIED_CHINESE)
.field("用户")
.isEquals(user.getTenantId(), currentTenantId);
if (!chain.passed()) {
throw new AccessDeniedException("无权访问该用户数据");
}
return UserDTO.from(user);
}
}
3.2 Repository 层自动过滤
更可靠的做法是在 Repository 层统一过滤,避免遗漏:
@Repository
public class UserRepositoryImpl implements UserRepository {
@Autowired
private JpaUserRepository jpaRepository;
@Override
public Optional<User> findById(Long id) {
Long tenantId = TenantContext.getCurrentTenant();
return jpaRepository.findByIdAndTenantId(id, tenantId);
}
@Override
public List<User> findAll() {
Long tenantId = TenantContext.getCurrentTenant();
return jpaRepository.findByTenantId(tenantId);
}
@Override
public boolean existsByUsername(String username) {
Long tenantId = TenantContext.getCurrentTenant();
return jpaRepository.existsByTenantIdAndUsername(tenantId, username);
}
}
3.3 JPA 的自动过滤(可选)
如果使用 JPA,可以通过 @Filter 实现自动过滤:
@Entity
@Table(name = "users")
@FilterDef(name = "tenantFilter", parameters = @ParamDef(name = "tenantId", type = "long"))
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
public class User {
// ...
}
四、多租户场景下的字段校验策略
4.1 租户级规则差异
不同租户可能有不同的业务规则:
| 租户类型 | 用户名长度 | 密码复杂度 | 手机号必填 |
|---|---|---|---|
| 基础版 | 4-20 位 | 6位以上 | 否 |
| 专业版 | 4-30 位 | 8位以上 + 特殊字符 | 是 |
| 企业版 | 4-50 位 | 12位以上 + 特殊字符 + 定期更换 | 是 |
4.2 动态校验规则
ValidX 链式 API 支持根据租户类型动态调整校验规则:
@Service
public class UserValidationService {
@Autowired
private TenantConfigService tenantConfigService;
public void validateCreateUser(CreateUserRequest request, Long tenantId) {
TenantConfig config = tenantConfigService.getConfig(tenantId);
ValidX chain = ValidX.init()
.withLocale(Locale.SIMPLIFIED_CHINESE);
// 根据租户配置动态校验用户名长度
int minUsernameLength = config.getMinUsernameLength();
int maxUsernameLength = config.getMaxUsernameLength();
if (request.getUsername().length() < minUsernameLength
|| request.getUsername().length() > maxUsernameLength) {
chain.field("用户名").fail(
String.format("用户名长度需在 %d-%d 位之间", minUsernameLength, maxUsernameLength));
}
// 根据租户配置校验密码复杂度
if (config.isRequireStrongPassword()) {
chain.field("密码").isPassword(request.getPassword(), 8, true);
} else {
chain.field("密码").isPassword(request.getPassword(), 6, false);
}
// 根据租户配置校验手机号是否必填
if (config.isPhoneRequired()
&& (request.getPhone() == null || request.getPhone().isBlank())) {
chain.field("手机号").fail("当前租户要求手机号必填");
}
if (!chain.passed()) {
throw new ValidationException(chain.getErrors());
}
}
}
4.3 注解 vs 链式:多租户场景的选择
| 场景 | 推荐方式 | 原因 |
|---|---|---|
| 所有租户规则一致(如手机号格式) | 注解 | 简单、声明式 |
| 租户间规则不同(如用户名长度) | 链式 API | 动态、可配置 |
| 依赖外部配置(如租户套餐) | 链式 API | 需要运行时判断 |
| 格式校验 + 业务规则混合 | 注解 + 链式 API | 分层处理 |
五、跨租户操作的校验边界
5.1 禁止跨租户操作
绝大多数场景下,用户只能操作自己租户的数据。以下场景需要特别校验:
- 批量操作:删除多个用户时,确保所有用户都属于当前租户;
- 关联操作:给订单添加商品时,确保商品和订单属于同一租户;
- 数据迁移:管理员在租户间迁移数据时,需要额外的权限校验。
5.2 批量操作的租户一致性校验
@Service
public class BatchUserService {
@Autowired
private UserRepository userRepository;
public void batchDelete(List<Long> userIds) {
Long currentTenantId = TenantContext.getCurrentTenant();
// 查出所有用户
List<User> users = userRepository.findAllById(userIds);
ValidX chain = ValidX.init()
.withLocale(Locale.SIMPLIFIED_CHINESE);
// 校验 1:所有用户必须存在
if (users.size() != userIds.size()) {
chain.field("用户").fail("部分用户不存在");
}
// 校验 2:所有用户必须属于同一租户
for (User user : users) {
if (!user.getTenantId().equals(currentTenantId)) {
chain.field("用户[" + user.getId() + "]")
.fail("无权删除其他租户的用户");
}
}
if (!chain.passed()) {
throw new AccessDeniedException(chain.getErrors().get(0));
}
// 执行删除
userRepository.deleteAll(users);
}
}
5.3 关联操作的租户一致性校验
@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository;
@Autowired
private ProductRepository productRepository;
public void addProductToOrder(Long orderId, Long productId, int quantity) {
Long currentTenantId = TenantContext.getCurrentTenant();
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new EntityNotFoundException("订单不存在"));
Product product = productRepository.findById(productId)
.orElseThrow(() -> new EntityNotFoundException("商品不存在"));
ValidX chain = ValidX.init()
.withLocale(Locale.SIMPLIFIED_CHINESE);
// 校验订单归属
if (!order.getTenantId().equals(currentTenantId)) {
chain.field("订单").fail("无权访问该订单");
}
// 校验商品归属
if (!product.getTenantId().equals(currentTenantId)) {
chain.field("商品").fail("无权访问该商品");
}
// 校验订单和商品是否属于同一租户
if (!order.getTenantId().equals(product.getTenantId())) {
chain.field("数据一致性").fail("订单和商品不属于同一租户");
}
if (!chain.passed()) {
throw new AccessDeniedException(chain.getErrors().get(0));
}
// 执行业务逻辑
order.addItem(product, quantity);
orderRepository.save(order);
}
}
六、完整实战:SaaS 用户管理模块
6.1 架构图
┌─────────────────────────────────────────────────────────────────┐
│ HTTP 请求 │
│ ↓ │
│ TenantInterceptor │
│ (提取并校验租户ID) │
│ ↓ │
│ TenantContext │
│ (ThreadLocal 存储) │
│ ↓ │
│ Controller (@Valid) │
│ (格式校验) │
│ ↓ │
│ Service (链式 API) │
│ (业务规则 + 数据隔离校验) │
│ ↓ │
│ Repository │
│ (自动过滤 tenant_id) │
└─────────────────────────────────────────────────────────────────┘
6.2 完整代码
Controller 层
@RestController
@RequestMapping("/api/{tenantId}/users")
public class UserController {
@Autowired
private UserService userService;
@PostMapping
public Result<Long> create(@PathVariable @Min(1) Long tenantId,
@Valid @RequestBody CreateUserRequest request) {
Long userId = userService.createUser(tenantId, request);
return Result.ok(userId);
}
@GetMapping("/{userId}")
public Result<UserDTO> get(@PathVariable @Min(1) Long tenantId,
@PathVariable @Min(1) Long userId) {
UserDTO user = userService.getUser(tenantId, userId);
return Result.ok(user);
}
@DeleteMapping("/{userId}")
public Result<Void> delete(@PathVariable @Min(1) Long tenantId,
@PathVariable @Min(1) Long userId) {
userService.deleteUser(tenantId, userId);
return Result.ok();
}
}
Service 层
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
@Autowired
private TenantValidationService tenantValidationService;
public Long createUser(Long tenantId, CreateUserRequest request) {
// 1. 校验租户
tenantValidationService.validateTenantExists(tenantId);
// 2. 校验当前用户是否有权操作该租户
validateTenantAccess(tenantId);
// 3. 业务规则校验
ValidX chain = ValidX.init()
.withLocale(Locale.SIMPLIFIED_CHINESE);
if (userRepository.existsByTenantIdAndUsername(tenantId, request.getUsername())) {
chain.field("用户名").fail("该用户名已被占用");
}
if (userRepository.existsByTenantIdAndEmail(tenantId, request.getEmail())) {
chain.field("邮箱").fail("该邮箱已被注册");
}
if (!chain.passed()) {
throw new ValidationException(chain.getErrors());
}
// 4. 创建用户
User user = new User();
user.setTenantId(tenantId);
user.setUsername(request.getUsername());
user.setEmail(request.getEmail());
user.setPhone(request.getPhone());
user.setCreatedAt(LocalDateTime.now());
return userRepository.save(user).getId();
}
public UserDTO getUser(Long tenantId, Long userId) {
// 1. 校验租户
tenantValidationService.validateTenantExists(tenantId);
validateTenantAccess(tenantId);
// 2. 查询(Repository 自动过滤 tenant_id)
User user = userRepository.findById(userId)
.orElseThrow(() -> new EntityNotFoundException("用户不存在"));
// 3. 数据隔离校验(双重保险)
if (!user.getTenantId().equals(tenantId)) {
throw new AccessDeniedException("无权访问该用户数据");
}
return UserDTO.from(user);
}
public void deleteUser(Long tenantId, Long userId) {
tenantValidationService.validateTenantExists(tenantId);
validateTenantAccess(tenantId);
User user = userRepository.findById(userId)
.orElseThrow(() -> new EntityNotFoundException("用户不存在"));
if (!user.getTenantId().equals(tenantId)) {
throw new AccessDeniedException("无权删除该用户");
}
userRepository.delete(user);
}
private void validateTenantAccess(Long tenantId) {
Long currentTenantId = TenantContext.getCurrentTenant();
if (!tenantId.equals(currentTenantId)) {
throw new AccessDeniedException("无权操作其他租户的数据");
}
}
}
七、多租户验证的常见坑与最佳实践
7.1 常见坑
| 坑 | 描述 | 解决方案 |
|---|---|---|
| 忘记加 tenant_id | 查询时漏掉 tenant_id 条件 | Repository 层统一封装,强制过滤 |
| 租户ID格式不校验 | 传入负数或超长字符串 | 入口层用 @Min(1) 校验 |
| 缓存穿透 | 缓存了错误租户ID的数据 | 校验通过后再缓存 |
| 跨租户SQL注入 | 用户传入 tenant_id=1 OR 1=1 | 使用参数化查询 |
| 批量操作漏校验 | 只校验了第一条数据 | 遍历所有数据逐一校验 |
| 子查询漏过滤 | 子查询忘记加 tenant_id | SQL 审计工具检测 |
7.2 最佳实践
- 拦截器统一提取租户ID:不要在每个 Controller 里手动提取;
- Repository 层强制过滤:不要依赖开发者自觉,在 DAO 层统一过滤;
- Service 层双重校验:即使 Repository 过滤了,Service 也要校验数据归属;
- 数据库加联合唯一键:
(tenant_id, username)而非单独的username唯一键; - 日志记录租户ID:方便审计和排查数据泄露问题;
- 定期SQL审计:检查是否有查询漏掉
tenant_id。
总结
多租户系统的数据验证不是"多一个字段"那么简单,它是一个贯穿系统各层的完整策略:
- 入口层:拦截器提取并校验
tenantId的格式和存在性; - Controller 层:注解校验
tenantId格式; - Service 层:链式 API 校验数据归属、租户权限、业务规则;
- Repository 层:自动过滤
tenant_id,防止漏加条件; - 数据库层:联合唯一键兜底,防止脏数据。
ValidX 的作用:
| 层次 | ValidX 工具 | 校验内容 |
|---|---|---|
| 入口层 | 链式 API | tenantId 格式、存在性 |
| Controller | 注解 | tenantId、userId 格式 |
| Service | 链式 API | 数据归属、租户权限、业务规则 |
| 批量操作 | 链式 API | 数据一致性、租户一致性 |
多租户系统的核心安全原则是:永远不要信任用户输入的 tenantId, always 从可信来源(如 JWT Token、Session)获取,并在每一层校验。
项目地址
- GitHub: github.com/vipxieliang…
- Gitee: gitee.com/vipxieliang…