Skip to content

参数检查

✨ 基于接口的参数校验机制,提供比 Bean Validation 更灵活的自定义校验能力。

  • 🎯 接口驱动:通过实现 CheckParamProvider 接口启用参数校验
  • 🔧 灵活自定义:在 check() 方法中编写任意复杂的校验逻辑
  • 🚀 自动执行:切面自动拦截并执行校验,无需手动调用
  • 🛠️ 工具方法:提供常用的集合、Pair 等参数检查工具方法
  • 🔗 无缝集成:与 Bean Validation 和全局异常处理无缝配合

快速开始

自动启用

引入 krismile-boot3-autoconfigure 依赖后,参数检查切面默认自动启用,无需任何配置。

xml
<dependency>
    <groupId>host.springboot</groupId>
    <artifactId>krismile-boot3-autoconfigure</artifactId>
</dependency>

基础示例

java
import host.springboot.framework3.core.model.CheckParamProvider;

// 1. 请求参数实现 CheckParamProvider 接口
public class UserQueryRequest implements CheckParamProvider {
    
    private Long userId;
    private List<Long> userIds;
    private String startDate;
    private String endDate;
    
    @Override
    public void check() {
        // 自定义校验逻辑:至少提供一个查询条件
        if (checkAssembleCollectionNull(userId, userIds)) {
            throw new ApplicationException(
                ErrorCodeEnum.INVALID_USER_INPUT,
                "用户ID和用户ID列表不能同时为空"
            );
        }
        
        // 日期范围校验
        if (startDate != null && endDate != null) {
            LocalDate start = LocalDate.parse(startDate);
            LocalDate end = LocalDate.parse(endDate);
            if (start.isAfter(end)) {
                throw new ApplicationException(
                    ErrorCodeEnum.INVALID_USER_INPUT,
                    "开始日期不能大于结束日期"
                );
            }
        }
    }
}

// 2. Controller 方法正常使用,切面自动执行校验
@RestController
@RequestMapping("/api/user")
public class UserController {
    
    @GetMapping("/query")
    public MultiVO<UserDTO> queryUsers(UserQueryRequest request) {
        // check() 方法会在进入此方法前自动执行
        // 校验失败会自动抛出异常,由全局异常处理器处理
        return R.okMulti(userService.queryUsers(request));
    }
}

核心架构

工作流程

HTTP 请求

CheckParamAspect 拦截

遍历方法参数

是否实现 CheckParamProvider?
    ├─ 是 → 执行 check() 方法
    │       ↓
    │   校验通过?
    │   ├─ 是 → 继续执行
    │   └─ 否 → 抛出异常(全局异常处理器捕获)

    └─ 否 → 跳过

执行 Controller 方法

核心类说明

类名职责说明
CheckParamAspect参数检查切面拦截 Controller 方法,自动执行参数校验
CheckParamProvider参数检查接口定义 check() 方法和工具方法
ExecuteOrder.Aop.CHECK_PARAM执行顺序常量值为 200,在 RequestLog (100) 之后执行

执行顺序

AOP 执行顺序(Order 数字越小越先执行):
  1. RequestLogAop (Order = 100)     - 记录请求信息

  2. CheckParamAspect (Order = 200)  - 参数校验

  3. Controller 方法执行

CheckParamProvider 接口

核心方法

java
public interface CheckParamProvider {
    
    /**
     * 参数校验方法(必须重写)
     * 校验失败时抛出异常即可
     */
    default void check() {
    }
    
    /**
     * 检查联合集合是否都为空
     * @return true-都为空, false-至少有一个不为空
     */
    default <T, C extends Collection<T>> boolean checkAssembleCollectionNull(
            T source, C sources) {
        return ObjectUtils.isEmpty(source) && CollectionUtils.isEmpty(sources);
    }
    
    /**
     * 检查 Pair 自身和内部值是否为 null
     * @return true-Pair为null或内部值都不为null, false-其他情况
     */
    default boolean checkPairNull(Pair<?, ?> source) {
        return Objects.isNull(source) || 
               Objects.nonNull(source.left()) && Objects.nonNull(source.right());
    }
}

