接口报错别让用户猜:统一响应、错误码与 requestId

SailTrack
2026-06-06
点 赞
0
热 度
2
评 论
0
  1. 首页
  2. 后端开发
  3. 接口报错别让用户猜:统一响应、错误码与 requestId

写在前面

项目早期最被低估的技术债,不是数据库设计,也不是架构分层,而是报错方式。

每个 Controller 自己拼返回结构:有的返回 {code: 200},有的返回 {success: true},有的直接把实体丢出去;报错更随意——有的抛 RuntimeException 变成前端眼里的 500,有的返回 200 但 body 里塞一句"操作失败"。前端同学每次对接新接口都要先问一句:“你这个接口报错的时候长什么样?”

一个所有接口都回答不一致的问题,就是系统性问题。这篇记录我后来怎么把响应、异常、错误码、requestId 这四件事一次性统一掉的。总共不到两百行代码,但它是全项目被引用最多的两百行。

一、先定形状:统一响应包

所有接口返回同一个外壳:

@Data
public class R<T> implements Serializable {

    private int code;
    private String message;
    private T data;
    private String requestId;

    public static <T> R<T> ok(T data) {
        R<T> r = new R<>();
        r.setCode(0);
        r.setMessage("ok");
        r.setData(data);
        r.setRequestId(resolveRequestId());
        return r;
    }

    public static <T> R<T> fail(int code, String message) { ... }
}

形状本身不稀奇,稀奇的是几个必须一开始就想清楚的决策:

code = 0 表示成功,不是 200。 0 在大多数语言里都是"无错误"的传统值,而且它和 HTTP 状态码 200 天然区分开——HTTP 状态码描述传输层,code 描述业务层,两套编码系统混在一起迟早打架。

data 泛型,成功才可能有数据。 失败响应的 data 永远是 null,错误信息只放 message。不要发明 {code: 500, data: {reason: ...}} 这种嵌套——错误详情属于 message 或者日志,不属于 data。

requestId 自动注入。 这是整个外壳里我最满意的部分:R.ok() 里面调 resolveRequestId(),从 RequestContextHolder 拿当前请求的关联 ID。业务代码完全不用管 requestId,它自己就在。

// 非 Web 环境(单测、异步线程)拿不到 request,返回 null 不报错
private static String resolveRequestId() {
    RequestAttributes attrs = RequestContextHolder.getRequestAttributes();
    ...
}

注意这个判空:统一响应包会被单测和异步线程复用,那里没有请求上下文。工具类对环境的宽容度,决定了它会不会变成"只在 Web 层能用"的半残品。

二、错误码:前缀编码,不用发明新体系

错误码我见过两种失败方案:一种是全靠乱编(30001、50002……半年后没人记得 30004 是什么),一种是过度设计(错误码分五段,每段有含义,配一张 Excel 对照表)。

我的方案是让业务码"长得像"它对应的 HTTP 状态:

public enum BusinessErrorCode {
    GENERAL(100000, HttpStatus.BAD_REQUEST),
    BAD_REQUEST(100400, HttpStatus.BAD_REQUEST),
    UNAUTHORIZED(100401, HttpStatus.UNAUTHORIZED),
    FORBIDDEN(100403, HttpStatus.FORBIDDEN),
    NOT_FOUND(100404, HttpStatus.NOT_FOUND),
    CONFLICT(100409, HttpStatus.CONFLICT),
    LOCKED(100423, HttpStatus.LOCKED),
    TOO_MANY_REQUESTS(100429, HttpStatus.TOO_MANY_REQUESTS),
    INTERNAL_ERROR(100500, HttpStatus.INTERNAL_SERVER_ERROR),
    ...
}

1 开头表示业务错误,后五位就是 HTTP 状态码。看到 100429 不用查表也知道是"请求过多",看到 100403 就是"没权限"。错误码的第一受众是人,不是程序——能让同事一眼读懂的编码,才不用维护对照表。

每个枚举值同时绑定 HttpStatus,业务抛错时只说"我是 CONFLICT",状态码由枚举统一决定,Controller 里从此不再出现 ResponseEntity.status(409) 这种手写数字。

三、业务异常:只带两个信息

@Getter
public class BizException extends RuntimeException {

    private final int code;
    private final HttpStatus httpStatus;

    public BizException(int httpStatus, String message) {
        this(BusinessErrorCode.fromHttpStatus(httpStatus), message);
    }

