技巧

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

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

精选理由

做 SaaS 的开发者可以看看,讲的是怎么用 ValidX 把租户 ID 校验、数据归属检查、跨租户隔离这三层验证都落到代码里,还附了完整的用户管理模块示例。

文章面向 SaaS 和 B2B 系统开发者,讲解如何用 ValidX 做多租户数据验证。内容覆盖三种多租户架构模式,指出国内多数 SaaS 采用共享数据库加共享 Schema、靠 tenant_id 字段隔离数据的方案。验证分入口层、权限层、数据层三个层次:入口层用 @NotNull、@Min(1) 等注解校验 tenantId 格式,权限层校验数据归属和跨租户操作边界。文末给出一个完整的 SaaS 用户管理模块实战,包含表结构设计、联合唯一键和租户存在性缓存校验等代码示例。

原文 · 掘金本周最热

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

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…