☰
Sa-Token框架下JSON Body验签实战:接口签名与防重放完整方案
2026/10/2 15:10:03 网站建设 项目流程

最近在鼓捣开放接口时遇到了一个很实际的问题:项目里已经上了Sa-Token做登录认证,接口只要StpUtil.checkLogin()一下就能挡住未登录请求,但开通第三方通道后,回调接口收到的参数却没法确认是不是对方原始发出的数据。说白了一套接口光证明"你是谁"还不够,还得证明"请求没被改过",这时候就要在SaToken框架基础上补一层JSON body验签。这篇文章就把我在项目里从零实现SaToken + JSON body验签的方案、踩过的坑和可直接抄的代码完整记录一下,给同样在SaToken项目里需要做接口签名校验的朋友做个参考。

我选定的技术路线是:继续沿用Sa-Token的拦截器机制,不额外引入Spring Security这类重量级框架,只在请求进入业务Controller之前,用SaInterceptor挂一个自定义的验签逻辑。核心工作其实只有三块:一是设计一套合理的签名规范,二是解决HTTP请求体body只能读取一次的经典问题,三是在Sa-Token的拦截器里把"验签不通过"和"登录态失效"两种情况区分处理。下面按我的实际开发顺序逐步讲清楚。

1. 登录态与接口签名:为什么SaToken框架下还要单独做body验签

1.1 两类校验解决的是两个不同维度的问题

很多刚接触服务端认证的同事会把"登录认证"和"接口验签"混为一谈,但实际上它们负责的问题维度完全不同。Sa-Token的StpUtil.checkLogin()验证的是"这个请求是由一个已登录用户发出的",靠的是token的合法性和会话状态,它解决的是身份认证问题——你是谁。而body验签解决的是数据完整性和请求来源可信度问题——你发过来的这串JSON,到底有没有在中途被人动过手脚。

举个最直白的例子:你给第三方开放一个下单回调接口,对方调用时带着登录态的概率很低,就算带着,你也无法确认请求里的金额字段是对方原始填写的还是被黑客抓包篡改过的。这时候你需要的不是"要求对方登录",而是"要求对方用约定好的密钥把整个JSON body算一个签名出来,服务端验签通过才放行"。签名一旦对不上,直接判定请求不合法,业务完全不需要关心数据是否真实。

1.2 哪些接口需要这种验签

结合我自己的项目经验,需要做JSON body验签的接口通常逃不出这几类:

  • 开放平台API:对外提供数据查询、提交订单的能力,调用方是第三方开发者,无法要求对方登录你的业务系统。
  • 服务端到服务端回调:比如支付回调、物流状态回调、内容审核结果回调,这些接口往往涉及资金或状态变更,参数一旦被篡改后果很严重。
  • App端的敏感操作:虽然App用户已经通过Sa-Token做了登录认证,但某些关键操作(比如修改手机号、提现、绑定银行卡)在登录态基础上再叠加一层body签名校验,可以防止中间人篡改请求体。
  • 服务间内部调用:微服务架构下A服务调B服务,如果整个内网环境不是绝对可信,签名校验能有效防止测试环境参数被恶意构造。

也就是说,登录态决定"要不要拦这个人",验签决定"拦下来的请求数据能不能信",两者层层叠加而不是互相替代。在Sa-Token的项目里,我遇到最多的情况是:部分接口只校验登录态,部分接口在登录态之上再验签,还有少数纯开放接口只验签不要求登录。这种灵活的接口权限模型,恰好是Sa-Token这种轻量级框架最擅长承载的。

2. 验签规则设计:先把签名规范和防重放机制定清楚

2.1 签名算法选择:HMAC-SHA256与MD5方案的取舍

在我接触过的项目里,接口签名算法用的最多的就是MD5和HMAC-SHA256。简单场景下,MD5+盐的方式足够用:把业务参数拼接成字符串,加上约定好的密钥salt后取MD5,优点是计算极快、代码简单、任何语言都有现成工具库。但MD5方案有个明显缺陷:如果不做拼接顺序混淆,对简单数据结构很容易被碰撞或枚举,而且在性能过剩的现代服务端,MD5的安全性只能算"够用但不推荐"。

