三层校验 + 13 类异常拦截 + 7 项自查清单:让每个接口报错都说人话
三层校验 + 13 类异常拦截 + 7 项自查清单:让每个接口报错都说人话
文章结尾提供可写入
CLAUDE.md的接口健壮性规则,以及一份通用全局异常处理器模板。你可以直接把文章喂给 AI,再按自己的项目习惯微调。适合自己的,才是最好的。
实习的时候,有件事让我特别难受。
不是加班,不是技术难,而是每次在公司内部平台上操作失败,系统就甩给我一句话:
“系统异常,请联系管理员。”
就这一句。
哪个字段有问题?是我不该这么操作,还是系统真的崩了?不知道。我只能打开 Chrome 开发者工具,切到 Network 面板,看请求的响应体。有时候能抓到点蛛丝马迹,有时候连 Response 都是空的。
那一刻我心里就埋下了一颗种子:
以后我写的接口,绝不能让用户猜自己错在哪。
后来开始用 AI 写代码,我发现一个更有意思的现象:AI 写业务逻辑很快,但写出来的接口,错误处理往往缺胳膊少腿。
要么字段校验没加全,要么异常拦截漏了几种,要么 update 失败也不检查,直接 return success 完事。
所以我花了一整个下午,拉着 AI 把“异常处理”这件事从头到尾捋了一遍。不是为了修一个 bug,而是要把它规则化、流程化,让它成为 AI Agent 写代码时的“肌肉记忆”。
这篇文章就是这个过程的完整记录。如果你也在用 AI 写后端,或者被“系统异常”折磨过,希望这套方法能帮到你。
目录
- 从 update 接口说起
- 三层防线,各管各的
- 全局异常处理器别只写一个 Exception
- 错误消息要让用户看得懂、改得了
- Service 层 7 项自查清单
- 让规则活在 Agent 的脑子里
- 可直接复用的规则片段
- 通用全局异常处理器模板
从 update 接口说起
假设你有个资产管理接口 POST /asset/update,前端传 JSON:
{ "id": 1, "title": "新标题", "domain": "aaaaa……300个a……aaa" }
一个请求进来了,它要经过三道关:
请求接入 → Service 处理 → DB 写入
每一道关能看到的信息不一样,能做的判断也不一样。这决定了一个核心原则:
校验必须分层,而不是全堆在一起。
请求接入阶段,还没进业务逻辑,只能看到字段有没有、类型对不对、长度合不合法。所以 DTO 层的注解校验就该在这里完成。
Service 处理阶段,已经进了业务逻辑,可以查 DB,可以判断业务规则。所以“资产是否存在”“能不能删除”“入库前要不要 trim”,这些就该在这里做。
DB 写入阶段,前面两道关都过了,数据要落库。正常情况下不该再出问题,但万一并发导致唯一键冲突呢?DDL 级别的约束就是最后的保险丝。
核心原则一句话:
每层只校验自己能拿到上下文来判断的事情,消息越往下越模糊。
三层防线,各管各的
DTO 结构校验
这层最简单,也最容易遗漏。
给你看一段典型的 DTO:
// 必填字段
@NotBlank(message = "{asset.title.notblank}")
private String title;
// 可选字段:null 时自动跳过,传了才校验
@Size(max = 255, message = "域名最长 255 个字符")
private String domain;
这里有个很多人会踩的坑:
@NotNull / @NotBlank 会把字段变成必填,@Size / @Pattern / @Min / @Max 在 null 时自动跳过。
所以可选字段只能用第二类注解。千万别给非必填字段加 @NotBlank,否则调用方不传这个字段,Spring 直接校验失败。
DTO 层的错误走 MethodArgumentNotValidException,由全局 handler 提取第一条校验消息返回。用户看到的就是“网页标题不能为空”,一眼知道怎么改。
DTO 层不做这些事:
- 不查 DB
- 不判断业务规则
- 不做 trim
Trim 是入库规范,不是校验。
Service 业务校验
这层是真正出力的地方。
看一个完整的 update 方法骨架:
public Boolean update(AssetUpdateDTO dto) {
// 1. 先查存不存在
Asset asset = this.getById(dto.getId());
if (asset == null) {
throw new ServiceException(ErrorStatus.ASSET_NOT_EXIST.getErrmsg(),
ErrorStatus.ASSET_NOT_EXIST.getErrno());
}
// 2. 按白名单 + copyNonNull:null 就是“不改”,防止无辜覆盖
BeanCopyUtils.copyNonNull(allowed, asset);
// 3. 入库前 trim
trimAssetStrings(asset);
// 4. DB 字段长度兜底校验
validateFieldLength(asset);
// 5. 写入,必须检查 affectedRows
if (!this.updateById(asset)) {
throw new ServiceException("更新失败,请重试", ...);
}
return true;
}
这里面有几个关键决策。
null = 不改,编辑时不可重置为空。
这是产品层面的决定。用 copyNonNull 策略:DTO 里哪些字段为 null,就代表调用方没传,Service 就不会覆盖数据库里的原有值。
如果你的产品允许清空字段,那就要换方案。但大多数内部管理系统的编辑接口,不支持清空反而是合理的。
trim 静默处理,不报错。
前后空格是输入习惯问题,不是用户犯了错。入库前统一 trim 一下就行,不需要抛异常通知调用方。
affectedRows 必须检查。
updateById 返回值是 boolean,true 表示至少影响了一行,false 表示根本没匹配到任何行。
什么情况下会 false?查出来到写入之间,那条数据被并发删除了。
如果你不检查,Controller 层就是 return success(true),用户以为更新成功了,其实没有。这是一个非常隐蔽的 bug。
DB 约束兜底
这层是安全网,不是业务逻辑。正常情况下不应该触发,但配件齐了才睡得着觉。
@ExceptionHandler(DataIntegrityViolationException.class)
public ResultBean handleDataIntegrity(DataIntegrityViolationException e) {
log.error("数据完整性约束冲突", e);
String msg = extractRootCause(e);
return ResultBean.error(msg);
}
万一前两层的字段长度校验有遗漏,或者并发导致唯一键冲突,DataIntegrityViolationException 会被 Spring 包装后抛出。
你在这里拦截,提取根因,返回一句用户能看懂的话,而不是让异常掉到 RuntimeException handler 里,变成一句含糊的“系统错误”。
全局异常处理器别只写一个 Exception
很多人,包括 AI 写的代码,喜欢这么偷懒:
@ExceptionHandler(Exception.class)
public ResultBean handleException(Exception e) {
return ResultBean.error("系统异常");
}
这有什么问题?看一张表就明白了。
| 场景 | 实际异常 | 用户看到 |
|---|---|---|
| JSON 格式写错了 | HttpMessageNotReadableException | “系统异常” |
| 必填字段没传 | MethodArgumentNotValidException | “系统异常” |
| 枚举值不合法 | HttpMessageNotReadableException | “系统异常” |
| Service 层主动抛业务异常 | ServiceException | “系统异常” |
你看出来了吗?
所有异常都走同一个 handler,所有消息都变成同一句废话。
这跟公司内部平台那个“系统异常,请联系管理员”一模一样。
那是不是多写几个 handler 就行了?是的。但问题是,到底要写几个?怎么判断够不够?
不用猜,按请求生命周期逐个检查就行。
一个 HTTP 请求从进来到出去,每个阶段都可能抛不同的异常:
1. URL 分发 → NoHandlerFoundException → 生产环境按需添加
2. HTTP Method 不匹配 → HttpRequestMethodNotSupported → 必须
3. GET 参数校验失败 → BindException → 必须
4. 参数类型转换失败 → MethodArgumentTypeMismatchException → 必须
5. 缺少必传参数 → MissingServletRequestParameter → 必须
6. 请求体反序列化失败 → HttpMessageNotReadableException → 必须
7. @Validated 校验失败 → MethodArgumentNotValidException → 必须
8. 文件超大 → MaxUploadSizeExceededException → 必须
9. 鉴权拦截 → AccessDeniedException → 有鉴权就加
10. Service 抛业务异常 → ServiceException → 必须
11. DB 写入失败 → DataIntegrityViolationException → 必须
12. 未知运行时异常 → RuntimeException → 兜底
13. 检查型异常 → Exception → 最后保险丝
只要你的项目用了 Spring MVC,前 8 类基本就是标配。不纠结,不省。
这里再澄清一个容易误解的点:Spring 的 @ExceptionHandler 匹配是精确优先的。
你写了 ServiceException handler,又写了 Exception handler,ServiceException 会精确匹配到前者,其他未知异常才会走到后者。不会冲突,也不会重复处理。
错误消息要让用户看得懂、改得了
消息这件事,我以前也没太当回事。
直到自己在内部平台被“系统异常”堵了好几次之后,才明白:
好的错误消息和坏的错误消息之间的差距,就是用户自己解决问题 vs 找你私聊的差距。
我给自己定的消息质量标准很简单:
用户看到后,知道哪个字段、什么问题、怎么改。
比如:
- “网页标题长度不能超过 255 个字符”
- “资产不存在”
- “SOC 来源资产不允许删除”
这些就比下面两句有用得多:
- “处理失败,请联系管理员”
- “系统错误”
如果你的项目有国际化开关,消息可以走 messages.properties 统一管理:
result.status.asset.not.exist = 资产不存在
result.status.asset.soc.delete.forbidden = SOC 来源资产不允许删除
DTO 校验注解的 message 也要指向国际化 key,而不是硬编码中文:
@NotBlank(message = "{result.status.asset.title.notblank}")
private String title;
最后,两条消息链路要汇总到同一个出口:
DTO 校验注解 → messages.properties → 全局 handler 提取 → 返回给用户
业务 ServiceException → ErrorStatus 枚举 → messages.properties → 全局 handler 提取 → 返回给用户
统一通道,管理方便,查问题也方便。
Service 层 7 项自查清单
Service 层是业务主战场,也是最容易漏校验的地方。
我给自己定了一份清单,每个 Service 方法写完都要过一遍:
| 检查项 | 要做什么 |
|---|---|
| 入参 null 判断 | id、DTO 关键字段,该非空的必须判 |
| 数据存在性 | getById 后判 null,不存在就抛 |
| 业务规则 | 来源、状态、权限、幂等,每一条都可能是 if |
| 入库前 trim | String 字段统一 trim,静默处理 |
| 字段长度 | 对照 DB DDL,每个 String 字段有长度上限 |
| affectedRows | updateById / deleteById 返回值必须检查 |
| 日志 | 写操作打上业务唯一标识、入参和结果 |
坦白说,单独看每一项都不复杂。
但全部写齐,靠人脑去记,大概率会漏。所以我选择把这份清单写入 CLAUDE.local.md,让 AI Agent 每次写 Service 方法时自动对照检查。
让规则活在 Agent 的脑子里
这次讨论的最终产物不是一份普通文档,而是两样东西。
一份 CLAUDE.local.md 规则段。
里面包含:
- 三层防线模型的完整说明
- 全局异常处理器 13 类标配清单
- 错误消息设计统一原则
- Service 层 7 项自查清单
- 明确禁止事项,例如 Controller 里 try-catch 后直接返回 error ResultBean、DTO 校验写业务消息等
一个项目作为实例验证。
我用一个真实业务项目做全套改造,验证这套规则不是“写着好看”,而是真的能落到代码里。
为什么要把这些规则写进 CLAUDE.local.md?
因为我的实际经验是:
用 AI 写代码时,你不能指望它“顺便”把校验和异常处理写好。
它不会。
它关注的是主线逻辑。这些“边角料”你不明确要求,它就很容易跳过去。
但只要你在项目规则文件里写清楚,它就会严格执行,而且执行得很彻底:不会遗漏,不会偷懒,也不会嫌麻烦。
这也是我说“种子”这个词的原因。
从被内部平台的错误消息折磨,到意识到 AI 写的代码也需要同样的约束,再到把这件事规则化、变成 Agent 的内置行为,整个过程形成了一个闭环。
可直接复用的规则片段
下面这段可以直接放进你的 CLAUDE.md 或 CLAUDE.local.md,再按项目实际情况改名、改异常类、改返回对象。
接口健壮性规则
DTO 入参校验
- 必填字段加 @NotNull/@NotBlank,可选字段加 @Size/@Pattern(null 时自动跳过)
- 每个校验注解必须写 message,指向国际化 key 或写中文,禁止依赖框架默认英文消息
- 项目有国际化链路就走 key 引用,没有就走项目现有消息管理方式,禁止裸写字符串分散各处
Service 业务校验
- 每条写操作过 6 项:入参判空 → 数据存在性 → 业务规则 → 入库前 trim → 写操作影响行数检查 → 关键日志(含业务唯一标识)
- 任何一项不通过,抛业务异常并带上具体错误消息,禁止 return null/false 糊弄过去
- update 默认 null=不改,产品未明确要求时不支持通过 API 清空已有字段
error 消息底线
- 用户看到时知道哪个字段、什么问题、怎么改
- 所有错误消息走统一枚举/常量管理,禁止在 Service 或 Controller 中临时拼接硬编码字符串
- DB 底层异常不允许裸消息返回前端
全局异常拦截
- 项目脚手架应自带标准 handler 集合。AI 的职责:检查现有 handler,缺失就补,消息不对就改
- 每种异常给出用户可理解的消息,不依赖框架默认英文提示
- RuntimeException 和 Exception 做最后兜底
通用全局异常处理器模板
下面是一个通用骨架,类名、返回对象、异常类型都可以按你项目里的约定替换。
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
// ==================== 请求解析阶段 ====================
/** 请求体格式错误:JSON 语法错误、枚举值反序列化失败 */
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResultBean handleHttpMessageNotReadable(...) {
// 用户传了非法格式的 JSON 或非法枚举值
}
/** 请求体 @Validated 校验失败 */
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResultBean handleMethodArgumentNotValid(...) {
// 提取第一个字段校验消息返回
}
/** GET 参数绑定校验失败(@Validated 打在 Controller 类上时) */
@ExceptionHandler(BindException.class)
public ResultBean handleBindException(...) {
// 同上,提取消息
}
/** URL 参数类型转换失败,如 ?id=abc 期望 Long */
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResultBean handleTypeMismatch(...) {
// “参数类型不匹配”
}
/** 缺少必传参数 */
@ExceptionHandler(MissingServletRequestParameterException.class)
public ResultBean handleMissingParam(...) {
// “缺少必要参数”
}
/** 请求方法不对:如 POST 接口用了 GET */
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResultBean handleMethodNotSupported(...) {
// “不支持的请求方法”
}
/** 文件上传超大 */
@ExceptionHandler(MaxUploadSizeExceededException.class)
public ResultBean handleMaxUploadSize(...) {
// “文件大小超出限制”
}
// ==================== 业务处理阶段 ====================
/** 业务异常(你主动 throw 的 ServiceException) */
@ExceptionHandler(ServiceException.class)
public ResultBean handleServiceException(ServiceException e) {
// 直接用 e.getMessage() 返回,消息已经够详细
}
// ==================== 权限阶段 ====================
/** 权限不足(Spring Security 或项目鉴权组件) */
@ExceptionHandler(AccessDeniedException.class)
public ResultBean handleAccessDenied(...) {
// “无权限访问”
}
// 未登录异常视项目鉴权机制添加:
// AuthenticationException / CspUpmException / 自定义异常
// ==================== DB 阶段 ====================
/** DB 约束冲突:唯一键、字段超长、外键等 */
@ExceptionHandler(DataIntegrityViolationException.class)
public ResultBean handleDataIntegrity(...) {
// 提取根因消息,过滤掉 SQL 语句
}
// ==================== 兜底 ====================
/** 未知运行时异常 */
@ExceptionHandler(RuntimeException.class)
public ResultBean handleRuntimeException(RuntimeException e) {
// log.error 完整堆栈,返回模糊消息
}
/** 非运行时异常(IOException、检查型异常等) */
@ExceptionHandler(Exception.class)
public ResultBean handleException(Exception e) {
// 最后的保险丝
}
}
最后总结一下:
好的接口,不只是成功时能跑通;失败时,也要让人知道为什么失败。
当你开始用 AI 写代码,这件事更不能靠临场发挥。
把三层校验、异常拦截、错误消息和 Service 自查清单写进规则文件,让它每次生成代码都自动过一遍,才是真正省心的做法。
如果你也在用 AI 写后端,可以先把上面的规则片段拿去试一轮。改完之后,你会明显感觉到:接口不只是“能用”,而是更像一个认真对用户负责的系统。
更多推荐


所有评论(0)