本文基于若依3.9.2、SpringBoot3版本。

常量与枚举设计规范:HttpStatus 自定义 601 警告码
业务代码里的字符串和数字字面量,一旦散落在各个类里,改动时就要全仓库搜索:Redis 的 key 前缀、HTTP 状态码、状态字段取值、分隔符,同一个值可能出现在十几处,改一处漏一处。
若依在 common 模块用两个包把字面量收拢:com.ruoyi.common.constant 放常量类,com.ruoyi.common.enums 放枚举类。常量负责收纳"固定的值",枚举负责收纳"封闭的取值集合",两者各管一类场景。本文沿这两个包展开,重点看 HttpStatus 里一个不在 HTTP 标准中的状态码------601。
常量:按业务域分组收纳
若依没有把所有常量塞进一个巨型类,而是按业务域拆成六个:
- CacheConstants:Redis 缓存 key
- Constants:全系统通用
- UserConstants:用户、角色、部门、菜单等系统管理场景
- GenConstants:代码生成
- ScheduleConstants:定时任务调度
- HttpStatus:响应状态码
CacheConstants 收纳的全部是 Redis key,节选几个:
java
/** 登录用户 redis key */
public static final String LOGIN_TOKEN_KEY = "login_tokens:";
/** 验证码 redis key */
public static final String CAPTCHA_CODE_KEY = "captcha_codes:";
/** 参数管理 cache key */
public static final String SYS_CONFIG_KEY = "sys_config:";
/** 登录账户密码错误次数 redis key */
public static final String PWD_ERR_CNT_KEY = "pwd_err_cnt:";
每个 key 都以冒号结尾,使用时在冒号后拼接业务标识组成完整的 Redis key:登录流程在认证通过、生成 Token 后,用 LOGIN_TOKEN_KEY + uuid 把登录用户信息存进 Redis;登录密码错误时用 PWD_ERR_CNT_KEY + username 累计错误次数。存和取引用同一个常量,前缀永远不会对不上。
分组的依据是改动原因的聚合度:定时任务的常量只在调整调度行为时变化,代码生成的常量只在调整生成模板时变化,把它们拆开,改一个功能只需要打开一个文件,也不必在一堆无关常量里翻找。
Constants 收纳的是真正全局通用的内容(字符集、令牌前缀、权限分隔符、JSON 白名单等),它内部还嵌套了一个 Dept 内部类:
java
/** 部门相关常量 */
public static class Dept {
/** 全部数据权限 */
public static final String DATA_SCOPE_ALL = "1";
/** 自定数据权限 */
public static final String DATA_SCOPE_CUSTOM = "2";
/** 部门数据权限 */
public static final String DATA_SCOPE_DEPT = "3";
/** 部门及以下数据权限 */
public static final String DATA_SCOPE_DEPT_AND_CHILD = "4";
/** 仅本人数据权限 */
public static final String DATA_SCOPE_SELF = "5";
}
数据权限的五种范围取值只在数据权限切面和部门业务里使用,聚成内部类之后,引用路径 Constants.Dept.DATA_SCOPE_ALL 自带业务归属,不会被误解成通用常量。
常量类的命名都以 Constants 结尾,唯一例外是 HttpStatus。⚠️ 这个名字与 org.springframework.http.HttpStatus 完全同名,写代码 import 时要选对:若依的 HttpStatus 是 int 常量类,服务于统一返回结构的业务 code 字段;Spring 的是枚举,服务于 HTTP 响应状态行,两者用途不同。
常量里也有一类特殊用法------用常量给布尔结果赋予语义,UserConstants 里的一对:
java
/** 校验是否唯一的返回标识 */
public final static boolean UNIQUE = true;
public final static boolean NOT_UNIQUE = false;
各 Service 的唯一性校验方法返回它们,以用户名校验为例:
java
public boolean checkUserNameUnique(SysUser user) {
...
return UserConstants.UNIQUE;
}
校验通过返回 UserConstants.UNIQUE,已存在同名用户返回 UserConstants.NOT_UNIQUE,比直接 return true 多一层可读性。
常量的局限也在这里:无论语义上代表什么,类型上都是同一个原始类型,编译器无法区分"表示状态的字符串"和"表示路径的字符串",传错了照样编译通过,问题被推迟到运行时;而且常量只是一个值,绑不上任何行为。需要限定取值范围、需要携带属性的场景,若依交给了枚举。
HttpStatus:在标准状态码之外定义 601
HttpStatus 定义的全是 int 状态码,节选主要的几个:
java
/** 操作成功 */
public static final int SUCCESS = 200;
/** 未授权 */
public static final int UNAUTHORIZED = 401;
/** 访问受限,授权过期 */
public static final int FORBIDDEN = 403;
/** 系统内部错误 */
public static final int ERROR = 500;
/** 系统警告消息 */
public static final int WARN = 601;
前面四个都在 HTTP 标准里,最后一个是例外。HTTP 标准的状态码从 1xx 到 5xx,601 不在其中,它是若依自定义的"系统警告消息"码。
这个常量在框架里的主要去向是统一返回结构的 code 字段,以 AjaxResult 的 warn 系列工厂方法为例:
java
public static AjaxResult warn(String msg) {
return AjaxResult.warn(msg, null);
}
public static AjaxResult warn(String msg, Object data) {
return new AjaxResult(HttpStatus.WARN, msg, data);
}
HTTP 响应的状态行始终是 200,业务结果由响应体里 AjaxResult 的 code 字段承载,HttpStatus 的常量填的就是这个字段。
前端对 601 有专门的处理分支,前端工程 request.ts 的响应拦截器里,500 和 601 是两个并列的分支:
java
} else if (code === 500) {
ElMessage({ message: msg, type: 'error' })
return Promise.reject(new Error(msg))
} else if (code === 601) {
ElMessage({ message: msg, type: 'warning' })
return Promise.reject(new Error(msg))
}
500 弹错误提示、601 弹警告提示,两者都走 Promise 的失败分支,不会进入调用方的成功回调。
warn 的典型用法是删除前的业务预检,SysDeptController 删除部门前先检查有没有下级部门和关联用户:
java
public AjaxResult remove(@PathVariable Long deptId) {
if (deptService.hasChildByDeptId(deptId)) {
return warn("存在下级部门,不允许删除");
}
if (deptService.checkDeptExistUser(deptId)) {
return warn("部门存在用户,不允许删除");
}
deptService.checkDeptDataScope(deptId);
return toAjax(deptService.deleteDeptById(deptId));
}
这类情形的特点是请求本身合法,只是业务前置条件不满足。如果用 500 表达,前端弹的是"错误",与"存在下级部门"这种可预期的业务情形不匹配;601 把它单独编码,前端才有了区别对待的依据。
枚举:封闭的取值集合
enums 包下定义了八个枚举:BusinessStatus(操作状态)、BusinessType(业务操作类型)、DataSourceType(数据源类型)、DesensitizedType(脱敏类型)、HttpMethod(HTTP 方法)、LimitType(限流类型)、OperatorType(操作人类型)、UserStatus(用户状态)。它们都在同一批场景下替代了常量:取值封闭、需要约束调用方。
枚举本质是继承 java.lang.Enum 的类,第一个价值是类型安全。@Log 注解的 businessType 属性声明为 BusinessType 类型而不是 String:
java
@Log(title = "用户管理", businessType = BusinessType.INSERT)
使用者只能从枚举的十个值(OTHER、INSERT、UPDATE、DELETE、GRANT、EXPORT、IMPORT、FORCE、GENCODE、CLEAN)里选,写错枚举值名编译都过不了;日志切面 LogAspect 拦截到方法后,用 log.businessType().ordinal() 把操作类型转成序号存进操作日志表。
OperatorType 和 BusinessStatus 也在切面里出现:前者是 @Log 的另一个属性,标注操作人类型;后者由切面用来记录本次操作成功还是失败,同样以 ordinal() 转成数字入库。
第二个价值是单例比较。每个枚举值在 JVM 中是单例,枚举值之间用 == 比较引用即可,限流切面判断限流类型就是这么写的:
java
if (rateLimiter.limitType() == LimitType.IP) {
...
}
不需要走字符串比较,也没有 null 比较抛异常的隐患。
第三个价值是携带属性。UserStatus 给每个状态绑定了 code 和 info 两个属性:
java
OK("0", "正常"), DISABLE("1", "停用"), DELETED("2", "删除");
private final String code;
private final String info;
UserStatus(String code, String info) {
this.code = code;
this.info = info;
}
public String getCode() {
return code;
}
public String getInfo() {
return info;
}
登录校验时,UserDetailsService 的实现类用 UserStatus.DELETED.getCode().equals(user.getDelFlag()) 拿状态码和用户的 delFlag 字段比较,而不是直接写 "2" 这种魔法字符串,状态码和它的语义绑在了一起。
枚举还能携带行为。DesensitizedType 给每个脱敏类型绑定一个 Function<String, String> 脱敏函数:
java
/** 姓名,第2位星号替换 */
USERNAME(s -> s.replaceAll("(\S)\S(\S*)", "$1*$2")),
/** 密码,全部字符都用*代替 */
PASSWORD(DesensitizedUtil::password),
调用方拿到枚举值就能直接执行脱敏,是策略模式的简洁写法,不需要再写一串 if-else。姓名、身份证、手机号这类脱敏用单行正则就能完成,直接写成 lambda;密码和车牌的脱敏逻辑分支较多,抽到了 DesensitizedUtil 里,以方法引用 DesensitizedUtil::password 的形式挂到枚举值上。这套脱敏体系通过 @Sensitive 注解和 JSON 序列化器配合生效,当前业务代码里还没有使用 @Sensitive 注解。
有些枚举还要支持从字符串解析回来。HttpMethod 维护了一个静态 map:
java
private static final Map<String, HttpMethod> mappings = new HashMap<>(16);
static {
for (HttpMethod httpMethod : values()) {
mappings.put(httpMethod.name(), httpMethod);
}
}
public boolean matches(String method) {
return (this == resolve(method));
}
静态块把每个枚举值按 name()(枚举常量在源码里声明的名称,如 HttpMethod.GET.name() 返回 "GET")存进 map,resolve 查 map 直接命中,比每次遍历 values() 逐个比对快。日志切面用 HttpMethod.PUT.name() 这类写法判断请求方法,XSS 过滤器用 HttpMethod.GET.matches(method)、HttpMethod.DELETE.matches(method) 跳过不需要清洗的 GET、DELETE 请求。
把枚举和常量放在一起,差异集中在三个维度:
- 类型安全:枚举是独立类型,编译器检查参数是否为合法取值;常量都是原始类型,传错不报错
- 携带能力:枚举可以定义字段和方法,把取值和语义、行为绑在一起;常量只是一个静态的值
- 集中与遍历 :枚举的全部取值集中在一个类里,
values()可以整体遍历;常量分散定义,没有内置遍历机制
💡 限定取值范围、需要携带属性或行为的场景优先用枚举;单纯表示一个固定字符串或数字(Redis key 前缀、分隔符、日期格式)用常量更轻量。若依八个枚举全部用在注解属性和状态判断上,六个常量类全部用在配置值和 key 拼接上,两边没有越界。
思考
601 为什么放在响应体而不是状态行:HTTP 状态行有一套全球通用的处理生态,网关按它告警重试、监控按它统计错误率、代理按它做缓存判断,把 601 直接写进状态行,这些通用设施都不认识它,轻则被当成异常流量,重则被中间环节改写。放在响应体里,HTTP 层保持 200,业务语义由响应体自描述,对通用设施完全透明。代价是只看状态行的通用 HTTP 客户端感知不到业务失败,必须理解响应体结构------若依前后端一体,前端的响应拦截器是自家代码,这个代价可以接受。601 这个码本身也选得稳妥:600 到 699 段 HTTP 标准未分配,不会与标准码冲突。
常量与枚举的边界是"值"与"类型"的边界:常量表达"一个固定的值",枚举表达"一类东西的取值集合"。判断方法可以看取值会不会出现在方法签名或注解属性里、需要被编译器约束:会,用枚举;只作为配置值被读出来用,用常量。HttpStatus 是一个有意思的中间态------状态码在语义上是封闭集合,按上面的标准更适合枚举,但它的消费方是统一返回结构的 int 字段,序列化和存储都以 int 进行,做成 int 常量类省去了枚举与数字之间的转换,这里取舍的天平偏向了常量。
常量类按业务域分文件的有效性来自改动原因的聚合:六个常量类对应六个相对独立的业务域,每个类内部的常量倾向于同时变化。这个分法与"单一职责"的思路一致,但对象不是功能而是字面量的生命周期------分错的表现是同一个需求要同时改好几个常量类。若依的分法里 Constants 略有例外,它收容了令牌、权限、JSON 白名单等不同特性的全局常量,只是因为它们确实都是"全局通用"这一类。