所以我最终选的是HMAC-SHA256。这个算法的好处是它本身就需要一个密钥参与运算,天然适合"双方共享同一个secret"的验签模型。相比直接SHA256(body + secret)这种拼接式写法,HMAC算法在实现上对密钥和消息做了分组填充处理,理论上更能抵抗长度扩展攻击。实测下来,一次HmacSHA256计算的耗时在微秒级别,对接口性能影响完全可以忽略。

如果你所在的团队比较保守,老项目里都是MD5,也完全可以在不改变算法框架的前提下兼容:只要把签名规则统一到一个工具类里,MD5和HMAC-SHA256无非是SignatureUtil中一个方法实现不同,不影响拦截器的主体逻辑。

2.2 待签字符串结构:method、path、timestamp、nonce、body的拼接顺序

签名规则里面最容易被忽略却又最关键的是"到底把哪些东西拿来算签名"。只签body是不够的,因为攻击者可以把整个请求复制下来换个时间重放。我的规范设计如下:

  • 参与签名的元素:HTTP方法(大写)、请求路径、时间戳timestamp(秒级)、随机字符串nonce、原始请求体字符串。

  • 拼接顺序固定为:POST\n/api/open/order\n1710000000\n6a2f8c9e-1b3d-4e5f-9a7b-0c1d2e3f4a5b\n{"amount":100}。

  • 每项之间用换行符\n分隔。

  • 用共享密钥对这个字符串做HmacSHA256计算,结果转为十六进制小写字符串。

这样设计有几个目的:把HTTP方法和路径放进签名串,可以防止攻击者把同一个body复制到另一个接口上重放;放时间戳和nonce是为了防重放;放body是为了校验内容完整性。如果双方约定了用POST提交且路径是固定的,理论上method和path可以不放,但我建议保留,因为实际项目里一个开放接口往往会有多个路径共用同一套签名逻辑,把路径放进去能避免很多串接口的麻烦。

2.3 防重放机制:时间戳窗口加nonce存Redis

防重放是验签体系里绝对不能省的一环。即便有签名,攻击者把截获的原始请求原封不动重新发送一次,服务端是没法通过签名识别出这是重放的——因为签名是正确的。所以必须对时间戳和nonce做联合校验:

  • 时间戳窗口:服务端收到请求后检查timestamp与当前时间的差值,超过5分钟直接拒绝。这个窗口不能太短,否则调用方服务器时钟稍有偏差就会被误杀;也不能太长,给攻击者留下宽裕的重放时间。我实测下来5分钟是开发和联调阶段最舒服的窗口。

  • nonce防重放:同一timestamp内,nonce只允许使用一次。服务端把timestamp-nonce作为key写入Redis并设置与窗口时间一致的过期时间,如果写入时发现key已存在,说明这个请求被重放过了,直接拒绝。

把nonce的key设计成sign:nonce:{timestamp}:{nonce}而不是只存nonce,是为了避免不同时间窗口内同一nonce导致误杀。实际联调中很多问题不是因为算法错,而是因为不同服务之间系统时间差太大,我建议在运维层面统一用NTP对时,否则客户端签名的时间戳和服务端校验的时间戳一旦差出几分钟,排查起来非常痛苦。

3. 上手实操:把SaInterceptor当成验签入口,路由式挂载

3.1 引入依赖和配置Sa-Token拦截器

项目基于Spring Boot,Sa-Token的引入非常简单。我这边用的是当前稳定版本,只需要在pom.xml中加入依赖:

<dependency> <groupId>cn.dev33</groupId> <artifactId>sa-token-spring-boot-starter</artifactId> <version>1.37.0</version> </dependency>

登录认证部分相关的配置(token名称、超时时间、token风格)我不过多展开,网上资料很多。重点是拦截器如何写。Sa-Token官方推荐的方式是注册一个SaInterceptor,通过SaRouter.match()做路由匹配,再调用check()或checkLogin()完成校验。我的做法是把验签逻辑封装成一个独立方法,在拦截器里对不同路径区分调用:

