Spring Boot参数校验与自定义校验
参数校验的目标不是把所有错误都挡在 Controller 之外,而是在系统边界尽早发现无效输入,并用统一、可理解的错误响应告诉调用方如何修正。
上一篇《Spring Boot统一异常处理方案》解决了异常如何转换成稳定 API 响应的问题。本文继续补齐入口侧的参数校验:使用 Bean Validation 完成常见约束、分组校验和嵌套校验,通过自定义注解沉淀业务规则,并将校验异常接入统一错误处理。
环境说明:示例使用 Spring Boot 3.x、Java 17、Jakarta Validation 3 和 Hibernate Validator。Spring Boot 3 使用
jakarta.validation.,不要再沿用旧项目中的javax.validation.包名。
一、为什么需要参数校验
下面这段代码在很多 Controller 中并不少见:
@PostMapping("/users")
public UserResponse create(@RequestBody CreateUserRequest request) {
if (request.username() == null || request.username().isBlank()) {
throw new IllegalArgumentException("用户名不能为空");
}
if (request.age() == null || request.age() < 0 || request.age() > 150) {
throw new IllegalArgumentException("年龄不合法");
}
if (request.email() == null || !request.email().contains("@")) {
throw new IllegalArgumentException("邮箱格式不正确");
}
return userService.create(request);
}随着字段增加,Controller 会被大量重复判断占满,而且不同接口的错误格式、校验顺序和提示文案容易不一致。
Bean Validation 把基础约束放到请求对象上:
public record CreateUserRequest(
@NotBlank(message = "用户名不能为空") String username,
@NotNull(message = "年龄不能为空")
@Min(value = 0, message = "年龄不能小于0")
@Max(value = 150, message = "年龄不能大于150") Integer age,
@NotBlank(message = "邮箱不能为空")
@Email(message = "邮箱格式不正确") String email
) {
}Controller 只需要增加 @Valid:
@PostMapping("/users")
public UserResponse create(@Valid @RequestBody CreateUserRequest request) {
return userService.create(request);
}声明式校验让规则更清晰,但并不意味着业务校验全部消失。唯一性、跨字段规则、数据库状态和业务权限仍需要 Service 或自定义校验配合。
二、引入校验依赖
Spring Boot 3 项目可以添加:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>同时需要 Web 依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>spring-boot-starter-validation 会引入 Jakarta Validation API 与 Hibernate Validator。不要把旧教程中的 validation-api 和 Spring Boot 2 的 javax 示例直接混用到 Spring Boot 3。
常见导入如下:
import jakarta.validation.Valid;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;@Valid 来自 Jakarta Validation,@Validated 来自 Spring。Controller 请求体通常使用 @Valid,方法级参数校验和分组处理常使用 @Validated。
三、常用约束注解
3.1 @NotNull、@NotEmpty 与 @NotBlank
@NotNull
String username;只要求不为 null,因此 "" 和 " " 仍可能通过。
@NotEmpty
String username;要求非空,但 " " 仍有长度,因此可能通过。
@NotBlank
String username;会检查去除首尾空白后的内容,适合用户名、标题等文本字段。
不要盲目给所有字段使用 @NotBlank。可选字段可以允许 null,只有提供值时才校验格式:
@Email(message = "邮箱格式不正确")
private String email;此时 email = null 可以通过,email = "invalid" 会失败。
3.2 数值校验注意包装类型
@NotNull(message = "年龄不能为空")
@Min(value = 0, message = "年龄不能小于0")
@Max(value = 150, message = "年龄不能大于150")
private Integer age;如果只用 @Min 和 @Max,age = null 可以通过,因为约束注解通常把 null 视为“未提供”,由其他注解决定是否必填。
四、请求体校验
4.1 创建用户请求
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
public record CreateUserRequest(
@NotBlank(message = "用户名不能为空")
@Size(min = 2, max = 20, message = "用户名长度应为2~20个字符")
String username,
@NotBlank(message = "手机号不能为空")
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
String phone,
@NotBlank(message = "邮箱不能为空")
@Email(message = "邮箱格式不正确")
String email,
@NotNull(message = "年龄不能为空")
@Min(value = 0, message = "年龄不能小于0")
@Max(value = 150, message = "年龄不能大于150")
Integer age
) {
}手机号正则只是常见中国大陆号段的格式示例,不代表号码真实可用,也不应替代必要的验证码验证。
4.2 Controller 使用 @Valid
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@PostMapping
public UserResponse create(
@Valid @RequestBody CreateUserRequest request
) {
return userService.create(request);
}
}如果请求体缺少字段、类型错误或校验失败,应在统一异常处理器中转换为 400,而不是直接返回 500。
五、路径变量与请求参数校验
对 @PathVariable、@RequestParam 使用约束注解时,需要在类上增加 @Validated:
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Positive;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@Validated
@RestController
@RequestMapping("/api/users")
public class UserQueryController {
@GetMapping("/{id}")
public UserResponse getById(
@Positive(message = "用户 ID 必须为正整数")
@PathVariable Long id
) {
return userService.getById(id);
}
@GetMapping
public List<UserResponse> page(
@Min(value = 1, message = "页码最小为1")
@RequestParam(defaultValue = "1") int page,
@Min(value = 1, message = "每页数量最小为1")
@Max(value = 100, message = "每页数量最大为100")
@RequestParam(defaultValue = "20") int size
) {
return userService.page(page, size);
}
}@Validated 激活 Spring 的方法校验。不同 Spring Framework 版本可能通过不同异常类型暴露错误,常见包括:
ConstraintViolationException;HandlerMethodValidationException;MethodArgumentTypeMismatchException。
统一异常处理时应根据实际版本和调用结果补齐,不要只处理请求体的 MethodArgumentNotValidException。
六、嵌套对象与集合校验
6.1 嵌套对象需要 @Valid
public record AddressRequest(
@NotBlank(message = "省份不能为空") String province,
@NotBlank(message = "城市不能为空") String city,
@NotBlank(message = "详细地址不能为空") String detail
) {
}public record UpdateProfileRequest(
@NotBlank(message = "昵称不能为空") String nickname,
@Valid AddressRequest address
) {
}如果 address 字段上没有 @Valid,内部约束不会自动执行。需要允许地址可选时,可以保留字段为 null;一旦提供地址,就校验其内部字段。
6.2 集合元素也需要 @Valid
public record BatchCreateUserRequest(
@NotEmpty(message = "用户列表不能为空")
@Size(max = 100, message = "单次最多创建100个用户")
List<@Valid CreateUserRequest> users
) {
}这里有两个层次:
@NotEmpty、@Size约束集合本身;@Valid让集合中的每个请求对象执行内部约束。
校验错误通常包含索引和属性路径,例如 users[0].phone,前端可以据此定位具体元素。
七、分组校验
创建和更新通常具有不同规则:创建时密码必填,更新时密码可选;创建时 ID 为空,更新时 ID 必填。分组校验可以复用同一个请求对象。
7.1 定义分组接口
public interface ValidationGroups {
interface Create {
}
interface Update {
}
}7.2 在约束上指定分组
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Null;
import jakarta.validation.constraints.Size;
public record UserRequest(
@Null(groups = ValidationGroups.Create.class,
message = "创建时不能传入用户 ID")
@NotNull(groups = ValidationGroups.Update.class,
message = "更新时用户 ID 不能为空")
Long id,
@NotBlank(message = "用户名不能为空")
String username,
@NotBlank(groups = ValidationGroups.Create.class,
message = "创建时密码不能为空")
@Size(min = 8, max = 64,
groups = ValidationGroups.Create.class,
message = "密码长度应为8~64个字符")
String password
) {
}username 没有指定分组,因此属于默认组 Default。
7.3 Controller 指定当前分组
@PostMapping
public UserResponse create(
@Validated(ValidationGroups.Create.class)
@RequestBody UserRequest request
) {
return userService.create(request);
}
@PutMapping
public UserResponse update(
@Validated(ValidationGroups.Update.class)
@RequestBody UserRequest request
) {
return userService.update(request);
}这里使用 @Validated(分组),而不是只写 @Valid。
7.4 默认组不会自动附带
指定 Create.class 后,不会自动验证默认组中的 username。如果希望同时校验默认约束,可以显式加入 Default.class:
@Validated({ValidationGroups.Create.class, Default.class})需要导入:
import jakarta.validation.groups.Default;也可以让业务分组继承 Default:
public interface Create extends Default {
}分组越多,规则越难理解。只有同一模型在不同生命周期确实存在差异时才使用分组,不必为每个接口都创建一个组。
八、自定义字段校验注解
当多个字段反复使用同一业务规则时,可以把约束封装成自定义注解。以下以“手机号或固定电话二选一格式”为例。
8.1 定义约束注解
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Documented
@Constraint(validatedBy = ContactPhoneValidator.class)
@Target({
ElementType.FIELD,
ElementType.METHOD,
ElementType.PARAMETER,
ElementType.ANNOTATION_TYPE
})
@Retention(RetentionPolicy.RUNTIME)
public @interface ContactPhone {
String message() default "联系电话格式不正确";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}@Constraint 指定实际校验器,groups 和 payload 是 Bean Validation 约束注解的标准组成。
8.2 实现 ConstraintValidator
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import java.util.regex.Pattern;
public class ContactPhoneValidator
implements ConstraintValidator<ContactPhone, String> {
private static final Pattern MOBILE =
Pattern.compile("^1[3-9]\\d{9}$");
private static final Pattern LANDLINE =
Pattern.compile("^0\\d{2,3}-?\\d{7,8}$");
@Override
public boolean isValid(
String value,
ConstraintValidatorContext context
) {
if (value == null || value.isBlank()) {
return true;
}
String normalized = value.trim();
return MOBILE.matcher(normalized).matches()
|| LANDLINE.matcher(normalized).matches();
}
}null 或空白值交给 @NotBlank 等必填约束处理。这样自定义格式校验只负责格式,不重复决定字段是否必填。
8.3 使用自定义注解
public record ContactRequest(
@NotBlank(message = "联系人不能为空") String name,
@NotBlank(message = "联系电话不能为空")
@ContactPhone(message = "请输入正确的手机号或固定电话")
String phone
) {
}自定义注解让错误提示、正则规则和复用方式集中在一处,减少不同 DTO 之间的重复代码。
九、类级校验:处理跨字段规则
字段级注解难以表达“两次密码一致”“结束日期晚于开始日期”等规则,这时可以使用类级约束。
9.1 定义类级注解
@Documented
@Constraint(validatedBy = PasswordMatchesValidator.class)
@Target({ElementType.TYPE, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface PasswordMatches {
String message() default "两次输入的密码不一致";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}9.2 校验整个请求对象
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
public class PasswordMatchesValidator
implements ConstraintValidator<PasswordMatches, PasswordRequest> {
@Override
public boolean isValid(
PasswordRequest value,
ConstraintValidatorContext context
) {
if (value == null) {
return true;
}
String password = value.password();
String confirmPassword = value.confirmPassword();
if (password == null || confirmPassword == null) {
return true;
}
return password.equals(confirmPassword);
}
}@PasswordMatches
public record PasswordRequest(
@NotBlank(message = "密码不能为空")
@Size(min = 8, max = 64, message = "密码长度应为8~64个字符")
String password,
@NotBlank(message = "确认密码不能为空")
String confirmPassword
) {
}类级错误默认关联整个对象,不一定映射到某个具体字段。若前端需要将错误定位到 confirmPassword,可以在校验器中自定义错误节点:
if (!password.equals(confirmPassword)) {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(
"两次输入的密码不一致"
).addPropertyNode("confirmPassword")
.addConstraintViolation();
return false;
}
return true;不要把密码、身份证号等敏感值写入校验日志或错误响应。
十、在校验器中使用 Spring Bean
某些校验需要读取配置或调用轻量查询服务。Hibernate Validator 支持注入 Spring Bean,但应谨慎控制校验器的副作用。
@Component
public class UniqueUsernameValidator
implements ConstraintValidator<UniqueUsername, String> {
private final UserDirectory userDirectory;
public UniqueUsernameValidator(UserDirectory userDirectory) {
this.userDirectory = userDirectory;
}
@Override
public boolean isValid(
String value,
ConstraintValidatorContext context
) {
if (value == null || value.isBlank()) {
return true;
}
return !userDirectory.exists(value.trim());
}
}数据库唯一性不能只依赖应用层校验。并发请求可能同时通过检查,仍应在数据库中建立唯一索引,并处理插入时的唯一键冲突。
对于复杂、耗时或依赖外部服务的规则,通常更推荐在 Service 中显式执行,而不是让 DTO 校验器承担大量业务逻辑和网络请求。
十一、手动调用 Validator
在 Service、单元测试或非 Web 场景中,可以注入 Validator 手动校验:
import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validator;
import java.util.Set;
import org.springframework.stereotype.Service;
@Service
public class UserValidationService {
private final Validator validator;
public UserValidationService(Validator validator) {
this.validator = validator;
}
public void validate(CreateUserRequest request) {
Set<ConstraintViolation<CreateUserRequest>> violations =
validator.validate(request);
if (!violations.isEmpty()) {
String message = violations.stream()
.map(violation -> violation.getPropertyPath()
+ ": " + violation.getMessage())
.distinct()
.reduce((left, right) -> left + "; " + right)
.orElse("请求参数不合法");
throw new IllegalArgumentException(message);
}
}
}手动校验适合非 Controller 调用链,但如果项目已经使用统一异常处理,建议抛出项目自己的参数异常,避免各处返回不同错误格式。
十二、统一校验错误响应
上一篇已经定义了全局异常处理器,本文为其增加字段级错误结构:
public record FieldViolation(
String field,
String message
) {
}可以在原有 ApiError 中增加:
List<FieldViolation> errors;如果现有接口不希望修改顶层结构,也可以将 errors 作为可选字段,仅在校验失败时返回。
12.1 请求体校验失败
import jakarta.servlet.http.HttpServletRequest;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiError> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex,
HttpServletRequest request
) {
List<FieldViolation> errors = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(error -> new FieldViolation(
error.getField(),
error.getDefaultMessage()
))
.toList();
return badRequest("请求参数校验失败", errors, request);
}响应示例:
{
"status": 400,
"code": "INVALID_PARAMETER",
"message": "请求参数校验失败",
"path": "/api/users",
"traceId": "4f8c0a1d7f2c",
"errors": [
{
"field": "phone",
"message": "手机号格式不正确"
},
{
"field": "age",
"message": "年龄不能大于150"
}
]
}12.2 方法参数校验失败
import jakarta.validation.ConstraintViolationException;
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ApiError> handleConstraintViolation(
ConstraintViolationException ex,
HttpServletRequest request
) {
List<FieldViolation> errors = ex.getConstraintViolations()
.stream()
.map(violation -> new FieldViolation(
violation.getPropertyPath().toString(),
violation.getMessage()
))
.toList();
return badRequest("请求参数校验失败", errors, request);
}12.3 新版 Spring 方法校验异常
较新的 Spring Framework 还可能抛出 HandlerMethodValidationException:
import org.springframework.web.method.annotation.HandlerMethodValidationException;
@ExceptionHandler(HandlerMethodValidationException.class)
public ResponseEntity<ApiError> handleMethodValidation(
HandlerMethodValidationException ex,
HttpServletRequest request
) {
return badRequest("请求参数校验失败", List.of(), request);
}可以进一步从 ex.getAllValidationResults() 提取字段错误,但不同小版本的 API 结构可能不同。应以项目实际依赖为准,并用真实请求确认响应。
同时,@ModelAttribute 和表单绑定还可能产生 BindException。一个健壮的全局处理器应覆盖项目实际使用的异常类型,而不是只复制某一种教程写法。
十三、完整示例:创建用户流程
请求对象:
public record CreateUserRequest(
@NotBlank(message = "用户名不能为空")
@Size(min = 2, max = 20, message = "用户名长度应为2~20个字符")
String username,
@NotBlank(message = "手机号不能为空")
@ContactPhone(message = "请输入正确的手机号或固定电话")
String phone,
@NotBlank(message = "邮箱不能为空")
@Email(message = "邮箱格式不正确")
String email,
@NotNull(message = "年龄不能为空")
@Min(value = 0, message = "年龄不能小于0")
@Max(value = 150, message = "年龄不能大于150")
Integer age
) {
}Controller:
@Validated
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@PostMapping
public UserResponse create(
@Valid @RequestBody CreateUserRequest request
) {
return userService.create(request);
}
@GetMapping("/{id}")
public UserResponse getById(
@Positive(message = "用户 ID 必须为正整数")
@PathVariable Long id
) {
return userService.getById(id);
}
}测试请求:
curl -i \
-H 'Content-Type: application/json' \
-d '{"username":"洋","phone":"123","email":"invalid","age":200}' \
http://localhost:8080/api/users应返回 400,并在 errors 中分别指出用户名长度、电话格式、邮箱格式和年龄边界问题。
正常请求:
curl -i \
-H 'Content-Type: application/json' \
-d '{"username":"洋洋","phone":"13800138000","email":"test@example.com","age":30}' \
http://localhost:8080/api/users校验通过后,Controller 才把请求交给 Service。
十四、使用 MockMvc 验证校验契约
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
@WebMvcTest(UserController.class)
class UserControllerValidationTest {
@Autowired
private MockMvc mockMvc;
@Test
void shouldRejectInvalidCreateUserRequest() throws Exception {
String body = """
{
"username": "洋",
"phone": "123",
"email": "invalid",
"age": 200
}
""";
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_PARAMETER"))
.andExpect(jsonPath("$.errors").isArray());
}
@Test
void shouldRejectInvalidUserId() throws Exception {
mockMvc.perform(org.springframework.test.web.servlet
.request.MockMvcRequestBuilders.get("/api/users/0"))
.andExpect(status().isBadRequest());
}
}如果 Controller 依赖 UserService,切片测试中需要使用当前 Spring Boot 版本支持的 Mockito Bean 注解提供模拟对象。较新版本可使用 @MockitoBean,旧版本可使用 @MockBean。
测试至少覆盖:
正常请求通过;
请求体字段校验失败返回 400;
路径变量和请求参数校验失败返回 400;
嵌套对象和集合元素校验路径正确;
分组规则只应用于目标场景;
自定义注解和跨字段规则生效;
错误响应不包含密码等敏感字段值。
十五、常见问题与最佳实践
15.1 校验没有生效
按顺序检查:
是否引入
spring-boot-starter-validation;请求体是否有
@Valid;嵌套对象和集合元素是否有
@Valid;路径变量、请求参数所在类是否有
@Validated;分组是否同时遗漏了
Default;是否使用了错误的
javax.与jakarta.包名组合;是否被自定义 Controller Advice 或过滤器提前处理。
15.2 不要重复承担所有业务校验
适合放在 Bean Validation 中的是:
必填项、长度、格式、范围;
嵌套结构完整性;
可复用的跨字段规则;
不依赖复杂外部状态的通用约束。
适合放在 Service 中的是:
用户是否启用、资源是否存在;
当前用户是否有权操作;
复杂库存、订单状态和流程规则;
需要事务或多表协作的业务判断。
15.3 不要只依赖前端校验
前端校验用于改善输入体验,后端校验用于保护系统边界。请求可以绕过浏览器直接发送,因此前后端都需要合理校验。
15.4 校验不等于清理输入
@Email、@Pattern 只能判断格式,不会自动清理 HTML、脚本或危险字符。输出编码、参数化查询、权限控制和安全过滤仍需单独完成。
15.5 错误提示不要泄露敏感信息
错误响应应描述字段和修正方式,不要回显密码、完整身份证号、访问令牌或内部 SQL。日志中也要避免输出整个请求体。
15.6 数据库约束是最后防线
格式校验和唯一性检查不能替代数据库约束。唯一索引、非空约束、外键和事务仍是保证数据一致性的必要手段。
十六、总结
一套完整的 Spring Boot 参数校验方案应做到:
使用
spring-boot-starter-validation和 Jakarta Validation。请求体使用
@Valid,方法参数所在类使用@Validated。根据字段语义选择
@NotNull、@NotEmpty、@NotBlank和范围约束。嵌套对象与集合元素通过
@Valid级联校验。分组处理创建、更新等生命周期差异,注意默认组不会自动附带。
字段级自定义注解复用通用格式规则,类级约束处理跨字段关系。
校验器保持简单,复杂业务规则和数据库唯一性仍需 Service 与数据库保障。
统一处理请求体、路径变量和请求参数产生的不同校验异常。
错误响应提供稳定字段路径和可读提示,不泄露敏感信息。
使用 MockMvc 固定校验规则与 API 错误契约。
参数校验不是简单地增加几个注解,而是把输入边界、业务规则和统一异常响应连成完整流程。
下一站预告
下一篇《Spring Boot拦截器与过滤器实战》将介绍 Filter 与 HandlerInterceptor 的执行顺序、适用场景和常见误区,并结合请求追踪、权限检查与统一异常处理构建清晰的 Web 请求链路。
作者:洋洋技术笔记 发布日期:2026-11-11 系列:Spring Boot实战 - 第8篇
原文链接:洋洋的技术笔记
评论区