欢迎光临
我们一直在努力

这可能是全网最好的 Spring Boot 接口通用响应对象设计

这可能是全网最好的 Spring Boot 接口通用响应对象设计

摘要: MetaLite 的 Resp<T> 用五个字段分别承载业务状态、对外提示、内部诊断、明文数据和密文数据。本文从完整类定义出发,拆解字段与工厂方法的设计,再结合入口切面、业务接口及网关处理器,讲清响应如何生成、服务端如何解密请求和加密响应,以及使用时必须明确的约束。

写一个 code、message、data 不难。接口多了,真正难的是:失败信息给谁看,异常在哪一层转换,数据加密后还怎么保留类型,以及服务调用拿到 HTTP 200 后该不该继续判断业务结果。

这篇拆解 MetaLite 的 Resp<T>:从五个字段到全部手写方法,再看它如何接入 Controller、异常处理和网关响应。哪些设计可以借鉴,哪些约束还需要补齐,一起说清楚。

环境:JDK 21、Spring Boot 3,使用 Lombok、Fastjson2 和 Jackson 注解。下文的 Resp、ErrorCode、处理器链属于应用自有代码,不是 Spring Boot 内置功能;先展示 Resp 完整源码,再分段拆解;相关处理器片段不能单独当成完整工程运行。

Resp 的完整定义与字段职责

先看完整的 Resp<T>,包括包声明、依赖、类注解、字段、构造器和方法。下面保留源码及原注释,后面的章节再分别解释它们的作用。

T 表示业务数据类型,implements Pojo 接入应用的公共对象约定;@Data 提供 Lombok 生成的方法,@Schema 描述接口文档。这个类依赖应用中的 ErrorCode、ServiceException 和工具类,不是复制到空项目就能独立运行的单文件。

原注释中的“返回前清空”需要结合实际处理器理解:响应 data 只在公网请求且 encryptData 非 null 时清空,errorReason 则尚未被所示出口统一清理。下面会分别展开,不能把注释当成已完成的安全保证。

/*
* Copyright (c) 2025 元界MetaLite
* MIT License
*/

package com.metalite.common.pojo;

import com.alibaba.fastjson2.annotation.JSONField;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.metalite.common.error.ErrorCode;
import com.metalite.common.error.ServiceException;
import com.metalite.common.util.ThrowableUtil;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;

import static com.metalite.common.util.AssertUtil.paramNotNull;
import static com.metalite.common.util.MixedUtil.safeFormat;

/**
* 响应对象标识
*
* @author 元界MetaLite
*/

@Data
@Schema
public class Resp<T> implements Pojo {
public static final int CODE_OK = 1000;
public static final String MESSAGE_OK = "ok";
public static final Resp NO_DATA_OK_INSTANCE = new Resp(CODE_OK, MESSAGE_OK);

/**
* 业务状态码, 表示应用层状态
*
* @see ErrorCode
*/

@Schema(title = "业务状态码")
private int code = CODE_OK;
/**
* 通常给调用方展示
*/

@Schema(title = "响应信息")
private String message = MESSAGE_OK;
/**
* 仅用于开发人员排查问题,返回给外部调用方前会清空
*/

@Schema(title = "错误详情", hidden = true)
private String errorReason;
/**
* 返回给外部调用方前会清空
*/

@Schema(title = "响应明文数据", description = "仅用于文档展示, 返回之前会清空, 实际数据需解密encryptData")
private T data;
/**
* 仅用于返回给外部调用方
*/

@Schema(title = "响应密文数据", description = "data字段转json后的加密内容")
private String encryptData;

public Resp() {
}

public Resp(int code, String message) {
this.code = code;
this.message = message;
}

public Resp(int code, String message, T data) {
this.code = code;
this.message = message;
this.data = data;
}

@Schema(hidden = true)
@JSONField(serialize = false)
@JsonIgnore
public boolean isOk() {
return CODE_OK == this.code;
}

public static Resp ok() {
return NO_DATA_OK_INSTANCE;
}

public static <T> Resp<T> ok(T data) {
return new Resp(CODE_OK, MESSAGE_OK, data);
}

public static <T> Resp<T> ok(String message, T data) {
return new Resp(CODE_OK, message, data);
}

/**
* 使用通用错误码响应
*/

public static Resp error(String message) {
int code = ErrorCode.OPERATE_FAIL.getCode();
String msg = ErrorCode.OPERATE_FAIL.getMessage();
if (message == null) {
return new Resp(code, String.format(msg, ""));
} else {
return new Resp(code, String.format(msg, message));
}
}

/**
* 使用通用错误码响应, 带String.format的占位符
*/

public static Resp error(String message, Object... args) {
int code = ErrorCode.OPERATE_FAIL.getCode();
String msg = ErrorCode.OPERATE_FAIL.getMessage();
String safeMessage = safeFormat(message, args);
if (safeMessage == null) {
return new Resp(code, String.format(msg, ""));
} else {
return new Resp(code, String.format(msg, safeMessage));
}
}

/**
* 使用指定错误码响应
*/

public static Resp error(ErrorCode errorCode) {
paramNotNull(errorCode, "errorCode");
return new Resp(errorCode.getCode(), String.format(errorCode.getMessage(), ""));
}

/**
* 使用带占位符的指定错误码响应
*/

public static Resp error(ErrorCode errorCode, String appendMessage) {
paramNotNull(errorCode, "errorCode");
int code = errorCode.getCode();
String msg = errorCode.getMessage();
if (appendMessage == null) {
return new Resp(code, String.format(msg, ""));
} else {
return new Resp(code, String.format(msg, appendMessage));
}
}

/**
* 使用带占位符的指定错误码响应
*/

public static Resp error(Throwable throwable) {
//参数校验异常,转换各层使用Assert进行的防御性编程抛出的异常
if (throwable instanceof IllegalArgumentException) {
return Resp.error(ErrorCode.PARAM_INVALID, throwable.getMessage());
}

//状态检测异常,转换各层使用Assert.state进行的防御性编程抛出的异常
if (throwable instanceof IllegalStateException) {
return Resp.error(ErrorCode.OPERATE_FAIL, throwable.getMessage());
}

// 服务层指定异常,转换service层使用显示抛出的业务异常
if (throwable instanceof ServiceException se) {
return new Resp(se.getCode(), se.getMessage());
}

// 服务层内部异常,转换各层抛出的意外的异常
Resp resp = Resp.error(ErrorCode.SERVER);
resp.setErrorReason(ThrowableUtil.getMessage(throwable));
return resp;
}

public void setData(T data) {
if (NO_DATA_OK_INSTANCE == this) {
// 返回对象的原始哈希码,即使重写了hashCode()方法
int identityHash = System.identityHashCode(NO_DATA_OK_INSTANCE);
throw new IllegalStateException("此实例是全局唯一的无数据成功Resp实例: " + identityHash + ", 不支持为其设置数据");
}
this.data = data;
}
}

