3. [深度交互] 高级请求处理与数据绑定
摘要: 在第二章的实战中,我们已经搭建了项目骨架并实现了核心的 CRUD 功能。这让我们对 Spring MVC 的基础工作流程有了扎实的体感。从本章开始,我们将深入框架的“毛细血管”,探索那些能让我们的代码更灵活、更健壮、更专业的高级功能。
3.1. 自定义类型转换器:实现枚举参数绑定
3.1.1. 需求分析:实现按状态筛选用户
在 2.x
版本中,我们的用户查询接口只能进行简单的分页。现在,产品经理提出了新需求:在查询用户列表时,能够根据用户状态(正常/禁用)进行筛选。
从 API 设计的角度,一个理想的请求 URL 应该是这样的:GET /users?status=1
,其中 1
代表“正常”。
在后端,为了代码的可读性和健壮性,我们不希望在代码里到处使用 1
、2
这样的“难懂数字”,而是倾向于使用更具语义的枚举 (Enum) 来代表用户状态。这就带来了一个问题:
Spring MVC 默认不知道如何将前端传来的字符串 "1"
转换为我们后端定义的 UserStatusEnum
枚举。 本节,我们就来优雅地解决这个问题。
3.1.2. 改造实践:在 DTO 与 Service 中使用枚举
1. 创建状态枚举
首先,我们创建一个代表用户状态的枚举类。
文件路径: src/main/java/com/example/springbootdemo/enums/UserStatusEnum.java
(新增文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| package com.example.springbootdemo.enums;
import lombok.AllArgsConstructor; import lombok.Getter;
@Getter @AllArgsConstructor public enum UserStatusEnum { NORMAL(1, "正常"), DISABLED(2, "已禁用");
private final int code; private final String description;
public static UserStatusEnum fromCode(int code) { for (UserStatusEnum status : values()) { if (status.getCode() == code) { return status; } } return null; } }
|
2. 更新查询 DTO
接下来,我们在分页查询 DTO 中,添加 status
字段,并将其类型定义为我们刚刚创建的枚举。
文件路径: src/main/java/com/example/springbootdemo/dto/User/UserPageQuery.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| package com.example.springbootdemo.dto.User;
import com.example.springbootdemo.enums.UserStatusEnum; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data;
@Data @Schema(description = "用户分页查询参数") public class UserPageQuery {
@Schema(description = "页码,从1开始", example = "1") private int pageNo = 1;
@Schema(description = "每页条数", example = "10") private int pageSize = 10; @Schema(description = "用户状态: 1-正常, 2-禁用", example = "1") private UserStatusEnum status; }
|
3. 更新 Service 层
现在,我们修改 Service 层的 findAllUsers
方法,让它能够根据传入的 status
参数,动态地构建查询条件。
文件路径: src/main/java/com/example/springbootdemo/service/impl/UserServiceImpl.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| package com.example.springbootdemo.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.example.springbootdemo.entity.User;
@Service @RequiredArgsConstructor public class UserServiceImpl implements UserService {
private final UserMapper userMapper;
@Override public List<UserVO> findAllUsers(UserPageQuery query) { Page<User> page = new Page<>(query.getPageNo(), query.getPageSize());
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
UserStatusEnum status = query.getStatus(); if (ObjectUtil.isNotEmpty(status)) { queryWrapper.eq(User::getStatus, status.getCode()); }
Page<User> pageResult = userMapper.selectPage(page, queryWrapper);
return pageResult.getRecords().stream() .map(this::convertToVO) .collect(Collectors.toList()); } }
|
3.1.3. 核心技术:实现并注册自定义 Converter
完成了业务逻辑的改造,现在我们来解决最核心的问题:搭建起前端传入的字符串 “1”
和后端 UserStatusEnum.NORMAL
之间的桥梁。
我们需要实现 Spring 提供的 Converter<S, T>
接口,其中 S
是源类型(String
),T
是目标类型(UserStatusEnum
)。
文件路径: src/main/java/com/example/springbootdemo/converter/StringToUserStatusEnumConverter.java
(新增文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| package com.example.springbootdemo.converter;
import com.example.springbootdemo.enums.UserStatusEnum; import org.springframework.stereotype.Component; import org.springframework.core.convert.converter.Converter;
@Component public class StringToUserStatusEnumConverter implements Converter<String, UserStatusEnum> { @Override public UserStatusEnum convert(String source) { if (source == null || source.isEmpty()) { return null; } int code = Integer.parseInt(source); return UserStatusEnum.fromCode(code); } }
|
自动注册的魔力:因为我们将这个转换器声明为了一个 @Component
Bean,Spring Boot 的自动配置机制会扫描到它,并自动将其添加到全局的转换服务中。这意味着我们无需任何额外配置,这个转换规则就会对所有 Controller 生效。
最妙的是,我们的 UserController
中的 getAllUsers
方法无需任何改动。Spring MVC 在进行参数绑定时,会自动发现并使用我们自定义的 StringToUserStatusEnumConverter
,将 status
请求参数(String 类型)转换为 UserPageQuery
对象中的 status
字段(UserStatusEnum
类型)。
示例流程如下图所示:

一个 HTTP 请求所承载的信息,远不止 URL 查询参数和请求体。请求头(Headers)和 Cookies 也是传递上下文信息的重要载体。本节,我们将通过一系列真实的业务场景,来学习如何通过注解,轻松地获取这些位置的数据。
@RequestHeader
注解用于将请求头(Request Header)中的字段值,绑定到控制器方法的参数上。
场景一:API 版本控制
在 API 开发中,我们经常通过请求头来传递版本号,以便后端可以针对不同版本的客户端返回不同的数据结构或执行不同的逻辑。
代码示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| package com.example.springbootdemo.controller;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RestController;
@RestController public class VersionController {
@GetMapping("/version") public String getApiVersion( @RequestHeader(value = "X-API-Version", defaultValue = "1.0") String apiVersion) { return "当前请求的 API 版本号是: " + apiVersion; } }
|
解释:
getApiVersion
方法通过 @RequestHeader("X-API-VERSION")
注解获取请求头中的版本信息,并提供了一个默认值 "1.0"
。
场景二:链路追踪
在微服务架构中,为了追踪一个请求在多个服务之间的调用链,通常会在初始请求时生成一个唯一的追踪ID(Trace ID),并通过请求头(如 X-Trace-Id
)在后续服务间传递。
代码示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| package com.example.springbootdemo.controller;
import cn.hutool.core.util.StrUtil; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RestController;
@RestController @Slf4j public class TraceController { @GetMapping("/trace") public String getTraceInfo( @RequestHeader(value = "X-Trace-Id", required = false) String traceId) { if (StrUtil.isBlank(traceId)) { traceId = cn.hutool.core.util.IdUtil.fastSimpleUUID(); }
log.info("处理业务逻辑, Trace ID: {}", traceId); return "请求已处理, Trace ID: " + traceId; } }
|
解释:
getTraceInfo
方法获取一个可选的 X-Trace-Id
请求头。我们可以在日志中记录它,这对于问题排查至关重要。
3.2.2. @CookieValue:获取 Cookie 信息
@CookieValue
注解是 Spring 框架中用于获取 HTTP 请求中 Cookie 值的便捷工具。
场景一:用户认证
在传统的会话管理中,用户的会话ID(Session ID)通常存储在 Cookie 中。通过 @CookieValue
注解,可以轻松获取用户的会话信息。
代码示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| package com.example.springbootdemo.controller;
import org.springframework.web.bind.annotation.CookieValue; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController;
@RestController public class AuthController {
@GetMapping("/auth/info") public String getUserInfo(@CookieValue("session-id") String sessionId) { String userInfo = getUserInfoFromSession(sessionId); return "获取到用户信息: " + userInfo; }
private String getUserInfoFromSession(String sessionId) { return "User_" + sessionId.substring(0, 6); } }
|
解释:
getUserInfo
方法通过 @CookieValue("session-id")
注解获取用户的会话 ID,并根据会话 ID 获取用户信息。
场景二:语言偏好设置
在多语言应用中,通常会将用户的语言偏好(如 en-US
, zh-CN
)存储在 Cookie 中。
代码示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| package com.example.springbootdemo.controller;
import org.springframework.web.bind.annotation.CookieValue; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController;
@RestController public class LanguageController {
@GetMapping("/language") public String getLanguagePreference( @CookieValue(value = "language", defaultValue = "zh-CN") String language) { return "您当前的语言偏好是: " + language; } }
|
解释:
getLanguagePreference
方法通过 @CookieValue("language")
注解获取用户的语言偏好,并优雅地使用了默认值 "zh-CN"
。
3.2.3. @PathVariable: 路径变量回顾
最后,我们再次回顾一个已经熟练使用的注解——@PathVariable
,以形成完整的知识体系。它专门用于从 URL 路径中提取动态片段。
回顾代码
文件路径: src/main/java/com/example/springbootdemo/controller/UserController.java
(回顾)
1 2 3 4 5 6 7 8 9 10
| @Operation(summary = "根据ID查询单个用户") @GetMapping("/{id}") public ResponseEntity<Result<UserVO>> getUserById( @Parameter(description = "用户ID", required = true, example = "1") @PathVariable Long id // @PathVariable 从路径 /users/{id} 中提取 id ) { }
|
总结:参数绑定的位置
至此,我们已经掌握了从 HTTP 请求不同位置获取数据的核心注解:
@PathVariable
: 从 URL 路径 (/users/{id}
) 中获取。@RequestParam
: 从 URL 查询参数 (?name=value
) 中获取。@RequestHeader
: 从 请求头 (Headers
) 中获取。@CookieValue
: 从 Cookie 中获取。@RequestBody
: 从 请求体 (Request Body
) 中获取。
3.3. 解构请求体:@RequestBody 与 Jackson 定制
在第二章,我们已经成功地使用 @RequestBody
将前端传来的 JSON 数据自动绑定到了 UserSaveDTO
上。这个过程之所以能自动完成,是因为 Spring Boot 默认集成的 Jackson
库在背后默默地承担了“反序列化”(JSON -> Java 对象)的工作。
然而,在真实的业务场景中,我们经常会遇到前端约定的 JSON 格式与后端 Java 对象的属性不完全一致的情况。例如,字段命名风格不同(下划线 vs. 驼峰)、日期格式需要特殊处理、某些字段需要被忽略等。本节,我们就将深入学习如何通过 Jackson 提供的注解,来精确地定制 JSON 与 Java 对象之间的相互转换,进一步优化我们的用户管理 API。
3.3.1. 需求升级:定制 JSON 字段与格式
现在,我们的项目收到了来自前端团队的两个新需求:
- 命名风格统一:前端团队习惯使用下划线命名法 (
snake_case
),他们希望所有 API 交互的 JSON 字段都遵循此规范。例如,Java 中的 username
属性,在 JSON 中应该显示为 user_name
。 - 日期格式化:我们需要为用户添加一个创建时间
createTime
字段。在查询用户时,需要将这个 LocalDateTime
类型的字段格式化为 yyyy-MM-dd HH:mm:ss
的标准字符串格式返回给前端。 - 安全增强:在任何情况下,用户的
password
字段都绝对不能出现在返回给前端的 JSON 数据中。
3.3.2. 改造实践:在 VO 与 DTO 中应用 Jackson 注解
1. 更新数据库与实体类
首先,我们需要为 t_user
表添加 create_time
字段。请在您的数据库中执行以下 SQL 语句:
1 2
| ALTER TABLE `t_user` ADD COLUMN `create_time` datetime NULL COMMENT '创建时间' AFTER `status`;
|
接着,更新 User
实体类。
文件路径: src/main/java/com/example/springbootdemo/entity/User.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12
| package com.example.springbootdemo.entity;
import java.time.LocalDateTime;
@Data @TableName("t_user") public class User { private Integer status; private LocalDateTime createTime; }
|
2. 定制 VO (View Object)
现在,我们来改造 UserVO
,以满足前端的输出格式需求。
文件路径: src/main/java/com/example/springbootdemo/vo/UserVO.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| package com.example.springbootdemo.vo;
import cn.hutool.core.annotation.Alias; import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Data; import java.time.LocalDateTime;
@Data @JsonInclude(JsonInclude.Include.NON_NULL) public class UserVO {
private Long id;
@JsonProperty("user_name") @Alias("username") private String name;
private String statusText;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime createTime; }
|
3. 定制 DTO (Data Transfer Object)
同样,我们也需要改造 UserSaveDTO
,以正确接收前端传递的输入数据。
文件路径: src/main/java/com/example/springbootdemo/dto/User/UserSaveDTO.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| package com.example.springbootdemo.dto.User;
import com.fasterxml.jackson.annotation.JsonProperty; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data;
@Data @Schema(description = "用户新增数据传输对象") public class UserSaveDTO {
@Schema(description = "用户名", required = true, example = "newuser") @JsonProperty("user_name") private String username;
@Schema(description = "密码", required = true, example = "123456") private String password;
@Schema(description = "邮箱", example = "newuser@example.com") private String email; }
|
4. 更新 Service 层
最后,我们需要在 Service 层中处理 createTime
字段的赋值和转换。
文件路径: src/main/java/com/example/springbootdemo/service/impl/UserServiceImpl.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| import java.time.LocalDateTime;
@Service @RequiredArgsConstructor public class UserServiceImpl implements UserService { private final UserMapper userMapper; @Override public Long saveUser(UserSaveDTO dto) { User user = Convert.convert(User.class, dto); user.setStatus(1); user.setCreateTime(LocalDateTime.now());
userMapper.insert(user); return user.getId(); } private UserVO convertToVO(User user) { if (user == null) { return null; } UserVO userVO = new UserVO(); BeanUtil.copyProperties(user, userVO, "username"); userVO.setName(user.getUsername()); if (user.getStatus() != null) { userVO.setStatusText(user.getStatus() == 1 ? "正常" : "已禁用"); } userVO.setCreateTime(user.getCreateTime()); return userVO; } }
|
3.3.3. 核心技术:Jackson 核心注解详解
我们刚刚在实战中使用了几个强大的 Jackson 注解,现在来系统性地总结一下:
注解 | 作用 | 常用场景 |
---|
@JsonProperty | 在 Java 属性和 JSON 字段之间建立双向映射关系。 | 解决 Java(驼峰)与 JSON(下划线)的命名不一致问题。 |
@JsonFormat | 在序列化时,将日期时间类型格式化为指定的字符串样式。 | 将 LocalDateTime 格式化为 yyyy-MM-dd HH:mm:ss 。 |
@JsonIgnore | 在序列化和反序列化时,完全忽略某个属性。 | 防止密码等敏感信息泄露到前端。 |
@JsonInclude | 在序列化时,可以指定包含属性的条件,最常用的是 NON_NULL 。 | 忽略值为 null 的字段,精简 API 响应体。 |
3.3.4. 回归测试:验证定制效果
重启应用并访问 http://localhost:8080/swagger-ui.html
。
测试新增接口 (POST)
- 在 Swagger UI 中,展开
POST /users
接口。 - 验证:您会发现
Request body
的 Schema 示例中,字段名已经变成了 user_name
。 - 使用
{ "user_name": "jackson_user", "password": "123", "email": "jackson@test.com" }
作为请求体执行请求。 - 请求会成功,证明我们的后端已能正确接收
user_name
字段。
测试查询接口 (GET)
- 在 Swagger UI 中,执行
GET /users/{id}
,查询我们刚刚新增的记录。 - 验证:您会看到响应的 JSON 中,
createTime
字段被格式化为了 "2025-08-17 10:30:00"
,由于我们之前的Vo对象并不期望
3.4. 数据校验:Validation API 最佳实践
目前,我们的新增(saveUser
)和修改(updateUser
)接口存在一个严重的安全隐患:我们对前端传来的数据完全信任。这会导致数据库中出现大量的“垃圾数据”,甚至引发程序异常。
本节,我们将学习如何通过 Jakarta Bean Validation API 和 Spring 的 @Validated
注解,实现声明式的、自动化的参数校验。
3.4.1. 关键一步:引入 Validation Starter
要使校验注解生效,我们必须首先在 pom.xml
中显式地添加 spring-boot-starter-validation
依赖。
文件路径: pom.xml
(修改)
1 2 3 4
| <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency>
|
3.4.2. 改造实践:为 DTO 添加 Validation 注解
现在,我们为 DTO 的字段添加上具体的校验规则。
文件路径: src/main/java/com/example/springbootdemo/dto/User/UserSaveDTO.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| package com.example.springbootdemo.dto.User;
import com.fasterxml.jackson.annotation.JsonProperty; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; import lombok.Data;
@Data @Schema(description = "用户新增数据传输对象") public class UserSaveDTO {
@Schema(description = "用户名", requiredMode = Schema.RequiredMode.REQUIRED, example = "newuser") @JsonProperty("user_name") @NotBlank(message = "用户名不能为空") private String username;
@Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456") @NotBlank(message = "密码不能为空") @Size(min = 6, max = 20, message = "密码长度必须在6-20位之间") private String password;
@Schema(description = "邮箱", example = "newuser@example.com") @Email(message = "邮箱格式不正确") private String email; }
|
文件路径: src/main/java/com/example/springbootdemo/dto/User/UserUpdateDTO.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| package com.example.springbootdemo.dto.User;
import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotNull; import lombok.Data;
@Data @Schema(description = "用户修改数据传输对象") public class UserUpdateDTO {
@Schema(description = "用户ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") @NotNull(message = "用户ID不能为空") private Long id;
@Schema(description = "邮箱", example = "new_email@example.com") @Email(message = "邮箱格式不正确") private String email; }
|
3.4.3. 核心技术:在 Controller 中使用 @Validated 激活校验
仅仅在 DTO 中添加注解还不够,我们还需要在 Controller 中明确地开启校验。
- 在
UserController
类上添加 @Validated
注解。 - 在需要校验的
@RequestBody
参数前,同样使用 @Validated
注解。
文件路径: src/main/java/com/example/springbootdemo/controller/UserController.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| package com.example.springbootdemo.controller;
import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*;
@Tag(name = "用户管理", description = "提供用户相关的CRUD接口") @RestController @RequestMapping("/users") @RequiredArgsConstructor @Validated public class UserController {
private final UserService userService;
@Operation(summary = "新增用户") @PostMapping public ResponseEntity<Result<Long>> saveUser(@Validated @RequestBody UserSaveDTO dto) { Long userId = userService.saveUser(dto); return ResponseEntity.status(HttpStatus.CREATED).body(Result.success(userId)); }
@Operation(summary = "修改用户信息") @PutMapping public ResponseEntity<Result<Void>> updateUser(@Validated @RequestBody UserUpdateDTO dto) { userService.updateUser(dto); return ResponseEntity.ok(Result.success()); } }
|
3.4.4. 回归测试:验证校验效果
重启应用并访问 http://localhost:8080/swagger-ui.html
。
- 展开
POST /users
接口,点击 “Try it out”。 - 在请求体中输入用户名为空格的非法数据:
1 2 3 4 5
| { "user_name": " ", "password": "password123", "email": "swagger@example.com" }
|
- 点击 “Execute”。
预期结果
这一次,请求会被成功拦截,您会看到服务器返回了一个 400 Bad Request
错误,响应体中包含了详细的、由 Spring Boot 默认格式化的校验失败信息
虽然校验成功了,但这个默认的错误响应格式并不清晰,对前端并不友好。在 第四章,我们将学习如何通过全局异常处理来捕获这类 MethodArgumentNotValidException
异常,并返回我们自定义的、结构统一的 Result
错误信息,从而完美解决这个问题。
3.4.5. 进阶:分组校验与 @Validated
痛点:我们当前的校验有一个潜在问题。@Validated
会触发 DTO 内所有它能找到的校验注解。但如果未来我们的 UserSaveDTO
和 UserUpdateDTO
中有同名字段,但校验规则却略有不同呢?或者,我们想创建一个包含所有字段的 UserDTO
,然后根据是“新增”还是“修改”场景,来执行不同的校验规则,应该怎么做?
解决方案:使用 @Validated
注解独有的分组校验功能
定义校验分组接口:
文件路径: src/main/java/com/example/springbootdemo/validation/ValidationGroups.java
(新增文件)
1 2 3 4 5
| package com.example.springbootdemo.validation; public interface ValidationGroups { interface Save {} interface Update {} }
|
我们将通过一次代码重构,来真正体验分组校验的强大之处。我们的目标是:废弃 UserSaveDTO
和 UserUpdateDTO
,只用一个 UserEditDTO
来同时服务于新增和修改两个场景。
1. 创建统一的 UserEditDTO
这个新的 DTO 将包含新增和修改所需的所有字段,并通过 groups
属性为每个字段的校验规则打上“场景标签”。
文件路径: src/main/java/com/example/springbootdemo/dto/User/UserEditDTO.java
(新增文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| package com.example.springbootdemo.dto.User;
import com.example.springbootdemo.validation.ValidationGroups; import com.fasterxml.jackson.annotation.JsonProperty; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import lombok.Data;
@Data @Schema(description = "用户编辑(新增/修改)数据传输对象") public class UserEditDTO {
@Schema(description = "用户ID,修改时必填", example = "1") @NotNull(message = "用户ID不能为空", groups = ValidationGroups.Update.class) private Long id;
@Schema(description = "用户名,新增时必填", example = "newuser") @JsonProperty("user_name") @NotBlank(message = "用户名不能为空", groups = ValidationGroups.Save.class) private String username;
@Schema(description = "密码,新增时必填,修改时可选", example = "123456") @NotBlank(message = "密码不能为空", groups = ValidationGroups.Save.class) @Size(min = 6, max = 20, message = "密码长度必须在6-20位之间", groups = {ValidationGroups.Save.class, ValidationGroups.Update.class}) private String password;
@Schema(description = "邮箱", example = "newuser@example.com") @Email(message = "邮箱格式不正确", groups = {ValidationGroups.Save.class, ValidationGroups.Update.class}) private String email; }
|
注解解析:
@NotNull(groups = ValidationGroups.Update.class)
: id
字段只在 Update
这个场景下才校验非空。@NotBlank(groups = ValidationGroups.Save.class)
: username
和 password
字段只在 Save
这个场景下才校验非空。@Size(groups = {Save.class, Update.class})
: 密码长度的校验,在 Save
和 Update
两种场景下都会生效(前提是 password
字段不为 null
)。
2. 重构 Service 层
现在,我们修改 UserService
接口和实现类,让它们都使用这个新的 UserEditDTO
。
文件路径: src/main/java/com/example/springbootdemo/service/UserService.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13
| package com.example.springbootdemo.service;
import com.example.springbootdemo.dto.User.UserEditDTO; import com.example.springbootdemo.dto.User.UserPageQuery; import com.example.springbootdemo.vo.UserVO; import java.util.List;
public interface UserService { Long saveUser(UserEditDTO dto); void updateUser(UserEditDTO dto); void deleteUserById(Long id); }
|
文件路径: src/main/java/com/example/springbootdemo/service/impl/UserServiceImpl.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| import com.example.springbootdemo.dto.User.UserEditDTO;
@Service @RequiredArgsConstructor public class UserServiceImpl implements UserService { @Override public Long saveUser(UserEditDTO dto) { } @Override public void updateUser(UserEditDTO dto) { } }
|
3. 重构 Controller 层 (见证奇迹)
最后,我们来修改 UserController
。
文件路径: src/main/java/com/example/springbootdemo/controller/UserController.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| package com.example.springbootdemo.controller;
import com.example.springbootdemo.dto.User.UserEditDTO;
import com.example.springbootdemo.validation.ValidationGroups;
@Tag(name = "用户管理", description = "提供用户相关的CRUD接口") @RestController @RequestMapping("/users") @RequiredArgsConstructor @Validated public class UserController {
private final UserService userService;
@Operation(summary = "新增用户") @PostMapping public ResponseEntity<Result<Long>> saveUser( @Validated(ValidationGroups.Save.class) @RequestBody UserEditDTO dto) { Long userId = userService.saveUser(dto); return ResponseEntity.status(HttpStatus.CREATED).body(Result.success(userId)); }
@Operation(summary = "修改用户信息") @PutMapping public ResponseEntity<Result<Void>> updateUser( @Validated(ValidationGroups.Update.class) @RequestBody UserEditDTO dto) { userService.updateUser(dto); return ResponseEntity.ok(Result.success()); } }
|
4. 清理与验证
现在,您可以安全地删除 UserSaveDTO.java
和 UserUpdateDTO.java
这两个文件了。
重启应用并访问 Swagger UI:
- 测试新增 (
POST /users
):- 如果您不提供
user_name
或 password
,请求将被 400 Bad Request
拦截。 - 如果您提供了
id
,它会被忽略。
- 测试修改 (
PUT /users
):- 如果您不提供
id
,请求将被 400 Bad Request
拦截。 - 您可以不提供
password
,只修改 email
,请求会成功。 - 如果您提供了
user_name
,它会被忽略(因为 DTO 到 PO 的转换不会处理这个字段)。
这才是分组校验的真正威力! 我们通过一个 UserEditDTO
,结合 @Validated
注解中不同的分组,实现了对“新增”和“修改”两个不同业务场景的精准校验,极大地提升了代码的复用性和可维护性。