    public BizException(BusinessErrorCode errorCode, String message) {
        super(message);
        this.code = errorCode.code();
        this.httpStatus = errorCode.httpStatus();
    }
}

业务异常只携带"业务码 + 人话"两样东西。它是 RuntimeException——业务错误不是调用方"必须当场处理"的受检异常,让它一路向上飘到统一出口即可。Service 层的写法因此变成一行:

throw new BizException(409, "该部门下仍有在职员工,无法删除");

不写 try、不写返回值判空、不用 Result<T> 包着 errorCode 往上传。异常的错误传播路径,比返回值干净一个数量级——前提是有下面的统一出口接住它。

四、统一出口:一层兜底,各管一段

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BizException.class)
    public ResponseEntity<R<Void>> handleBizException(BizException e) {
        return ResponseEntity.status(e.getHttpStatus()).body(R.fail(e.getCode(), e.getMessage()));
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public R<Void> handleValidationException(MethodArgumentNotValidException e) {
        String message = ...; // 取字段校验信息
        return R.fail(BusinessErrorCode.BAD_REQUEST.code(), message);
    }

    // 兜底:未预期的异常统一返回 500,详细信息只记日志不暴露给前端
    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public R<Void> handleException(Exception e) {
        log.error("系统异常", e);
        return R.fail(BusinessErrorCode.INTERNAL_ERROR.code(), "系统内部错误");
    }
}

结构是"从具体到兜底"的漏斗:业务异常按自己的状态码返回;参数校验、认证、权限、上传超限这些"可预期错误"各有专属文案;最后 Exception.class 兜底——记完整堆栈到日志,给前端一句毫无信息量的"系统内部错误"。

这句"毫无信息量"是故意的。数据库连接串、SQL 片段、内部路径,这些堆栈里的东西对用户毫无价值,对攻击者价值连城。用户要的从来不是异常详情,是"出错了,requestId 是 abc123,找客服能查到"。

五、一个真实的坑:multipart 的两面性

统一异常里有个比较绕的处理,值得单独讲:

@ExceptionHandler(MultipartException.class)
public ResponseEntity<R<Void>> handleMultipartException(MultipartException e) {
    if (isMalformedMultipart(e)) {
        return ResponseEntity.badRequest()
            .body(R.fail(BusinessErrorCode.BAD_REQUEST.code(), "上传请求格式错误"));
    }
    log.error("解析 multipart 请求失败", e);
    return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
        .body(R.fail(BusinessErrorCode.INTERNAL_ERROR.code(), "系统内部错误"));
}

同样是"解析上传请求失败",可能是用户的错(请求体被截断、boundary 错乱、用 curl 传坏了文件),也可能是服务端的错(临时目录不可写、IO 异常)。前者该 400 让用户检查请求,后者该 500 让运维看日志。Spring 把这两种都包成同一个 MultipartException,所以只能顺着 cause 链往下挖,判断根因是不是 FileUploadException 家族。

这个小函数背后是个大原则:异常的 HTTP 语义由"谁造成了这个错误"决定,而不是由"抛了什么类"决定。 4xx 意味着"你重试也一样,请改你的请求",5xx 意味着"我这边出问题了"——分错了,监控系统会报警报向错误的方向。

六、小结

三点体会:

  1. 统一响应的价值在"一致性",不在结构本身。 {code, message, data, requestId} 没有任何高明之处,高明的是全项目两千个接口都长这样——前端可以写一个通用的请求封装,错误提示、跳转登录、requestId 上报全部只写一次。
  2. 错误码要能被人类直接读懂。 编码方案的第一受众是凌晨排查问题的同事。100429 不需要查表,这就赢了。
  3. 兜底 handler 是安全边界,不是垃圾桶。 它负责两件事:不让内部细节泄露给外界,不让任何异常裸奔成 Tomcat 默认错误页。做到这两件事,其余的都可以"具体错误具体处理"。

统一之后,前端的请求封装长这样:code !== 0 就弹 message,401 跳登录,其他一律带上 requestId 上报到监控。对接新接口的问题从"你报错长什么样"变成了"你的业务码是哪个"——进步不大,但每一次对接都省下了那五分钟。


让我们忠于理想,让我们面对显示

SailTrack

entp 辩论家

站长

不具版权性
不具时效性

文章内容不具时效性。若文章内容有错误之处,请您批评指正。

目录

欢迎来到SailTrack的站点,为您导航全站动态

65 文章数
11 分类数
5 评论数
60标签数