五个字段分别承担以下职责:

字段负责什么
code 调用方据此判断业务结果
message 给调用方看的提示
errorReason 留给开发人员的异常细节
data 服务内部操作的类型化业务数据
encryptData 按调用方配置生成的业务数据密文

这套划分最值得借鉴的地方,是让展示、排错和数据传输各用各的字段。 但字段分开,并不代表出口隔离已经自动完成。

业务提示与内部诊断:异常如何转换成响应

数据库连接地址、SQL 片段、下游服务地址,都可能出现在异常消息里。如果不分异常类型,直接把 exception.getMessage() 填进 message,就把内部细节暴露给了调用方。

这里的设计是:已知业务错误保留业务提示,未知异常统一提示“系统内部错误”,细节另放 errorReason。

有一个必须先说清的限制:@Schema(hidden = true) 只影响接口文档,不会阻止字段写进 JSON。 errorReason 的注释写着对外清空,但所示出口实现没有完成这一步。不能仅凭注释就认为它不会泄露。这个属性的职责可对照 Schema 官方文档。

异常转换集中在 error(Throwable),业务接口不用每次写一套 try/catch 和错误包装。

/**
* 使用带占位符的指定错误码响应
*/

public static Resp error(Throwable throwable) {
//参数校验异常,转换各层使用Assert进行的防御性编程抛出的异常
if (throwable instanceof IllegalArgumentException) {
return Resp.error(ErrorCode.PARAM_INVALID, throwable.getMessage());
}

//状态检测异常,转换各层使用Assert.state进行的防御性编程抛出的异常
if (throwable instanceof IllegalStateException) {
return Resp.error(ErrorCode.OPERATE_FAIL, throwable.getMessage());
}

// 服务层指定异常,转换service层使用显示抛出的业务异常
if (throwable instanceof ServiceException se) {
return new Resp(se.getCode(), se.getMessage());
}

// 服务层内部异常,转换各层抛出的意外的异常
Resp resp = Resp.error(ErrorCode.SERVER);
resp.setErrorReason(ThrowableUtil.getMessage(throwable));
return resp;
}

四条分支的含义很清楚。

异常类型转换结果
IllegalArgumentException 参数无效,1002
IllegalStateException 操作失败,1011
ServiceException 保留异常携带的业务码和消息
其他异常 1999,固定提示,细节写入 errorReason

ThrowableUtil.getMessage 取的是异常消息及根因消息,不是完整堆栈。完整堆栈应由服务端异常日志记录。

调用示例:

Resp<?> invalid = Resp.error(
new IllegalArgumentException("userId"));
System.out.println(invalid.getCode());
// 预期:1002
System.out.println(invalid.getMessage());
// 预期:参数无效 userId

Resp<?> failed = Resp.error(
new RuntimeException("downstream unavailable"));
System.out.println(failed.getMessage());
// 预期:系统内部错误
System.out.println(failed.getErrorReason());
// 预期:downstream unavailable

这里有两个使用条件。

参数异常和状态异常的消息会直接进入对外提示,因此这些异常消息本身也必须适合公开。不能把任意库抛出的 IllegalArgumentException 都默认当成安全的业务文案。

另外,分类针对传入的异常本身。若 ServiceException 被包在 CompletionException 里面,这段方法不会递归查找业务异常,而会走未知异常分支。

出口隔离还需要补齐。 可采用独立的对外响应 DTO,或者在公网出口统一排除 errorReason。这是接入建议,不是上面代码已经实现的功能;只隐藏 Swagger 字段、只改字段注释都不够。

业务状态与 HTTP 状态,分别表达什么

“请求处理完成”和“业务操作成功”并不是同一层结果。接口可以正常返回一份“需要登录”的响应;如果调用端只判断 HTTP 状态,不再看 code,后续逻辑就可能继续使用不存在的数据。

另一种常见误判是 data != null 才算成功。创建、删除等操作没有返回数据,也完全可以成功。成功条件应该明确,不依赖消息文字或数据有没有值。

成功常量和判断方法是:

public static final int CODE_OK = 1000;
public static final String MESSAGE_OK = "ok";
public static final Resp NO_DATA_OK_INSTANCE = new Resp(CODE_OK, MESSAGE_OK);

@Schema(hidden = true)
@JSONField(serialize = false)
@JsonIgnore
public boolean isOk() {
return CODE_OK == this.code;
}

成功码使用 1000,普通成功消息是 ok。选 0、200 还是 1000 没有统一答案,关键是调用双方使用同一份契约,不能与 HTTP 状态码混为一谈。

