三层校验 + 13 类异常拦截 + 7 项自查清单:让每个接口报错都说人话

文章结尾提供可写入 CLAUDE.md 的接口健壮性规则,以及一份通用全局异常处理器模板。你可以直接把文章喂给 AI,再按自己的项目习惯微调。适合自己的,才是最好的。

实习的时候,有件事让我特别难受。

不是加班,不是技术难,而是每次在公司内部平台上操作失败,系统就甩给我一句话:

“系统异常,请联系管理员。”

就这一句。

哪个字段有问题?是我不该这么操作,还是系统真的崩了?不知道。我只能打开 Chrome 开发者工具,切到 Network 面板,看请求的响应体。有时候能抓到点蛛丝马迹,有时候连 Response 都是空的。

那一刻我心里就埋下了一颗种子:

以后我写的接口,绝不能让用户猜自己错在哪。

后来开始用 AI 写代码,我发现一个更有意思的现象:AI 写业务逻辑很快,但写出来的接口,错误处理往往缺胳膊少腿。

要么字段校验没加全,要么异常拦截漏了几种,要么 update 失败也不检查,直接 return success 完事。

所以我花了一整个下午,拉着 AI 把“异常处理”这件事从头到尾捋了一遍。不是为了修一个 bug,而是要把它规则化、流程化,让它成为 AI Agent 写代码时的“肌肉记忆”。

这篇文章就是这个过程的完整记录。如果你也在用 AI 写后端,或者被“系统异常”折磨过,希望这套方法能帮到你。

目录

  1. 从 update 接口说起
  2. 三层防线,各管各的
  3. 全局异常处理器别只写一个 Exception
  4. 错误消息要让用户看得懂、改得了
  5. Service 层 7 项自查清单
  6. 让规则活在 Agent 的脑子里
  7. 可直接复用的规则片段
  8. 通用全局异常处理器模板

从 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
入库前 trimString 字段统一 trim,静默处理
字段长度对照 DB DDL,每个 String 字段有长度上限
affectedRowsupdateById / 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 写后端,可以先把上面的规则片段拿去试一轮。改完之后,你会明显感觉到:接口不只是“能用”,而是更像一个认真对用户负责的系统。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