第十一章. common-core 校验框架:Validation 与自定义注解

实现“二开”中复杂的校验需求的。

第十一章. common-core 校验框架:Validation 与自定义注解

摘要:本章我们将深入 RVP 的参数校验体系。我们将从 ValidatorConfig 配置(如“快速失败”)开始,掌握 @Validated@NotBlank 等基础用法。重点是深入 RVP 的“二开”实践:如何利用 分组校验AddGroup, EditGroup)实现不同场景(如“新增”与“修改”)的校验,以及如何使用 自定义注解@EnumPattern, @DictPattern)实现动态业务校验。

在前面的章节中,我们已经深入分析了 StringUtils, StreamUtils, ReflectUtilsTreeBuildUtils 等核心工具。现在,我们转向 common-core 模块中一个至关重要的组成部分——参数校验(Validation)

在任何 Web 应用中,对前端传入的数据进行校验,是保证数据安全和业务逻辑正确性的第一道防线。RVP 框架基于 spring-boot-starter-validation(它封装了 hibernate-validator)构建了一套强大且可扩展的校验体系。

本章,我们将深入 common-core 中的 validate 包,不仅要学会 如何使用 @NotBlank, @Email 等标准注解,更要重点掌握 RVP 是如何通过 自定义注解(如 @DictPattern, @EnumPattern)和 分组校验AddGroup, EditGroup)来构建这一套完整的验证体系的

本章学习路径

校验框架:Validation 与自定义注解


11.1. RVP 校验体系概览:ValidatorConfig

RVP 的校验能力首先来源于 pom.xml 中的依赖和 config 包下的配置类。

11.1.1. 依赖引入:spring-boot-starter-validation

RVP 框架通过在 ruoyi-common 模块(具体在 common-core 根目录的 pom.xml 中)引入 spring-boot-starter-validation 依赖,从而自动获得了 hibernate-validator 这一 JSR 303/JSR 380 规范的实现。

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>

引入这个依赖后,我们就可以在 Spring Boot 环境中使用 @NotBlank@Email 等注解了。