方法说明

1. check() - 参数校验入口

所有自定义校验逻辑都在此方法中实现。

示例

java
@Override
public void check() {
    // 必填字段校验
    if (StringUtils.isBlank(username)) {
        throw new ApplicationException(
            ErrorCodeEnum.INVALID_USER_INPUT,
            "用户名不能为空"
        );
    }
    
    // 业务规则校验
    if (age != null && age < 18) {
        throw new ApplicationException(
            ErrorCodeEnum.INVALID_USER_INPUT,
            "年龄必须大于18岁"
        );
    }
}

2. checkAssembleCollectionNull() - 联合集合空值检查

用于检查单个值和集合是否都为空,常用于「至少提供一个查询条件」的场景。

示例

java
public class UserQueryRequest implements CheckParamProvider {
    private Long userId;           // 单个用户ID
    private List<Long> userIds;    // 批量用户ID
    
    @Override
    public void check() {
        // 检查 userId 和 userIds 是否都为空
        if (checkAssembleCollectionNull(userId, userIds)) {
            throw new ApplicationException(
                ErrorCodeEnum.INVALID_USER_INPUT,
                "至少提供一个用户ID"
            );
        }
    }
}

3. checkPairNull() - Pair 空值检查

用于检查 Pair<L, R> 对象及其内部值是否为 null。

示例

java
public class RangeQueryRequest implements CheckParamProvider {
    private Pair<Integer, Integer> ageRange;  // 年龄范围 (最小值, 最大值)
    
    @Override
    public void check() {
        // 检查 Pair 是否为 null 或内部值是否都有值
        if (!checkPairNull(ageRange)) {
            throw new ApplicationException(
                ErrorCodeEnum.INVALID_USER_INPUT,
                "年龄范围必须同时提供最小值和最大值"
            );
        }
        
        // 范围合理性校验
        if (ageRange != null && ageRange.left() > ageRange.right()) {
            throw new ApplicationException(
                ErrorCodeEnum.INVALID_USER_INPUT,
                "年龄范围最小值不能大于最大值"
            );
        }
    }
}

常见场景

场景1:联合查询参数校验

至少提供一个查询条件的场景:

java
public class OrderQueryRequest implements CheckParamProvider {
    
    private Long orderId;              // 订单ID
    private List<Long> orderIds;       // 批量订单ID
    private String orderNo;            // 订单号
    private Long userId;               // 用户ID
    
    @Override
    public void check() {
        // 方式1:使用工具方法检查
        boolean noOrderId = checkAssembleCollectionNull(orderId, orderIds);
        boolean noOrderNo = StringUtils.isBlank(orderNo);
        boolean noUserId = userId == null;
        
        if (noOrderId && noOrderNo && noUserId) {
            throw new ApplicationException(
                ErrorCodeEnum.INVALID_USER_INPUT,
                "至少提供订单ID、订单号或用户ID之一"
            );
        }
        
        // 方式2:手动检查
        if (orderId == null && 
            CollectionUtils.isEmpty(orderIds) && 
            StringUtils.isBlank(orderNo) && 
            userId == null) {
            throw new ApplicationException(
                ErrorCodeEnum.INVALID_USER_INPUT,
                "查询条件不能全部为空"
            );
        }
    }
}

场景2:日期范围校验

java
public class ReportQueryRequest implements CheckParamProvider {
    
    private String startDate;  // 格式: yyyy-MM-dd
    private String endDate;
    
