微信公众号二维码
本文已同步发布到微信公众号「人言兑
👈 扫描二维码关注,第一时间获取更新!

微信支付退款回调报错 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?问题一定出在微信发送的通知本身

三、根本原因:退款回调有两种协议版本

经过查阅微信官方文档和社区讨论,发现微信支付存在 V2V3 两套协议并存的情况:

退款发起方式回调协议版本请求头特征处理方式
通过 APIv3 接口发起退款V3Wechatpay-SerialRSA 公钥验签
通过微信支付商户后台直接退款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 通知相比,有两个显著区别:

  1. 没有 sign 字段 —— 不需要像普通 V2 接口那样做 MD5 签名验证
  2. 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 解析到 mapGo 的 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 或自行实现

十、最佳实践建议

  1. 优先统一退款入口:尽量通过 APIv3 接口发起退款,这样回调始终走 V3 协议,无需兼容 V2 逻辑,代码更简洁。

  2. 如果必须兼容后台退款:在代码中通过 Wechatpay-Serial header 自动判断协议版本,不要写死逻辑。

  3. 做好日志记录:V2 解密后的 XML 建议打印到日志(注意脱敏敏感字段),方便排查解析问题。

  4. 区分两个密钥:APIv2 密钥仅用于 V2 通知解密,APIv3 密钥用于 V3 验签,两者不要混用。

十一、参考资源


也可以看看