代码 抖音支付回调验签一直 false:Byte-* 头和 Douyinpay-* 头是两套体系

2026-10-29 09:03:14

抖音支付回调验签一直 false:Byte-* 头和 Douyinpay-* 头是两套体系

回调进来,验签返回一直是 false。第一反应是密钥填错,平台公钥、商户私钥来回换了两轮,还是 false。把请求里的头全部打出来,发现 Byte-Timestamp、Byte-Nonce-Str、Byte-Signature 三个字段都是 null;换成另一套(抖音支付 douyinpay)能看到 Douyinpay-*,签名却也验不过。还有一种更迷惑的:验签先跳过,去读 msg.status,解析出来是 undefined。

这三个现象指向同一件事:小程序开放平台的交易系统回调,和抖音支付(douyinpay)的回调,是两套并行的体系。待验签串的拼法恰好一样,但头名、密钥、证书、报文是否加密,全都不一样。

先把两套分开

开放平台交易系统(小程序)抖音支付 douyinpay
标识AppID,tt 开头,如 ttcfdbb96650e33350商户号 mchid
验签头Byte-Timestamp / Byte-Nonce-Str / Byte-SignatureDouyinpay-Timestamp / Douyinpay-Nonce / Douyinpay-Signature / Douyinpay-Serial
签名算法SHA256withRSA + Base64同左
验签用的密钥开放平台「平台公钥」平台证书里的公钥,按 Douyinpay-Serial 匹配
回调报文明文 JSON需用「接口加密密钥」AES 解密

两个容易混的点:开放平台这套没有 Serial 头,不做也不能做证书匹配;抖音支付这套验签和解密是两步,签名通过之后 body 仍然是密文。

文档在这里:开放平台支付结果通知、公共参数、抖音支付验签步骤、签名验签概述。

待验签串:三行,每行都以 \n 结尾

两套规则一致:timestamp\nnonce\nbody\n。最后一行后面那个 \n 也要有;如果 body 为空,最后一行就是单独一个 \n。

手工验证(以抖音支付为例,开放平台只需把头名换成 Byte-*、公钥换成平台公钥):

# 1. 从证书导出公钥
openssl x509 -in cert.pem -pubkey -noout > pub.pem

# 2. 签名 base64 解码
openssl base64 -d -A <<< 'T8o8UlZm...' > signature.txt

# 3. 拼三行,最后一行也要 \n
printf '1722072480\n5rZ6K4zP41JEcR7dfbtTdeK5jCaMOsAd\n{"prepay_id":"dy0v449y..."}\n' > data.txt

# 4. 验签
openssl dgst -sha256 -verify pub.pem -signature signature.txt data.txt

printf 不会替你补末尾换行,字符串里写了几个 \n 就是几个。用 echo -n -e 也一样,三行手写。

body 必须是原始报文。 任何框架、过滤器、中间件动过 body——去空格、重排 JSON、把流读了两遍导致第二次读到空字符串——验签都会失败。有一次网关层对 JSON 做了 pretty print,肉眼比对日志和文档示例一模一样,字节已经不是了。

官方 Java 示例里那行 replaceAll 不能照抄

开放平台的 Java 示例里有个 getRequestBodyParamsStr:

while ((str = br.readLine()) != null) { wholeStr += str; }
wholeStr = wholeStr.replaceAll(" ", "");   // 示例代码把空格全删掉了

这段的本意大概是清掉 JSON 格式化产生的缩进空格,问题是 replaceAll(" ", "") 删的是全部空格,message 字段、带空格的文案、任何缩进,一起受牵连。照抄到生产上,只要报文里出现一个空格验签必失败,而且是偶发——线上安静了一阵,某个用户取消订单时 message 带了内容,又开始 false。

处理方式很简单:宁可多打一行日志保留原始字节,也不要对 body 做任何字符级处理。同理,别用 req.getParameter() 去取回调参数,也别让框架的 form 解析器先跑一遍再拼 body——application/json 的 body 一旦被当成表单参数解析,原始字节就没了。

msg 是字符串,不是对象

小程序回调的外层长这样:

{"version":"3.0","type":"payment","msg":"{\"app_id\":\"tt07e371xxxxxxx\",\"status\":\"SUCCESS\",...}"}

msg 的类型是 string,里面装的是一段被转义的 JSON。直接 body.msg.status 当然是 undefined,得先 JSON.parse(body.msg) 一次。

