支付回调验签连续失败三天,最后发现是网关的时间戳单位问题

55 阅读4分钟

三天、五个错误方向,最后发现是「毫秒」和「秒」的默认值不一致。

回调链路:三方支付 → 验签网关 → 支付服务

图 1|失败点不在支付服务,在网关的时间戳窗口——这是三天里最后才看到的一格。

读完你能拿到三样东西:① 验签失败的 5 步拆解排查法(不用再猜);② 为什么「签名无效」是垃圾日志,该怎么改成能定位的日志;③ 灰度前必做的一件事——抓真实样本,别用自己构造的请求。

全程 5 分钟。下次回调 401,可以直接照表排查。

背景

  • 架构:网关(Spring Cloud Gateway)+ 支付服务 + 回调方
  • 回调链路:三方 → 网关(签名校验、时间戳窗口、nonce 防重放)→ 支付服务
  • 改造内容:把验签从支付服务前移到网关,做统一入口

现象

  • 灰度 5% 后,回调 401 从 0 飙升到 30% 以上,高峰期接近全失败
  • 错误统一是「签名无效」,日志只有一行 sign invalid
  • 本地用同一套密钥和参数构造请求,能验过

第一轮排查:逐条排除

  1. 密钥:三方后台、网关配置、支付服务三处比对,一致,排除
  2. 拼接规则:按文档排序、空值处理、URL 编码,和抓包原文逐字节比对,一致
  3. 服务器时钟:NTP 同步正常,偏差 200ms 内,排除
  4. nonce:Redis 写入读回正常,排除
  5. 签名算法:SHA256WithRSA,换成已知正确样本交叉验证,能过

每条都对,但生产就是失败。

五个排查方向与依据:全部排除

图 2|五个方向全部排除,真正的信号是「全被排除」,不是继续挖同一个点。

卡点:本地复现不出来

本地构造的请求能过;把生产抓包的原文复制到本地脚本里跑,也能过。这说明问题不在「签名内容」,而在「校验发生的环境」。

于是做了两件事:

  • 在验签失败分支里把失败步骤拆开打点:timestamp / sign / nonce 三段分别判定
  • 灰度 1% 并保存失败样本(脱敏后只留字段结构)

根因

失败样本里 100% 卡在时间戳窗口:

  • 网关的时间戳单位配置默认 seconds
  • 回调方 SDK 发的是 13 位毫秒时间戳
  • 窗口校验把毫秒当秒,差值瞬间变成 45000+,全部判定「过期」

三个「为什么」也就通了:

  • 为什么本地能过:本地脚本用的是 10 位秒级时间戳,和生产不一致
  • 为什么偶发:高峰期走的是另一条回调通道,时间戳单位不同
  • 为什么日志没用:拆点之前只打「验签失败」,看不出卡在哪一段

13 位毫秒 vs 10 位秒:差值 45000+,全部判过期

图 3|同一个回调,10 位和 13 位差 1000 倍;本地能过,是因为本地用的是 10 位。

修复

  1. 时间戳单位改成显式配置,并对 10 位 / 13 位做自动归一化
  2. 单位不匹配时打 WARN,不再静默失败
  3. 验签失败日志分步骤输出,带脱敏样本
  4. nonce 写入失败改为可观测降级,不再无声放行或拒绝
  5. 灰度开关 + 验签成功率指标,低于 99% 自动回滚

复盘:三条能迁移到别处

  1. 协议里的每个数字都要显式声明单位。时间戳、超时、金额精度、距离,文档没写,代码就有两种解释,两种解释就有一个是错的。
  2. 「签名无效」是垃圾日志。验签是复合流程,失败必须分步骤,否则排查成本是十倍。
  3. 本地能过不代表对。本地构造请求用的是「你以为的格式」,不是真实生产格式。灰度前先抓真实样本。

三条可迁移的复盘结论

图 4|三条可以直接搬到下一个支付/网关项目里的做法。

最后

这类问题有个共同特征:不是某个点写错了,而是两个模块的「默认值」不一致。

所以现在我做支付、网关、回调这类改造,第一步不是写代码,是先拉一张「字段与单位约定表」,各方确认后再动手。

遇到类似问题可以在评论区聊,能复现的我都会回。