    @Override
    public void check() {
        // 1. 必填校验
        if (StringUtils.isAnyBlank(startDate, endDate)) {
            throw new ApplicationException(
                ErrorCodeEnum.INVALID_USER_INPUT,
                "开始日期和结束日期不能为空"
            );
        }
        
        // 2. 格式校验
        LocalDate start, end;
        try {
            start = LocalDate.parse(startDate);
            end = LocalDate.parse(endDate);
        } catch (DateTimeParseException e) {
            throw new ApplicationException(
                ErrorCodeEnum.INVALID_USER_INPUT,
                "日期格式错误,请使用 yyyy-MM-dd 格式"
            );
        }
        
        // 3. 范围合理性校验
        if (start.isAfter(end)) {
            throw new ApplicationException(
                ErrorCodeEnum.INVALID_USER_INPUT,
                "开始日期不能大于结束日期"
            );
        }
        
        // 4. 时间跨度校验
        long days = ChronoUnit.DAYS.between(start, end);
        if (days > 90) {
            throw new ApplicationException(
                ErrorCodeEnum.INVALID_USER_INPUT,
                "查询时间跨度不能超过90天"
            );
        }
    }
}

场景3:分页参数校验

java
public class PageQueryRequest implements CheckParamProvider {
    
    private Integer pageNo = 1;      // 当前页
    private Integer pageSize = 10;   // 每页大小
    private String sortField;        // 排序字段
    private String sortOrder;        // 排序方向: ASC/DESC
    
    // 允许的排序字段白名单
    private static final Set<String> ALLOWED_SORT_FIELDS = 
        Set.of("createTime", "updateTime", "id", "name");
    
    @Override
    public void check() {
        // 1. 分页参数范围校验
        if (pageNo != null && pageNo < 1) {
            throw new ApplicationException(
                ErrorCodeEnum.INVALID_USER_INPUT,
                "页码必须大于0"
            );
        }
        
        if (pageSize != null && (pageSize < 1 || pageSize > 100)) {
            throw new ApplicationException(
                ErrorCodeEnum.INVALID_USER_INPUT,
                "每页大小必须在1-100之间"
            );
        }
        
        // 2. 排序字段白名单校验(防止SQL注入)
        if (StringUtils.isNotBlank(sortField)) {
            if (!ALLOWED_SORT_FIELDS.contains(sortField)) {
                throw new ApplicationException(
                    ErrorCodeEnum.INVALID_USER_INPUT,
                    "不支持的排序字段: " + sortField
                );
            }
        }
        
        // 3. 排序方向校验
        if (StringUtils.isNotBlank(sortOrder)) {
            if (!"ASC".equalsIgnoreCase(sortOrder) && 
                !"DESC".equalsIgnoreCase(sortOrder)) {
                throw new ApplicationException(
                    ErrorCodeEnum.INVALID_USER_INPUT,
                    "排序方向只能是 ASC 或 DESC"
                );
            }
        }
    }
}

场景4:复杂业务规则校验

java
public class TransferRequest implements CheckParamProvider {
    
    private Long fromAccountId;    // 转出账户
    private Long toAccountId;      // 转入账户
    private BigDecimal amount;     // 转账金额
    private String password;       // 交易密码
    
    @Override
    public void check() {
        // 1. 必填校验
        if (fromAccountId == null || toAccountId == null || 
            amount == null || StringUtils.isBlank(password)) {
            throw new ApplicationException(
                ErrorCodeEnum.INVALID_USER_INPUT,
                "转账信息不完整"
            );
        }
        
        // 2. 业务规则:不能转给自己
        if (fromAccountId.equals(toAccountId)) {
            throw new ApplicationException(
                ErrorCodeEnum.INVALID_USER_INPUT,
                "不能向自己的账户转账"
            );
        }
        
        // 3. 金额范围校验
        if (amount.compareTo(BigDecimal.ZERO) <= 0) {
            throw new ApplicationException(
                ErrorCodeEnum.INVALID_USER_INPUT,
                "转账金额必须大于0"
            );
        }
        
        if (amount.compareTo(new BigDecimal("50000")) > 0) {
            throw new ApplicationException(
                ErrorCodeEnum.INVALID_USER_INPUT,
                "单笔转账金额不能超过5万元"
            );
        }
        
        // 4. 密码格式校验
        if (password.length() != 6 || !password.matches("\\d{6}")) {
            throw new ApplicationException(
                ErrorCodeEnum.INVALID_USER_INPUT,
                "交易密码必须是6位数字"
            );
        }
    }
}