isOk() 是 Java 端的派生判断,不需要再输出成 JSON 的 ok 字段。所以这里同时使用 Fastjson2 的 @JSONField(serialize = false) 和 Jackson 的 @JsonIgnore,分别约束两种序列化方式;@Schema 则负责文档展示。

调用示例:

Resp<?> success = Resp.ok();
System.out.println(success.isOk());
// 预期:true
System.out.println(success.getData() == null);
// 预期:true

Resp<?> needLogin = Resp.error(ErrorCode.NEED_LOGIN);
System.out.println(needLogin.isOk());
// 预期:false
System.out.println(needLogin.getCode());
// 预期:1010

网关另用处理器设置 HTTP 状态:

@Override
public void postHandle(AspectInfo aspectInfo, Object result) {
if (!(result instanceof Resp resp)) {
return;
}
if (Objects.equals(resp.getCode(), ErrorCode.SERVER.getCode())) {
WebUtil.setResponseStatusCode(HttpStatus.INTERNAL_SERVER_ERROR.value());
}
}

这段实现只把业务码 1999 映射成 HTTP 500,其他情况不主动更改 HTTP 状态。它没有把参数错误自动映射为 400,也没有把限流自动映射为 429。是否需要这些映射,应由接口协议决定。

Resp 本身也不能保证所有框架异常都返回同一种 JSON。请求绑定失败、路由错误等不一定进入 Controller 方法切面;另一条全局异常处理路径返回的是 ResponseEntity<String>。客户端需要兼容这种情况,或者在应用中进一步统一出口。

Spring MVC 也提供 ProblemDetail/ErrorResponse 错误响应体系。通用业务响应是另一种应用协议选择,并非 Spring 缺少异常响应能力,详见 Spring 官方文档。

静态工厂:成功与失败响应如何创建

无数据、有数据和自定义成功提示

三个成功工厂分别照顾常见业务写法。

public static Resp ok() {
return NO_DATA_OK_INSTANCE;
}

public static <T> Resp<T> ok(T data) {
return new Resp(CODE_OK, MESSAGE_OK, data);
}

public static <T> Resp<T> ok(String message, T data) {
return new Resp(CODE_OK, message, data);
}

ok(T data) 把类型留在返回值里。Resp<String>、Resp<List<UserDto>>、Resp<PageResultDto<UserDto>> 可以使用同一层外壳,不必为每种数据形态再造一个响应类。

注意,Resp.ok("创建成功") 调用的是 ok(T data):这句话会进入 data,message 仍是 ok。如果要自定义提示,应该用两个参数的重载。

调用示例:

Resp<String> value = Resp.ok("创建成功");
System.out.println(value.getMessage());
// 预期:ok
System.out.println(value.getData());
// 预期:创建成功

Resp<Void> saved = Resp.<Void>ok("创建成功", null);
System.out.println(saved.getMessage());
// 预期:创建成功

Resp<String> independent = Resp.<String>ok(null);
independent.setData("后续填入的数据");
System.out.println(independent.getData());
// 预期:后续填入的数据

最后一种写法会新建响应对象,适合 HTTP 客户端先建空响应、再填入响应正文的过程。它与无参 ok() 的对象生命周期不同。

错误工厂:普通消息与稳定错误码

不需要调用方区分的普通失败,可使用消息工厂:

/**
* 使用通用错误码响应
*/

public static Resp error(String message) {
int code = ErrorCode.OPERATE_FAIL.getCode();
String msg = ErrorCode.OPERATE_FAIL.getMessage();
if (message == null) {
return new Resp(code, String.format(msg, ""));
} else {
return new Resp(code, String.format(msg, message));
}
}

/**
* 使用通用错误码响应, 带String.format的占位符
*/

public static Resp error(String message, Object... args) {
int code = ErrorCode.OPERATE_FAIL.getCode();
String msg = ErrorCode.OPERATE_FAIL.getMessage();
String safeMessage = safeFormat(message, args);
if (safeMessage == null) {
return new Resp(code, String.format(msg, ""));
} else {
return new Resp(code, String.format(msg, safeMessage));
}
}

两种方法都使用 OPERATE_FAIL,业务码为 1011。第二种多做一次格式化,便于把业务参数放进消息里。

需要前端明确处理登录失效、权限不足等情况时,应使用稳定错误码:

/**
* 使用指定错误码响应
*/

public static Resp error(ErrorCode errorCode) {
paramNotNull(errorCode, "errorCode");
return new Resp(errorCode.getCode(), String.format(errorCode.getMessage(), ""));
}

/**
* 使用带占位符的指定错误码响应
*/

public static Resp error(ErrorCode errorCode, String appendMessage) {
paramNotNull(errorCode, "errorCode");
int code = errorCode.getCode();
String msg = errorCode.getMessage();
if (appendMessage == null) {
return new Resp(code, String.format(msg, ""));
} else {
return new Resp(code, String.format(msg, appendMessage));
}
}

调用示例:

System.out.println(Resp.error("库存不足").getMessage());
// 预期:操作失败 库存不足

System.out.println(Resp.error("订单 %s 已关闭", "A1001").getMessage());
// 预期:操作失败 订单 A1001 已关闭

System.out.println(Resp.error(ErrorCode.PARAM_REQUIRED, "userId").getMessage());
// 预期:参数缺失 userId

System.out.println(Resp.error(ErrorCode.NEED_LOGIN).getCode());
// 预期:1010

ErrorCode 使用 code + message 组合,并非枚举。公共码放在统一类里,业务模块可以定义自己的错误码区间。已有构造器允许创建新的错误码对象,不必把所有模块的错误都塞进公共类。

这里使用的是 String.format 风格的 %s,不是日志常见的 {}。safeFormat 并不捕获所有格式化异常;格式与参数不匹配仍可能报错。它也不是脱敏或安全转义方法。

