Spring Boot 4.0 参数校验:分组校验、自定义注解与国际化
前言
上一章已经能返回结构化参数错误。但创建订单和更新订单复用了同一个请求对象:
- 创建时必须提供客户编号。
- 更新时只改金额,客户编号可以省略。
- 金额必须落在业务允许的范围内。
- 中文和英文客户端应该收到各自语言的消息。
如果所有字段都写 @NotNull,更新请求会被“创建规则”误伤;如果只写一个 @DecimalMin,又很难表达业务上限和自定义比较逻辑。
本章会组合三个能力:分组校验、自定义注解和国际化。
配套源码:dvsusan/spring-boot4-demo。
最终结果
创建订单时缺少客户编号:
curl -X POST "http://localhost:8080/api/v1/orders" \
-H "Accept-Language: zh-CN" \
-H "Content-Type: application/json" \
-d '{"amount":0}'返回:
{
"code": "INVALID_ARGUMENT",
"message": "请求参数不合法",
"data": [
{
"field": "customerId",
"message": "客户编号不能为空"
},
{
"field": "amount",
"message": "订单金额必须在 0.01 到 1000000.00 之间"
}
]
}同一个请求头改成 Accept-Language: en,字段消息切换为英文,错误码和 HTTP 状态不变。

图 1:分组决定哪些约束参与本次请求,消息资源再按客户端语言生成文案。
第 1 步:声明创建与更新分组
创建:
demo-web/src/main/java/com/susan/training/web/validation/ValidationGroups.java/**
* 用接口作为校验分组标记。
*/
public final class ValidationGroups {
private ValidationGroups() {
}
// 创建接口只执行属于 Create 组的规则。
public interface Create {
}
// 更新接口只执行属于 Update 组的规则。
public interface Update {
}
}分组接口不需要方法。它们只是类型标记,让 Jakarta Validation 知道当前运行哪一组约束。
第 2 步:在请求对象上声明分组
修改:
demo-web/src/main/java/com/susan/training/web/CreateOrderRequest.java/**
* 创建与更新共用请求对象,由分组决定哪些字段必填。
*/
public record CreateOrderRequest(
@NotBlank(
groups = ValidationGroups.Create.class,
message = "{order.customerId.required}"
)
@Size(
max = 32,
groups = {ValidationGroups.Create.class, ValidationGroups.Update.class},
message = "{order.customerId.size}"
)
String customerId,
@NotNull(
groups = {ValidationGroups.Create.class, ValidationGroups.Update.class},
message = "{order.amount.required}"
)
@MoneyAmount(
groups = {ValidationGroups.Create.class, ValidationGroups.Update.class},
message = "{order.amount.range}"
)
BigDecimal amount
) {
}这里的规则很清楚:
customerId只在创建时不能为空。- 如果更新请求携带
customerId,仍然检查最大长度。 amount在创建和更新时都必填。- 金额同时执行自定义范围规则。
{order.customerId.required} 不是最终文案,而是消息 key。真正内容从语言资源读取。
第 3 步:创建金额校验注解
创建:
demo-web/src/main/java/com/susan/training/web/validation/MoneyAmount.java@Documented
@Constraint(validatedBy = MoneyAmountValidator.class)
@Target({FIELD, PARAMETER, ANNOTATION_TYPE})
@Retention(RUNTIME)
public @interface MoneyAmount {
String message() default "{order.amount.range}";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
// 注解属性必须使用编译期常量,金额用字符串避免 double 精度。
String min() default "0.01";
String max() default "1000000.00";
}@Constraint 指向真正执行比较的校验器。groups 和 payload 是 Bean Validation 规定的标准属性,自定义注解也需要保留。
第 4 步:用 BigDecimal 实现校验器
创建:
demo-web/src/main/java/com/susan/training/web/validation/MoneyAmountValidator.java/**
* 使用 BigDecimal 比较金额,避免二进制浮点误差。
*/
public class MoneyAmountValidator
implements ConstraintValidator<MoneyAmount, BigDecimal> {
private BigDecimal minimum;
private BigDecimal maximum;
@Override
public void initialize(MoneyAmount annotation) {
minimum = new BigDecimal(annotation.min());
maximum = new BigDecimal(annotation.max());
}
@Override
public boolean isValid(BigDecimal value, ConstraintValidatorContext context) {
// 空值交给 @NotNull,一个校验器只负责一个规则。
if (value == null) {
return true;
}
return value.compareTo(minimum) >= 0
&& value.compareTo(maximum) <= 0;
}
}不要用 equals 比较金额。99.0 和 99.00 的 equals 结果可能不同,业务关心的是数值大小,所以使用 compareTo。
这里 null 返回 true 是 Bean Validation 的常见组合方式。如果校验器也把 null 判为非法,而字段又写了 @NotNull,同一字段会生成两条重复错误。
第 5 步:在 Controller 中启用不同组
创建接口使用 Create 组:
@PostMapping
public ApiResponse<OrderView> create(
@Validated(ValidationGroups.Create.class)
@RequestBody
CreateOrderRequest request
) {
// 只有创建组通过后才会进入业务服务。
return ApiResponse.success(orderService.create(
new CreateOrderCommand(request.customerId(), request.amount())
));
}更新接口使用 Update 组:
@PatchMapping("/{orderNo}")
public ApiResponse<OrderView> update(
@PathVariable String orderNo,
@Validated(ValidationGroups.Update.class)
@RequestBody
CreateOrderRequest request
) {
// 更新组允许 customerId 为空,由业务层保留原值。
return ApiResponse.success(orderService.update(
orderNo,
request.customerId(),
request.amount()
));
}注意,@Validated 与 @Valid 的用途不同。@Valid 适合直接验证整个对象,@Validated 可以指定分组。
第 6 步:准备中英文消息
在 demo-web/src/main/resources/ 中创建三个文件:
messages.properties
messages_en.properties
messages_zh_CN.properties默认资源:
# 无法匹配请求语言时使用的英文兜底。
order.customerId.required=Customer ID is required
order.customerId.size=Customer ID must not exceed 32 characters
order.amount.required=Order amount is required
order.amount.range=Order amount must be between 0.01 and 1000000.00
order.limit.range=Limit must be between 1 and 100简体中文资源:
# 中文客户端使用这些消息。
order.customerId.required=客户编号不能为空
order.customerId.size=客户编号不能超过 32 个字符
order.amount.required=订单金额不能为空
order.amount.range=订单金额必须在 0.01 到 1000000.00 之间
order.limit.range=返回条数必须在 1 到 100 之间公共配置:
spring:
messages:
# 指定基础资源名,Spring 会自动匹配语言后缀。
basename: messages
encoding: UTF-8
fallback-to-system-locale: falsefallback-to-system-locale: false 让未匹配语言时使用 messages.properties,而不是根据服务器操作系统语言随机变化。
第 7 步:验证中文、英文和边界值
启动:
mvn clean package -DskipTests
java -jar demo-web/target/demo-web-1.0.0-SNAPSHOT.jar中文错误:
curl -X POST "http://localhost:8080/api/v1/orders" \
-H "Accept-Language: zh-CN" \
-H "Content-Type: application/json" \
-d '{"customerId":"","amount":0}'英文错误:
curl -X POST "http://localhost:8080/api/v1/orders" \
-H "Accept-Language: en" \
-H "Content-Type: application/json" \
-d '{"customerId":"","amount":0}'查询参数边界:
curl -i "http://localhost:8080/api/v1/orders?customerId=C1001&limit=0" \
-H "Accept-Language: zh-CN"边界通过:
curl -i -X POST "http://localhost:8080/api/v1/orders" \
-H "Content-Type: application/json" \
-d '{"customerId":"C1001","amount":0.01}'最后一个请求应返回 201。测试金额上限时,1000000.00 应通过,1000000.01 应失败。
自动化测试:
mvn testOrderApiIntegrationTest.validationUsesGroupsAndChineseMessages 会同时验证分组、消息语言和字段明细。
常见问题排查
| 现象 | 常见原因 | 处理方式 |
|---|---|---|
更新接口仍要求 customerId | 使用了 @Valid,没有指定 Update 组 | 改成 @Validated(Update.class) |
| 自定义注解完全没执行 | 注解没写 @Constraint 或校验器类未关联 | 检查注解和 validatedBy |
消息显示为 {order.amount.range} | 资源 key 拼错或 basename 不匹配 | 检查属性文件和 spring.messages.basename |
| 中文环境总显示英文 | 文件名不是 messages_zh_CN.properties | 检查语言和国家后缀 |
null 产生两条重复错误 | 自定义校验器也拒绝了 null | 自定义规则只校验非空值 |
本章小结
分组解决“同一个对象在不同接口使用不同规则”,自定义注解解决“标准注解表达不了业务边界”,国际化解决“错误码稳定但文案可翻译”。
三者组合后的顺序是:
- Controller 选择校验组。
- Bean Validation 执行标准与自定义规则。
- 消息解释器按请求语言解析文案。
- 全局异常处理器转换成统一响应。
练习题
增加 @AllowedChannel 自定义校验:
- 允许
web、app、mini-program。 - 只在创建组执行。
- 中文消息为“订单渠道不受支持”。
- 英文消息为
Unsupported order channel。 - 为空时交给
@NotBlank,不要在自定义校验器重复处理。
上一节:全局异常处理与错误码体系
下一节:OpenAPI 文档与客户端代码生成
相关阅读:
13 个企业级实战项目 · 源码 + 教程 + 答疑
简历包装指导AI Agent 实战高并发·微服务Offer 导向持续更新3000+ 同行交流1V1 答疑3 天包退
覆盖高并发、微服务、分布式与 AI Agent:100万QPS短链、企业智能知识库、商城微服务、SaaS点餐、秒杀、刷题小程序、代码生成器、AI Agent 等。券后 ¥129(原价 ¥159),不满意 3 天包退。