场景5:Pair 参数校验

java
public class PriceRangeQueryRequest implements CheckParamProvider {
    
    private Pair<BigDecimal, BigDecimal> priceRange;  // 价格范围
    private Pair<Integer, Integer> stockRange;        // 库存范围
    
    @Override
    public void check() {
        // 1. 检查 Pair 是否有值
        boolean hasPriceRange = !checkPairNull(priceRange);
        boolean hasStockRange = !checkPairNull(stockRange);
        
        // 至少提供一个查询条件
        if (!hasPriceRange && !hasStockRange) {
            throw new ApplicationException(
                ErrorCodeEnum.INVALID_USER_INPUT,
                "至少提供价格范围或库存范围之一"
            );
        }
        
        // 2. 价格范围合理性校验
        if (priceRange != null) {
            BigDecimal min = priceRange.left();
            BigDecimal max = priceRange.right();
            
            if (min == null || max == null) {
                throw new ApplicationException(
                    ErrorCodeEnum.INVALID_USER_INPUT,
                    "价格范围必须同时提供最小值和最大值"
                );
            }
            
            if (min.compareTo(max) > 0) {
                throw new ApplicationException(
                    ErrorCodeEnum.INVALID_USER_INPUT,
                    "价格范围最小值不能大于最大值"
                );
            }
            
            if (min.compareTo(BigDecimal.ZERO) < 0) {
                throw new ApplicationException(
                    ErrorCodeEnum.INVALID_USER_INPUT,
                    "价格不能为负数"
                );
            }
        }
        
        // 3. 库存范围合理性校验
        if (stockRange != null) {
            Integer min = stockRange.left();
            Integer max = stockRange.right();
            
            if (min == null || max == null) {
                throw new ApplicationException(
                    ErrorCodeEnum.INVALID_USER_INPUT,
                    "库存范围必须同时提供最小值和最大值"
                );
            }
            
            if (min > max) {
                throw new ApplicationException(
                    ErrorCodeEnum.INVALID_USER_INPUT,
                    "库存范围最小值不能大于最大值"
                );
            }
        }
    }
}

与 Bean Validation 对比

Bean Validation

优点

  • 声明式注解,简洁直观
  • 标准规范,通用性强
  • 内置常用校验器

局限

  • 难以实现复杂业务规则
  • 跨字段校验不够灵活
  • 错误信息定制能力有限
java
public class UserRequest {
    @NotBlank(message = "用户名不能为空")
    private String username;
    
    @Min(value = 18, message = "年龄必须大于18岁")
    private Integer age;
    
    // ❌ 难以实现:用户名和邮箱至少提供一个
    // ❌ 难以实现:开始日期必须小于结束日期
}

CheckParamProvider

优点

  • 灵活强大,支持任意复杂逻辑
  • 跨字段校验轻松实现
  • 可调用 Service 进行数据库校验
  • 错误信息完全自定义

适用场景

  • 复杂业务规则校验
  • 跨字段关联校验
  • 需要查询数据库的校验
  • 动态校验规则
java
public class UserRequest implements CheckParamProvider {
    private String username;
    private String email;
    private Integer age;
    
    @Override
    public void check() {
        // ✅ 灵活实现:用户名和邮箱至少提供一个
        if (StringUtils.isAllBlank(username, email)) {
            throw new ApplicationException(
                ErrorCodeEnum.INVALID_USER_INPUT,
                "用户名和邮箱至少提供一个"
            );
        }
        
        // ✅ 灵活实现:年龄校验
        if (age != null && age < 18) {
            throw new ApplicationException(
                ErrorCodeEnum.INVALID_USER_INPUT,
                "年龄必须大于18岁"
            );
        }
    }
}

最佳实践:组合使用

java
public class UserRegisterRequest implements CheckParamProvider {
    
    // 简单校验使用 Bean Validation
    @NotBlank(message = "用户名不能为空")
    @Length(min = 3, max = 20, message = "用户名长度3-20位")
    private String username;
    
