
微信支付退款回调报错 Wechatpay-Serial is empty?支付回调正常但退款回调失败?
本文完整记录问题根因、排查过程和可直接使用的 Go 代码,兼容 APIv3 和 V2 两种退款通知协议。
一、问题现象
接入微信支付 后,支付回调(用户付款后的通知)运行正常,但退款回调(用户退款后的通知)频繁报错:
err: key `Wechatpay-Serial` is empty in header
报错信息很明确:HTTP 请求头里缺少 Wechatpay-Serial 字段。但奇怪的是,同样的验签代码在支付回调里完全正常,为什么到了退款回调就找不到这个 header 了?
二、前置知识:Wechatpay-Serial 是什么?
在微信支付 APIv3 中,所有回调通知都需要验签,而 Wechatpay-Serial 是验签的关键参数之一。它表示微信支付平台证书的序列号,商户需要用对应的公钥来验证通知的签名是否来自微信官方。
支付回调能正常验签,说明:
- 商户的验签代码是正确的
- 微信支付发送的支付回调确实携带了
Wechatpay-Serial
那么退款回调为什么会缺失这个 header?问题一定出在微信发送的通知本身。
三、根本原因:退款回调有两种协议版本
经过查阅微信官方文档和社区讨论,发现微信支付存在 V2 和 V3 两套协议并存的情况:
| 退款发起方式 | 回调协议版本 | 请求头特征 | 处理方式 |
|---|---|---|---|
| 通过 APIv3 接口发起退款 | V3 | 有 Wechatpay-Serial | RSA 公钥验签 |
| 通过微信支付商户后台直接退款 | V2 | 无 Wechatpay-Serial | 无需验签,需解密 |
核心发现:wechatpay-go SDK(微信支付官方 Go SDK)提供的 ParseNotifyRequest 方法只支持 V3 协议。当运营人员从微信支付商户后台直接操作退款时,微信发送的是 V2 加密通知,不包含任何 V3 的 header 字段,因此 SDK 验签时找不到 Wechatpay-Serial,必然报错。
四、V2 退款通知的结构特点
V2 退款通知的 HTTP Body 是一段 XML,结构如下:
<xml>
<return_code>SUCCESS</return_code>
<appid>wx_xxx</appid>
<mch_id>xxx</mch_id>
<nonce_str>xxx</nonce_str>
<req_info>Base64加密字符串...</req_info>
</xml>
与 V3 通知相比,有两个显著区别:
- 没有
sign字段 —— 不需要像普通 V2 接口那样做 MD5 签名验证 - 有
req_info字段 —— 退款详情被加密了,必须先解密才能读取
需要注意的是:回调请求参数是xml,处理回调的接口也需要返回特定的 XML 格式给微信,而不是 JSON。
<xml>
<return_code><![CDATA[SUCCESS]]></return_code>
<return_msg><![CDATA[OK]]></return_msg>
</xml>
五、解密 req_info 的官方步骤
根据微信支付官方文档,req_info 的解密流程如下:
步骤 1:Base64 解码
将 req_info 的值进行 Base64 解码,得到二进制加密数据。
步骤 2:生成解密密钥
对商户的 APIv2 密钥(注意:不是 APIv3 密钥)做 MD5,得到 32 位小写十六进制字符串。这个 32 位字符串直接作为 32 字节的 AES 密钥使用。
// 假设 v2Key 是商户的 APIv2 密钥
hash := md5.Sum([]byte(v2Key))
key := fmt.Sprintf("%x", hash) // 输出类似 "a1b2c3d4..." 共 32 字节
常见误区:有人以为要取 MD5 结果的前 16 字节作为 AES-128 密钥。实际上微信要求的是 AES-256-ECB,必须使用完整的 32 字节。
步骤 3:AES-256-ECB 解密
使用上一步生成的 32 字节密钥,对加密数据进行 AES-256-ECB 模式解密,填充算法为 PKCS7Padding。
Go 语言的标准库
crypto/aes只提供了 CBC 模式,没有 ECB 模式。需要引入第三方库或自行实现 ECB 解密逻辑。
步骤 4:解析解密后的 XML
解密后得到一段 XML 文本,根标签是 <root>(不是 <xml>),结构如下:
<root>
<out_refund_no>退款单号</out_refund_no>
<out_trade_no>原订单号</out_trade_no>
<refund_id>微信退款单号</refund_id>
<refund_fee>100</refund_fee>
<refund_status>SUCCESS</refund_status>
<success_time>2018-11-19 16:24:13</success_time>
<total_fee>100</total_fee>
<transaction_id>微信订单号</transaction_id>
</root>
注意:解密后的 XML 根标签是 <root>,Go 的 xml.Unmarshal 解析时需要将 struct 标签设为 xml:"root"。
六、完整代码实现(可直接复制使用)
以下代码完全独立,不依赖任何特定的项目结构,可直接复制到你的项目中使用。
6.1 数据结构和事件定义
package wechatpay
import (
"crypto/md5"
"encoding/base64"
"encoding/xml"
"fmt"
"io"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/go-think/openssl"
)
// RefundNotifyEvent 标准化的退款回调事件
// 无论底层是 V2 还是 V3 协议,都统一为此结构
type RefundNotifyEvent struct {
EventType string // 事件类型:REFUND_SUCCESS / REFUND_CLOSED / REFUND_ABNORMAL / UNKNOWN
ChannelOrderNo string // 微信渠道订单号(transaction_id)
OrderNo string // 商户订单号(out_trade_no)
RefundNo string // 商户退款单号(out_refund_no)
Amount int64 // 退款金额(分)
RefundAmount int64 // 实际退款金额(分)
Status string // 退款状态
PaidTime int64 // 退款成功时间戳
RawData any // 原始数据
}
// RefundNotifyHandler 退款通知处理器
type RefundNotifyHandler struct {
APIv2Key string // APIv2 密钥(用于 V2 通知解密)
V3NotifyHandler any // V3 通知处理器(wechatpay-go SDK 的 notify.Handler)
}
// NewRefundNotifyHandler 创建退款通知处理器
// apiV2Key: 商户的 APIv2 密钥
// v3Handler: wechatpay-go SDK 的 notify.Handler 实例(用于 V3 验签)
func NewRefundNotifyHandler(apiV2Key string, v3Handler any) *RefundNotifyHandler {
return &RefundNotifyHandler{
APIv2Key: apiV2Key,
V3NotifyHandler: v3Handler,
}
}
6.2 入口函数:自动判断协议版本
// ParseRefundNotify 解析退款回调通知
// 自动判断是 V3 还是 V2 协议,分别处理
// 返回标准化的 RefundNotifyEvent,上层无需关心底层协议差异
func (h *RefundNotifyHandler) ParseRefundNotify(c *gin.Context) (*RefundNotifyEvent, error) {
// 通过关键 header 判断协议版本
if c.GetHeader("Wechatpay-Serial") != "" {
// V3 协议:有 Wechatpay-Serial,使用 SDK 验签
return h.parseRefundNotifyV3(c)
}
// V2 协议:无 Wechatpay-Serial,需要解密 req_info
return h.parseRefundNotifyV2(c)
}
6.3 V3 退款通知处理
// parseRefundNotifyV3 处理 V3 退款通知
// 依赖 wechatpay-go SDK 的 notify.Handler 进行 RSA 验签
func (h *RefundNotifyHandler) parseRefundNotifyV3(c *gin.Context) (*RefundNotifyEvent, error) {
// 这里假设你已有 wechatpay-go 的 notify.Handler
// 具体实现取决于你的 SDK 版本,以下为伪代码示意
//
// refund := new(RefundResource)
// req, err := h.V3NotifyHandler.ParseNotifyRequest(c, c.Request, refund)
// if err != nil {
// return nil, fmt.Errorf("V3 验签失败: %v", err)
// }
//
// 实际使用时,替换为你项目中已有的 V3 解析逻辑
return nil, fmt.Errorf("V3 解析请使用你项目中的现有实现")
}
6.4 V2 加密退款通知处理
// parseRefundNotifyV2 处理 V2 加密退款通知
// 步骤:解析外层 XML → 解密 req_info → 解析退款详情
func (h *RefundNotifyHandler) parseRefundNotifyV2(c *gin.Context) (*RefundNotifyEvent, error) {
// 读取请求 Body
body, err := io.ReadAll(c.Request.Body)
if err != nil {
return nil, fmt.Errorf("读取请求体失败: %v", err)
}
// 解析外层 XML,提取 req_info
var outer struct {
XMLName xml.Name `xml:"xml"`
ReturnCode string `xml:"return_code"`
ReturnMsg string `xml:"return_msg"`
AppID string `xml:"appid"`
MchID string `xml:"mch_id"`
NonceStr string `xml:"nonce_str"`
ReqInfo string `xml:"req_info"`
}
if err := xml.Unmarshal(body, &outer); err != nil {
return nil, fmt.Errorf("解析外层 XML 失败: %v", err)
}
if outer.ReturnCode != "SUCCESS" {
return nil, fmt.Errorf("微信返回失败: %s", outer.ReturnMsg)
}
if outer.ReqInfo == "" {
return nil, fmt.Errorf("req_info 为空")
}
// 解密 req_info
decryptedXML, err := DecryptRefundReqInfo(outer.ReqInfo, h.APIv2Key)
if err != nil {
return nil, fmt.Errorf("解密 req_info 失败: %v", err)
}
// 解密后的 XML 根标签是 <root>
var v2Notify struct {
XMLName xml.Name `xml:"root"`
OutRefundNo string `xml:"out_refund_no"`
OutTradeNo string `xml:"out_trade_no"`
RefundAccount string `xml:"refund_account"`
RefundFee int64 `xml:"refund_fee"`
RefundID string `xml:"refund_id"`
RefundRecvAccout string `xml:"refund_recv_accout"`
RefundRequestSource string `xml:"refund_request_source"`
RefundStatus string `xml:"refund_status"`
SettlementRefundFee int64 `xml:"settlement_refund_fee"`
SettlementTotalFee int64 `xml:"settlement_total_fee"`
SuccessTime string `xml:"success_time"`
TotalFee int64 `xml:"total_fee"`
TransactionID string `xml:"transaction_id"`
CashRefundFee int64 `xml:"cash_refund_fee"`
}
if err := xml.Unmarshal([]byte(decryptedXML), &v2Notify); err != nil {
// 兼容处理:某些情况下可能需要将 <root> 替换为 <xml> 才能解析
fixedXML := strings.Replace(decryptedXML, "<root>", "<xml>", 1)
fixedXML = strings.Replace(fixedXML, "</root>", "</xml>", 1)
if err2 := xml.Unmarshal([]byte(fixedXML), &v2Notify); err2 != nil {
return nil, fmt.Errorf("解析退款详情失败: %v, 原始XML=%s", err, decryptedXML)
}
}
// 映射退款状态
var eventType string
switch v2Notify.RefundStatus {
case "SUCCESS":
eventType = "REFUND_SUCCESS"
case "CHANGE":
eventType = "REFUND_ABNORMAL"
case "REFUNDCLOSE":
eventType = "REFUND_CLOSED"
default:
eventType = "UNKNOWN"
}
// 解析时间(V2 格式:"2018-11-19 16:24:13")
var paidTime int64
if v2Notify.SuccessTime != "" {
if t, err := time.Parse("2006-01-02 15:04:05", v2Notify.SuccessTime); err == nil {
paidTime = t.Unix()
}
}
// 优先使用结算退款金额,回退到退款金额
refundAmount := v2Notify.SettlementRefundFee
if refundAmount == 0 {
refundAmount = v2Notify.RefundFee
}
return &RefundNotifyEvent{
EventType: eventType,
ChannelOrderNo: v2Notify.TransactionID,
OrderNo: v2Notify.OutTradeNo,
RefundNo: v2Notify.OutRefundNo,
Amount: refundAmount,
RefundAmount: refundAmount,
Status: v2Notify.RefundStatus,
PaidTime: paidTime,
RawData: v2Notify,
}, nil
}
6.5 解密函数(第三方库实现)
// DecryptRefundReqInfo 解密 V2 退款通知的 req_info
// 使用 github.com/go-think/openssl 库实现 AES-256-ECB 解密
//
// 官方步骤:
// 1. 对 req_info 做 Base64 解码
// 2. 对商户 APIv2 密钥做 MD5,得到 32 位小写字符串作为 AES 密钥
// 3. 使用 AES-256-ECB 模式解密(PKCS7Padding)
func DecryptRefundReqInfo(reqInfo, apiV2Key string) (string, error) {
if apiV2Key == "" {
return "", fmt.Errorf("APIv2 密钥未配置")
}
// 步骤 1:Base64 解码
ciphertext, err := base64.StdEncoding.DecodeString(reqInfo)
if err != nil {
return "", fmt.Errorf("Base64 解码失败: %v", err)
}
// 步骤 2:MD5 生成 32 位密钥
hash := md5.Sum([]byte(apiV2Key))
key := fmt.Sprintf("%x", hash) // 32 位小写十六进制字符串
// 步骤 3:AES-256-ECB 解密(使用第三方库)
decrypted, err := openssl.AesECBDecrypt(ciphertext, []byte(key), openssl.PKCS7_PADDING)
if err != nil {
return "", fmt.Errorf("AES 解密失败: %v", err)
}
return string(decrypted), nil
}
6.6 解密函数(纯 Go 自定义实现,无需第三方库)
如果你不想引入第三方库,可以使用以下纯 Go 实现:
// DecryptRefundReqInfoPureGo 纯 Go 实现解密 V2 退款通知的 req_info
// 不依赖任何第三方库,使用标准库的 crypto/aes 自行实现 ECB 模式
func DecryptRefundReqInfoPureGo(reqInfo, apiV2Key string) (string, error) {
if apiV2Key == "" {
return "", fmt.Errorf("APIv2 密钥未配置")
}
// 步骤 1:Base64 解码
ciphertext, err := base64.StdEncoding.DecodeString(reqInfo)
if err != nil {
return "", fmt.Errorf("Base64 解码失败: %v", err)
}
// 步骤 2:MD5 生成 32 位密钥
hash := md5.Sum([]byte(apiV2Key))
key := fmt.Sprintf("%x", hash) // 32 位小写十六进制字符串
// 步骤 3:AES-256-ECB 解密(纯 Go 实现)
block, err := aes.NewCipher([]byte(key))
if err != nil {
return "", fmt.Errorf("创建 AES Cipher 失败: %v", err)
}
// ECB 模式:逐块解密
blockSize := block.BlockSize()
if len(ciphertext)%blockSize != 0 {
return "", fmt.Errorf("密文长度 %d 不是块大小 %d 的整数倍", len(ciphertext), blockSize)
}
decrypted := make([]byte, len(ciphertext))
for i := 0; i < len(ciphertext); i += blockSize {
block.Decrypt(decrypted[i:i+blockSize], ciphertext[i:i+blockSize])
}
// 去除 PKCS7 填充
decrypted = pkcs7Unpad(decrypted)
if decrypted == nil {
return "", fmt.Errorf("PKCS7 去填充失败")
}
return string(decrypted), nil
}
// pkcs7Unpad 去除 PKCS7 填充
func pkcs7Unpad(data []byte) []byte {
if len(data) == 0 {
return nil
}
length := len(data)
unpadding := int(data[length-1])
if unpadding > length || unpadding == 0 {
return nil
}
// 验证填充值是否正确
for i := 0; i < unpadding; i++ {
if data[length-1-i] != byte(unpadding) {
return nil
}
}
return data[:length-unpadding]
}
6.7 使用示例
package main
import (
"log"
"net/http"
"github.com/gin-gonic/gin"
"github.com/go-think/openssl"
)
func main() {
r := gin.Default()
// 初始化退款通知处理器
// apiV2Key: 你的微信支付 APIv2 密钥
// v3Handler: 你已有的 wechatpay-go notify.Handler 实例
handler := wechatpay.NewRefundNotifyHandler("your_apiv2_key", nil)
// 退款回调路由
r.POST("/refund", func(c *gin.Context) {
event, err := handler.ParseRefundNotify(c)
if err != nil {
log.Printf("解析退款通知失败: %v", err)
c.XML(http.StatusOK, gin.H{
"return_code": "FAIL",
"return_msg": err.Error(),
})
return
}
log.Printf("收到退款通知: OrderNo=%s, RefundNo=%s, Status=%s, Amount=%d",
event.OrderNo, event.RefundNo, event.Status, event.Amount)
// TODO: 在这里处理你的业务逻辑
// 例如:更新订单状态、记录退款日志等
// 返回成功响应给微信
c.XML(http.StatusOK, gin.H{
"return_code": "SUCCESS",
"return_msg": "OK",
})
})
r.Run(":8080")
}
七、依赖安装
方案 A:使用第三方库(推荐,代码更简洁)
go get github.com/go-think/openssl
方案 B:纯 Go 实现(无需额外依赖)
不需要安装任何第三方库,直接使用上述 DecryptRefundReqInfoPureGo 函数即可。
八、配置注意事项
除了 APIv3 所需的配置外,必须额外配置 APIv2 密钥:
# 在微信支付商户平台 "账户中心 → API 安全" 中设置API密钥
api_key:
# APIv3 配置
v3: "your_apiv3_key"
# APIv2 配置(用于退款通知解密)
v2: "your_apiv2_key"
APIv2 密钥和 APIv3 密钥是两个独立的密钥,不要混用。
九、踩坑总结
| 错误现象 | 原因 | 解决办法 |
|---|---|---|
Wechatpay-Serial is empty | 收到的是 V2 加密通知,不是 V3 | 通过 header 判断协议版本,分别处理 |
unknown type map[string]string | 尝试用 xml.Unmarshal 解析到 map | Go 的 XML 包不支持 map,使用 struct 或 xml.Decoder |
invalid character entity | 解密后的 XML 根标签是 <root> | struct 标签设为 xml:"root" |
| AES 解密失败 / 乱码 | 密钥长度错误(用了 16 字节而非 32 字节) | md5(key) 的完整 32 位字符串作为密钥 |
| 没有 ECB 模式可用 | Go 标准库不提供 ECB | 使用 github.com/go-think/openssl 或自行实现 |
十、最佳实践建议
优先统一退款入口:尽量通过 APIv3 接口发起退款,这样回调始终走 V3 协议,无需兼容 V2 逻辑,代码更简洁。
如果必须兼容后台退款:在代码中通过
Wechatpay-Serialheader 自动判断协议版本,不要写死逻辑。做好日志记录:V2 解密后的 XML 建议打印到日志(注意脱敏敏感字段),方便排查解析问题。
区分两个密钥:APIv2 密钥仅用于 V2 通知解密,APIv3 密钥用于 V3 验签,两者不要混用。
十一、参考资源
- 微信支付 APIv3 官方文档 —— 回调通知验签说明
- 微信支付 V2 官方文档
—— 退款结果通知中的
req_info解密说明 - <code>wechatpay-go</code> SDK 官方仓库 —— Go 语言 SDK 源码和 Issues 讨论
- 微信开放社区 —— 开发者问答和官方技术支持回复