另外,error(null) 会因多个引用类型重载而产生编译歧义。真要表达空消息,必须明确类型,例如 Resp.error((String) null),不能用无类型的 null 猜重载。

构造器、Lombok 与共享实例的使用约束

三个构造器分别支持反序列化/普通实例创建、码和消息、码消息及数据。

public Resp() {
}

public Resp(int code, String message) {
this.code = code;
this.message = message;
}

public Resp(int code, String message, T data) {
this.code = code;
this.message = message;
this.data = data;
}

无参构造器依靠字段初值形成默认成功响应;后两个构造器则直接接受调用方传入的值,没有校验“失败码不得带数据”之类的组合规则。

无参 ok() 和几个 error 方法返回的是原始类型 Resp,并非所有工厂都保留了泛型检查。业务接口宜明确声明 Resp<T>,但不能把这些现有签名说成完全没有 unchecked 风险。

@Data 为非 final 实例字段生成访问方法,并生成 equals/hashCode/toString;已有手写 setData 不会被覆盖。参见 Lombok 官方说明。

因此,所有手写方法的职责可以收拢成这张表:

入口用途
3 个构造器 创建默认响应或指定字段
isOk() 判断业务成功
3 个 ok 重载 无数据、有数据、自定义提示
2 个字符串 error 重载 通用操作失败,可格式化消息
2 个 ErrorCode 重载 稳定业务码,可附加消息
error(Throwable) 把异常分类为响应
setData(T) 设置数据,并保护共享成功对象的 data

对响应对象直接使用 toString(),也可能把明文数据或异常详情写进日志。接口序列化注解不会自动约束 Lombok 生成的 toString()。

无数据成功单例:少创建对象,但不能当成不可变对象

无参 ok() 返回同一个 NO_DATA_OK_INSTANCE。为了避免有人给共享响应填数据,setData 做了身份检查:

public void setData(T data) {
if (NO_DATA_OK_INSTANCE == this) {
// 返回对象的原始哈希码,即使重写了hashCode()方法
int identityHash = System.identityHashCode(NO_DATA_OK_INSTANCE);
throw new IllegalStateException("此实例是全局唯一的无数据成功Resp实例: " + identityHash + ", 不支持为其设置数据");
}
this.data = data;
}

这里使用 == 是为了判断“是不是那一个实例”,不受 equals 的字段比较规则影响。identityHashCode 只用于报错定位,不保证全局唯一,也不是安全凭证。

但这段保护只覆盖 data。由 Lombok 生成的 setCode、setMessage、setErrorReason、setEncryptData 仍然可以修改共享对象。改变共享对象的 message,之后其他请求拿到的成功提示也会受影响。

所以无参 ok() 只能作为只读结果返回。需要调整任何字段时,使用创建新对象的工厂或构造器。要从类型层面杜绝误改,还需取消这种共享方式,或者改成真正不可变的实现;不能只凭一个 setData 防护就宣称线程安全。

响应处理链:明文、密文、日志与出口清理

业务层使用 data,网关按调用方配置把它序列化、加密,再写入 encryptData。业务 Service 不必为“这个调用方要密文”改成返回字符串。

加密处理器首先判断是否需要处理:

@Override
public void postHandle(AspectInfo aspectInfo, Object result) {
ExternalReq req = aspectInfo.findParam(ExternalReq.class);
// 所有接口(除第三方回调、健康检查、短链跳转等特殊接口)请求参数类型必须是ExternalReq及其子类
// 如果不是Resp或无响应业务具体数据或设置为不需要加密直接返回
if (req == null || !(result instanceof Resp resp) || resp.getData() == null || !callerAuthService.isNeedEncrypt(req.getAppId())) {
return;
}
encryptRespData(req.getAppId(), (Resp) result);
}

实际加密方法支持 SM4/AES 的 GCM/CBC 分支,最终执行 resp.setEncryptData(encryptData)。加密只覆盖 data,没有把整份响应或 message/errorReason 一起加密。

清理明文由另一个后置处理器负责:

@Override
public void postHandle(AspectInfo aspectInfo, Object result) {
ExternalReq req = aspectInfo.findParam(ExternalReq.class);
// 所有接口(除第三方回调、健康检查、短链跳转等特殊接口)请求参数类型必须是ExternalReq及其子类
if (req == null) {
return;
}
// 打印后才清除明文,不在解密后清除
if (result instanceof Resp resp && resp.getEncryptData() != null) {
resp.setData(null);
}
}

按处理器的升序配置,正常执行路径为:

生成密文 → 设置 HTTP 状态 → 打印响应日志 → 清除 data → 序列化响应。

这也解释了为什么不能把 data 的字段注释照字面理解成“所有外部响应都会清空”。

场景实际处理
内部请求 不走这两个外部请求处理器的数据转换
外部请求、不要求加密 通常仍返回明文 data
外部请求、产生非 null 密文 最后将 data 设为 null
没有业务数据 加密处理器直接跳过

清理条件是 encryptData != null,并没有再次验证密文有效性。因此不能由业务层随意填写这个字段。明文日志发生在清理之前,仍需要日志权限、字段脱敏和保留周期管理。

还要注意,这条后置链没有逐处理器异常隔离;加密步骤抛异常时,不能假定后面的日志、清理一定执行。接口加密也不能代替 HTTPS、身份认证和访问控制。

业务接口如何返回单对象、列表、分页与无数据结果

下面是用户接口中三个真实方法。请求参数封装在 InternalBizParamReq 中,调用的 userService 负责业务查询;这里只观察返回类型和包装位置。

@Operation(summary = "获取单个用户信息", description = "使用带参数的内部请求对象")
@PostMapping(path = "/get")
public Resp<UserEntity> getUser(@RequestBody @Valid InternalBizParamReq<UserIdParam> req,
@Parameter(hidden = true) BindingResult bindingResult) {
return Resp.ok(userService.getUser(req.getBizParam().getUserId()));
}