    @NotBlank(message = "密码不能为空")
    @Length(min = 6, max = 20, message = "密码长度6-20位")
    private String password;
    
    @Email(message = "邮箱格式错误")
    private String email;
    
    @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式错误")
    private String phone;
    
    // 复杂校验使用 CheckParamProvider
    @Override
    public void check() {
        // 业务规则:邮箱和手机号至少提供一个
        if (StringUtils.isAllBlank(email, phone)) {
            throw new ApplicationException(
                ErrorCodeEnum.INVALID_USER_INPUT,
                "邮箱和手机号至少提供一个"
            );
        }
        
        // 密码强度校验
        if (!isStrongPassword(password)) {
            throw new ApplicationException(
                ErrorCodeEnum.INVALID_USER_INPUT,
                "密码必须包含字母、数字和特殊字符"
            );
        }
    }
    
    private boolean isStrongPassword(String password) {
        return password.matches("^(?=.*[a-zA-Z])(?=.*\\d)(?=.*[@#$%]).+$");
    }
}

最佳实践

✅ 推荐做法

java
// 1. 简单校验用 Bean Validation,复杂校验用 CheckParamProvider
public class OrderRequest implements CheckParamProvider {
    @NotNull(message = "商品ID不能为空")
    private Long productId;
    
    private Long userId;
    private List<Long> userIds;
    
    @Override
    public void check() {
        // 复杂的联合校验
        if (checkAssembleCollectionNull(userId, userIds)) {
            throw new ApplicationException(
                ErrorCodeEnum.INVALID_USER_INPUT,
                "至少提供一个用户ID"
            );
        }
    }
}

// 2. 使用工具方法简化校验逻辑
@Override
public void check() {
    if (checkAssembleCollectionNull(id, ids)) {
        throw new ApplicationException(
            ErrorCodeEnum.INVALID_USER_INPUT,
            "ID参数不能为空"
        );
    }
}

// 3. 提取复杂校验逻辑为私有方法
@Override
public void check() {
    validateDateRange();
    validatePriceRange();
    validateStock();
}

private void validateDateRange() {
    // 日期范围校验逻辑
}

// 4. 使用有意义的错误提示
throw new ApplicationException(
    ErrorCodeEnum.INVALID_USER_INPUT,
    "查询时间跨度不能超过90天,当前跨度: " + days + "天"
);

// 5. 在需要数据库校验时注入 Service
public class UpdateUserRequest implements CheckParamProvider {
    
    @Autowired
    private transient UserService userService;
    
    private Long userId;
    private String newEmail;
    
    @Override
    public void check() {
        // 检查邮箱是否已被使用
        if (userService.existsByEmail(newEmail, userId)) {
            throw new ApplicationException(
                ErrorCodeEnum.INVALID_USER_INPUT,
                "该邮箱已被其他用户使用"
            );
        }
    }
}

❌ 不推荐做法

java
// ❌ 不要在 check() 中返回 boolean 或错误信息
@Override
public void check() {
    if (invalid) {
        return false;  // 错误:应该抛出异常
    }
}

// ❌ 不要在 check() 中捕获并吞掉异常
@Override
public void check() {
    try {
        validateSomething();
    } catch (Exception e) {
        // 错误:不要吞掉异常
    }
}

// ❌ 不要过度使用,简单校验应该用 Bean Validation
public class SimpleRequest implements CheckParamProvider {
    private String username;
    
    @Override
    public void check() {
        // 错误:这种简单校验应该用 @NotBlank
        if (StringUtils.isBlank(username)) {
            throw new ApplicationException(
                ErrorCodeEnum.INVALID_USER_INPUT,
                "用户名不能为空"
            );
        }
    }
}

// 正确做法:
public class SimpleRequest {
    @NotBlank(message = "用户名不能为空")
    private String username;
}

// ❌ 不要在 check() 中执行业务逻辑
@Override
public void check() {
    // 错误:不要在校验中执行业务逻辑
    userService.updateUser(this.userId, this.username);
}