msg 字段类型说明
app_idstring小程序 app_id,tt 开头
out_order_nostring开发者订单号,≤64 byte
order_idstring平台订单 id,≤64 byte
statusstringSUCCESS 支付成功 / CANCEL 支付取消
total_amountint64支付金额,单位分
discount_amountint64优惠金额,单位分,可选
pay_channelint321 微信 / 2 支付宝 / 10 抖音支付 / 20 抖音钻石,支付成功才有
channel_pay_idstring渠道支付单号,SUCCESS 时一定有值
merchant_uidstring卖家商户号,SUCCESS 时一定有值
messagestring取消原因:USER_CANCEL / TIME_OUT
event_timeint64毫秒时间戳
user_bill_pay_idstring抖音账单里的支付单号,SUCCESS 时一定有值

实付金额是 total_amount - discount_amount,别把 total_amount 直接记成到手金额。

应答怎么写,失败怎么重发

成功必须同时满足 HTTP 200 和 {"err_no":0,"err_tips":"success"},缺一平台就认为通知失败。失败重试节奏是 12s / 12s / 33s / 1m / 2m / 3m / 4m / 5m / 6m / 7m,累计约 29 分钟(按文档,未实测)。

验签失败时返回 4xx/5xx,让平台带正确签名重发,不要吞掉返回 200——返回 200 等于告诉平台“处理完了”,之后没有纠正机会。

重复通知要做幂等,用 out_order_no 或 order_id 落唯一键,处理过直接返回成功。发放权益只认服务端回调,前端 success 不可信:用户在前端超时、但实际已付款的场景真实存在。

回调地址的两条规则

JS API 下单传了 payNotifyUrl,平台优先用它。但解决方案里配置的回调地址仍然必须配,否则 payNotifyUrl 不生效——文档写得很直白,实际接线时最容易漏。

notify_url 的要求:https 开头、完整全路径(只给域名不算)、外网可访问、不能带参数、不能用 localhost / 127.0.0.1 / 192.168.x.x。细节见回调常见问题。

抖音支付侧:证书按 Serial 匹配,头可能被代理吃掉

平台证书 5 年有效,到期需要平滑切换,否则接口调用会异常。验签前先比对 Douyinpay-Serial 与本地证书序列号,不一致就重新下载平台证书,公钥对不上私钥,怎么验都是 false。

另一个更隐蔽:部分 CDN 或反向代理会过滤扩展 HTTP 头,Douyinpay-* 到不了应用层,表现就是前面说的“头字段打出来是 null”。建议直连,或者检查代理的头透传配置。

抖音支付回调用「接口加密密钥」对称解密,验签与解密是两步,顺序别弄反。官方 SDK 的 NotificationParser 已经串起了这两步:取 header 构建 RequestParam、拿 JSON 纯文本 body、平台证书验签、AES 解密,douyinpay-java。

APP 调起支付的签名是四行

预下单拿到的 prepayid 有效期 2 小时。调起签名串是四行,每行结尾都要 \n:

appid\ntimestamp\nnoncestr\nprepayid\n

用商户私钥 SHA256 with RSA 签名后 Base64:

echo -n -e "dy8888...\n1670748766\n1A2B...\n dy12345678987654321\n" \
  | openssl dgst -sha256 -sign apiclient_key.pem | openssl base64 -A

SDK 的 resultCode:

值含义
0成功(仍建议服务端查单)
1用户取消
2其他错误:签名错误、appid 未注册、网络错误等
3抖音未返回明确结果(建议查单)
100未安装抖音或版本过低
103重复支付被拦截

SDK 结果只作参考,准确状态以服务端查单为准。

排查清单

  1. 先确认是哪套体系:头名是 Byte-* 还是 Douyinpay-*,两套不要混。
  2. 头字段是否为空。为空说明请求可能没到应用层,也可能是代理吃掉了扩展头。
  3. 打印原始 body 的字节长度和 sha256,确认中间件没改过内容。
  4. 检查拼串:三行、每行都有 \n、最后一行也有。
  5. msg 是否二次 JSON.parse,status 是否取到。
  6. 验签公钥来源是否正确:开放平台用平台公钥,抖音支付按 Douyinpay-Serial 匹配证书。
  7. 有没有照抄 replaceAll(" ", "")。
  8. 应答是否 HTTP 200 + err_no:0;验签失败是否返回了 4xx/5xx。
  9. 幂等键是否落库。
  10. payNotifyUrl 与解决方案里配置的回调地址是否都配了。
  11. 抖音支付侧:验签之后是否还做了解密,接口加密密钥是否对。
  12. 最后用查单接口兜底,比盯着回调日志猜快。

推荐文章

程序员茄子在线接单