@Operation(summary = "获取用户列表", description = "不分页, 支持搜索、排序")
@PostMapping(path = "/list")
public Resp<List<UserEntity>> listUser(@RequestBody @Valid InternalBizParamReq<QueryUserParam> req,
@Parameter(hidden = true) BindingResult bindingResult) {
return Resp.ok(userService.listUser(req.getBizParam()));
}

@Operation(summary = "分页获取用户列表", description = "支持分页、搜索、排序")
@PostMapping(path = "/listByPage")
public Resp<PageResultDto<UserEntity>> pageUser(@RequestBody @Valid InternalBizParamReq<QueryUserParam> req,
@Parameter(hidden = true) BindingResult bindingResult) {
return Resp.ok(userService.pageUser(req.getBizParam()));
}

新增、更新等无返回数据操作,可以声明 Resp<Void>,直接返回 Service 的操作结果;无需硬塞一个 true。

示例中的类型是 UserEntity,这也说明 Resp<T> 并没有把 T 限定为 DTO。它能约束调用处的数据类型,但不会自动删掉数据库实体的敏感字段。公网接口更适合定义只包含必要字段的 DTO;Dto extends Pojo extends Serializable 只是标记约定,不是字段白名单。

分页时,total/list 放在 PageResultDto<E> 里,再作为 data 返回。不分页接口不必也带一堆分页字段。空列表是否固定返回 [],还应由分页对象初始化和序列化配置约定,不能只靠 Resp 保证。

跨服务调用还会遇到类型恢复问题。InternalServiceClient 提供单对象、列表、分页三种入口,并分别接收对象或元素的 Class。它会先判断 HTTP 调用结果,再判断解析出来的业务 Resp,最后恢复 data 类型。

泛型声明不会跨 JSON 自动保留具体类型。这里的三个入口服务于已知的数据形态,也不能据此宣称支持任意嵌套泛型。

入口切面如何统一执行结果和异常

入口切面把执行、前置拦截和异常转换连在一起:

protected Object entranceMethodAspectAround(ProceedingJoinPoint pjp, AspectTypeEnum aspectTypeEnum) {
AspectInfo aspectInfo = AspectInfoFactory.create(pjp, aspectTypeEnum);
Object result = null;
try {
Resp resp = aspectHandlerChain.applyPreHandle(aspectInfo);
if (resp.isOk()) {
result = pjp.proceed();
} else {
result = resp;
}
} catch (Throwable ex) {
aspectHandlerChain.applyErrorHandle(aspectInfo, ex);
result = Resp.error(ex);
} finally {
aspectHandlerChain.applyPostHandle(aspectInfo, result);
AspectInfoFactory.recovery(aspectInfo);
}
return result;
}

前置处理失败就返回失败 Resp,业务方法不再执行;业务方法抛出的异常则交给 Resp.error(ex)。这让 Controller 保持简单,响应格式的决定集中在入口层。

非入口调用层仍然抛异常,不在每层都“吃掉异常再返回成功”。不过,这段入口代码并不构成完整异常安全保证:异常日志处理或 finally 内的后置处理再次抛错,仍可能打断正常返回。统一响应对象和完整异常处理链,需要分别审视。

第三方回调、文件下载、SSE、健康检查等有既定协议或流式输出的接口,也不应为了“统一”强塞进这个外壳。尤其使用宽泛 Controller 切点时,要检查这些接口的返回类型与异常处理是否匹配。

服务端加密协议:字段分工、配置与算法

请求明文与响应明文属于不同对象

Resp.data 是 Java 业务对象,Resp.encryptData 是业务对象序列化后的密文字符串。Resp 没有 decryptData,也没有 plaintext 字段,更没有自动解密方法。

本文只拆服务端实现:入站时解密请求参数,出站时加密响应业务数据。Resp 自身是响应容器,不负责执行密码算法。

另一个容易混淆的名字是 ExternalReq.plaintext。它属于请求协议,不属于响应。

服务端阶段输入处理器输出
请求进入 ExternalReq.encryptData BizParamDecryptHandler ExternalReq.plaintext
业务参数转换 ExternalReq.plaintext WebUtil.genInternalReq InternalBizParamReq.bizParam
响应返回 Resp.data ApiRespEncryptHandler Resp.encryptData
响应出口清理 已生成密文的 Resp ApiClearRespDataHandler data 设为 null

两个 encryptData 名字一样,所属对象、数据方向不同。不能把请求解密处理器解释成“网关会自动解密响应给客户端”。

加密开关、算法和密钥从哪里来

响应里没有算法标识,也没有密钥。网关根据请求的 appId 查调用方配置,调用方则按事先约定的配置解密。配置实体相关字段如下:

@Schema(title = "加密类型:0-不加密 1-对称加密 2-非对称加密 3-证书加密")
private int encryptType;

@Schema(title = "加密算法")
private String encryptAlgorithm;

@Schema(title = "密钥")
private String encryptSecret;

判断是否需要加密的真实方法只有一个条件:

public boolean isNeedEncrypt(String appId) {
SysExternalCallerEntity caller = getCallerCache(appId);
return caller.getEncryptType() > 0;
}

这里有个配置边界:encryptType > 0 只表示“进入加密路径”,不表示所有非零类型都已实现。后面的响应加密方法只支持 encryptType == 1。实体注释列出的非对称加密、证书加密,不能据此当成已支持的响应能力。

配置响应加密路径
encryptType = 0 不加密,data 可按明文返回
encryptType = 1 继续匹配四种对称加密算法
encryptType = 2 或 3 有业务数据且进入处理器时抛“不支持该加密类型”
算法名不匹配 抛“不支持该加密算法”
调用方不存在或配置缺失 不能依赖此方法兜底;它直接读取缓存对象字段