常见问题

Q: CheckParamProvider 和 Bean Validation 如何选择?

A: 根据校验复杂度选择:

  • Bean Validation:简单的单字段校验(非空、长度、格式等)
  • CheckParamProvider:复杂的跨字段校验、业务规则校验
  • 组合使用:简单校验用注解,复杂校验实现 check() 方法
java
public class Request implements CheckParamProvider {
    @NotBlank  // 简单校验用注解
    private String name;
    
    private String startDate;
    private String endDate;
    
    @Override
    public void check() {  // 复杂校验用 check()
        if (startDate != null && endDate != null) {
            // 复杂的日期范围校验逻辑
        }
    }
}

Q: check() 方法什么时候执行?

A: 在 Controller 方法执行之前自动执行:

1. 请求到达 Controller
2. RequestLogAop 记录请求信息 (Order=100)
3. CheckParamAspect 执行 check() (Order=200)  ← 这里执行
4. Controller 方法执行

如果 check() 抛出异常,Controller 方法不会执行,异常会被全局异常处理器捕获。

Q: 为什么我的 check() 方法没有执行?

A: 检查以下几点:

  1. 确认实现了接口
java
// ✅ 正确
public class Request implements CheckParamProvider {
    @Override
    public void check() { ... }
}

// ❌ 错误:忘记实现接口
public class Request {
    public void check() { ... }
}
  1. 确认参数是请求参数
java
// ✅ 正确
@GetMapping("/query")
public BaseVO query(QueryRequest request) { ... }

// ❌ 错误:不是请求参数
@GetMapping("/query")
public BaseVO query() {
    QueryRequest request = new QueryRequest();
    // 手动创建的对象不会触发切面
}
  1. 确认引入了自动配置
xml
<dependency>
    <groupId>host.springboot</groupId>
    <artifactId>krismile-boot3-autoconfigure</artifactId>
</dependency>

Q: 如何在 check() 方法中访问数据库?

A: 通过注入 Service 实现:

java
public class UpdateEmailRequest implements CheckParamProvider {
    
    // 使用 @Autowired 注入 Service
    @Autowired
    private transient UserService userService;  // 加 transient 避免序列化
    
    private Long userId;
    private String newEmail;
    
    @Override
    public void check() {
        // 调用 Service 查询数据库
        if (userService.existsByEmail(newEmail, userId)) {
            throw new ApplicationException(
                ErrorCodeEnum.INVALID_USER_INPUT,
                "该邮箱已被使用"
            );
        }
    }
}

💡 注意

  • transient 关键字避免序列化 Service
  • 确保请求对象被 Spring 管理(通常框架会自动处理)

Q: 如何禁用参数检查切面?

A: 目前参数检查切面没有单独的开关,但可以通过以下方式控制:

  1. 方法级别:不实现 CheckParamProvider 接口即可
  2. 全局禁用:排除自动配置类
java
@SpringBootApplication(exclude = {KsAopAutoConfiguration.class})
public class Application {
    // ...
}

Q: checkAssembleCollectionNull() 的逻辑是什么?

A: 检查单个值和集合是否都为空

java
// 返回 true 表示都为空
checkAssembleCollectionNull(null, null)           // true
checkAssembleCollectionNull(null, List.of())      // true
checkAssembleCollectionNull(null, Collections.emptyList())  // true

// 返回 false 表示至少有一个不为空
checkAssembleCollectionNull(1L, null)             // false
checkAssembleCollectionNull(null, List.of(1L))    // false
checkAssembleCollectionNull(1L, List.of(2L))      // false

典型用法

java
// 至少提供一个查询条件
if (checkAssembleCollectionNull(userId, userIds)) {
    throw new ApplicationException(
        ErrorCodeEnum.INVALID_USER_INPUT,
        "至少提供一个用户ID"
    );
}

💡 提示:切面的执行顺序为 RequestLogAop (100) → CheckParamAspect (200),确保请求信息已记录后再进行参数校验。

Released under the Apache-2.0 License