全网最好的 Spring Boot 接口请求对象设计
摘要: 登录、查询自己、创建用户和服务间调用,应该使用同一种请求对象吗?MetaLite 用六类请求区分公网与内网、登录与免登录、有参与无参,把系统协议和业务参数分开。本文给出完整类定义和真实接口,沿请求进入、校验、验签、解密、转换及转发的过程,拆解每个字段为什么存在、业务代码怎么使用,以及哪些约束仍需单独落实。
如果所有接口都接收一个万能 Request,登录接口会带着不需要的 token,内部服务也会看到签名和密文字段。反过来,每个接口各写一套系统字段,又容易出现同一个时间戳、同一种鉴权要求,到了不同接口就变成不同规则。
请求对象应该让开发者一眼看清:谁来调用,要不要登录,业务参数是什么。MetaLite 的做法是把这些区别放进类型声明,让共用处理器按类型工作,让业务 Service 只接收业务参数。
标题里的"最好",是对这套设计的评价,不是全网方案排名。下面直接看实现,也把尚未成为强制约束的部分说明白。
环境:JDK 21、Spring Boot 3,使用 Jakarta Validation、Lombok、OpenAPI 注解和 Fastjson2。六类请求及相关处理器都是 MetaLite 自有实现,不是 Spring Boot 内置协议。类定义保留源码和原注释;接口与处理器代码注明摘录位置,依赖所在工程,不能把片段当作独立应用运行。
请求类型先分清,业务字段才能放对地方
选类型只需要回答三个问题:从公网进来还是服务之间调用,需要登录吗,有业务参数吗?
| 场景 | 请求类型 | 业务参数 |
|---|---|---|
| 公网、免登录、无参 | ExternalReq | 无 |
| 公网、免登录、有参 | ExternalBizParamReq<T> | 如 LoginParam |
| 公网、需登录、无参 | ExternalLoginReq | 无 |
| 公网、需登录、有参 | ExternalLoginBizParamReq<T> | 如 SaveSysUserParam |
| 内网、无参 | InternalReq | 无 |
| 内网、有参 | InternalBizParamReq<T> | 如 LoginParam |
这里的"无参"是没有业务参数,不是 HTTP 请求体为空。查询自己的信息仍需提供公网系统字段和用户凭证;内部无参调用也有 provider、consumer。
继承关系很短:
text
Pojo
├── ExternalReq
│ ├── ExternalBizParamReq<T extends Param>
│ └── ExternalLoginReq
│ └── ExternalLoginBizParamReq<T extends Param>
└── InternalReq
└── InternalBizParamReq<T extends Param>
业务参数:Param extends Pojo
内部请求没有再分"带 token"和"不带 token"。用户身份由入口建立,再通过受控的调用上下文传递;业务服务不需要每调用一跳就重新解析公网凭证。但内部接口仍可能需要业务授权,不能因为它属于内网就跳过权限检查。
六类请求的完整定义
这些类刻意保持简单。字段和继承关系表达协议,校验与转换交给各自的处理器和工具方法,不在请求类里访问 Redis、读取 HTTP 请求或调用远程服务。
下面六段分别是六个 Java 文件的完整内容。
ExternalReq:公网系统协议的共同基类
java
/*
* Copyright (c) 2025 元界MetaLite
* MIT License
*/
package com.metalite.common.pojo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;
/**
* 南北流量(公网)(不需要登录)(不带业务参数)接口调用的通用请求对象
*
* @author 元界MetaLite
*/
@Data
@Schema
public class ExternalReq implements Pojo {
@NotBlank
@Schema(title = "系统参数|api版本号", description = "签名顺序1|字母v加1位数字|示例:v1", requiredMode = REQUIRED)
private String apiVersion;
@NotBlank
@Schema(title = "系统参数|调用方标识", description = "签名顺序2|对接前由平台统一分配", requiredMode = REQUIRED)
private String appId;
@Schema(title = "系统参数|bizParam字段转json后的加密数据", description = "签名顺序3|仅要求加密才传此字段, 如为空不参与签名")
private String encryptData;
@Schema(title = "系统参数|bizParam字段转json后的明文数据", description = "签名顺序3|仅不要求加密才传此字段, 如为空不参与签名")
private String plaintext;
@Min(1677654864000L)
@Schema(title = "系统参数|当前时间戳", description = "签名顺序4|unix毫秒时间戳|示例:1677654864124", requiredMode = REQUIRED)
private long timestamp;
@Schema(title = "系统参数|签名", description = "所有非空系统参数的签名内容|仅要求签名才传此字段|示例:签名算法(k1=v1&k2=v2, salt)")
private String sign;
}
六个字段都是系统协议,不能混入 LoginParam、SaveSysUserParam 之类的业务对象。
| 字段 | 为什么放在公共外壳 | 实际约束 |
|---|---|---|
| apiVersion | 让公共协议有版本标识 | @NotBlank;版本格式和支持范围没有在此验证 |
| appId | 标识平台分配的调用方 | @NotBlank;网关另查配置与启用状态 |
| timestamp | 判断请求时间是否在允许窗口内 | @Min;网关另比较时间差 |
| sign | 承载系统参数的校验值 | 是否必填由调用方配置决定 |
| plaintext | 不加密时传递业务 JSON,也接收解密结果 | String,不是业务对象 |
| encryptData | 传递业务 JSON 的密文 | String;算法与密钥来自服务端配置 |
apiVersion 描述里的"字母 v 加 1 位数字"没有对应的 @Pattern;仅有版本字段,也不代表已经实现版本路由。
timestamp 使用 long。没有传值时通常保持默认值 0,@Min 会将其判为不合法。这个注解只是下限检查,不能判断请求是否刚刚发出。字段协议写明的是毫秒时间戳;即使底层时间工具能识别秒,也不能推导出这个接口允许传秒值,常见十位秒值会先被 @Min 拦截。
sign 没有直接加 @NotBlank,是因为同一套请求类要适配不同调用方配置。动态规则留在网关,避免一个静态注解把所有调用方都限制成相同模式。
ExternalLoginReq:明确增加登录要求
java
/*
* Copyright (c) 2025 元界MetaLite
* MIT License
*/
package com.metalite.common.pojo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;
/**
* 南北流量(公网)(需要登录)(不带业务参数)接口调用的通用请求对象
*
* @author 元界MetaLite
*/
@Data
@Schema
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
public class ExternalLoginReq extends ExternalReq {
@NotBlank
@Schema(title = "系统参数|用户访问凭证", description = "签名顺序5|不要求登录的接口不参与签名", requiredMode = REQUIRED)
private String userToken;
}
这个类只多一个 userToken。其余字段继承自 ExternalReq,因此需要登录的接口仍然要经过调用方检查。
appId 表达"哪一个接入方在调用",userToken 表达"以哪一个用户身份调用"。两者不应合成一个字段。登录接口本身使用免登录类型,登录成功后才获得用于后续接口的用户凭证。
@ToString(callSuper = true) 和 @EqualsAndHashCode(callSuper = true) 把父类状态纳入对应的生成方法。这便于排查对象内容,却也意味着直接打印对象可能带出 token、明文和签名;请求类型不是日志脱敏机制。
两种公网有参请求:文档类型与传输数据分别保留
免登录、有业务参数:
java
/*
* Copyright (c) 2025 元界MetaLite
* MIT License
*/
package com.metalite.common.pojo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.*;
import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;
/**
* 南北流量(公网)(不需要登录)(带业务参数)接口调用的通用请求对象
*
* @author 元界MetaLite
*/
@Data
@Schema
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
public class ExternalBizParamReq<T extends Param> extends ExternalReq {
@Schema(title = "业务参数|仅用于接口文档展示, 调用方请勿上送此字段", requiredMode = REQUIRED)
private T bizParam;
}
需要登录、有业务参数:
java
/*
* Copyright (c) 2025 元界MetaLite
* MIT License
*/
package com.metalite.common.pojo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.*;
import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;
/**
* 南北流量(公网)(需要登录)(带业务参数)接口调用的通用请求对象
*
* @author 元界MetaLite
*/
@Data
@Schema
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
public class ExternalLoginBizParamReq<T extends Param> extends ExternalLoginReq {
@Schema(title = "业务参数|仅用于接口文档展示, 调用方请勿上送此字段", requiredMode = REQUIRED)
private T bizParam;
}
这两个 bizParam 字段最容易看错。原注释已经说明:仅用于接口文档展示,调用方不要直接上送。
公网实际携带的是 plaintext 或 encryptData。引入 T,是为了让接口文档仍能描述业务字段,而不是只剩一个无法看出内容的字符串。传输可以是密文,文档仍能说明解密后的数据长什么样。
这里也有一个需要认真处理的取舍:bizParam 标了文档 REQUIRED,却要求调用方不要直接传这个字段。自动生成 SDK、自动构造请求的工具可能因此误解协议,接入文档必须补充真实载荷示例。
这两个字段没有 @NotNull、@Valid,也没有禁止反序列化的注解。"请勿上送"是文档约定,并不等于收到该字段就自动报错。标准转换方法不会读取它,更不会在 plaintext 为空时拿它补位。
@Schema 描述 OpenAPI 模型,不替代运行时校验或 JSON 读写控制。相关属性职责见 Schema 官方文档。
InternalReq:内部调用只保留服务关系
java
/*
* Copyright (c) 2025 元界MetaLite
* MIT License
*/
package com.metalite.common.pojo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;
/**
* 东西流量(内网)(不带业务参数)接口调用的通用请求对象
*
* @author 元界MetaLite
*/
@Data
@Schema
public class InternalReq implements Pojo {
@NotBlank
@Schema(title = "系统参数|服务提供者", description = "提供者注册的服务名称, 由框架自动填充", requiredMode = REQUIRED)
private String provider;
@NotBlank
@Schema(title = "系统参数|服务消费者", description = "消费者注册的服务名称, 由框架自动填充", requiredMode = REQUIRED)
private String consumer;
}
provider 是服务提供者,consumer 是服务消费者。它们用于表达服务调用关系,不应从公网请求原样复制。
业务参数与路由元信息分开后,服务重命名、调用链变化不必修改业务 Param。与之对应,provider、consumer 都只是字符串,不能作为服务身份已被认证的证明。
InternalBizParamReq:业务参数真正落地的地方
java
/*
* Copyright (c) 2025 元界MetaLite
* MIT License
*/
package com.metalite.common.pojo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import lombok.*;
import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;
/**
* 东西流量(内网)(带业务参数)的接口调用的通用请求对象
* 只需要子类Builder, 父类字段由框架填写
*
* @author 元界MetaLite
*/
@Data
@Schema
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
public class InternalBizParamReq<T extends Param> extends InternalReq {
@NotNull
@Valid
@Schema(title = "业务参数", requiredMode = REQUIRED)
private T bizParam;
}
内部 bizParam 与公网同名字段的职责不同。这里实际承载业务对象,并用 @NotNull、@Valid 表达"对象要存在,内部字段也要校验"。
原注释提到"子类 Builder",但类上没有 @Builder 或 @SuperBuilder。不能根据这句注释调用不存在的 builder();实际代码使用无参创建和 setter,后文的 RpcRequest.builder() 属于另一个类。
六类请求没有手写业务方法。Lombok 生成访问方法及对象比较、字符串表示等方法,校验不会因为调用了 setter 就自动执行。它们是可变的、单次请求使用的数据容器,不宜跨请求共享,也不宜在字段可能变化时用作 HashMap 的键。
Param 管业务输入,不承包系统协议
Param 的完整定义如下:
java
/*
* Copyright (c) 2025 元界MetaLite
* MIT License
*/
package com.metalite.common.pojo;
/**
* 参数对象标识
* <p>
* 所有接口请求参数对象必须实现该接口
* </p>
*
* @author 元界MetaLite
*/
public interface Param extends Pojo {
}
它继承的 Pojo 也只有一个标记:
java
/*
* Copyright (c) 2025 元界MetaLite
* MIT License
*/
package com.metalite.common.pojo;
import java.io.Serializable;
/**
* pojo标识
* <p>
* 所有自定义的普通对象必须实现该接口
* </p>
*
* @author 元界MetaLite
*/
public interface Pojo extends Serializable {
}
T extends Param 在正常泛型使用下限制载荷类型。普通 Map、String 或未实现 Param 的 Entity,不能直接放进这个泛型位置。它不保证参数不可变、不含业务方法,也不能阻止开发者让一个不合适的类实现 Param。
例如登录参数只关心用户名和密码:
java
/*
* Copyright (c) 2025 元界MetaLite
* MIT License
*/
package com.metalite.admin.sys.domain.param;
import com.metalite.common.pojo.Param;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;
/**
* 登录参数
*
* @author 元界MetaLite
*/
@Data
@Schema
public class LoginParam implements Param {
@NotBlank
@Schema(title = "用户名", requiredMode = REQUIRED)
private String userName;
@NotBlank
@Schema(title = "密码", requiredMode = REQUIRED)
private String password;
}
把签名、appId、provider 再塞到 LoginParam 里,就失去了外壳分层的意义。同一个 LoginParam 可以被公网登录声明和内部登录声明引用,业务层无需知道它最初来自明文还是密文。
这里有三种不同约束,不能混为一谈:
| 约束 | 解决什么 | 不负责什么 |
|---|---|---|
| T extends Param | 编译期限定参数类别 | 不验证字段值 |
| @NotNull | 内部 bizParam 必须存在 | 不验证对象内部 |
| @Valid | 级联检查内部已有约束 | 不自动补上缺失的业务规则 |
以 SaveSysUserParam 为例,"更新时 userId 必填"目前写在文档中,不能仅凭这句话认为注解已经区分创建与更新。操作差异仍应在业务检查、校验分组或拆分参数类型中落实。
登录、查询自己、创建用户:真实接口怎么声明
下面的方法来自 Gateway 和 Admin 两侧的 SysUserApi,两侧类级路径都是 /api/admin/sys/user。保留方法注解和实现,不省略 RPC 的 provider、endpoint、调用模式等必要信息。
登录:免登录,但仍要验证调用方
Gateway 的登录入口:
java
@Operation(summary = "用户登录", description = "用户登录接口")
@PostMapping(path = "/login")
public Resp<LoginDto> login(@RequestBody @Valid ExternalBizParamReq<LoginParam> req,
@Parameter(hidden = true) BindingResult bindingResult) {
Resp<LoginDto> loginResp = internalServiceClient.callOneInstanceRtnData(
RpcRequest.builder()
.provider(InternalServiceEnum.ADMIN.getCode())
.endpoint(WebUtil.getRequestUri())
.rpcMode(RpcModeEnum.HTTP_POST_JSON)
.param(WebUtil.genInternalReq(req, LoginParam.class))
.build(), LoginDto.class);
userAuthService.afterLogin(loginResp);
return loginResp;
}
Admin 的登录入口:
java
@Operation(summary = "用户登录", description = "用户登录接口")
@PostMapping(path = "/login")
public Resp<LoginDto> login(@RequestBody @Valid InternalBizParamReq<LoginParam> req,
@Parameter(hidden = true) BindingResult bindingResult) {
return sysUserService.login(req.getBizParam());
}
外部使用 ExternalBizParamReq<LoginParam>,因为登录之前还没有用户凭证。它依旧继承 ExternalReq,会进入调用方认证。
Gateway 按 LoginParam.class 恢复业务对象并调用 Admin;Admin 拿到 req.getBizParam() 后交给 Service。登录成功后,Gateway 的 afterLogin 再处理对外用户凭证。这段后处理不能在示例里省掉,否则读者会误以为内部登录响应已经包含最终公网 token。
查询自己:需要登录,不需要客户端指定 userId
Gateway 的方法:
java
@Operation(summary = "获取我的信息", description = "个人中心获取自己的信息")
@PostMapping(path = "/get/myself")
public Resp<SysUserEntity> getMyself(@RequestBody @Valid ExternalLoginReq req,
@Parameter(hidden = true) BindingResult bindingResult) {
return internalServiceClient.callOneInstanceRtnData(
RpcRequest.builder()
.provider(InternalServiceEnum.ADMIN.getCode())
.endpoint(WebUtil.getRequestUri())
.rpcMode(RpcModeEnum.HTTP_POST_JSON)
.param(WebUtil.genInternalReq(req))
.build(), SysUserEntity.class);
}
Admin 的方法:
java
@Operation(summary = "获取我的信息", description = "个人中心获取自己的信息")
@PostMapping(path = "/get/myself")
public Resp<SysUserEntity> getMyself(@RequestBody @Valid InternalReq req,
@Parameter(hidden = true) BindingResult bindingResult) {
String myUserId = ThreadContext.getLoginUserId();
return Resp.ok(sysUserService.getUserDtoByUserId(myUserId));
}
公网使用 ExternalLoginReq,内网使用 InternalReq。内部 Service 查询哪个用户,由 ThreadContext.getLoginUserId() 决定,不让客户端用一个任意 userId 代替当前身份。
这种接口只回答"我是谁",不能直接替代管理员查询他人的接口。后者需要独立业务参数,也需要独立授权。
创建用户:登录要求和业务参数同时存在
Gateway 的方法:
java
@Operation(summary = "创建用户", description = "")
@PostMapping(path = "/create")
public Resp<Void> createUser(@RequestBody @Valid ExternalLoginBizParamReq<SaveSysUserParam> req,
@Parameter(hidden = true) BindingResult bindingResult) {
return internalServiceClient.callOneInstanceRtnData(
RpcRequest.builder()
.provider(InternalServiceEnum.ADMIN.getCode())
.endpoint(WebUtil.getRequestUri())
.rpcMode(RpcModeEnum.HTTP_POST_JSON)
.param(WebUtil.genInternalReq(req, SaveSysUserParam.class))
.build(), Void.class);
}
Admin 的方法:
java
@Operation(summary = "创建用户", description = "")
@PostMapping(path = "/create")
public Resp<Void> createUser(@RequestBody @Valid InternalBizParamReq<SaveSysUserParam> req,
@Parameter(hidden = true) BindingResult bindingResult) {
return sysUserService.createUser(req.getBizParam());
}
创建用户选 ExternalLoginBizParamReq<SaveSysUserParam>。它继承 ExternalLoginReq,再继承 ExternalReq,因此同时命中调用方检查和登录检查。
但这段声明只能证明框架会检查登录,不能证明任何登录用户都有创建用户的权限。管理员权限、组织范围和字段可写权限,必须继续检查业务授权实现。
公网免登录无参:框架支持,不冒充已有业务接口
前面真实接口覆盖了五种类型。ExternalReq 还适合"需要识别调用方,但不要求用户登录,也没有业务载荷"的场景。
下面只是接口声明示意,不是仓库已有的状态查询方法,返回对象和实现需按实际业务定义:
java
@PostMapping("/status")
public Resp<String> status(
@RequestBody @Valid ExternalReq req,
BindingResult bindingResult)
健康检查、第三方回调等接口如果遵循别的协议,不必为了凑统一而套用这个类型。免用户登录,也不等于完全公开且没有调用方约束。
类型如何触发认证,而不是只让名字好看
AspectInfo 从方法参数中寻找指定类型,使用的是可赋值关系,子类也会命中:
java
/**
* 从切面拦截的方法参数中获取指定类型的参数
*/
public <T> T findParam(Class<T> paramClass) {
if (null == methodParamMap || methodParamMap.isEmpty()) {
return null;
}
for (Map.Entry<String, Object> entry : methodParamMap.entrySet()) {
Object param = entry.getValue();
if (null != param && paramClass.isAssignableFrom(param.getClass())) {
return paramClass.cast(param);
}
}
return null;
}
CallerAuthHandler 查找 ExternalReq:
java
@Override
public Resp preHandle(AspectInfo aspectInfo) {
ExternalReq req = aspectInfo.findParam(ExternalReq.class);
// 所有接口(除第三方回调、健康检查、短链跳转等特殊接口)请求参数类型必须是ExternalReq及其子类
if (req == null) {
return Resp.ok();
}
return callerAuthService.authApp(req);
}
UserAuthHandler 查找 ExternalLoginReq:
java
@Override
public Resp preHandle(AspectInfo aspectInfo) {
// 需要userToken的接口必须继承 ExternalLoginReq
ExternalLoginReq needLoginReq = aspectInfo.findParam(ExternalLoginReq.class);
if (needLoginReq == null) {
return Resp.ok();
}
return userAuthService.authUser(needLoginReq);
}
所以 ExternalLoginBizParamReq 能先接受调用方检查,再接受用户检查。开发者不必在每个方法里手写两次认证调用。
这个机制也解释了错误选型的风险。把需要登录的接口声明成 ExternalBizParamReq,只是在 JSON 里多传一个 userToken,并不会让 UserAuthHandler 自动执行。它判断的是 Java 参数类型,不是请求 JSON 是否出现某个字段。
找不到对应参数时,两个处理器都返回成功,目的是给特殊协议接口留出口。这不是"所有 Controller 自动安全"。普通公网接口必须正确使用类型,并处于已注册切面和处理器覆盖范围内。代码评审或架构测试应检查这些条件。
一个接口宜只接收一个标准请求外壳。findParam 返回第一个匹配对象,不是批量处理所有同类参数。
校验分两次:外部协议与内部业务参数
标准链路中,Gateway 先完成请求绑定与外层校验;业务 JSON 解密和转换后,内部服务再验证具体 Param。
Spring MVC 支持 @RequestBody 配合 @Valid,并通过紧邻的 BindingResult 接收校验结果。MetaLite 继续使用这套能力,在切面中统一消费结果,而不是每个 Controller 重复判断。见 Spring MVC 官方说明。
ApiReceiveParamHandler 的处理方法为:
java
@Override
public Resp preHandle(AspectInfo aspectInfo) {
// 从参数列表中提取BindingResult
BindingResult bindingResult = aspectInfo.findParam(BindingResult.class);
if (bindingResult == null || !bindingResult.hasErrors()) {
return Resp.ok();
}
for (FieldError fieldError : bindingResult.getFieldErrors()) {
String message = fieldError.getDefaultMessage();
String fieldName = fieldError.getField();
//自定义的message以@开头
if (Strings.CS.startsWith(message, "@")) {
Resp resp = Resp.error(ErrorCode.PARAM_INVALID);
resp.setMessage(StringUtils.substringAfter(message, "@"));
return resp;
}
//必填参数未传
if (Strings.CS.equalsAny(fieldError.getCode(), PARAM_REQUIRED_CODES)) {
return Resp.error(ErrorCode.PARAM_REQUIRED, fieldName);
}
//参数格式不对
return Resp.error(ErrorCode.PARAM_INVALID, fieldName);
}
return Resp.ok();
}
@NotBlank、@NotEmpty、@NotNull 被归为参数缺失;其他字段错误按参数无效处理。以 @ 开头的自定义错误消息另有分支,响应只返回当前遍历到的第一条字段错误,不是把所有错误一次性返回。
| 阶段 | 当前检查内容 |
|---|---|
| Gateway 绑定与校验 | apiVersion、appId、timestamp;登录请求还检查 userToken 非空 |
| Gateway 业务转换 | plaintext 的对象形态和反序列化 |
| Admin 绑定与校验 | provider、consumer、bizParam 存在性及 Param 内部约束 |
| Service | 用户是否存在、是否允许修改、数据范围等业务规则 |
特别注意:公网泛型字段没有 @Valid,而 plaintext 只是字符串。Gateway 给请求加了 @Valid,不代表字符串内部的 userName、password 已经校验过。
如果某个 Gateway 方法不转发,直接调用本地 Service,那么必须另行验证解密并转换后的 Param;不能依赖一条根本不会发生的内部 HTTP 校验。
BindingResult 不存在时,所示处理器会直接放行;这不表示 Spring 一定跳过验证,没有 BindingResult 的校验失败可能在进入 Controller 切面前就抛出。JSON 格式错误、字段绑定失败等出口也应分别处理,不能承诺所有错误都必然由这一个处理器包装成 Resp。
调用方检查:appId、时间、IP 与签名各管什么
CallerAuthService.authApp 按以下顺序工作:
| 检查 | 作用 |
|---|---|
| 查调用方配置与启用状态 | 拒绝不存在或被禁用的接入方 |
| 设置上下文 appId | 后续流程使用已查到的调用方标识 |
| 比较请求时间 | 拒绝偏离当前时间窗口的请求 |
| 检查 IP 白名单 | 按配置限制来源地址 |
| 按配置验证 sign | 检查约定系统参数的校验值 |
| 调用方限流 | 对通过上述检查的调用方执行限流 |
时间比较使用非负整秒差,判断条件是大于 30。过去和未来 60 秒都会超过窗口;相差 30.999 秒按整秒得到 30,并不满足大于 30。若业务要求精确到毫秒,不能把这条判断直接描述为严格的 30 秒截止线。
时间窗能缩短重复请求的可用时间,但窗口内仍可再次提交同一份请求。这里没有 nonce 去重或业务幂等存储,因此不能把它称为完整防重放方案。
IP 白名单使用逗号拆分后的字符串匹配,不是 CIDR 网段匹配。来源 IP 的可信度还取决于代理层如何处理转发头。
sign 签的是哪些字段
服务端拼接待签名字符串的真实代码:
java
StringBuilder signSB = new StringBuilder();
signSB.append("apiVersion=").append(req.getApiVersion());
signSB.append("&appId=").append(req.getAppId());
if (StringUtils.isNotBlank(req.getEncryptData())) {
signSB.append("&encryptData=").append(req.getEncryptData());
}
if (StringUtils.isNotBlank(req.getPlaintext())) {
signSB.append("&plaintext=").append(req.getPlaintext());
}
signSB.append("×tamp=").append(req.getTimestamp());
if (req instanceof ExternalLoginReq externalLoginReq) {
signSB.append("&userToken=").append(externalLoginReq.getUserToken());
}
// 待签名字符串
String waitSignStr = signSB.toString();
固定顺序为 apiVersion、appId、非空 encryptData、非空 plaintext、timestamp;只有 ExternalLoginReq 及其子类再加入 userToken。sign 本身不加入,文档占位的 bizParam 也不加入。
这是按请求字段原始内容拼接,不是把 JSON 解析后排序再签。因此业务 JSON 的空格、属性顺序等变化,可能让同样业务含义的数据产生不同待签名文本。
当前串没有包含 HTTP Method 和路径。不能宣称校验值已经绑定了目标接口;跨接口约束、字段转义和规范化策略都需要协议另行明确。
检查分支按调用方配置选择 SM3 或 SHA-256,并可使用盐值。工具方法实际做哈希或加盐哈希,不是公私钥数字签名,也不是 HMAC;没有保密认证材料的普通哈希不能证明调用者身份。面向不可信网络设计协议时,应另行评估标准消息认证机制,不能仅因字段叫 sign 就认定认证已经充分。
还有一个实际运维风险:验签失败日志会打印待签名串和 signSalt。待签名串可能包含业务明文及 userToken,日志策略必须去除或脱敏这些内容。对象字段拆得清楚,并不会自动阻止敏感数据进入日志。
服务端解密:密文只在网关还原一次
相关前置处理器的排序为:节点限流、参数校验、调用方检查、用户检查、业务参数解密。后面才进入 Gateway 方法。普通前置处理返回失败 Resp 后,入口不再执行目标业务方法;异常仍按入口异常处理路径转换。
BizParamDecryptHandler 先判断有没有需要解密的数据:
java
@Override
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);
}
返回 Resp.ok() 只代表允许继续。解密结果不放进 Resp.data,而是写回请求对象。
实际解密方法:
java
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();
}
算法和密钥由 appId 对应的服务端配置决定,请求不能临时指定另一把密钥或任意算法。实现支持 encryptType == 1 下的四个对称分支,算法名区分大小写。其他类型或算法返回失败 Resp,不会继续把密文当作明文使用。
| 输入状态 | 这段实现的行为 |
|---|---|
| encryptData 为 null、空串或空白 | 不解密 |
| 有密文,配置受支持且解密成功 | 写入 plaintext |
| 同时有明文和密文 | 先按收到的字段验签,解密后覆盖 plaintext |
| 配置类型或算法不支持 | 返回失败 Resp |
| 密钥、编码或密文校验异常 | 由工具类抛出,进入异常处理路径 |
原注释说 authApp 已校验加密配置,但该方法实际没有完成"必须传密文""明密文互斥"等检查。这部分不能靠注释补齐。
如果协议要求请求强制加密,需要明确拒绝缺少密文或明密文并存的输入。以上是应增加的约束,不是所示实现已有能力。
解密后原 encryptData 没有被清空,plaintext 则保存明文。请求对象、切面日志、异常上下文都要按敏感数据管理。服务端业务层应读取转换后的 Param,不再自行解密。
公网请求转内网请求,究竟转换了什么
有业务参数时,Gateway 调用 WebUtil.genInternalReq:
java
/**
* 带业务参数的外网请求转内网请求
*
* @param externalReq - 公网请求参数基类对象
* @param bizParamCalss - 业务参数的对象类型
* @return 返回内网请求参数实际对象
*/
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;
}
方法只读取 plaintext,按业务代码传入的 Class 恢复对象,再填入内部 bizParam。它不读取公网 req.getBizParam(),也不自动从泛型声明推导目标类。
这让转换目标可见,也留下需要核对的两处声明:方法签名里的 LoginParam 和转换时的 LoginParam.class 必须一致。
方法返回的是原始类型 InternalBizParamReq,类参数限制为 Class<? extends Param>,但没有用方法泛型把输入 Class 与返回类型连起来。因此不能把现有实现说成完整的端到端泛型安全;正常调用保留具体类型仍然很重要。
| plaintext 内容 | 转换结果 |
|---|---|
| null、空串、空白 | 返回未设置 bizParam 的内部请求 |
| 非对象形式 | 抛参数无效异常 |
| 对象形式、字段可解析 | 按指定 Param 类构造对象 |
| 对象形式、字段类型不兼容等 | 正式解析仍可能失败 |
| 空对象 {} | 可构造空字段 Param,后续还要校验必填 |
外层形态检查不代替完整 JSON 解析,也不代替 Bean Validation。FastJson.isValidObjStr 实际只检查字符串是否以 { 开头、以 } 结尾,并没有先 trim;对象外面多出空格或换行也会被这一步拒绝。数组载荷应设计成某个 Param 的列表字段,不能直接把顶层数组塞给这条对象转换路径。
以下是免登录、明文模式的登录请求形态示意,字段值是虚构示例。timestamp 为历史示意值,实际调用必须使用当前毫秒时间戳;是否需要 sign 由接入配置决定,此处不展示签名算法。
json
{
"apiVersion": "v1",
"appId": "demo-app",
"timestamp": 1700000000000,
"plaintext": "{\"userName\":\"demo-user\",\"password\":\"DEMO_ONLY\"}"
}
plaintext 是一段 JSON 字符串,所以内层引号需要转义;不能直接传成对象。加密模式改为把这段业务 JSON 的密文放进 encryptData。对于需要登录的接口,再在外层增加 userToken,不要把它塞进业务 JSON。
经过转换和 RPC 字段填充后,内部请求形态如下。服务名仍为示意值,实际由调用设置和应用名称决定:
json
{
"provider": "admin-service",
"consumer": "gateway-service",
"bizParam": {
"userName": "demo-user",
"password": "DEMO_ONLY"
}
}
外部用字符串承载传输内容,内部用对象承载业务字段,两者不能直接交换请求体。示例中的明文也不意味着可以省略 HTTPS。
无业务参数时,方法更简单:
java
/**
* 不带业务参数的外网请求转内网请求
*
* @param externalReq - 公网请求参数基类对象
* @return 返回内网请求参数实际对象
*/
public static InternalReq genInternalReq(ExternalReq externalReq) {
return new InternalReq();
}
它没有复制外部的签名、密文、用户凭证,也没有在这里填 provider、consumer。该重载甚至不读取 externalReq;它只创建内部请求壳。
如果已经在服务内部拿到了 Param,可以用另一个入口包装:
java
/**
* 根生成带业务参数的内网请求对象
*
* @param param - 业务参数
* @return 返回内网请求参数实际对象
*/
public static InternalBizParamReq genInternalBizParamReq(Param param) {
InternalBizParamReq internalBizParamReq = new InternalBizParamReq();
internalBizParamReq.setBizParam(param);
return internalBizParamReq;
}
这里没有深拷贝,也没有主动验证 param。包装后继续修改原对象,内部请求看到的仍是同一个对象。普通 Java 方法调用不会因为字段存在 @Valid 就自动触发验证,不能绕过 HTTP 入口后仍假定原来的校验链会运行。
provider、consumer 和当前用户如何传到下一跳
WebUtil 只创建请求并填业务载荷。真正发起内部 RPC 前,InternalServiceClient.checkAndFillRpcRequest 会校验并补齐调用信息:
java
private RpcRequest checkAndFillRpcRequest(RpcRequest rpcRequest) {
// 校验请求参数
paramNotNull(rpcRequest, "rpcRequest");
paramHasLength(rpcRequest.getProvider(), "rpcRequest.provider");
paramHasLength(rpcRequest.getEndpoint(), "rpcRequest.endpoint");
paramNotNull(rpcRequest.getParam(), "rpcRequest.param");
paramCheck(InternalReq.class.isAssignableFrom(rpcRequest.getParam().getClass()),
"rpcRequest.param", "必须是InternalReq类型或其子类");
// 填充内部参数的服务提供者名称和消费者名称
InternalReq internalReq = (InternalReq) rpcRequest.getParam();
internalReq.setProvider(rpcRequest.getProvider());
internalReq.setConsumer(EnvUtil.APPLICATION_NAME);
// 超时时间逐层递减
int timeoutMillis = rpcRequest.getTimeoutMillis();
timeoutMillis = timeoutMillis <= 0 ? API_REQUEST_TIMEOUT_MILLIS : timeoutMillis - API_REQUEST_TIMEOUT_MILLIS_MINUS;
rpcRequest.setTimeoutMillis(timeoutMillis);
// 构建或填充请求头
if (rpcRequest.getHeaders() == null) {
rpcRequest.setHeaders(new HashMap<>());
}
Map<String, String> headers = rpcRequest.getHeaders();
headers.put(TRACE_ID, ThreadContext.getTraceId());
headers.put(LOGIN_USER_ID, ThreadContext.getLoginUserId());
headers.put(APP_ID, ThreadContext.getAppId());
headers.put(SEATA_XID, ThreadContext.getSeataXid());
// 追加传递调用方IP
headers.put("X-Forwarded-For", IpUtil.appendClientIp());
return rpcRequest;
}
provider 来自 RpcRequest 的目标服务,consumer 来自当前应用名称,两个值会写进 InternalReq。已有同名字段会被覆盖,避免业务调用者随意把请求壳中的字符串当作最终路由事实。
用户身份、调用方、traceId 和事务上下文通过 Header 传递,不放到每一个 Param 中。接收侧 ThreadContext 的 API 入口读取对应 Header,业务方法再从上下文获取当前用户。
这样分工后,三个对象的用途就不容易混淆:
| 对象 | 保存什么 |
|---|---|
| ExternalReq 及其子类 | 公网系统协议和传输载荷 |
| InternalReq 及其子类 | 内部服务关系和业务参数 |
| RpcRequest | 目标服务、endpoint、调用模式、超时和 Header 等调用设置 |
RpcRequest.builder() 是内部调用描述的构建入口,不是六类请求对象共同具有的方法。也不能把 ExternalReq 直接塞进 RpcRequest.param;校验要求它属于 InternalReq 体系。
Header 传递仍有安全前提。ThreadContext 的入口会读取请求头,而字段名字本身没有防伪能力。公网入口应清理或重建身份相关 Header,内部端口需要网络隔离或服务认证。尤其免登录入口不能信任客户端自行传来的 X-Login-User-Id。
用户鉴权还有一个应补齐的检查:当前 token 缓存值包含 userId 和 appId,但 authUser 读取时只取 userId,没有核对 token 所属 appId 与本次调用方是否一致。不能仅凭缓存里存了 appId 就宣称已经实现调用方之间的 token 隔离。
把请求链路连起来看
以需要登录、携带密文业务参数的创建用户请求为例,正常路径是:
text
ExternalLoginBizParamReq<SaveSysUserParam>
→ Spring MVC 绑定与外层字段校验
→ 调用方检查、验签
→ userToken 校验与用户上下文
→ encryptData 解密到 plaintext
→ 按 SaveSysUserParam.class 恢复对象
→ InternalBizParamReq.bizParam
→ RPC 填 provider、consumer 和上下文 Header
→ Admin 绑定、校验内部请求及业务字段
→ Service 执行业务检查与操作
控制器处理的是某个具体业务,处理器面对的是某一类请求。新增一个需要登录的有参接口,只要正确选择类型并接入现有链路,就能复用已经集中维护的协议处理。
响应方向另由 Resp<T> 承担:Service 返回业务结果,网关在需要时把 Resp.data 加密到 Resp.encryptData。请求上的 plaintext 与响应上的 data 不是同一字段,两个 encryptData 也属于不同方向。这些职责不用揉进一个无所不包的 RequestResponse 对象。
接入时应当验证的契约
这套设计适合自己掌握协议的业务网关与内部服务,不要求替代 OAuth、标准 REST 资源模型、文件上传、流式通信或第三方规定的回调格式。统一对象能减少重复实现,但也增加一层外壳和业务 JSON 转换成本;已有稳定协议不必强行迁移。
落地时,可以用以下场景检查类型选择与实际执行是否一致。这是验收清单,不是对部署环境已经通过测试的声明。
| 场景 | 应重点观察 |
|---|---|
| 登录接口没有 userToken | 应使用免登录类型,不能先要求用户已登录 |
| 登录态接口 token 无效 | Service 不执行 |
| 请求仅传公网 bizParam | 标准转换不会读取它,不能意外当作有效载荷 |
| 解密成功但业务必填字段为空 | 内部校验应拦截,不能因解密成功就放行 |
| 需要加密却只传 plaintext | 增加强制策略测试,现有解密处理器会跳过 |
| 重复发送窗口内同一请求 | 单独验证幂等或重放限制,不能只看时间戳 |
| token 属于另一个 appId | 补充归属校验,不能只取出 userId |
| 直接访问内网并伪造身份头 | 网络或服务认证必须拒绝不可信来源 |
| 创建、修改等敏感操作 | 登录之后仍需检查权限与可写字段 |
| 日志记录请求对象 | 不得泄露密码、token、密钥材料与业务明文 |
MetaLite 这套请求设计值得借鉴的地方,是让接口签名、公共处理器和业务参数形成可追踪的对应关系。看到 ExternalLoginBizParamReq<SaveSysUserParam>,就能讨论它为什么需要登录、业务参数是否合适;看到 InternalReq,就知道该去检查服务来源和上下文,而不是再找公网签名字段。
六类对象只做明确的分类,安全策略由处理链执行,业务规则留在业务层。真正接入时,把这三部分逐一核对,比再给一个万能请求类增加十几个可选字段更容易维护。
框架简介
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