第 50 章:统一响应封装
学习目标
- 设计规范的 API 响应结构
- 实现自动包装,避免样板代码
- 掌握分页响应封装
一、为什么要统一响应?
// ❌ 每个接口返回结构都不一样
@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 自动跳登录)
二、响应结构设计
@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();
}
}响应示例:
// 成功
{
"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 拦截器统一处理),其他业务错误交给业务层处理更灵活。
四、自动包装:避免样板代码
// ❌ 每个接口都手动包一层,重复
@GetMapping("/{id}")
public Result<UserVO> getUser(@PathVariable Long id) {
return Result.success(userService.getVOById(id));
}用 ResponseBodyAdvice 自动包装:
@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 可以这样写:
@GetMapping("/{id}")
public UserVO getUser(@PathVariable Long id) { // ✅ 直接返回业务对象
return userService.getVOById(id);
}③ 为什么 String 要特殊处理? Spring 处理
String返回值时用的是StringHttpMessageConverter(优先级高于 Jackson)。它拿到Result对象会直接toString(),输出Result(code=0,...)这样的乱码。必须自己序列化成 JSON 字符串。这是ResponseBodyAdvice最经典的坑。
什么时候不该自动包装?
@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() { }五、分页响应封装
@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;
}
}使用:
@GetMapping("/page")
public PageResult<UserVO> page(UserPageQuery query) {
IPage<User> page = userService.page(query);
return PageResult.of(page, UserConvert.INSTANCE::toVO); // ① 顺带完成 DO→VO
}分页查询入参基类:
@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会让数据库一次查出所有数据,直接把服务打挂(内存溢出 + 数据库慢查询)。这是最常见的接口安全漏洞之一。
六、错误码规范
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位)
① 为什么密码错误和用户不存在返回同一个提示? 如果分开返回,攻击者可以通过「用户不存在」的提示批量枚举出系统里有哪些用户名,然后针对性撞库。**统一返回「用户名或密码错误」**是安全基线要求。
七、给前端的对接约定
// 前端 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 章:全局异常处理 →