Skip to content
第 50 / 250 章后端⏱ 10 分钟阅读

第 50 章:统一响应封装

学习目标

  • 设计规范的 API 响应结构
  • 实现自动包装,避免样板代码
  • 掌握分页响应封装

一、为什么要统一响应?

java
// ❌ 每个接口返回结构都不一样
@GetMapping("/user/1")
public User getUser() { return user; }               // 直接返回对象

@GetMapping("/users")
public List<User> listUsers() { return list; }       // 返回数组

@PostMapping("/user")
public Map<String, Object> create() {                // 返回 Map
    return Map.of("success", true, "id", 1);
}

前端痛点

  • 每个接口都要写不同的解析逻辑
  • 出错时结构又变了(可能返回 Spring 默认的错误 JSON)
  • 无法统一做拦截处理(比如 401 自动跳登录)

二、响应结构设计

java
@Data
@Schema(description = "统一响应结构")
public class Result<T> implements Serializable {

    @Schema(description = "业务状态码,0 表示成功")
    private int code;

    @Schema(description = "提示信息")
    private String message;

    @Schema(description = "业务数据")
    private T data;

    @Schema(description = "链路追踪 ID")
    private String traceId;

    @Schema(description = "服务器时间戳")
    private long timestamp;

    // ---------- 静态工厂方法 ----------

    public static <T> Result<T> success() {
        return success(null);
    }

    public static <T> Result<T> success(T data) {
        Result<T> r = new Result<>();
        r.code = ErrorCode.SUCCESS.getCode();
        r.message = ErrorCode.SUCCESS.getMessage();
        r.data = data;
        r.traceId = MDC.get("traceId");           // ① 自动带上链路 ID
        r.timestamp = System.currentTimeMillis();
        return r;
    }

    public static <T> Result<T> fail(ErrorCode errorCode) {
        return fail(errorCode.getCode(), errorCode.getMessage());
    }

    public static <T> Result<T> fail(int code, String message) {
        Result<T> r = new Result<>();
        r.code = code;
        r.message = message;
        r.traceId = MDC.get("traceId");
        r.timestamp = System.currentTimeMillis();
        return r;
    }

    @JsonIgnore                                    // ② 不序列化给前端
    public boolean isSuccess() {
        return code == ErrorCode.SUCCESS.getCode();
    }
}

响应示例

json
// 成功
{
  "code": 0,
  "message": "成功",
  "data": { "id": 1, "username": "张三" },
  "traceId": "a1b2c3d4e5f6g7h8",
  "timestamp": 1723449600000
}

// 失败
{
  "code": 20001,
  "message": "用户不存在",
  "data": null,
  "traceId": "a1b2c3d4e5f6g7h8",
  "timestamp": 1723449600000
}

① traceId 的价值:用户报障时截图给你,你直接 grep traceId 就能定位到那次请求的全部日志。这是排查线上问题效率提升最大的一个小设计。

三、HTTP 状态码 vs 业务状态码

两种流派

流派做法优点缺点
RESTful 严格派业务错误也用 HTTP 码(400/404/409)语义标准,能用 HTTP 缓存和中间件业务错误种类远超 HTTP 码数量;网关/CDN 可能对 4xx/5xx 做特殊处理
统一 200 派一律 HTTP 200,业务结果看 body.code前端处理简单,业务码可无限扩展不符合 REST 规范;监控要额外配置

推荐折中方案

  • 认证失败 → HTTP 401(前端拦截器需要靠这个自动跳登录)
  • 无权限 → HTTP 403
  • 路径不存在 → HTTP 404
  • 其他所有业务错误 → HTTP 200 + body.code

理由:401/403 是前端必须用 HTTP 层拦截的(axios 拦截器统一处理),其他业务错误交给业务层处理更灵活。

四、自动包装:避免样板代码

java
// ❌ 每个接口都手动包一层,重复
@GetMapping("/{id}")
public Result<UserVO> getUser(@PathVariable Long id) {
    return Result.success(userService.getVOById(id));
}