@Configuration public class SaTokenConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new SaInterceptor(handler -> { // 开放验签接口:只验签,不要求登录 SaRouter.match("/open/**").check(r -> SignInterceptor.checkSign()); // 业务接口:先验登录,再验签(商品下单等敏感操作) SaRouter.match("/api/**") .check(r -> StpUtil.checkLogin()) .check(r -> SignInterceptor.checkSign()); // 其他接口:只验登录 SaRouter.match("/**") .notMatch("/open/**", "/api/**", "/auth/**") .check(r -> StpUtil.checkLogin()); })).excludePathPatterns("/auth/**", "/error"); } }

这里有个设计细节要提醒:SaRouter.match的匹配顺序是自上而下的,但不是"匹配到就停止",而是每个match都会对符合路径的请求执行自己的check,如果某个请求同时符合多个match,多个check都会执行。所以我在/api/**里同时挂登录和验签,而/open/**只挂验签。对于纯公开接口(比如获取公钥、下载服务端公钥),直接放进excludePathPatterns即可。

3.2 自定义请求包装器:解决body只能读一次的问题

凡是做过接口验签的人,十有八九都栽过同一个跟头:HttpServletRequest的getInputStream()只能读取一次。问题在于,业务层Controller里的@RequestBody也要读body,如果拦截器先读了一次body来做验签,到Controller那边再读就拿到空串或直接报Stream closed。

解决办法是写一个RequestWrapper,在请求进入拦截器之前把body字节缓存下来,后续任何一次getInputStream()和getReader()都返回缓存的内容。我是在过滤器(Filter)里做包装的,这样能确保在Spring的DispatcherServlet和Sa-Token拦截器之前就把body读进缓存。唯一需要注意的是,用了包装器后,getParameter()、getParameterMap()这些方法也要一起重写,因为表单参数可能也走了body流。

public class BodyCachingRequestWrapper extends HttpServletRequestWrapper { private final byte[] body; public BodyCachingRequestWrapper(HttpServletRequest request) throws IOException { super(request); // 读取原始body this.body = request.getInputStream().readAllBytes(); } @Override public ServletInputStream getInputStream() { ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(body); return new ServletInputStream() { @Override public int read() { return byteArrayInputStream.read(); } @Override public boolean isFinished() { return byteArrayInputStream.available() == 0; } @Override public boolean isReady() { return true; } @Override public void setReadListener(ReadListener listener) { // 不需要实现 } }; } @Override public BufferedReader getReader() { return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8)); } @Override public String getParameter(String name) { // 如果body是JSON,这里也可以从解析结果中取值 return super.getParameter(name); } }

注册Filter时,注意@WebFilter配合@ServletComponentScan,或者直接用FilterRegistrationBean。一定要把Filter的优先级放到最高(Ordered.HIGHEST_PRECEDENCE),否则框架层的Filter可能在某些场景抢先把body读走。同时要过滤掉GET请求和Content-Type不是application/json的请求,避免白读了非同类型接口的body。

3.3 验签拦截器核心代码:SaRouter.match加check回调

完成包装器之后,验签拦截器本身就可以专心做业务了。我把它设计成一个静态方法checkSign(),在Sa-Token的check()回调里调用。之所以用静态方法而不是单独实现一个HandlerInterceptor,是因为Sa-Token官方拦截器模式下check()回调本身就是拦截逻辑的落点,再用一个HandlerInterceptor会多一层复杂度。

public class SignInterceptor { public static void checkSign() { SaRequest saRequest = SaHolder.getRequest(); HttpServletRequest request = saRequest.getRequest(); String appId = request.getHeader("X-AppId"); String timestamp = request.getHeader("X-Timestamp"); String nonce = request.getHeader("X-Nonce"); String sign = request.getHeader("X-Sign"); // 基础参数校验 if (StringUtils.isAnyBlank(appId, timestamp, nonce, sign)) { throw new SaTokenException("签名参数缺失"); } // 1. 根据appId获取密钥 String appSecret = SecretManager.getSecretByAppId(appId); if (appSecret == null) { throw new SaTokenException("未知的AppId"); } // 2. 时间戳窗口校验 long ts = Long.parseLong(timestamp); if (Math.abs(System.currentTimeMillis() / 1000 - ts) > 300) { throw new SaTokenException("请求已过期"); } // 3. nonce防重放 String nonceKey = "sign:nonce:" + timestamp + ":" + nonce; Boolean success = RedisTemplate.opsForValue().setIfAbsent(nonceKey, "1", Duration.ofSeconds(600)); if (Boolean.FALSE.equals(success)) { throw new SaTokenException("重复请求"); } // 4. 读取body并验签 String body = getRawBody(request); String serverSign = SignatureUtil.hmacSha256(buildSignContent(request, timestamp, nonce, body), appSecret); if (!serverSign.equalsIgnoreCase(sign)) { throw new SaTokenException("签名不匹配"); } } private static String getRawBody(HttpServletRequest request) { try { return IOUtils.toString(request.getInputStream(), StandardCharsets.UTF_8); } catch (IOException e) { throw new SaTokenException("读取body失败"); } } private static String buildSignContent(HttpServletRequest request, String timestamp, String nonce, String body) { return request.getMethod() + "\n" + request.getRequestURI() + "\n" + timestamp + "\n" + nonce + "\n" + body; } }

这段代码里有个细节值得说:验签时的request.getRequestURI()要能拿到原始路径,不要用带context-path或拼了query的完整URL。如果网关做了路径重写,客户端签名用的path和服务端实际收到的path不一致,签名就会对不上。这种情况我一般建议客户端直接用"请求发给网关时的相对路径"参与签名,服务端拿request.getRequestURI()时注意和网关侧的约定保持一致。

4. 完整验签代码实现

4.1 签名生成工具类

服务端验签和客户端验签共用一个算法,工具类非常简单。我习惯把签名相关的方法收拢到一个SignatureUtil里,方便测试和复用。HmacSHA256的Java实现不依赖第三方库,直接用JDK自带的javax.crypto.Mac即可。

public class SignatureUtil { public static String hmacSha256(String data, String secret) { try { Mac mac = Mac.getInstance("HmacSHA256"); SecretKeySpec keySpec = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); mac.init(keySpec); byte[] bytes = mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); return toHex(bytes); } catch (NoSuchAlgorithmException | InvalidKeyException e) { throw new RuntimeException("签名计算失败", e); } } private static String toHex(byte[] bytes) { StringBuilder sb = new StringBuilder(); for (byte b : bytes) { String hex = Integer.toHexString(b & 0xFF); if (hex.length() == 1) { sb.append('0'); } sb.append(hex); } return sb.toString(); } }

这里有一个非常容易被忽视的小坑:secret和data在用getBytes()时一定要显式指定StandardCharsets.UTF_8,不同环境默认字符集不同,一旦服务器或客户端的默认编码不是UTF-8,两边算出来的签名就会不一致。你可能会想"我本地明明好的,怎么上了服务器就对不上",多半就是编码问题。

4.2 验签逻辑拆解:从拿到rawBody到比对结果

把验签逻辑整体拆开来看,流程是:

  1. 从Header中取出X-AppId、X-Timestamp、X-Nonce、X-Sign四个字段。

  2. 用X-AppId查密钥。密钥管理我建议做成独立的SecretManager,不要硬编码在代码里。我最初图省事直接写在配置文件里,后来新增合作方时每次都要重新构建发布。改成数据库表或独立的配置中心之后,增加一个合作方只需要插一条记录,效率提升非常明显。

  3. 时间戳窗口校验。这里有个细节:客户端生成时间戳时用的是"秒级",也就是System.currentTimeMillis() / 1000,服务端校验时也要用秒级,否则放大1000倍再相减,任何正常请求都会过期。

  4. nonce防重放。我把setIfAbsent当分布式锁用,setIfAbsent成功说明这个nonce第一次出现;失败则说明已经处理过同nonce请求。这一步要放在时间戳校验之后、签名比对之前,避免攻击者用过期时间戳疯狂刷新nonce。

  5. 读取body、拼接签名内容、计算服务端签名、和请求头里的sign做比对。注意比对时用equalsIgnoreCase兼容客户端大小写差异。

这里插一句:验签失败的异常处理。因为Sa-Token的check()回调抛出的SaTokenException会被全局异常处理器捕获,所以需要在@RestControllerAdvice里给SaTokenException写一个统一的异常处理,返回约定的响应体(比如code=401或code=40002)。Sa-Token的NotLoginException已经有一套默认返回了,但它默认返回的是登录失效语义,验签失败如果也走这层,客户端会分不清到底是你没登录还是签名不对。建议分两个业务异常:SignException和SaTokenException,前者返回签名错误码,后者返回登录失效码。

@RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(NotLoginException.class) public Result<?> handleNotLogin(NotLoginException e) { return Result.error(401, "未登录或登录已过期"); } @ExceptionHandler(SignException.class) public Result<?> handleSign(SignException e) { return Result.error(40002, e.getMessage()); } @ExceptionHandler(SaTokenException.class) public Result<?> handleSaToken(SaTokenException e) { return Result.error(500, e.getMessage()); } }

4.3 错误响应与状态码设计

验签相关状态码设计得越清晰,联调阶段越省心。我这边用的编码如下,供参考:

状态码含义场景
40001签名参数缺失Header缺少AppId/Timestamp/Nonce/Sign任一字段
40002未知AppId服务端没有该调用方的密钥
40003请求已过期时间戳超出5分钟窗口
40004重复请求nonce在Redis中已存在
40005签名不匹配服务端重算签名与请求头不一致
40006请求体为空约定传body但实际为空

响应体我统一返回JSON:{"code": 40005, "msg": "签名不匹配", "data": null}。不要返回堆栈信息,内部异常细节只在服务端日志里记录,对调用方只暴露业务可读的错误信息。

5. 客户端如何生成签名:给调接口的人一份可直接抄的模板

5.1 Java客户端签名示例

服务端验签做好之后,还要给客户端一个能跑通的签名模板。我这边接触的调用方大部分也是Java技术栈,给了一份最简单的示例。注意客户端这里要能拿到原始请求body字符串,如果框架层已经帮你做了对象序列化,要保证序列化出来的内容和实际发送的body完全一致。

public class ClientSignDemo { public static void main(String[] args) { String appId = "your-app-id"; String appSecret = "your-app-secret"; String url = "https://api.example.com/open/order"; String method = "POST"; // 请求体必须是和实际发送完全一致的字符串 String body = "{\"userId\":1001,\"amount\":99.9}"; String timestamp = String.valueOf(System.currentTimeMillis() / 1000); String nonce = UUID.randomUUID().toString().replace("-", ""); String content = method + "\n" + "/open/order" + "\n" + timestamp + "\n" + nonce + "\n" + body; String sign = SignatureUtil.hmacSha256(content, appSecret); System.out.println("X-AppId: " + appId); System.out.println("X-Timestamp: " + timestamp); System.out.println("X-Nonce: " + nonce); System.out.println("X-Sign: " + sign); } }

客户端拼content时有一个隐含要求:body字符串从左到右的字节排列,必须和服务端收到的body字节完全一致。很多语言里JSON序列化会自动把中文转成\uXXXX,这种转义会导致字符串内容和原始发送的body不一致。比如Java里用new JSONObject(map).toString()输出的中文会被转义成\uXXXX,但HTTP传输时实际body可能是UTF-8明文中文,两边算出来的签名就永远对不上。

5.2 常见语言与环境差异:JSON字符串化时ensure_ascii、空格、换行

这是签名联调里最折磨人的地方,没有之一。Python、Java、JavaScript、Go各自对JSON序列化的默认行为差异极大。Python的json.dumps()默认ensure_ascii=True,会把中文转成ascll码形式;JavaScript的JSON.stringify()默认不转义中文;Go的json.Marshal也不转义中文。这三方生成同样的JSON结构,body字节流很可能不同,签名自然不同。

我的建议是约定一条硬性规则:客户端参与签名和实际传输的body必须是"同一份字符串",也就是先把body字符串计算好,传输时就发这串字符串,签名也直接对这串字符串计算,千万不要"序列化成对象→发HTTP→再对对象做签名"这样分开操作。只要保证签名用的字符串 == HTTP请求体字节经过UTF-8解码后的字符串,跨语言问题基本都能绕过去。如果为了做演示讲清楚,也可以约定对body做一次规范化后再签名,但规范化规则要详细到"冒号后是否有空格、数组元素之间是否有换行、中文字段是否转义",这个复杂度太高,我建议能用"直接对原始body签名"就坚决不做规范化。

6. 实测踩坑:body重新格式化、环境差异和拦截器生效顺序

6.1 最大的坑:JSON格式化导致签名对不上

我在联调阶段踩过最狠的一次坑,就是客户端那边用了Jackson的writerWithDefaultPrettyPrinter()把body格式化成带缩进的美化格式去发送,签名也是按美化后的内容算的。按理说两边应该一致,结果服务端用@RequestBody接收到的对象再序列化后,格式完全不一样了。问题表面上看是"服务端验签失败",实际上是因为服务端验签时根本不关心body长什么样子,它只认request.getInputStream()读到的原始字节。只要客户端发送的是美化格式,服务端在拦截器里拿到的就是美化格式,签名理论上应该能过。真正的问题出在另一种情况:客户端签名时用对象序列化后的内容(比如{"amount":99.9,"userId":1001}),发送时却走了框架,框架自动把body重新序列化成了另一种顺序(比如{"userId":1001,"amount":99.9}),两边字节流不一致。

解决这个问题的唯一可靠办法,就是在客户端创建HTTP请求时,手动物化body字符串,签名和请求体共用同一个字符串变量。

6.2 编码与大小写:Hex还是Base64

HmacSHA256计算出来的结果是字节数组,转成字符串有两种常见方式:十六进制(Hex)和Base64。我见过同一个平台里服务端用Hex、客户端用Base64,两边怎么验都不通过。这个纯属约定问题,但很容易埋雷。

方案对比:

编码方式长度特点适用场景
Hex小写64字符可读性好,url-safe,无特殊字符默认推荐,最简单
Hex大写64字符和Hex小写只差大小写兼容历史项目
Base6444字符更短,但含+///=,Header中可能出现转义问题偶尔在移动端见到

我建议统一用Hex小写,并且在Header传输时使用X-Sign这种自定义头,不要往Authorization里塞,避免和Sa-Token的token头冲突。

6.3 拦截器与全局过滤器的执行顺序

Sa-Token的SaInterceptor本身是一个HandlerInterceptor,它执行的时机是在Spring MVC的DispatcherServlet之后、Controller之前。而我的BodyCachingRequestWrapper是在自定义Filter中做的。过滤器的执行顺序天然早于拦截器,这个顺序正好满足需求:先包装请求把body缓存下来,后续拦截器和Controller都可以放心读取body。

但有一个很隐蔽的顺序问题:如果你在项目中同时用了其它过滤器,比如CharacterEncodingFilter、HiddenHttpMethodFilter,一定要用@Order把包装请求的Filter放到最前面。否则Spring的CharacterEncodingFilter一旦先设置了编码,理论上没问题;但某些框架版本中,如果其它Filter先调用了getParameter(),body里的内容可能被消耗掉,包装器再读就拿到空串了。

我在一个老项目里还遇到过这样的情况:Sa-Token的拦截器依赖SaHolder获取上下文,而SaHolder的上下文初始化是在SaTokenContext过滤器中完成的,如果我自己注册的Filter在Sa-Token的上下文过滤器之前就尝试访问SaHolder,会抛空指针。解决方法是:验签逻辑不要写在Filter里,而是像前文那样写在Sa-Token的SaInterceptor.check()回调中,确保Sa-Token上下文已经初始化完成。这个细节是我反复调整代码后总结出来的,最早我图省事把验签写进全局Filter,结果被SaHolder.getRequest()的空指针折腾了一下午。

6.4 日志与调试技巧

验签联调时最怕的是"两边都觉得自己算得对"。我的做法是在验签失败时把服务端参与签名的原始串打出来,但把密钥打码;客户端也同样打印自己的签名串。两边把签名串对齐,只要字符串一致,签名一定一致。我在SignException里加了一个字段存原始签名内容,全局异常处理里做了脱敏后打到日志。

@ExceptionHandler(SignException.class) public Result<?> handleSign(SignException e) { log.warn("验签失败, reason: {}, signContent: {}", e.getMessage(), e.getSignContent()); return Result.error(40005, e.getMessage()); }

实际排查时还有一个捷径:先用Postman直接发一个最简单的请求,body固定为{},把timestamp和nonce设为固定值,服务端和客户端分别计算,这样能把变量压缩到最小,快速判断是算法问题、编码问题还是路径问题。定位清楚后再逐步替换成真实数据。

最后分享一个我个人的体会:body验签这套机制,看起来是给接口加了一道门槛,实际上它更大的价值是逼着你把接口调用边界理清楚。过去"反正有登录态,参数不用太担心"的粗放思维,在开放第三方接口和服务间调用时一定会出事。Sa-Token把登录认证这块做得很轻,我们程序员要做的只是在它留出的扩展点上,把"数据可信"这条线补上。上面这套实现,我在项目里已经稳定跑了大半年,新增一个合作方平台只需要在密钥表里加一行记录,后面再遇到同类需求,你完全可以照着这个思路快速落地。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询