Strings.CS.equals 区分大小写,配置要使用准确的 AES-GCM、AES-CBC、SM4-GCM 或 SM4-CBC。不应把“加密类型非零”作为配置校验通过的全部依据。

isNeedEncrypt 与实际加密分别读取一次缓存;这两段代码没有把配置固定为同一个请求快照。动态切换配置时,需要考虑请求执行期间配置变化,以及客户端何时切换密钥。

从 data 到 encryptData,完整实现是什么

下面是响应处理器真正执行加密的方法。保留原实现和原注释:

private void encryptRespData(String appId, Resp resp) {
// 获取明文
String plaintext = FastJson.obj2Json(resp.getData());
// 获取加密方式和密钥
SysExternalCallerEntity externalCallerEntity = callerAuthService.getCallerCache(appId);
// 对称加密
String encryptData;
if (externalCallerEntity.getEncryptType() == 1) {
if (Strings.CS.equals(externalCallerEntity.getEncryptAlgorithm(), "SM4-GCM")) {
encryptData = SM4.encryptGcm(plaintext, externalCallerEntity.getEncryptSecret());
} else if (Strings.CS.equals(externalCallerEntity.getEncryptAlgorithm(), "SM4-CBC")) {
encryptData = SM4.encryptCbc(plaintext, externalCallerEntity.getEncryptSecret());
} else if (Strings.CS.equals(externalCallerEntity.getEncryptAlgorithm(), "AES-GCM")) {
encryptData = AES.encryptGcm(plaintext, externalCallerEntity.getEncryptSecret());
} else if (Strings.CS.equals(externalCallerEntity.getEncryptAlgorithm(), "AES-CBC")) {
encryptData = AES.encryptCbc(plaintext, externalCallerEntity.getEncryptSecret());
} else {
throw new ServiceException("加密失败, 暂不支持该加密算法: " + externalCallerEntity.getEncryptAlgorithm());
}
} else {
throw new ServiceException("加密失败, 暂不支持该加密类型: " + externalCallerEntity.getEncryptType());
}

resp.setEncryptData(encryptData);
}

输入是 FastJson.obj2Json(resp.getData()),不是 resp.toString(),也不是整份 Resp 的 JSON。因此解密后直接得到业务数据的 JSON,不应该再把这段明文当成带 code/message 的响应外壳解析。

encryptData 使用 String,是为了让二进制加密结果通过 Base64 放进 JSON。Base64 只负责编码,不能靠 Base64 解码恢复业务明文。

data 则保留泛型。一份 Resp<List<UserDto>> 在加密前仍然能表达列表元素类型,接口文档也能描述这些字段。若让同一个 data 有时是对象、有时是密文字符串,服务端泛型、接口文档和调用方解析都会多一个分支。分成两个字段,变化集中在传输出口。

不过,data 的类型信息不会被秘密编码进密文。调用方仍要知道解密结果应当按对象、数组、分页对象还是字符串读取。

密文的格式、IV 和密钥如何对齐

四个分支使用的是应用里的 AES、SM4 工具类。它们把随机 IV 和加密结果拼接后,一次性做 Base64 编码。

算法Base64 解码后的字节布局密钥原始字节数
AES-GCM 12 字节 IV + 密文 + 16 字节认证标签 16、24 或 32
SM4-GCM 12 字节 IV + 密文 + 16 字节认证标签 16
AES-CBC 16 字节 IV + 填充后加密的密文 16、24 或 32
SM4-CBC 16 字节 IV + 填充后加密的密文 16

AES 使用 JDK 的 JCE;SM4 使用 Bouncy Castle Provider,需要带上相应依赖。正文环境为 JDK 21,Fastjson2 2、Bouncy Castle 1 系列;具体依赖小版本由工程管理。

两端都要约定 UTF-8、标准 Base64、密钥编码、模式及填充方式。这里的 encryptSecret 是“原始密钥字节的 Base64”,不是随便一段密码,也不是把十六进制密钥文本再次 Base64 就能通用。

GCM 使用 NoPadding,认证标签为 128 位。CBC 的 Java transformation 使用 PKCS5Padding;跨语言库若使用 PKCS7 命名,需要确认对 16 字节分组的填充行为一致。不要把整段 Base64 字符串直接当作密文字节交给密码库,也不要把 GCM 标签丢掉。

工具类每次用 SecureRandom 生成 IV,因此同一数据重复加密通常得到不同字符串。服务端解密工具从密文包里取出 IV,不需要请求额外提供一个独立 IV 字段。随机生成并不等于数学上保证永不重复;同一密钥下的 GCM IV 不能复用,密钥使用量和轮换也要管理。GCM 的认证标签与 IV 要求可对照 JDK Cipher 文档。

服务端解密:请求密文如何进入业务处理

服务端解密处理的是请求对象 ExternalReq.encryptData,不会拿响应的 Resp.encryptData 再解密。处理器为 BizParamDecryptHandler,属于 API 接收阶段的前置链,在调用方鉴权和用户鉴权之后执行。

入口判断如下:

public Resp preHandle(AspectInfo aspectInfo) {
ExternalReq req = aspectInfo.findParam(ExternalReq.class);
// 所有接口(除第三方回调、健康检查、短链跳转等特殊接口)请求参数类型必须是ExternalReq及其子类
if (req == null) {
return Resp.ok();
}
if (StringUtils.isBlank(req.getEncryptData())) {
return Resp.ok();
}
// CallerAuthService.authApp中已校验加密配置,encryptData不为空则必须解密
return decryptReqData(req);
}

没有 ExternalReq,或请求密文为 null、空串、空白字符时,处理器直接返回 Resp.ok() 放行。这里返回的 Resp 表示前置步骤是否通过,不是把解密结果放进 Resp.data。