ResponseBodyAdvice 自动包装

java
@RestControllerAdvice(basePackages = "com.taskflow.modules")
public class ResultWrapperAdvice implements ResponseBodyAdvice<Object> {

    @Override
    public boolean supports(MethodParameter returnType,
                            Class<? extends HttpMessageConverter<?>> converterType) {
        // ① 已经是 Result 的不再包装
        if (Result.class.isAssignableFrom(returnType.getParameterType())) {
            return false;
        }
        // ② 标注了 @NoWrap 的跳过(如文件下载、第三方回调)
        if (returnType.hasMethodAnnotation(NoWrap.class)) {
            return false;
        }
        return true;
    }

    @Override
    public Object beforeBodyWrite(Object body, MethodParameter returnType,
                                  MediaType selectedContentType,
                                  Class<? extends HttpMessageConverter<?>> converterType,
                                  ServerHttpRequest request, ServerHttpResponse response) {

        // ③ String 返回值特殊处理
        if (body instanceof String) {
            // StringHttpMessageConverter 不走 Jackson,必须手动转 JSON 字符串
            try {
                return objectMapper.writeValueAsString(Result.success(body));
            } catch (JsonProcessingException e) {
                throw new BusinessException(ErrorCode.SYSTEM_ERROR, e);
            }
        }

        return Result.success(body);
    }
}

现在 Controller 可以这样写

java
@GetMapping("/{id}")
public UserVO getUser(@PathVariable Long id) {      // ✅ 直接返回业务对象
    return userService.getVOById(id);
}

③ 为什么 String 要特殊处理? Spring 处理 String 返回值时用的是 StringHttpMessageConverter(优先级高于 Jackson)。它拿到 Result 对象会直接 toString(),输出 Result(code=0,...) 这样的乱码。必须自己序列化成 JSON 字符串。这是 ResponseBodyAdvice 最经典的坑。

什么时候不该自动包装?

java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NoWrap { }

// ① 文件下载
@NoWrap
@GetMapping("/download")
public ResponseEntity<Resource> download() { }

// ② 第三方回调(对方要求固定格式,如微信支付要 "SUCCESS")
@NoWrap
@PostMapping("/wechat/notify")
public String notify() { return "SUCCESS"; }

// ③ 对外开放 API(有自己的协议规范)
@NoWrap
@GetMapping("/open/v1/data")
public OpenApiResponse openApi() { }

五、分页响应封装

java
@Data
public class PageResult<T> implements Serializable {

    private List<T> records;         // 当前页数据
    private long total;              // 总记录数
    private long current;            // 当前页码
    private long size;               // 每页条数
    private long pages;              // 总页数

    /** 从 MyBatis-Plus 的 IPage 转换 */
    public static <T> PageResult<T> of(IPage<T> page) {
        PageResult<T> result = new PageResult<>();
        result.records = page.getRecords();
        result.total = page.getTotal();
        result.current = page.getCurrent();
        result.size = page.getSize();
        result.pages = page.getPages();
        return result;
    }

    /** 转换实体类型:DO 分页 → VO 分页 */
    public static <T, R> PageResult<R> of(IPage<T> page, Function<T, R> converter) {
        PageResult<R> result = new PageResult<>();
        result.records = page.getRecords().stream().map(converter).toList();
        result.total = page.getTotal();
        result.current = page.getCurrent();
        result.size = page.getSize();
        result.pages = page.getPages();
        return result;
    }

    /** 空分页 */
    public static <T> PageResult<T> empty() {
        PageResult<T> result = new PageResult<>();
        result.records = Collections.emptyList();
        result.total = 0;
        return result;
    }
}

使用

java
@GetMapping("/page")
public PageResult<UserVO> page(UserPageQuery query) {
    IPage<User> page = userService.page(query);
    return PageResult.of(page, UserConvert.INSTANCE::toVO);   // ① 顺带完成 DO→VO
}

分页查询入参基类

java
@Data
public class PageQuery {

