资讯动态

Go接入微信支付API v3全攻略:从签名验签到回调对账避坑指南

发布时间:2026/10/17 19:12:14 来源:尧图企业网站定制
接入微信支付这件事真的能劝退不少Go后端。我最早接第一个Native支付单的时候光是在签名和回调验签上就磨了两天文档内容算不上复杂但知识点特别零散往往是这里通了那里又报401。今天这篇文章把我用Go对接微信支付API v3的整个链路完整复盘一遍——从商户平台的配置项怎么填到请求签名、Native下单出二维码再到回调验签解密、查单关单、退款对账最后附一份我自己整理的高频踩坑清单。适合两类人一类是项目里第一次接微信支付准备处理下单和回调另一类是支付已经跑通但回调、对账总出诡异问题的团队。1. 动手前先理清API v3的证书-密钥体系1.1 商户号、API v3密钥、商户证书、平台公钥各管什么微信支付API v3的鉴权体系是分层的很多人一上来就被一堆证书密钥绕晕。先记住四样东西后面所有代码都围绕它们转。商户号mchid你的商户身份标识下单、查单、退款请求里都要带。这个没什么好说的是一串数字。商户API证书包含apiclient_key.pem商户私钥和apiclient_cert.pem商户公钥证书。私钥只用来给发出去的请求签名证明这个请求确实是商户发的。证书的序列号serial_no会出现在Authorization头里用来告诉微信请用这把公钥验证我。API v3密钥一个32字节的字符串在商户平台手动设置。它不参与请求签名它的作用是解密微信推送过来的回调里的密文以及解压下载的账单。这个跟商户私钥是两码事最容易搞混。平台公钥或平台证书微信侧的密钥你的代码里要有它用来验证回调通知确实是微信支付的服务器发来的。用一句话记忆商户私钥管出去的请求平台公钥管进来的回调API v3密钥管拿到的密文。三个角色互不替代缺一个都会在某个环节报错。1.2 生产环境的配置组织方式配置项最好集中放在一个结构体里从环境变量或配置中心加载。下面是我习惯的定义type WxPayConfig struct { MchID string // 商户号如 190000XXXX AppID string // 小程序/公众号/开放平台应用的AppID SerialNo string // 商户证书序列号 PrivateKey *rsa.PrivateKey // apiclient_key.pem 解析出来的私钥 PlatformPubKey *rsa.PublicKey // 平台公钥用于验签回调 APIv3Key string // 32字节的API v3密钥 NotifyURL string // 公网HTTPS回调地址 }有两个细节必须注意。第一私钥文件千万不要提交到Git仓库建议走配置中心或密钥管理系统本地开发再用文件。第二序列号可以用openssl直接读取比去商户平台复制省事openssl x509 -in apiclient_cert.pem -noout -serial输出结果形如serial5B8B17C8A0BE9D0B72B4C46D4A7F6D2C代码里用的就是后面这串大写十六进制原始的16进制表示里可能带冒号记得去掉。2. API v3请求签名签名串的每一行都是考点2.1 签名串为什么长这样微信支付API v3的每个请求都要带一个Authorization头格式固定为WECHATPAY2-SHA256-RSA2048开头后面跟一组键值对Authorization: WECHATPAY2-SHA256-RSA2048 mchid190000XXXX,nonce_str随机串,signaturebase64签名,timestamp1715000000,serial_no商户证书序列号其中的signature是对下面这个字符串做 SHA256 摘要后再用商户私钥做 RSAPKCS1v15签名得到的HTTP请求方法\n 请求URL路径且带query\n 请求时间戳\n 请求随机串\n 请求报文主体\n五个部分用换行符\n连接最后还要以\n结尾。这里有几个容易翻车的点URL部分是域名后面的路径查询参数比如/v3/pay/transactions/native绝对不带https://api.mch.weixin.qq.com这个前缀。时间戳是Unix秒不是毫秒Go里就是time.Now().Unix()。随机串nonce每次请求都必须新生成不能复用长度一般32位。请求主体body是你真实发送的那份JSON原始字节不是结构体、不是格式化之后的字符串。2.2 签名生成与Authorization头的组装下面是一个可以直接用的Go实现。核心就一个函数把方法、路径、body、时间戳、nonce拼起来签名func rsaSignWithSHA256(priv *rsa.PrivateKey, message string) (string, error) { h : sha256.Sum256([]byte(message)) signature, err : rsa.SignPKCS1v15(rand.Reader, priv, crypto.SHA256, h[:]) if err ! nil { return , err } return base64.StdEncoding.EncodeToString(signature), nil } func buildAuthHeader(method, path string, body []byte, cfg *WxPayConfig) (string, error) { ts : time.Now().Unix() nonce : randomString(32) msg : strings.Join([]string{ method, path, strconv.FormatInt(ts, 10), nonce, string(body), }, \n) \n sig, err : rsaSignWithSHA256(cfg.PrivateKey, msg) if err ! nil { return , err } return fmt.Sprintf( WECHATPAY2-SHA256-RSA2048 mchid%s,nonce_str%s,signature%s,timestamp%d,serial_no%s, cfg.MchID, nonce, sig, ts, cfg.SerialNo, ), nil }配合发请求的方法大致长这样func (c *WxPayClient) postJSON(path string, reqBody any) (*http.Response, error) { data, err : json.Marshal(reqBody) if err ! nil { return nil, err } auth, err : buildAuthHeader(http.MethodPost, path, data, c.cfg) if err ! nil { return nil, err } req, err : http.NewRequest(http.MethodPost, c.baseURLpath, bytes.NewReader(data)) if err ! nil { return nil, err } req.Header.Set(Authorization, auth) req.Header.Set(Content-Type, application/json) req.Header.Set(Accept, application/json) resp, err : c.client.Do(req) if err ! nil { return nil, err } return resp, nil }加载私钥时要注意证书格式兼容微信给的是PKCS#1开头还是PKCS#8开头不一定稳妥的写法是两者都试一遍func loadPrivateKey(data []byte) (*rsa.PrivateKey, error) { if block, _ : pem.Decode(data); block ! nil { if k, err : x509.ParsePKCS1PrivateKey(block.Bytes); err nil { return k, nil } if k, err : x509.ParsePKCS8PrivateKey(block.Bytes); err nil { if rk, ok : k.(*rsa.PrivateKey); ok { return rk, nil } } } return nil, errors.New(unsupported private key format) }2.3 同一个字节流原则签名用哪份body请求就发哪份body这是我最想强调的一条。很多同学会在代码里定义一个结构体json.Marshal之后发给微信然后签名的时候又重新Marshal了一次觉得反正内容一样。实际上只要中间改了结构体字段或者用了map[string]anyGo的encoding/json会对map的key排序生成出来的JSON字节流就可能不一样签名自然就校验不过。我的建议是从Marshal得到[]byte的那一刻起到签名、再到设置请求体全程都用同一个变量不要中途再转回结构体或Map。类似地URL路径如果你用url.URL构造要小心EscapedPath()和你原始传入的字符串可能不一致签名用的路径字符串必须和实际HTTP请求发出去的路径完全一致。3. Native下单从零生成一张可扫的二维码3.1 下单参数的核心字段与常见误解Native支付是商户展示二维码用户用微信扫的场景。下单接口是POST /v3/pay/transactions/native请求体核心字段如下{ appid: wx1234567890abcdef, mchid: 190000XXXX, description: 测试商品, out_trade_no: 20240506123456789, notify_url: https://yourdomain.com/wxpay/notify, time_expire: 2024-05-06T12:00:0008:00, attach: 业务附加数据, amount: { total: 100, currency: CNY } }这里有几个必须刻进DNA的常识金额单位是分而且是整数。100表示1元不是0.01元。用浮点数做金额计算是财务事故的前奏Go这边建议金额一律用int64表示分。out_trade_no是商户订单号必须唯一。微信会把这个号原样带回回调里是你关联本地订单的主键。如果重复下单微信不会报错而是返回已存在的订单信息这算是一个天然幂等。notify_url必须是公网能访问的HTTPS地址不能是http://localhost不然回调过不来。description有长度限制别把一把梭的商品详情塞进去几十个字以内足够。Native只是其中一种支付场景。小程序/公众号内支付走JSAPI下单需要传用户的openid手机浏览器里走H5支付App内走App支付。那几种场景唯一共同点就是下单接口路径不同、请求参数略有差异签名、回调、退款这套底层逻辑完全一样。所以你把Native这条链路吃透其他场景只是换一个URL和几个字段的问题。3.2 code_url如何变成可扫二维码下单成功后响应里最关键的是code_url{ code_url: weixin://wxpay/bizpayurl?prxxxxxxxxx }这个值不是给你前端用的链接而是微信支付协议的串。收银台页面需要把它渲染成二维码。后端直接用开源二维码库生成PNG返回给前端即可import qrcode github.com/skip2/go-qrcode png, err : qrcode.Encode(codeURL, qrcode.Medium, 320) if err ! nil { // 处理生成失败 } // png就是二维码图片字节可以直接写响应也可以先传对象存储再返回URL这里有一个交互细节值得说用户扫码之后手机会弹出一个确认支付页在用户真正确认之前订单一直处于未支付状态。前端页面可以每2秒轮询一次订单查询接口直到订单变成已支付或已关闭。轮询的超时时间建议不要长于你在下单时设置的time_expire。3.3 支付状态怎么回到你的系统支付成功之后微信会把结果主动推送到你下单时填的notify_url。这是主链路官方要求收到通知后及时应答不能只靠前端轮询。但是回调不是100%可靠所以标准做法是回调为主主动查单兜底收到回调 → 验签解密 → 更新本地订单状态前端轮询查的是本地订单状态不是直接查微信后端定时对超时未支付的订单主动调微信查单接口核对。这套组合下来即使回调丢了也能在几分钟内被兜底任务捞回来。4. 回调通知验签、解密、幂等、应答一个都不能少4.1 先用平台公钥验签微信支付回调请求头里会带这四个字段Wechatpay-Timestamp回调时间戳Wechatpay-Nonce随机串Wechatpay-Signature签名Wechatpay-Serial平台的证书序列号或公钥ID验签的字符串构造方式和请求签名类似但主体变成了时间戳 换行 nonce 换行 原始请求体 换行func verifyNotifySignature(pub *rsa.PublicKey, body []byte, ts, nonce, signature string) error { msg : ts \n nonce \n string(body) \n h : sha256.Sum256([]byte(msg)) sig, err : base64.StdEncoding.DecodeString(signature) if err ! nil { return err } return rsa.VerifyPKCS1v15(pub, crypto.SHA256, h[:], sig) }验签通过之前不要做任何业务处理。验签通过后还要检查Wechatpay-Timestamp与当前时间的差距建议5分钟以内的才接受防止历史请求被重放。这里用的公钥是平台公钥不是商户自己的公钥我用错过一次排查了半天才发现是公钥加载错了。4.2 AES-256-GCM解密resource验签通过后回调的body长这样{ id: EV-xxxx, create_time: 2024-05-06T10:00:0008:00, event_type: TRANSACTION.SUCCESS, resource: { algorithm: AEAD_AES_256_GCM, ciphertext: base64密文, nonce: 随机串, associated_data: 附加数据 } }真正有用的交易数据全在resource.ciphertext里需要用API v3密钥做AES-256-GCM解密。这里的nonce不是签名里的nonce而是resource.nonceassociated_data对应用resource.associated_datafunc decryptResource(nonce, ciphertext, associatedData, apiV3Key string) ([]byte, error) { ct, err : base64.StdEncoding.DecodeString(ciphertext) if err ! nil { return nil, err } block, err : aes.NewCipher([]byte(apiV3Key)) if err ! nil { return nil, err } gcm, err : cipher.NewGCM(block) if err ! nil { return nil, err } plaintext, err : gcm.Open(nil, []byte(nonce), ct, []byte(associatedData)) if err ! nil { return nil, err } return plaintext, nil }解密出来的明文是订单信息关键字段包括out_trade_no、transaction_id、trade_state、success_time、amount.total。记住一点回调里说成功一定要以trade_state SUCCESS为准不要因为收到通知就以为一定成功。4.3 幂等更新与应答规范微信回调可能会重复推送多次频率大概是15秒、15秒、30秒之后间隔逐渐拉长最长会重试到24小时。所以你的回调处理逻辑必须保证幂等。我的做法是数据库更新用条件更新而不是先查再改UPDATE orders SET status PAID, transaction_id ?, paid_at ? WHERE out_trade_no ? AND status PENDING;用RowsAffected判断是否真的从待支付翻成了已支付受影响行数为0说明这个单子已经处理过了直接返回成功即可不需要再执行加库存、发通知之类的副作用操作。如果担心并发同一单极短时间收到两条回调可以把更新语句放进事务里或者给订单表加一个唯一索引兜底。应答格式有讲究。处理成功要返回HTTP 200且body必须是{code:SUCCESS,message:成功}如果业务处理失败、或解密异常、或返回非200状态码微信会认为回调没送达继续按上面的频率重试。官方建议5秒内应答处理时间不够就先把关键状态落库再返回成功但千万不要先返回成功、异步慢慢处理因为一旦异步任务崩了这笔订单就永远卡在待支付状态还没人提醒你。5. 查单、关单与超时兜底支付状态机的最后一环5.1 主动查单接口查单是常用的兜底手段接口是GET /v3/pay/transactions/out-trade-no/{out_trade_no}?mchid190000XXXX注意两点。第一out_trade_no放进路径后要做URL编码如果订单号里带了特殊符号不编码会直接404。第二这个URL带query参数签名时构造的URL字符串必须包含?mchidxxx这一整段path : /v3/pay/transactions/out-trade-no/ url.PathEscape(outTradeNo) ?mchid c.cfg.MchID // 签名用的是 pathHTTP请求用的也是 path两者保持一致查询结果里的trade_state有几种SUCCESS支付成功、NOTPAY未支付、CLOSED已关闭、USERPAYING支付中、PAYERROR支付失败、REFUND转入退款。你的状态机至少要覆盖PENDING → PAID → REFUNDED这条主线再补上CLOSED这个终态。5.2 关单与过期时间设计如果用户下单后一直不付款订单不能永远挂在未支付状态。两种处理方式下单时设置time_expire到了过期时间微信侧订单自动关闭业务上主动关单调POST /v3/pay/transactions/out-trade-no/{out_trade_no}/close请求体只要带上mchid。关单只能关未支付的订单已经支付成功或已关闭的订单不能重复操作。我的建议是time_expire设个30分钟左右同时业务侧维护一个定时清理任务扫描超过15分钟仍处于PENDING的订单先调一次查单确认如果微信侧已经是CLOSED本地直接翻状态如果还是NOTPAY调关单接口主动关闭释放订单号占用的资源。5.3 回调丢失的补偿任务回调这个东西理论上可靠实战中总会有丢失或延迟的情况。我经历过最夸张的一次回调比用户支付时间晚了快一小时。所以一定不能只依赖回调。补偿任务我一般这样写每5分钟扫一次订单表找出创建时间超过10分钟、状态仍是PENDING的订单逐个调微信查单接口。查到SUCCESS就执行和回调一样的支付成功处理逻辑查到CLOSED就关单查到NOTPAY就继续等下一轮。这样的设计能兜住绝大部分回调异常也让前端轮询的体验好很多——用户明明付款了本地状态却迟迟不变投诉最多就是这个问题。6. 退款和对账支付做完只是开始6.1 退款接口与退款回调支付功能上线只是开始退款能力多半在第一个月就会被提上日程。退款接口是POST /v3/refund/domestic/refunds请求体示例{ out_refund_no: R20240506123456789, out_trade_no: 20240506123456789, notify_url: https://yourdomain.com/wxpay/refund_notify, amount: { refund: 50, total: 100, currency: CNY } }退款和下单一样out_refund_no必须是唯一的。可以部分退款也可以多次退款但每次退款的refund加上已退金额不能超过total。total是这笔订单在微信侧的原始支付金额不是本次剩余可退金额。退款结果同样通过回调推送event_type是REFUND.SUCCESS、REFUND.ABNORMAL或REFUND.CLOSED。退款不是即时到账的状态会先经历处理中所以本地订单状态里要加一个REFUNDING的中间态收到成功回调再翻成REFUNDED。6.2 交易账单下载别栽在GBK编码上对账必须依赖微信侧的交易账单不能只依赖自己数据库。接口是GET /v3/bill/tradebill?bill_date2024-05-06bill_typeALL响应里会给一个download_url这个下载地址有效期很短拿到要立刻请求。下载下来是个.tar.gz压缩包解压之后是CSV。这里有个大坑账单文件是GBK编码。在Windows上用Excel打开没事Linux上按UTF-8读就是一堆乱码。处理时要先做编码转换Go里可以用golang.org/x/text/encoding/simplifiedchinese的GBK解码器转换前最好先判断文件头部字节避免已经转过一次的文件被二次转换。6.3 对账逻辑怎么设计对账任务我建议每天凌晨跑一次逻辑不复杂但很重要从微信下载昨日交易账单按out_trade_no建立微信侧成功交易的Map和本地订单表按单号Join分成三类结果微信有、本地没有/本地不是成功状态这是漏单需要触发补偿本地成功、微信没有多半是联调残留或测试数据要人工确认金额不一致直接告警金额问题不能自动修复必须人工介入。账单里的金额单位同样是分解析CSV时注意强转成int64别用float碰钱。退款场景建议单独拉bill_typeREFUND的退款账单和退款状态机做一轮核对避免出现微信退了本地订单还是已支付的状态错位。7. 实测踩坑清单症状、根因、解法7.1 按症状定位的高频问题表以下都是我实际见过、修过的坑按症状整理成表排查时可以对着找症状根因解决办法请求返回401提示invalid serialAuthorization里的serial_no填成证书序号、或者填了平台证书序列号用openssl x509 -in apiclient_cert.pem -noout -serial读取商户证书序列号去掉冒号401 invalid signature签名用的body和请求体不一致比如对map二次Marshal签名和请求全程使用同一份[]byte401提示时间戳问题服务器时钟偏差太大或者签名时timestamp和头里的timestamp不一致同步NTP签名函数里的时间戳必须和Header里填的保持一致403提示产品权限未开通商户号没有开通对应支付产品登录商户平台在产品中心里申请开通Native/JSAPI等权限回调返回200但本地订单没更新幂等SQL的条件写错比如少了statusPENDING检查更新语句的WHERE条件打印RowsAffected日志回调解密失败用了商户私钥去解AES或者API v3密钥填错API v3密钥在商户平台API安全里设置不是证书私钥下载账单乱码账单是GBK编码用GBK解码器转UTF-8后再解析查单返回404out_trade_no不存在、已被关闭、或URL没做编码区分CLOSED与不存在url.PathEscape订单号7.2 建议的排查链路很多人在签名问题上耗一整天是因为在对着文档猜。我的经验是遇到401先把自己构造的签名串完整打印出来然后和微信官方文档的示例逐行比对。经常是少了最后一个换行、URL多了个/、或者body里多了个空格——这种肉眼看不出来的差异用fmt.Printf(%q, msg)打印转义字符一眼就能定位。另外强烈建议在debug阶段把每个请求的原始请求头和原始响应体落日志至少保留最近24小时。微信返回的错误信息其实很有价值invalid signature、invalid serial、signature expired各有各的含义很多团队却在报错前就把信息截断了。微信支付官方维护了一个Go SDK仓库在GitHub上搜wechatpay-go就能找到。里面把签名、验签、解密、证书自动更新这些都封装好了生产项目直接拿来用肯定比手写省事。我的建议是就算你决定用SDK也一定要把本文这套原理过一遍——因为真出问题的时候对着黑盒你连日志都看不懂而支付这种场景最贵的永远是排查时间。接支付这段路我走下来的最大感受是微信支付的接口不难难的是把签名、回调、幂等、对账这个闭环想完整。只要状态机设计清楚、每个环节都考虑好兜底后面基本就是复制粘贴换参数的事。先把订单状态机和回调幂等写扎实再谈业务扩展这是支付项目能不能睡得着觉的分水岭。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