11.1.2. 源码解析 ValidatorConfig:为何要配置 MessageSource?(国际化 i18n

仅仅引入依赖是不够的。默认情况下,hibernate-validator 的校验失败信息是英文的(例如 must not be blank)。RVP 作为一个支持国际化(i18n)的框架,需要让这些提示信息变成中文(或根据请求头动态切换语言)。

这就是 ValidatorConfig 存在的核心价值。

文件路径ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/config/ValidatorConfig.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@AutoConfiguration // 这是一个自动配置类
public class ValidatorConfig {

@Bean
public Validator validator(MessageSource messageSource) {
LocalValidatorFactoryBean factoryBean = new LocalValidatorFactoryBean();

// 【核心一】关联 Spring 的 MessageSource
// 告诉校验器:不要用你自己的默认 message,
// 去 Spring 的 i18n 资源文件(messages_zh_CN.properties)里找
factoryBean.setValidationMessageSource(messageSource);

// 【核心二】设置实现提供者
factoryBean.setProviderClass(HibernateValidator.class);

// ... 设置 fail_fast ...

factoryBean.afterPropertiesSet();
return factoryBean.getValidator();
}
}

分析
ValidatorConfig 向 Spring 容器注册了一个 Validator Bean。在注册时,它通过 factoryBean.setValidationMessageSource(messageSource),将 Spring 的 MessageSource(国际化资源)注入给了 hibernate-validator

这样,当校验失败时,@NotBlank(message = "{user.name.not.blank}") 这样的注解就能自动去 ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties 文件中查找对应的中文提示:“用户名不能为空”。

11.1.3. 源码解析 快速失败 vs 全量校验

ValidatorConfig 中还有一段至关重要的配置:

1
2
3
4
5
// 位于 ValidatorConfig.java 的 @Bean 方法中
Properties properties = new Properties();
// 【核心三】设置“快速失败”为 true
properties.setProperty("hibernate.validator.fail_fast", "true");
factoryBean.setValidationProperties(properties);

fail_fast (快速失败) 模式是什么意思?

  • false (默认值)全量校验。校验器会检查 DTO 上的 所有 字段,然后返回一个 包含所有错误 的列表。例如,如果 nameemail 都为空,前端会一次性收到两条错误信息:“姓名不能为空”、“邮箱不能为空”。
  • true (RVP 的选择)快速失败。校验器在检查字段时,只要遇到 第一个 校验失败的字段(例如 name 为空),就会 立即停止 后续所有校验,并马上返回这一个错误。

RVP 为什么选择 true
对于 API 接口而言,“快速失败”是更高效、更简洁的选择。它能更快地给前端一个明确的反馈,减少了不必要的计算开销,也简化了前端对错误信息的处理逻辑(一次只处理一个)。


11.2. 【基础】@Validated 与标准注解 (JSR 303)

配置完成后,我们就可以开始在“二开”中实战了。@Validated 是 Spring 提供的注解,用于“触发”对某个 Bean 的校验。

11.2.1. 测试准备:创建 ValidateControllerStudent DTO

和前面的章节一样,Validator 的测试必须在 Spring 环境中进行,所以我们不能使用 main 方法。我们将在 ruoyi-demo 模块中创建一个 Controller

文件路径ruoyi-modules/ruoyi-demo/src/main/java/org/dromara/demo/controller/ValidateController.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
package org.dromara.demo.controller;

import jakarta.validation.constraints.*; // 导入 jakarta.validation 包
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.domain.R;
import org.dromara.common.doc.annotation.SaIgnore;
import org.springframework.validation.annotation.Validated; // 导入 Spring 的 @Validated
import org.springframework.web.bind.annotation.*;

/**
* Validation 校验框架实战
*/
@SaIgnore // 忽略权限认证,方便测试
@Slf4j
@RestController
@RequestMapping("/validate")
// @Validated // 暂不开启,场景二再开启
public class ValidateController {

/**
* 模拟用于接收参数的 DTO
* @Data (Lombok) 会自动生成 getter/setter
*/
@Data
static class Student {
// ... 我们将在这里添加校验注解 ...
}

// ... 我们将在这里添加测试接口 ...
}

11.2.2. 【场景一】校验请求体

这是最常见的场景:校验前端 POSTPUT 请求发来的 JSON 数据。

步骤 1:为 DTO 添加注解
我们修改 Student 类,添加标准校验注解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// ... 位于 ValidateController 内部 ...
@Data
static class Student {

@NotBlank(message = "姓名不能为空")
@Size(min = 2, max = 10, message = "姓名长度必须在 {min} 到 {max} 之间")
private String name;

@NotNull(message = "年龄不能为空")
@Min(value = 1, message = "年龄必须大于等于 {value}")
private Integer age;

@NotBlank(message = "邮箱不能为空")
@Email(message = "邮箱格式不正确")
private String email;

@Pattern(regexp = "^[a-zA-Z0-9_]+$", message = "英文名只能包含字母、数字、下划线")
private String enName;
}

步骤 2:在 Controller 方法参数上添加 @Validated

1
2
3
4
5
6
7
8
9
10
// ... 位于 ValidateController ...
/**
* 场景一:校验 Request Body (JSON)
* @Validated 注解是关键
*/
@PostMapping("/valid4")
public R<Student> valid4(@Validated @RequestBody Student student) {
log.info("校验通过,Student: {}", student);
return R.ok(student);
}

步骤 3:实战验证(使用 ApiFox/Postman)

  1. 重启 DromaraApplication
  2. 使用 ApiFox 向 POST http://localhost:8080/demo/validate/valid4 发送请求。
  3. 测试“快速失败”
    • Body (JSON): {} (空对象)
    • 返回 (HTTP 400): {"code": 400, "msg": "姓名不能为空"}
    • 分析:校验器按 DTO 字段顺序(name, age, email)检查,name 首先失败,触发“快速失败”,程序 立即返回,不再检查 ageemail
  4. 测试通过
    • Body (JSON): {"name": "张三", "age": 20, "email": "test@qq.com", "enName": "zhangsan"}
    • 返回 (HTTP 200): {"code": 200, "msg": "操作成功", "data": ...}

11.2.3. 【场景二】校验 Query 参数

【二开痛点】:如果我们只是想校验 GET 请求的一个 Query 参数(.../valid2?name=xxx),总不能也创建一个 DTO 吧?

解决方案

  1. ValidateController 上添加 @Validated 注解。
  2. 在方法 参数 上直接添加校验注解。

步骤 1:修改 ValidateController

1
2
3
4
5
6
7
// ...
@Slf4j
@RestController
@RequestMapping("/validate")
@Validated // 【关键】将注解加在类上
public class ValidateController {
// ...

步骤 2:添加 valid2 接口

1
2
3
4
5
6
7
8
9
10
11
// ...
/**
* 场景二:校验 Query 参数
* 依赖类上的 @Validated 和参数上的 @NotBlank
*/
@GetMapping("/valid2")
public R<String> valid2(@NotBlank(message = "姓名不能为空") String name) {
log.info("校验通过,name: {}", name);
return R.ok(name);
}
// ...

步骤 3:实战验证(使用 ApiFox/Postman)

  1. 重启 DromaraApplication
  2. 测试失败
    • URL: GET http://localhost:8080/demo/validate/valid2 (不传 name)
    • 返回 (HTTP 400): {"code": 400, "msg": "valid2.name: 姓名不能为空"}
  3. 测试成功
    • URL: GET http://localhost:8080/demo/validate/valid2?name=zhangsan
    • 返回 (HTTP 200): {"code": 200, "msg": "操作成功", "data": "zhangsan"}

11.2.4. 国际化(i18n)实战:message 键与 Content-Language

在 11.1.2 中我们知道 RVP 支持国际化。如何使用?

步骤 1:修改 DTO(使用 message 键)
我们修改 Student DTO,将 message 从“硬编码”改为“i18n 键”:

1
2
3
4
5
6
7
8
9
10
11
12
// 位于 ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
// user.email.not.blank=邮箱不能为空

// 位于 ValidateController.Student
@Data
static class Student {
// ...
@NotBlank(message = "{user.email.not.blank}") // 使用 {} 包裹 i18n 键
@Email(message = "{user.email.not.valid}") // 使用已存在的国际化键
private String email;
// ...
}

步骤 2:实战验证(使用 ApiFox/Postman)

  1. 重启 DromaraApplication
  2. POST http://localhost:8080/demo/validate/valid4 发送请求。
  3. 测试中文(默认)
    • Body (JSON): {"name": "张三", "age": 20, "email": "bad-email"}
    • Headers: (不加 Content-Language
    • 返回 (HTTP 400): {"code": 400, "msg": "邮箱格式不正确"} (中文)
  4. 测试英文
    • Body (JSON): {"name": "Zhangsan", "age": 20, "email": "bad-email"}
    • Headers: Content-Language: en-US
    • 返回 (HTTP 400): {"code": 400, "msg": "Mailbox format error"} (英文)

结论ValidatorConfig 自动关联了 MessageSource,RVP 的校验体系已 原生支持国际化


11.3. 【二开核心】分组校验:AddGroupEditGroup

我们已经掌握了基础校验,但现在遇到了一个 最常见 的“二开”痛点。

11.3.1. 【二开痛点】“新增”时 name 必填,但“修改”时 name 选填

我们通常会为“新增”和“修改”复用同一个 DTO(Student)。但它们的校验规则是不同的:

  • 新增 (Add)name, age, email 都必须提供。
  • 修改 (Edit)id 必须提供,name, age, email 允许 部分 更新(即选填)。

问题@NotBlank(message = "姓名不能为空") 会同时应用于“新增”和“修改”。这导致“修改”时,即使用户只想改 email,也必须强制传入 name,这非常不合理。

解决方案分组校验(Validation Groups)

11.3.2. 源码解析:AddGroup, EditGroup 标记接口

RVP 已经在 common-core 中为我们定义好了最常用的分组。

文件路径ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/validate/

1
2
3
4
5
6
7
8
9
10
11
// AddGroup.java
public interface AddGroup {
}

// EditGroup.java
public interface EditGroup {
}

// QueryGroup.java
public interface QueryGroup {
}

它们是 标记接口。它们唯一的价值就是充当一个“标签”或“分组 Key”,让我们可以给注解“分类”。

11.3.3. DTO 改造:@NotBlank(groups = AddGroup.class)

我们重构 Student DTO,使用 groups 属性来为校验规则“打标签”:

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
// ... 位于 ValidateController 内部 ...
import org.dromara.common.core.validate.AddGroup;
import org.dromara.common.core.validate.EditGroup;
// ...
@Data
static class Student {

// 1. id: 只有在 Edit 组时才校验
@NotNull(message = "ID 不能为空", groups = EditGroup.class)
private Long id;

// 2. name: 只有在 Add 组时才校验 "必填"
@NotBlank(message = "姓名不能为空", groups = AddGroup.class)
// Size 校验在 Add 和 Edit 时都生效
@Size(min = 2, max = 10, message = "姓名长度...", groups = {AddGroup.class, EditGroup.class})
private String name;

// 3. age: 在 Add 组时必填
@NotNull(message = "年龄不能为空", groups = AddGroup.class)
@Min(value = 1, message = "...", groups = {AddGroup.class, EditGroup.class})
private Integer age;

// 4. email: 在 Add 和 Edit 组时,都必填
@NotBlank(message = "邮箱不能为空", groups = {AddGroup.class, EditGroup.class})
@Email(message = "...", groups = {AddGroup.class, EditGroup.class})
private String email;
}

11.3.4. 激活分组

DTO 改造完成后,我们在 Controller 中通过 @Validated(...) 注解来 指定 本次调用激活哪个“分组”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// ... 位于 ValidateController ...
/**
* 场景三:【新增】校验 (激活 AddGroup)
*/
@PostMapping("/add")
public R<Student> addStudent(
// 激活 AddGroup.class
@Validated(AddGroup.class) @RequestBody Student student
) {
log.info("【Add】校验通过: {}", student);
return R.ok(student);
}

/**
* 场景四:【修改】校验 (激活 EditGroup)
*/
@PutMapping("/edit")
public R<Student> editStudent(
// 激活 EditGroup.class
@Validated(EditGroup.class) @RequestBody Student student
) {
log.info("【Edit】校验通过: {}", student);
return R.ok(student);
}

结论分组校验(Groups)是 RVP “二开”中实现 DTO 复用场景化校验标准解决方案,但并不是我们最优雅的方案


11.4. 【二开核心】ValidatorUtils 工具类

@Validated 注解非常方便,但它的作用范围仅限于“方法入口”。如果我们想在方法的“内部”进行编程方式的校验,@Validated 就无能为力了。

ValidatorUtils 就是 RVP 提供的 手动校验 工具。

11.4.1. 源码解析:ValidatorUtils.validate(object, groups...)

我们打开 ValidatorUtils 的源码:

文件路径ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/ValidatorUtils.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
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class ValidatorUtils {

// 1. 在类加载时,就从 Spring 容器中获取 ValidatorConfig 里配置好的校验器
private static final Validator VALIDATOR = SpringUtils.getBean(Validator.class);

/**
* 校验对象
*
* @param obj 待校验对象
* @param groups 待校验的校验组
*/
public static void validate(Object obj, Class<?>... groups) {
// 2. 调用校验器的 validate 方法
Set<ConstraintViolation<Object>> violations = VALIDATOR.validate(obj, groups);

// 3. 如果 violations 集合不为空,说明有错误
if (!violations.isEmpty()) {
// 4. 提取错误信息并抛出异常
// ... (省略了提取 message 的逻辑)
throw new ConstraintViolationException(message, violations);
}
}
}

分析

  1. ValidatorUtils 通过 SpringUtils.getBean(Validator.class)静态持有 了我们在 11.1 节中配置的那个“支持 i18n”和“快速失败”的 Validator 实例。
  2. validate 方法允许我们传入一个 对象(obj)和一个或多个分组(groups...
  3. 它手动调用 VALIDATOR.validate()
  4. 如果校验失败(violations 不为空),它会 立即抛出 ConstraintViolationException,这个异常会被 GlobalExceptionHandler 捕获,并以 400 错误返回给前端。

11.4.2. 为何需要手动校验?

@Validated 注解只能在 Controller 方法上自动触发,但在 Service 层是无效的。

【二开痛点】:假设我们有一个复杂的业务逻辑,Controller 接收了一个 Student DTO,但这个 DTO 只是部分数据。我们还需要在 Service 层调用 otherService 填充 另外几个字段,然后才 完整地 得到了一个需要入库的对象。此时,我们希望在 调用 mapper.insert() 之前,对这个“完整对象”进行一次 最终校验

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 在 MyServiceImpl.java 中
public void createStudent(Student student) {

// 1. @Validated 在 Controller 已经校验了 AddGroup

// 2. Service 层填充额外数据
String extraInfo = otherService.getExtraInfo();
student.setExtra(extraInfo); // 假设 extra 字段有 @NotBlank(groups = ServiceCheck.class)

// 3. 【痛点】@Validated 在这里无效
// 4. 【解决方案】在 Service 层手动调用 ValidatorUtils
ValidatorUtils.validate(student, ServiceCheck.class);

// 5. 校验通过,安全入库
mapper.insert(student);
}

ValidatorUtils.validate() 提供了在任何业务层(Service、Component…)内,手动触发特定分组校验的能力。

11.4.3. 实战:ValidatorUtils.validate(student, AddGroup.class, ...)

我们来模拟这个“手动调用”的场景。我们在 ValidateController 中添加一个 valid7 接口。

关键:这次,我们在 Controller 方法参数的 @RequestBody不加 @Validated,模拟数据直接“裸奔”进入方法体,然后我们 手动 调用 ValidatorUtils

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// ... 位于 ValidateController.java ...

/**
* 场景五:手动校验 (ValidatorUtils)
* 注意:@RequestBody 前面【没有】@Validated
*/
@PostMapping("/valid7")
public R<Student> valid7(@RequestBody Student student) {

log.info("--- 1. 进入方法体,尚未校验 ---");

// 我们手动调用 ValidatorUtils,并指定校验 AddGroup
ValidatorUtils.validate(student, AddGroup.class);

// 如果上面一行抛了异常,这里就不会执行
log.info("--- 2. 手动校验 AddGroup 通过 ---");

// 我们也可以一次校验多个分组
// ValidatorUtils.validate(student, AddGroup.class, EditGroup.class);

return R.ok(student);
}
// ...

实战验证(使用 ApiFox/Postman)
(提醒:我们在 11.3.3 中定义的 Student DTO,AddGroup 组要求 name, age, email 必填)

  1. 重启 DromaraApplication
  2. 测试 /valid7 (手动校验 AddGroup)
    • URL: POST http://localhost:8080/validate/valid7
    • Body: {"email": "test@qq.com"} (不传 nameage)
    • 返回 (HTTP 400): {"code": 400, "msg": "姓名不能为空"}
    • 分析:请求成功进入了 valid7 方法体 (log.info("--- 1. ...") 会打印)。但 ValidatorUtils.validate(student, AddGroup.class) 捕获了 name 为空,抛出异常,程序中断。
  3. 测试 /valid7 (绕过 AddGroup)
    • Body: {"id": 1} (不传 name, age, email)
    • 分析id 字段只在 EditGroup 中校验。我们 校验了 AddGroup,因此 id 上的 @NotNull(groups = EditGroup.class) 未被激活
    • 返回 (HTTP 400): {"code": 400, "msg": "姓名不能为空"} (依然是 AddGroupname 校验失败)
  4. 测试 /valid7 (通过 AddGroup)
    • Body: {"name": "张三", "age": 20, "email": "test@qq.com"}
    • 返回 (HTTP 200): {"code": 200, "msg": "操作成功"}
    • 分析ValidatorUtils.validate 校验通过,程序继续执行,返回成功。

11.5. 【RVP 增强】自定义注解(一):@EnumPattern

11.5.1. 【二开痛点】如何校验 gender 字段必须是 01

标准注解有局限性。假设 DTO 中有一个 String gender 字段,我们只接受 “0”(男)或 “1”(女)。

  • @NotBlank?不行,它会放行 “2”、“3” 或 “abc”。
  • @Pattern(regexp = "[01]")?可行,但可读性差,且如果值是 “male”, “female” 呢?
  • @Min(0) @Max(1)?这只对 Integer 类型有效,对 String 无效。

更复杂的场景:String userType 必须是 UserTypeEnum 枚举中定义的 code 值(例如 “00” “01” “02”)。

RVP 框架提供了 @EnumPattern 注解来完美解决“值必须在指定枚举的某个字段中”这一类校验需求。

“二开”使用示例:假设我们有一个 UserTypeEnum

1
2
3
4
5
6
7
8
public enum UserTypeEnum {
ADMIN("00", "管理员"),
USER("01", "普通用户");

@Getter
private final String code;
// ...
}

我们可以在 Student DTO 中这样使用:

1
2
3
4
5
6
@EnumPattern(
type = UserTypeEnum.class, // 1. 指定枚举类
fieldName = "code", // 2. 指定要匹配的字段名
message = "用户类型不正确,必须是 00 或 01"
)
private String userType;

11.5.2. 源码解析 @EnumPattern

@EnumPattern 是一个元注解(注解的注解),它定义了校验的“契约”。

文件路径ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/validate/enumd/EnumPattern.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Documented
@Target({METHOD, FIELD, ...}) // 声明可以用于字段、方法参数等
@Retention(RUNTIME) // 运行时保留,反射才能读到
@Constraint(validatedBy = {EnumPatternValidator.class}) // 【核心】指定校验逻辑实现类
public @interface EnumPattern {

/**
* 需要校验的枚举类型
*/
Class<? extends Enum<?>> type();

/**
* 枚举类型校验值字段名称 (例如 "code")
* 需确保该字段实现了 getter 方法 (例如 getCode())
*/
String fieldName();

String message() default "输入值不在枚举范围内";

Class<?>[] groups() default {};
// ...
}

分析
@EnumPattern 的核心是 @Constraint(validatedBy = {EnumPatternValidator.class})。它告诉 hibernate-validator:当看到 @EnumPattern 时,请使用 EnumPatternValidator.class 这个类去执行真正的校验逻辑。

11.5.3. 源码解析 EnumPatternValidator

EnumPatternValidator 负责实现“遍历枚举”的逻辑。

文件路径ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/validate/enumd/EnumPatternValidator.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
public class EnumPatternValidator implements ConstraintValidator<EnumPattern, String> {

private EnumPattern annotation; // 用于存储注解实例

@Override
public void initialize(EnumPattern annotation) {
// 1. 校验器初始化时,把注解实例存起来
this.annotation = annotation;
}

@Override
public boolean isValid(String value, ConstraintValidatorContext constraintValidatorContext) {
// value 是前端传入的字段值 (例如 "01")
if (StringUtils.isNotBlank(value)) {

// 2. 从注解中获取要匹配的字段名 (例如 "code")
String fieldName = annotation.fieldName();

// 3. 遍历枚举的所有实例 (例如 UserTypeEnum.ADMIN, UserTypeEnum.USER)
for (Object e : annotation.type().getEnumConstants()) {

// 4. 【核心】使用反射工具类,动态调用 getter
// e.g., ReflectUtils.invokeGetter(UserTypeEnum.ADMIN, "code")
// 返回 "00"
if (value.equals(ReflectUtils.invokeGetter(e, fieldName))) {
return true; // 匹配成功,校验通过
}
}
}
// 5. 如果值为空,或循环结束都没匹配到,校验失败
return false;
}
}

分析
@EnumPattern 是一个绝佳的实战案例,它 完美地结合了我们之前所学的知识

  1. EnumPatternValidator 实现了 ConstraintValidator 接口,这是 hibernate-validator 的标准扩展方式。
  2. isValid 方法 是核心逻辑所在。
  3. ReflectUtils.invokeGetter(e, fieldName):这是关键!它利用了我们在 第八章 学习的 ReflectUtils,实现了 动态 调用枚举实例的 getter 方法(如 getCode()getValue())。

11.6. 【RVP 增强】自定义注解(二):@DictPattern

@EnumPattern 解决了硬编码(Enum)的校验。但 RVP 中更多的数据(如“用户状态”、“通知类型”)是存在 数据库字典表sys_dict_data)中的。

11.6.1. 【二开痛点】如何校验 userStatus 必须是 sys_user_status 字典中的有效值?

当“新增用户”时,前端传入 status: "0"。我们如何校验 "0" 确实是 sys_user_status 字典中定义的有效值(“0”=“正常”)?如果前端恶意传入 status: "99",我们必须拦截。

@EnumPattern 无法访问数据库。因此,RVP 提供了 @DictPattern

“二开”使用示例

1
2
3
4
5
6
// 在 DTO 中使用
@DictPattern(
dictType = "sys_user_status", // 1. 指定要查的字典类型
message = "用户状态必须是字典 {dictType} 中的有效值"
)
private String status; // 前端传入 "0", "1"

11.6.2. 源码解析 @DictPattern

文件路径ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/validate/dicts/DictPattern.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Constraint(validatedBy = DictPatternValidator.class) // 【核心】指定实现类
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface DictPattern {

/**
* 字典类型,如 "sys_user_sex"
*/
String dictType();

String separator() default ","; // 支持 "0,1" 这种多选值的校验

String message() default "字典值无效";
// ...
}

11.6.3. 源码解析 DictPatternValidator

DictPatternValidator 的实现比 EnumPatternValidator 更进一步:它 需要访问 Spring 容器和数据库

文件路径ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/validate/dicts/DictPatternValidator.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
public class DictPatternValidator implements ConstraintValidator<DictPattern, String> {

private String dictType;
private String separator;

@Override
public void initialize(DictPattern annotation) {
// 1. 初始化时,存储字典类型
this.dictType = annotation.dictType();
this.separator = annotation.separator();
}

@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (StringUtils.isBlank(dictType) || StringUtils.isBlank(value)) {
// 注意:这里返回 false。如果允许为空,应使用 @DictPattern + @NotBlank(allowNull=true)
return false;
}

// 2. 【核心】从 Spring 容器中“手动”获取 DictService
// DictService 是 common-core 提供的、已实现字典缓存的服务
DictService dictService = SpringUtils.getBean(DictService.class);

// 3. 调用服务,查询字典(DictService 内部会走 Redis 缓存)
// getDictLabel 会检查 value 是否存在于 dictType 中
String dictLabel = dictService.getDictLabel(dictType, value, separator);

// 4. 如果能查到 Label (不为空),说明这个 value 是合法的
return StringUtils.isNotBlank(dictLabel);
}
}

分析
@DictPattern 是另一个综合了前面知识的完美案例:

  1. ConstraintValidator:实现了标准校验扩展。
  2. SpringUtils.getBean(DictService.class):这是关键!我们知道,ConstraintValidator(校验器)是由 hibernate-validator 管理的,它不一定被 Spring 完全接管,因此 @Autowired 注入 DictService 可能会失败
    • RVP 使用了我们在 第四章 学习的 SpringUtils(上下文“后门”),100% 可靠地 从 Spring 容器中“抓取”DictService 实例。
  3. DictService:它封装了对 sys_dict_data 的查询(并内置了 Redis 缓存)。getDictLabel 返回非空,即证明 value 是一个合法的字典值。

11.7. 本章总结

在本章中,我们完整地剖析了 RVP common-core 模块中的 三层校验体系。作为“二开”开发者,我们必须熟练掌握这三层武器:

  1. 第一层:标准注解 (JSR 303)

    • 内容@NotBlank, @NotNull, @Size, @Min, @Max, @Pattern, @Email
    • 触发:通过在 Controller 方法上使用 @Validated 自动触发。
    • 配置:RVP ValidatorConfig 默认开启了**快速失败(fail_fast 国际化(i18n)**支持。
  2. 第二层:分组校验 (Groups)

    • 内容AddGroup.class, EditGroup.class, QueryGroup.class(空标记接口)。
    • 痛点:解决 DTO 在“新增”和“修改”等不同场景下,校验规则不一致的问题。
    • 触发@Validated(AddGroup.class)@NotBlank(groups = AddGroup.class)
  3. 第三层:自定义注解 (RVP 增强)

    • 内容@EnumPattern@DictPattern
    • 痛点:解决标准注解无法覆盖的 动态业务逻辑 校验。
    • 原理
      • @EnumPattern -> EnumPatternValidator -> ReflectUtils.invokeGetter反射校验
      • @DictPattern -> DictPatternValidator -> SpringUtils.getBean -> DictService.getDictLabel数据库/缓存校验