    @Min(value = 1, message = "页码从 1 开始")
    private long current = 1;

    @Min(value = 1, message = "每页至少 1 条")
    @Max(value = 100, message = "每页最多 100 条")     // ① 必须限制上限!
    private long size = 10;

    private String orderBy;
    private Boolean asc = true;
}

① 为什么必须限制 size 上限? 不限制的话,攻击者传 size=99999999 会让数据库一次查出所有数据,直接把服务打挂(内存溢出 + 数据库慢查询)。这是最常见的接口安全漏洞之一

六、错误码规范

java
public enum ErrorCode {

    SUCCESS(0, "成功"),

    // 1xxxx: 通用/系统
    PARAM_INVALID(10001, "参数校验失败"),
    UNAUTHORIZED(10401, "未登录或登录已过期"),
    FORBIDDEN(10403, "没有操作权限"),
    NOT_FOUND(10404, "资源不存在"),
    METHOD_NOT_ALLOWED(10405, "请求方法不支持"),
    RATE_LIMITED(10429, "请求过于频繁,请稍后重试"),
    SYSTEM_ERROR(10500, "系统繁忙,请稍后重试"),

    // 2xxxx: 用户模块
    USER_NOT_FOUND(20001, "用户不存在"),
    USER_EXISTS(20002, "用户名已被占用"),
    PASSWORD_ERROR(20003, "用户名或密码错误"),      // ① 故意模糊,防用户名枚举
    USER_DISABLED(20004, "账号已被禁用"),

    // 3xxxx: 订单模块
    ORDER_NOT_FOUND(30001, "订单不存在"),
    ORDER_STATUS_ERROR(30002, "订单状态不允许此操作"),
    STOCK_NOT_ENOUGH(30003, "库存不足"),

    // 4xxxx: 支付模块
    PAY_FAILED(40001, "支付失败"),
    REFUND_FAILED(40002, "退款失败");

    private final int code;
    private final String message;
    // 构造器、getter 省略
}

编码规则模块号(1-2位) + 类型(1位) + 序号(2-3位)

① 为什么密码错误和用户不存在返回同一个提示? 如果分开返回,攻击者可以通过「用户不存在」的提示批量枚举出系统里有哪些用户名,然后针对性撞库。**统一返回「用户名或密码错误」**是安全基线要求。

七、给前端的对接约定

typescript
// 前端 axios 拦截器
axios.interceptors.response.use(
  (response) => {
    const res = response.data;

    if (res.code === 0) {
      return res.data;              // ① 成功直接返回业务数据,业务代码无感知
    }

    // ② 特殊码特殊处理
    if (res.code === 10401) {
      router.push('/login');
      return Promise.reject(new Error('未登录'));
    }

    // ③ 其他业务错误统一弹窗
    ElMessage.error(res.message);
    return Promise.reject(new Error(res.message));
  },
  (error) => {
    // ④ HTTP 层错误(网络断开、500、超时)
    if (error.response?.status === 401) {
      router.push('/login');
    }
    ElMessage.error('网络异常,请稍后重试');
    return Promise.reject(error);
  }
);

有了统一响应结构,前端只需写一次拦截器,所有接口自动获得统一的错误处理和登录跳转。

八、本章小结

要点关键
结构code + message + data + traceId
traceId报障时定位问题的关键
状态码401/403/404 用 HTTP,其他用 body.code
自动包装ResponseBodyAdvice,注意 String 的坑
@NoWrap文件下载、第三方回调要排除
分页PageResult必须限制 size 上限
错误码枚举 + 模块分段
安全密码错误不区分「用户不存在」

动手练习

练习 1:基础题

实现 Result<T>PageResult<T>,写一个 Controller 分别返回单对象、列表、分页三种数据,验证格式统一。

练习 2:进阶题

实现 ResponseBodyAdvice 自动包装,并加上 @NoWrap 注解支持。特别验证返回 String 类型时不会报错。


下一章第 51 章:全局异常处理

本站基于 VitePress 构建 · 由 Codebook 团队维护