ValidX 多租户系统的数据验证方案

1,862 阅读11分钟

ValidX 多租户系统的数据验证方案

📋 目录


概述

如果你在做 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.comtenant1

无论哪种渠道,第一步都是格式校验

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_idSQL 审计工具检测

7.2 最佳实践

  1. 拦截器统一提取租户ID:不要在每个 Controller 里手动提取;
  2. Repository 层强制过滤:不要依赖开发者自觉,在 DAO 层统一过滤;
  3. Service 层双重校验:即使 Repository 过滤了,Service 也要校验数据归属;
  4. 数据库加联合唯一键(tenant_id, username) 而非单独的 username 唯一键;
  5. 日志记录租户ID:方便审计和排查数据泄露问题;
  6. 定期SQL审计:检查是否有查询漏掉 tenant_id

总结

多租户系统的数据验证不是"多一个字段"那么简单,它是一个贯穿系统各层的完整策略:

  • 入口层:拦截器提取并校验 tenantId 的格式和存在性;
  • Controller 层:注解校验 tenantId 格式;
  • Service 层:链式 API 校验数据归属、租户权限、业务规则;
  • Repository 层:自动过滤 tenant_id,防止漏加条件;
  • 数据库层:联合唯一键兜底,防止脏数据。

ValidX 的作用

层次ValidX 工具校验内容
入口层链式 APItenantId 格式、存在性
Controller注解tenantIduserId 格式
Service链式 API数据归属、租户权限、业务规则
批量操作链式 API数据一致性、租户一致性

多租户系统的核心安全原则是:永远不要信任用户输入的 tenantId, always 从可信来源(如 JWT Token、Session)获取,并在每一层校验

项目地址