这也说明,该处理器本身没有强制“配置要求加密的请求必须携带密文”。原注释提到 authApp 已校验加密配置,但其实现不能替代本方法缺少的密文必填检查;是否强制加密,需要另有明确校验。

真正的解密方法根据 appId 读取调用方配置:

private Resp decryptReqData(ExternalReq req) {
String plaintext;
// 获取加密方式和密钥
SysExternalCallerEntity externalCallerEntity = callerAuthService.getCallerCache(req.getAppId());
// 对称加密
if (externalCallerEntity.getEncryptType() == 1) {
if (Strings.CS.equals(externalCallerEntity.getEncryptAlgorithm(), "SM4-GCM")) {
plaintext = SM4.decryptGcm(req.getEncryptData(), externalCallerEntity.getEncryptSecret());
} else if (Strings.CS.equals(externalCallerEntity.getEncryptAlgorithm(), "SM4-CBC")) {
plaintext = SM4.decryptCbc(req.getEncryptData(), externalCallerEntity.getEncryptSecret());
} else if (Strings.CS.equals(externalCallerEntity.getEncryptAlgorithm(), "AES-GCM")) {
plaintext = AES.decryptGcm(req.getEncryptData(), externalCallerEntity.getEncryptSecret());
} else if (Strings.CS.equals(externalCallerEntity.getEncryptAlgorithm(), "AES-CBC")) {
plaintext = AES.decryptCbc(req.getEncryptData(), externalCallerEntity.getEncryptSecret());
} else {
return Resp.error("解密失败, 暂不支持该解密算法: " + externalCallerEntity.getEncryptAlgorithm());
}
} else {
return Resp.error("解密失败, 暂不支持该加密类型: " + externalCallerEntity.getEncryptType());
}

// 解密成功后回写明文字段
req.setPlaintext(plaintext);
return Resp.ok();
}

四条分支分别调用 SM4/AES 的 GCM/CBC 解密方法。算法和密钥来自服务端配置,不接受请求临时指定任意算法。工具类负责解码密文、取出 IV、执行解密;GCM 还会校验认证标签。

解密成功后,明文写入 req.plaintext。如果请求原先也带了 plaintext,这次赋值会覆盖它;原来的 encryptData 没有在这里清空。业务参数转换使用的是覆盖后的明文。

plaintext 如何变成业务参数

解密拿到 JSON 文本,还不能直接把它当成业务参数对象。网关通过 WebUtil.genInternalReq 把公网请求转换成内部请求:

public static InternalBizParamReq genInternalReq(ExternalReq externalReq, Class<? extends Param> bizParamCalss) {
paramNotNull(externalReq, "externalReq");
paramNotNull(bizParamCalss, "bizParamCalss");

InternalBizParamReq internalBizParamReq = new InternalBizParamReq();

String plaintext = externalReq.getPlaintext();
if (StringUtils.isBlank(plaintext)) {
return internalBizParamReq;
}
if (!FastJson.isValidObjStr(plaintext) && StringUtils.isNotBlank(externalReq.getEncryptData())) {
throw new ServiceException(ErrorCode.PARAM_INVALID, "encryptData解密后不是有效的json对象形式");
}
if (!FastJson.isValidObjStr(plaintext) && StringUtils.isBlank(externalReq.getEncryptData())) {
throw new ServiceException(ErrorCode.PARAM_INVALID, "plaintext不是有效的json对象形式");
}
Param param = FastJson.json2Obj(plaintext, bizParamCalss);
internalBizParamReq.setBizParam(param);
return internalBizParamReq;
}

业务代码提供具体的 Param 类型,这个方法读取 plaintext,再把解析出来的 Param 放进 InternalBizParamReq.bizParam。因此服务端入站数据依次经过:

ExternalReq.encryptData → 解密 → ExternalReq.plaintext → JSON 解析 → InternalBizParamReq.bizParam。

plaintext 为空时返回尚未填入 bizParam 的内部请求;业务必填约束还需要后续校验。这里要求的是对象形式的 JSON,数组或字符串字面量不满足这条转换路径。外层形式检查也不代表 JSON 内容一定合法,正式解析仍可能抛异常。

解密失败怎样进入 Resp 错误处理

解密方法既有“返回失败 Resp”的路径,也有“抛异常”的路径。

情况服务端行为
请求没有非空密文 前置处理返回 Resp.ok(),不执行解密
加密类型不是 1 返回通用业务失败 Resp,业务码 1011
算法名称不匹配 返回通用业务失败 Resp,业务码 1011
GCM 标签校验失败 工具类抛出 SecurityException,正常进入入口异常转换时得到 1999
Base64、密钥或密文格式错误 由工具类参数校验或异常包装抛出;最终按实际异常类型转换
解密成功但 JSON 不合法 在后续业务参数转换/解析阶段报错,不是密码算法解密失败
解密成功且转换成功 业务逻辑使用 bizParam,不直接操作密文

前置处理器返回失败 Resp 后,处理器链中止后续普通前置处理,入口切面不再调用业务方法。若解密抛出异常,入口切面捕获并交给 Resp.error(Throwable);异常日志和后置处理本身再次抛错的边界仍然存在。

因此,Resp 在这里还承担了处理器之间的结果协议:ok() 表示允许继续,error(…) 表示停止并返回业务错误。解密后的业务数据保存在请求里,处理是否成功由 Resp 表达,两者不能混用。

回到出站方向,业务执行结果放进 Resp.data,再由响应处理器生成 Resp.encryptData、打印日志、清除响应明文。入站解密和出站加密共享调用方配置,但处理不同对象,也发生在不同阶段。

响应加密的适用条件与安全边界

空数据和失败数据如何处理

加密处理器判断的是 data != null,没有判断 resp.isOk()。只要请求和配置满足条件,带数据的失败响应也会进入加密路径。

