主题
界面
主题
《Spring MVC 请求映射与参数绑定》 把接口跑通了。本节解决两个"丑问题":
// 问题一:校验代码淹没业务逻辑
public ResponseEntity<?> create(CreateOrderRequest request) {
if (request.productId() == null || request.productId().isBlank()) {
return ResponseEntity.badRequest().body("商品编号不能为空"); // ← 一直写下去
}
if (request.quantity() <= 0) { ... }
if (request.address() == null || request.address().length() > 200) { ... }
// ... 业务逻辑还没开始
}
// 问题二:每个接口的错误响应格式都不一样
{"error": "..."} / {"msg": "..."} / {"code": 1} / 纯文本Spring 的答案:校验交给注解、异常交给统一处理器——Controller 里只剩下成功路径。
完成本节后,你应该能够:
@Valid 与 @Validated 的区别;@Valid 级联)处理"对象里包含对象";@RestControllerAdvice + @ExceptionHandler 实现统一异常处理;@RequestBody 与 @PathVariable 吗?(《Spring MVC 请求映射与参数绑定》)先看"没有校验"会发生什么:
@PostMapping
public OrderResponse create(@RequestBody CreateOrderRequest request) {
return orderService.create(request); // 直接透传
}前端传 {"productId":null,"quantity":-5,"address":""}——请求进到 Service:
| 位置 | 可能的后果 |
|---|---|
Service 里 productId.toUpperCase() | NPE → 500 |
| 数据库 | quantity = -5 写进去了 → 脏数据 |
| 业务逻辑 | 金额算出负数 → 对账出问题 |
核心问题:非法数据会一路穿透到底层,然后在某个意想不到的地方爆炸——而且往往在生产环境才暴露。
正确的做法是"在边界拦住它":
HTTP 请求 → 【参数校验】← 在这里拦住格式错误(400/422)
→ Controller
→ Service → 【业务校验】← 在这里拦住业务规则错误(409)
→ 数据库 → 【约束】← 最后一道防线([S3-002](../mysql/S3-002))三层防线,各管各的 ✓ 本节讲前两层。
做法:把校验规则写在字段上。
public record CreateOrderRequest(
@NotBlank(message = "商品编号不能为空")
String productId,
@Min(value = 1, message = "数量必须大于 0")
@Max(value = 100, message = "单次最多购买 100 件")
int quantity,
@NotBlank(message = "收货地址不能为空")
@Size(max = 200, message = "收货地址长度不能超过 200")
String address
) {
}Controller 里只加一个 @Valid:
@PostMapping
public ResponseEntity<OrderResponse> create(@Valid @RequestBody CreateOrderRequest request) { }校验失败时,Spring 抛 MethodArgumentNotValidException——方法体根本不会执行 ✓
| 注解 | 作用 | 注意 |
|---|---|---|
@NotNull | 不能为 null | 空串会通过 ✗ |
@NotBlank | 不能为 null、空串或纯空白 | 用于字符串(最常用) |
@NotEmpty | 不能为 null 且不能为空 | 用于集合/数组/字符串 |
@Size(min, max) | 长度 / 大小范围 | 字符串、集合、数组 |
@Min(v) / @Max(v) | 数值范围 | 含边界 |
@Positive / @Negative | 正数 / 负数 | 语义比 @Min 更直白 |
@DecimalMin / @DecimalMax | BigDecimal 范围 | 金额场景 |
@Email | 邮箱格式 | 宽松校验,不做真实性验证 |
@Pattern(regexp) | 正则 | 手机号、身份证 |
@Past / @Future | 过去 / 未来时间 | 生日、截止日期 |
@Null | 必须为 null | 少见(如"创建时 id 必须为空") |
三个最容易搞混的:
@NotNull | @NotEmpty | @NotBlank | |
|---|---|---|---|
| null | ❌ 拒绝 | ❌ 拒绝 | ❌ 拒绝 |
"" 空串 | ✅ 通过 | ❌ 拒绝 | ❌ 拒绝 |
" " 纯空白 | ✅ 通过 | ✅ 通过 | ❌ 拒绝 |
| 适用类型 | 任意 | 字符串、集合 | 只用于字符串 |
经验:字符串字段几乎总是该用 @NotBlank——因为 "" 和 " " 在业务上通常都算"没填"。
| 方式 | 触发点 | 抛出的异常 |
|---|---|---|
@Valid + @RequestBody | 方法参数是对象 | MethodArgumentNotValidException |
类上加 @Validated + 方法参数上的注解 | 方法参数是简单类型 | ConstraintViolationException |
手动调用 validator.validate(obj) | 代码里主动校验 | 返回 Set<ConstraintViolation> |
第二种用得多,值得展开:
@RestController
@Validated // ← 类上必须有这个,方法参数上的校验才生效
@RequestMapping("/api/orders")
public class OrderController {
@GetMapping("/{orderNo}")
public OrderResponse find(
@PathVariable @Pattern(regexp = "ORD-\\d+", message = "订单号格式不正确") String orderNo) { }
}为什么两种要分开:
@Valid | @Validated | |
|---|---|---|
| 用在 | 方法参数(对象) | 类上(开启方法级校验) |
| 支持分组校验 | ❌ | ✅ |
| 抛出 | MethodArgumentNotValidException | ConstraintViolationException |
实践建议:
| 场景 | 选择 |
|---|---|
@RequestBody 的对象 | @Valid(最常用) |
| 路径变量 / 查询参数的格式校验 | 类上加 @Validated + 参数上写注解 |
| 需要分组(新增/修改用不同规则) | @Validated(Group.class) |
问题:对象里包含另一个对象时,默认不会级联校验。
public record CreateUserRequest(
@NotBlank String username,
AddressRequest address // ← 不写 @Valid 的话,address 内部不校验
) { }
public record AddressRequest(
@NotBlank String city,
@NotBlank String street
) { }修复:在嵌套字段上加 @Valid:
public record CreateUserRequest(
@NotBlank String username,
@Valid AddressRequest address // ← 加了这个,才会校验 address 里的字段
) { }同理,集合里的元素也要加:
public record CreateOrderRequest(
@NotEmpty @Valid List<OrderItemRequest> items // ← 每个元素都校验
) { }这是一个非常容易漏的点——后果是"嵌套对象的非法数据一路穿透" ✓
没有它的时候,每个 Controller 都要写 try-catch:
@PostMapping
public ResponseEntity<?> create(@RequestBody CreateOrderRequest request) {
try {
return ResponseEntity.ok(orderService.create(request));
} catch (IllegalArgumentException exception) {
return ResponseEntity.badRequest().body(exception.getMessage()); // ← 每个方法都写
} catch (Exception exception) {
return ResponseEntity.status(500).body("系统错误"); // ← 复读
}
}有了它之后,Controller 只剩成功路径:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException exception) {
List<ErrorResponse.FieldErrorItem> details = exception.getBindingResult().getFieldErrors().stream()
.map(error -> new ErrorResponse.FieldErrorItem(error.getField(), error.getDefaultMessage()))
.toList();
return build(HttpStatus.UNPROCESSABLE_ENTITY, "VALIDATION_FAILED", "参数校验失败", details);
}
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusiness(BusinessException exception) {
return build(exception.getStatus(), exception.getCode(), exception.getMessage(), List.of());
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleUnexpected(Exception exception) {
log.error("未预期的异常", exception); // ← 详细记日志
return build(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_ERROR", "服务内部错误,请稍后重试", List.of());
}
}三个注解的分工:
| 注解 | 作用 |
|---|---|
@RestControllerAdvice | 声明"全局的控制器增强"(= @ControllerAdvice + @ResponseBody) |
@ExceptionHandler | 指定"处理哪一类异常" |
@ResponseStatus | 简单场景下直接标状态码(无需返回 ResponseEntity) |
匹配规则:Spring 会选"最具体的"处理器。
抛 ResourceNotFoundException
→ 有 @ExceptionHandler(ResourceNotFoundException.class)?用(但本例没有)
→ 有 @ExceptionHandler(BusinessException.class)?用 ✓(父类匹配)
→ 有 @ExceptionHandler(Exception.class)?兜底所以"父类 handler 能接住子类异常" ✓ 这也是本项目 BusinessException 设计的用意:一个 handler 统一处理所有业务异常,状态码由异常自己携带。
{
"code": "VALIDATION_FAILED",
"message": "参数校验失败",
"traceId": "4b3a3398-e05b-4ccd-9a39-1f938afaf8cc",
"details": [
{ "field": "quantity", "message": "数量必须大于 0" },
{ "field": "address", "message": "收货地址不能为空" }
]
}| 字段 | 给谁看 | 作用 |
|---|---|---|
code | 机器 | 客户端据此做分支、做多语言文案映射(是契约,不能随便改) |
message | 人 | 可直接展示,也可让客户端覆盖(是文案,可随时改) |
traceId | 排查 | 用户报错时给客服,能直接定位日志 |
details | 表单 | 逐字段错误,前端能精确标红 |
traceId 的实践中怎么来的:真实项目用它关联分布式链路追踪:
| 来源 | 说明 |
|---|---|
上游传入的 X-Trace-Id 请求头 | 微服务间透传 |
| MDC(日志上下文) | 日志与响应共用同一个 id |
本处用 UUID.randomUUID() | 最简形式——能关联"这一次请求的日志"就够了 |
关键:traceId 必须是"能被检索到的"——它要出现在日志里 ✓ 否则给了用户也查不到。
一张必须设计的对照表:
| 异常 | 状态码 | code | 谁抛 |
|---|---|---|---|
| 参数校验失败 | 422 | VALIDATION_FAILED | Spring(@Valid) |
| 参数类型错误 | 400 | TYPE_MISMATCH | Spring(类型转换) |
| 请求体格式错误(JSON 语法错) | 400 | MALFORMED_JSON | Spring(Jackson) |
| 资源不存在 | 404 | RESOURCE_NOT_FOUND | 业务代码 |
| 业务规则冲突(如商品下架) | 409 | PRODUCT_UNAVAILABLE | 业务代码 |
| 未认证 / Token 失效 | 401 | UNAUTHORIZED | 认证过滤器 |
| 无权限 | 403 | FORBIDDEN | 权限校验 |
| 限流 | 429 | TOO_MANY_REQUESTS | 限流器 |
| 其他所有异常 | 500 | INTERNAL_ERROR | 兜底 |
三个设计要点:
① 400 与 422 的区分
| 码 | 含义 | 例子 |
|---|---|---|
| 400 | 请求本身有问题 | JSON 语法错、类型转不过去 |
| 422 | 请求格式对,但值不合法 | quantity = 0 |
② 业务异常要携带状态码
public class BusinessException extends RuntimeException {
private final String code;
private final HttpStatus status; // ← 让异常自己决定状态码
public BusinessException(String code, String message, HttpStatus status) {
super(message);
this.code = code;
this.status = status;
}
}
// 使用
throw new BusinessException("PRODUCT_UNAVAILABLE", "商品已下架", HttpStatus.CONFLICT);为什么不直接在 Controller 里返回 ResponseEntity:那样业务规则就会渗进 Web 层(《Spring MVC 请求映射与参数绑定》 的 9.6 节),Service 层也无法复用(同一个规则在定时任务里也要用)✓
③ 兜底 handler 必须记详细日志
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleUnexpected(Exception exception) {
log.error("未预期的异常", exception); // ← 完整堆栈进日志
return build(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_ERROR", "服务内部错误,请稍后重试", List.of());
// ↑ 对外只给通用提示
}"日志详细、响应笼统"是一个必须坚持的原则(见 5.8)。
反例:
{
"error": "java.sql.SQLSyntaxErrorException: Table 'shop.orders' doesn't exist",
"stackTrace": "...省略 50 行..."
}三个危害:
| 危害 | 说明 |
|---|---|
| 泄露内部结构 | 表名、类名、框架版本——给攻击者提供了地图 |
| 用户体验差 | 用户看不懂,也无法处理 |
| 暴露实现细节 | 以后改表名/改框架都成了"破坏 API" |
正确做法:
{ "code": "INTERNAL_ERROR", "message": "服务内部错误,请稍后重试", "traceId": "..." }用户看到通用提示并可以报出 traceId;工程师拿 traceId 去日志里查完整堆栈 ✓ 双方都得到了需要的东西。
这是本节最需要记住的一条边界:
| 类型 | 判断什么 | 放在哪 | 失败时 |
|---|---|---|---|
| 参数校验 | 格式、范围、必填 | DTO 上的注解 | 422 |
| 业务校验 | 业务规则(唯一性、状态流转、余额充足) | Service 里 | 409 / 422 |
举例:
| 校验 | 属于 | 理由 |
|---|---|---|
quantity > 0 且 ≤ 100 | 参数校验 | 不需要查任何数据就能判断 |
| 商品是否存在 | 业务校验 | 要查数据库 |
| 用户名是否已被占用 | 业务校验 | 要查数据库 |
订单状态是否是 CREATED | 业务校验 | 要读当前状态 |
| 地址长度 ≤ 200 | 参数校验 | 纯格式判断 |
为什么这条边界重要:
一句话:
只在"当前请求的数据"上就能判断的,是参数校验; 需要"查别的数据"才能判断的,是业务校验。
public record CreateOrderRequest(
@NotBlank(message = "商品编号不能为空") String productId,
@Min(value = 1, message = "数量必须大于 0") int quantity
) { }
@PostMapping
public ResponseEntity<OrderResponse> create(@Valid @RequestBody CreateOrderRequest request) { }见 5.5 节与第 7 节。
@PostMapping
public ResponseEntity<?> create(@RequestBody CreateOrderRequest request) {
if (request.quantity() <= 0) {
return ResponseEntity.badRequest().body(Map.of("msg", "数量必须大于 0")); // ✗
}
// ... 每个接口都要写一遍,且响应格式各不相同
}三个问题:重复(每个接口都写)、格式不一(msg / error / message 混用)、规则分散(改一次规则要改所有入口)。
修复:注解 + 全局处理器 ✓
文件清单(labs/spring-boot-web/):
src/main/java/cn/kb/learn/web/
├── dto/CreateOrderRequest.java ← 校验注解
├── exception/BusinessException.java ← 携带 code + status
├── exception/ResourceNotFoundException.java
├── exception/ErrorResponse.java ← 统一错误响应
└── exception/GlobalExceptionHandler.java ← 全局处理DTO 里的校验:
public record CreateOrderRequest(
@NotBlank(message = "商品编号不能为空")
String productId,
@Min(value = 1, message = "数量必须大于 0")
@Max(value = 100, message = "单次最多购买 100 件")
int quantity,
@NotBlank(message = "收货地址不能为空")
@Size(max = 200, message = "收货地址长度不能超过 200")
String address
) {
}全局异常处理器:
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
/** 参数校验失败:@Valid 校验不通过时由 Spring 抛出 */
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException exception) {
List<ErrorResponse.FieldErrorItem> details = exception.getBindingResult().getFieldErrors().stream()
.map(error -> new ErrorResponse.FieldErrorItem(error.getField(), error.getDefaultMessage()))
.toList();
return build(HttpStatus.UNPROCESSABLE_ENTITY, "VALIDATION_FAILED", "参数校验失败", details);
}
/** 参数类型不匹配:如路径上要 int 却给了字符串 */
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<ErrorResponse> handleTypeMismatch(MethodArgumentTypeMismatchException exception) {
return build(HttpStatus.BAD_REQUEST, "TYPE_MISMATCH", "参数类型不正确:" + exception.getName(), List.of());
}
/** 业务异常:错误码与状态码由异常自己携带 */
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusiness(BusinessException exception) {
return build(exception.getStatus(), exception.getCode(), exception.getMessage(), List.of());
}
/** 兜底:未预期异常记详细日志、对外只给通用提示 */
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleUnexpected(Exception exception) {
log.error("未预期的异常", exception);
return build(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_ERROR", "服务内部错误,请稍后重试", List.of());
}
private ResponseEntity<ErrorResponse> build(HttpStatus status, String code, String message,
List<ErrorResponse.FieldErrorItem> details) {
return ResponseEntity.status(status)
.body(new ErrorResponse(code, message, UUID.randomUUID().toString(), details));
}
}运行命令:
cd labs
./mvnw -pl spring-boot-web package -DskipTests
cd spring-boot-web
./demo.ps1预期输出(四种错误场景的真实响应):
===== 3. 查询不存在的订单(期望 404)=====
{"code":"RESOURCE_NOT_FOUND","message":"订单不存在:ORD-999","traceId":"e29b9399-690a-4cc0-a1ff-fe71f181c28c","details":[]}
[HTTP 404]
===== 4. 校验失败:quantity=0、address 为空(期望 422)=====
{"code":"VALIDATION_FAILED","message":"参数校验失败","traceId":"8ccd253a-9bb2-4103-b8c0-e63c9ef9ee44",
"details":[{"field":"quantity","message":"数量必须大于 0"},{"field":"address","message":"收货地址不能为空"}]}
[HTTP 422]
===== 5. 业务冲突:商品已下架(期望 409)=====
{"code":"PRODUCT_UNAVAILABLE","message":"商品已下架","traceId":"b87e56fb-2660-4059-ace7-bd128b242bdc","details":[]}
[HTTP 409]
===== 7. 参数类型不匹配:page=abc(期望 400)=====
{"code":"TYPE_MISMATCH","message":"参数类型不正确:page","traceId":"186e6f0a-0a60-480e-818f-799ac6a9a297","details":[]}
[HTTP 400]四处值得对照:
| 输出 | 含义 |
|---|---|
| 四种错误,同一个响应结构 | 客户端只需写一套错误处理逻辑 |
details 里有两个字段错误 | 一次性返回所有校验失败——用户不用"改一个、提交、再发现下一个" |
code 各不相同(RESOURCE_NOT_FOUND / VALIDATION_FAILED / PRODUCT_UNAVAILABLE / TYPE_MISMATCH) | 机器可读的契约 |
每个响应都有 traceId | 用户报错时能直接定位日志 |
注意第 4 条:校验失败时 details 里有两个错误——因为 quantity = 0 与 address = "" 同时不合法,Bean Validation 会收集全部错误再一次性抛出 ✓ 这与"抛异常就中断"的写法不同,对用户更友好。
handleValidation 的第一行打断点:展开 exception.getBindingResult().getFieldErrors()——能看到每个字段的错误对象(字段名、消息、拒绝的值);CreateOrderRequest 的构造器上打断点:观察校验失败时它根本没被调用——校验发生在反序列化之后、方法调用之前;ResourceNotFoundException 抛出处与 handleBusiness 入口各打一个断点,确认是父类的 handler 接住了子类异常;NullPointerException,观察日志里有完整堆栈、而响应体里只有通用提示 ✓ 这是"日志详细、响应笼统"的现场;@Valid),传一个内部字段非法的请求——观察它没有被校验;加上 @Valid 再试 ✓@Valid @PostMapping
public ResponseEntity<OrderResponse> create(@RequestBody CreateOrderRequest request) { }
// ↑ 少了 @Valid,注解形同虚设症状:非法数据一路进到 Service,然后在某处 NPE 或写进数据库。
排查:"注解写了但没生效"时,第一件事就是检查 @Valid 加没加。
@Valid public record CreateUserRequest(
@NotBlank String username,
AddressRequest address // ✗ 没有 @Valid,内部字段不校验
) { }症状:address.city 传 null 也能通过校验。
修复:@Valid AddressRequest address ✓
这是最容易漏的一点——因为外层校验通过了,看起来"校验生效了"。
@Validated 加在了错误的位置 public class OrderController {
@Validated // ✗ 加在方法上无效
@GetMapping("/{orderNo}")
public OrderResponse find(@PathVariable @Pattern(regexp = "ORD-\\d+") String orderNo) { }
}修复:@Validated 加在类上(它开启"方法级校验"这个能力)。
public record CreateOrderRequest(
@NotBlank String productId,
@Min(1) int quantity
) {
}
// 想校验"商品是否存在" → 没有任何注解能做这个(要查数据库)修复:业务校验放 Service,抛 BusinessException ✓(见 5.9 节的分工表)
@ExceptionHandler 顺序写反 @ExceptionHandler(Exception.class) // 兜底
public ResponseEntity<ErrorResponse> handleAll(Exception e) { }
@ExceptionHandler(BusinessException.class) // 业务异常
public ResponseEntity<ErrorResponse> handleBusiness(BusinessException e) { }问题:方法顺序无关——Spring 按异常类型的具体程度匹配 ✓ 所以上面这样写也是对的。
但真正的问题是:如果你忘记写"具体异常"的 handler,所有异常都会落到兜底 handler 上 → 全是 500 ✗
排查技巧:看响应体里的 code——如果是 INTERNAL_ERROR 但你期望的是业务码,说明没有对应的 handler(或异常类型不匹配)。
见 5.8 节。安全 + 体验双重问题。
自查方法:搜代码里有没有把 exception.getMessage() 或 exception.toString() 直接写进响应的地方 ✓
注意:BusinessException 的消息可以返回(它是精心设计的业务提示)✓ 而**"未预期异常"的消息不能返回**(它可能是 "NullPointerException at ...")✗
ConstraintViolationException 没有被处理 症状:用了 @Validated + 参数注解后,校验失败返回 500 而不是 422。
原因:@Validated 的方法级校验抛的是 ConstraintViolationException(不是 MethodArgumentNotValidException)✗ 没写对应的 handler 就落到兜底了。
修复:补一个 handler:
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ErrorResponse> handleConstraintViolation(ConstraintViolationException exception) {
List<ErrorResponse.FieldErrorItem> details = exception.getConstraintViolations().stream()
.map(violation -> new ErrorResponse.FieldErrorItem(
violation.getPropertyPath().toString(), violation.getMessage()))
.toList();
return build(HttpStatus.UNPROCESSABLE_ENTITY, "VALIDATION_FAILED", "参数校验失败", details);
}目标:把"校验 + 异常"的完整链路走一遍。
步骤:
details 里是不是三条;CreateOrderRequest 加 @Pattern(regexp = "P-\\d+", message = "商品编号格式应为 P-数字"),测试合法与非法的输入;AddressRequest(city, street),把 CreateOrderRequest.address 改成这个对象(加 @Valid),然后故意传 {"city":null} 观察是否被拦住;OrderService.create 里临时加 throw new NullPointerException("测试"),观察: traceId 对应的异常);ConstraintViolationException 加处理器,并用 @Validated + @Pattern 校验路径变量,验证 422。验收方式:能说出第 4 步加 @Valid 前后的差异、第 5 步"响应与日志的差异"。
扩展任务:实现一个自定义校验注解 @Phone(校验中国大陆手机号),并用它替换 @Pattern。说明自定义注解需要哪三个东西(注解定义、校验逻辑 ConstraintValidator、错误消息)。
基础题
@NotNull 会拒绝空字符串。(对 / 错)@RequestBody 的校验用 ______ 触发,失败抛 ______;类上加 ______ 可开启方法参数的校验,失败抛 ______。代码阅读题
public record UserRegisterRequest(
@NotBlank String username,
@NotBlank @Size(min = 8, max = 32) String password,
@NotBlank @Email String email,
ProfileRequest profile
) {
public record ProfileRequest(
@NotBlank String nickname,
@Min(1) @Max(150) int age
) { }
}{"username":"alice","password":"12345678","email":"a@b.c","profile":{"nickname":null,"age":0}},会被校验拦住吗?会被哪几条规则拦住?编程题
POST /api/users/register,字段:username(4-20 位)、password(8-32 位且必须含数字)、email、age(1-150)、profile(嵌套:nickname、city);@Pattern 实现;BusinessException,状态码 409);GET /api/users/{id} 的 id 必须是数字且 > 0);@Pattern 之类的注解实现?综合任务
模块_原因:ORDER_NOT_FOUND、USER_DUPLICATED);GlobalExceptionHandler;提示 1(方向):第 4 题的关键是 profile 字段上没有 @Valid。
提示 2(关键点):第 6 题的"密码必须含数字"用 @Pattern(regexp = ".*\\d.*")——注意正则里的反斜杠在 Java 字符串里要写成 \\d。
提示 3(接近答案):第 7 题的"错误码用枚举"的好处:编译期检查、IDE 能跳转、能统一维护"码 → 状态码 → 消息"的映射关系。
@NotNull 只拒绝 null——空串 "" 和纯空白 " " 都会通过。对比:
| 值 | @NotNull | @NotEmpty | @NotBlank |
|---|---|---|---|
null | ❌ | ❌ | ❌ |
"" | ✅ 通过 | ❌ | ❌ |
" " | ✅ 通过 | ✅ 通过 | ❌ |
结论:字符串字段用 @NotBlank。
@Valid;MethodArgumentNotValidException;@Validated;ConstraintViolationException。记忆方法:@Valid 管"对象参数"、@Validated 管"方法级校验" ✓ 两者抛的异常不同,所以需要两个 handler。
更根本的理由:如果硬要在注解里做业务校验(如写一个 @UsernameUnique 的自定义校验器),它会:
| 问题 | 说明 |
|---|---|
| 耦合数据库 | 校验器里要注入 Repository——校验逻辑与数据访问纠缠 |
| 事务边界混乱 | 校验发生在 Controller 层,此时事务还没开始 |
| 场景不匹配 | "更新用户"时不需要校验用户名唯一(用户名不可改),但校验器无法区分 |
| 性能 | 每次请求都查一次库,且无法利用缓存 |
正确分工(5.9 节):格式范围用注解、业务规则用 Service + 异常 ✓
第 4 题:不会被"完整"校验。
会拦截到的:username、password、email 三条(它们在外层,注解会生效)。
不会拦截到的:profile.nickname 与 profile.age ✗ —— 因为 ProfileRequest profile 这个字段上没有 @Valid。
结果:请求通过校验,进入 Service,profile 里的非法数据(nickname = null、age = 0)一路穿透 ✓
第 5 题:在 profile 字段上加 @Valid。
public record UserRegisterRequest(
@NotBlank String username,
@NotBlank @Size(min = 8, max = 32) String password,
@NotBlank @Email String email,
@Valid ProfileRequest profile // ← 加这一个注解
) { }修复后会拦截到的:
| 字段 | 规则 | 结果 |
|---|---|---|
profile.nickname | @NotBlank | ❌ 被拦(是 null) |
profile.age | @Min(1) | ❌ 被拦(是 0) |
注意 details 里的字段名:会是 profile.nickname、profile.age(带路径)✓
这对前端很有价值:它能直接定位到表单里具体哪个输入框 ✓ 这也是"嵌套校验必须加 @Valid"的实际收益。
package cn.kb.learn.web.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
public record UserRegisterRequest(
@NotBlank(message = "用户名不能为空")
@Size(min = 4, max = 20, message = "用户名长度必须在 4-20 之间")
String username,
@NotBlank(message = "密码不能为空")
@Size(min = 8, max = 32, message = "密码长度必须在 8-32 之间")
@Pattern(regexp = ".*\\d.*", message = "密码必须包含至少一个数字")
String password,
@NotBlank(message = "邮箱不能为空")
@Email(message = "邮箱格式不正确")
String email,
@Min(value = 1, message = "年龄必须大于 0")
@Max(value = 150, message = "年龄不能超过 150")
int age,
@Valid // ← 嵌套校验的关键
@NotNull(message = "用户资料不能为空")
ProfileRequest profile
) {
public record ProfileRequest(
@NotBlank(message = "昵称不能为空")
@Size(max = 32, message = "昵称不能超过 32 个字符")
String nickname,
@NotBlank(message = "城市不能为空")
String city
) {
}
}Service 里的业务校验:
@Service
public class UserService {
private final Set<String> existingUsernames = ConcurrentHashMap.newKeySet();
public UserResponse register(UserRegisterRequest request) {
// 业务校验:需要"查数据"才能判断的,放在这里
if (!existingUsernames.add(request.username())) {
throw new BusinessException("USER_DUPLICATED", "用户名已被占用", HttpStatus.CONFLICT);
}
// ... 保存用户
}
}Controller 的方法级校验:
@RestController
@Validated // ← 类上加,方法参数上的注解才生效
@RequestMapping("/api/users")
public class UserController {
@GetMapping("/{id}")
public UserResponse find(
@PathVariable @Min(value = 1, message = "用户 id 必须大于 0") Long id) {
return userService.find(id);
}
}五种场景的实测结果:
| 场景 | 请求 | 期望状态码 | 期望 code |
|---|---|---|---|
| 全部合法 | 完整正确的请求 | 201 | — |
| 格式错误 | password = "1234567"(7 位) | 422 | VALIDATION_FAILED + details |
| 嵌套字段错误 | profile.nickname = null | 422 | details 里字段名带 profile. |
| 用户名重复 | 同一个 username 注册两次 | 409 | USER_DUPLICATED |
| id 非法 | GET /api/users/0 | 422 | VALIDATION_FAILED(来自 @Validated) |
为什么"用户名已存在"不用注解实现:
| 理由 | 说明 |
|---|---|
| 要查数据库 | 注解的校验器拿不到 Repository(除非注入,但那会让校验器依赖数据层) |
| 事务语义 | 校验应在业务事务内(且唯一性最终靠数据库唯一约束保证,《锁机制:行锁、表锁与乐观悲观锁》 讲过并发下的"检查-插入"竞态) |
| 场景差异 | "注册"要查重,"更新资料"不需要(用户名不可改)——注解无法区分 |
| 性能与缓存 | 业务校验可以做缓存、可以批量预检;注解做不到 |
最后一条最重要的提醒:业务校验只能"优化体验",不能"保证正确"。
并发下两个请求可能同时通过"用户名已存在"的检查——唯一性最终靠数据库的
UNIQUE约束保证(《表设计与约束》)。应用层校验负责"给用户友好提示",数据库约束负责"数据永远正确" ✓ 两者缺一不可。
错误码体系的四个设计决定:
① 命名规范
格式:<资源>_<原因>
ORDER_NOT_FOUND 资源 + 原因
ORDER_STATUS_CONFLICT
USER_DUPLICATED
PAYMENT_AMOUNT_MISMATCH
通用码(与业务无关):
VALIDATION_FAILED
TYPE_MISMATCH
UNAUTHORIZED
FORBIDDEN
INTERNAL_ERROR| 规则 | 说明 |
|---|---|
| 全大写下划线 | 常量风格的稳定性 |
资源_原因 结构 | 看到码就知道"哪里出了什么问题" |
不用数字码(如 10001) | 字符串语义自明,日志里更好搜(对比:数字码要靠文档查) |
| 不用 HTTP 状态码做业务码 | 一个状态码可能对应多个业务原因(都是 409:商品下架 / 库存不足 / 状态冲突) |
② 错误码用枚举实现
package cn.kb.learn.web.exception;
import org.springframework.http.HttpStatus;
public enum ErrorCode {
// 通用
VALIDATION_FAILED("参数校验失败", HttpStatus.UNPROCESSABLE_ENTITY),
TYPE_MISMATCH("参数类型不正确", HttpStatus.BAD_REQUEST),
INTERNAL_ERROR("服务内部错误,请稍后重试", HttpStatus.INTERNAL_SERVER_ERROR),
// 订单
ORDER_NOT_FOUND("订单不存在", HttpStatus.NOT_FOUND),
ORDER_STATUS_CONFLICT("订单状态不允许该操作", HttpStatus.CONFLICT),
// 用户
USER_DUPLICATED("用户名已被占用", HttpStatus.CONFLICT),
PRODUCT_UNAVAILABLE("商品已下架", HttpStatus.CONFLICT);
private final String defaultMessage;
private final HttpStatus status;
ErrorCode(String defaultMessage, HttpStatus status) {
this.defaultMessage = defaultMessage;
this.status = status;
}
public String defaultMessage() {
return defaultMessage;
}
public HttpStatus status() {
return status;
}
}异常改造:
public class BusinessException extends RuntimeException {
private final ErrorCode errorCode;
public BusinessException(ErrorCode errorCode) {
this(errorCode, errorCode.defaultMessage());
}
public BusinessException(ErrorCode errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
public ErrorCode getErrorCode() {
return errorCode;
}
}使用的样子:
throw new BusinessException(ErrorCode.ORDER_NOT_FOUND, "订单不存在:" + orderNo);对比"散落字符串常量"的好处:
| 好处 | 说明 |
|---|---|
| 编译期检查 | 拼错码名直接编译不过(字符串不会) |
| IDE 支持 | 能跳转、能查找引用("这个码在哪些地方被抛出?") |
| 映射集中 | "码 → 状态码 → 默认消息"在一处维护 |
| 可枚举 | 能自动生成文档(遍历所有枚举值) |
| 不会重复定义 | 同一个语义只可能有一个常量 |
③ 错误码与状态码的映射:多对一
409 CONFLICT ← ORDER_STATUS_CONFLICT
← USER_DUPLICATED
← PRODUCT_UNAVAILABLE为什么不是一对一:HTTP 状态码是"类别的粗粒度描述",错误码是"具体原因"——一个状态码对应多个业务原因是完全正常的 ✓
反过来的约束:一个错误码只能对应一个状态码 ✓(否则客户端无法根据码推断状态)
④ 错误码要公开吗
要公开,但要用契约的方式:
| 方式 | 说明 |
|---|---|
| OpenAPI 文档 | 在接口文档里列出"这个接口可能返回哪些错误码" |
| 错误码清单 | 发布一份"所有错误码 + 含义 + 状态码"的表格,前端据此写文案映射 |
| 契约测试 | 用测试保证"文档里写的码 = 代码里实际返回的码"(防止文档漂移) |
如何保证前后端理解一致:
| 手段 | 效果 |
|---|---|
| 错误码是契约,消息是文案 | 前端只用 code 做逻辑判断,message 仅作兜底展示 |
| 前端维护文案映射 | case 'ORDER_NOT_FOUND': return '该订单已被删除' |
| 契约测试 | 后端改错误码时,测试会红 |
| 变更流程 | 删除/重命名错误码必须走"废弃周期"(先新增、后下线) |
一条容易忽略的实践:code 一旦发布就不能随便改——它是接口契约的一部分(与 URL、字段名同级)。
想改怎么办:新增一个码,逐步迁移,老码标记为 deprecated——这与"改 URL"的处理方式一样(S3-REST 的版本化)。
改造后的 GlobalExceptionHandler:
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException exception) {
List<ErrorResponse.FieldErrorItem> details = exception.getBindingResult().getFieldErrors().stream()
.map(error -> new ErrorResponse.FieldErrorItem(error.getField(), error.getDefaultMessage()))
.toList();
return build(ErrorCode.VALIDATION_FAILED, details);
}
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusiness(BusinessException exception) {
return build(exception.getErrorCode(), exception.getMessage(), List.of());
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleUnexpected(Exception exception) {
log.error("未预期的异常", exception);
return build(ErrorCode.INTERNAL_ERROR, ErrorCode.INTERNAL_ERROR.defaultMessage(), List.of());
}
private ResponseEntity<ErrorResponse> build(ErrorCode code, List<ErrorResponse.FieldErrorItem> details) {
return build(code, code.defaultMessage(), details);
}
private ResponseEntity<ErrorResponse> build(ErrorCode code, String message,
List<ErrorResponse.FieldErrorItem> details) {
return ResponseEntity.status(code.status())
.body(new ErrorResponse(code.name(), message, UUID.randomUUID().toString(), details));
}
}注意 code.name():枚举的 name() 正好就是错误码字符串(ORDER_NOT_FOUND)✓ 枚举名即错误码,不需要额外维护一个字符串字段。
@NotBlank / @NotEmpty / @NotNull 的区别;@Valid 与 @Validated 的区别及各自抛出的异常;@Valid(这是最容易漏的一点);@RestControllerAdvice + @ExceptionHandler 实现统一异常处理与兜底;code / traceId / details 的错误响应;Spring 与 Spring Boot 的核心部分到此完成。接下来可以:
暂无