全网最好的SpringBoot接口请求对象设计

全网最好的 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("&timestamp=").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

相关推荐
蜗牛互联网1 小时前
AI给面试打分不够,求职者更需要可核对的证据
java·人工智能·后端
EatFan1 小时前
gRPC 为什么比 HTTP+JSON 快?详细讲解gRPC
后端·微服务·grpc
此时不提桶,更待何时1 小时前
02-05-B-虚拟线程面试与生产事故实战
java·面试
名字还没想好☜1 小时前
Spring Boot 3.2 RestClient 实战:替代 RestTemplate 调外部接口,超时、连接池与错误处理
java·后端·spring
雪隐2 小时前
16GB 显卡跑 Qwen3.8-27B,只要 7 GB:三进制模型 Bonsai 2 部署手记与双格式实测
人工智能·后端
云浪2 小时前
Go Context 到底是什么?
后端·go
摇滚侠2 小时前
《Spring Boot 3:高级与架构设计》第 2 章 IOC容器的高级机制 BeanFactoryPostProcessor 个人理解 6
java·spring boot·笔记·后端
苏三说技术2 小时前
推荐一个牛逼的AgentScope系统
后端
行百里er2 小时前
5 分钟跑起 Redis(Docker 版)
redis·后端·docker