响应内容正常链路中的结果
成功,data 为对象、列表或分页 序列化并加密,随后清除 data
成功,data 为 null 跳过加密,不能把缺少密文当成失败
成功,data 是空列表或空 Map 仍然非 null,继续加密
成功,data 是空字符串 先序列化为 JSON 字符串字面量,再加密
失败,只有 code/message 通常没有 data,不产生密文
失败,同时携带 data 条件满足时,data 也加密;code/message 仍在外层

服务端应按接口契约区分“应返回业务数据却意外为空”和“本来就不返回数据”。例如删除成功可以没有 encryptData,不能仅凭密文为空就把服务端结果改成失败。

没有独立开关要求请求和响应必须同时使用密文。请求解密处理器按请求是否携带非空密文工作;响应加密处理器按调用方配置工作。因此“这次请求没传 encryptData”不代表“响应不会加密”。请求是否必须加密,还需要明确的入口约束,不能由响应逻辑推断。

清理处理器使用 encryptData != null,连空字符串也满足条件。手工设成空字符串、重用带旧密文的 Resp,都会带来丢失明文或返回陈旧数据的风险。业务代码只设置业务数据,不手填传输密文;响应按请求创建,不跨请求缓存整个可变 Resp。

加密不负责哪些安全问题

这份实现只加密 data。外层 code、message、errorReason 没有因此变成密文。响应也没有 sign、keyId、时间戳或请求关联字段,不能把请求上的签名、时间窗检查当成响应已经具备的保护。

AES/SM4 的这两个 GCM 方法没有调用 updateAAD。它们的标签校验覆盖所加密的数据,没有把外层业务码、提示和请求标识一起绑定进去。解密成功不等于已经验证整份响应外壳或证明它属于当前请求。

CBC 工具方法没有额外的 MAC 校验,不能因为一次解密没抛异常就认定密文未被修改。若兼容协议必须使用 CBC,要单独设计完整性校验;不要把一次 BadPaddingException 捕获当作完整的防篡改方案。GCM 也不自动解决重放、授权或业务幂等。

密钥不能放在响应里,不应写入普通日志。浏览器或 App 持有共享密钥时,不能假设这个密钥能对客户端使用者保密。服务对服务、浏览器与移动端的密钥保管能力不同,需要分别设计。

此外,协议没有携带密钥版本;服务端直接切换密钥后,旧客户端可能无法解密。需要约定切换窗口,或扩展 keyId 等协议字段并实现相应校验。这里说明的是接入时要补齐的能力,不是 Resp 已经实现了自动协商与轮换。

序列化行为、对象生命周期与接入约定

setData(null) 只是把 Java 字段设为空,不保证线上一定出现 "data": null。是否省略 null 字段,取决于实际消息转换器和序列化配置。这里加密调用的是 FastJson.obj2Json,不是名称相近的 obj2JsonWriteNulls,不要假设两者输出完全一样。

业务数据中的日期格式、大整数精度、枚举形式,也是在生成 JSON 时决定的。加密只保护已经生成的字节,不会修正前面序列化时丢失的信息;解密后的前端还可能遇到 JavaScript 大整数精度问题。

Resp.ok(data) 不会深拷贝 data。把一个可变 List 放入响应后继续修改它,响应持有的仍是同一个 List。Lombok 生成的 equals/hashCode 也会使用实例字段,不应把还会被处理器修改的 Resp 当作稳定的 HashMap 键。

setCode 不会联动修改 message,setEncryptData 不会主动清空 data,构造器不会校验码、消息和数据是否一致。工厂方法让常见组合写起来简洁,处理器负责出口转换;这些约定没有全部变成对象内部的强制约束。

服务内部的 InternalServiceClient 处理 HTTP 调用结果和业务 Resp,再恢复明文 data 类型,没有读取 encryptData 并解密的流程。不要把内部 RPC 客户端当作公网加密协议客户端使用。

接入时可以按这张表检查

使用场景建议入口或动作
无数据成功,只读返回 Resp.ok(),不要再调用 setter
有数据成功 Resp.ok(data),声明具体 Resp<T>
自定义成功提示,无数据 Resp.<Void>ok(“创建成功”, null),获得独立对象
业务失败,需要稳定分支 Resp.error(ErrorCode),客户端按 code 处理
内部异常 入口转换成 Resp,服务端记日志,对外排除 errorReason
公网加密响应 Controller 返回明文业务对象,网关生成 encryptData 并清理 data
公网加密请求进入 前置处理器解密到 plaintext,再转换成内部 bizParam
内部服务调用 使用内部请求和类型恢复入口,不额外套公网解密
已有协议或流式响应 不强制包装成 Resp

最容易漏掉的验收项,是未知异常能否泄露内部信息、无数据成功是否被客户端误判,以及错误密钥或密文损坏后是否被错误降级成明文成功。单例保护、配置校验、HTTP 错误出口和字段清理也要单独检查,不能只测试一次正常查询。


框架简介 MetaLite 是面向企业生产环境的新一代 Java 微服务技术底座。系列文章重点分享代码背后的设计思路、技术取舍与工程实践。

源码基线 JDK 21、Spring Boot 3.2.9、Spring Cloud 2023.0.1、Spring Cloud Alibaba 2023.0.1.3,具体组件版本以项目 backend-bom 为准。

作者简介 15 年 Spring 体系企业级开发经验,专注于 Java 微服务架构、工程治理与生产实践。

持续更新 MetaLite 系列内容将持续更新,围绕核心设计、源码链路、技术取舍与生产实践展开。欢迎关注作者,及时获取后续内容。

在线演示 演示地址: https://admin.metalite.top/ 演示账号: guess 演示密码: admin@2026

赞(0)
未经允许不得转载:171主机测评 » 这可能是全网最好的 Spring Boot 接口通用响应对象设计
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址