这可能是全网最好的 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 编码。
| 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



