接入微信支付这件事,真的能劝退不少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输出结果形如serial=5B8B17C8A0BE9D0B72B4C46D4A7F6D2C,代码里用的就是后面这串大写十六进制,原始的16进制表示里可能带冒号,记得去掉。
2. API v3请求签名:签名串的每一行都是考点
2.1 签名串为什么长这样
微信支付API v3的每个请求都要带一个Authorization头,格式固定为WECHATPAY2-SHA256-RSA2048开头,后面跟一组键值对:
Authorization: WECHATPAY2-SHA256-RSA2048 mchid="190000XXXX",nonce_str="随机串",signature="base64签名",timestamp="1715000000",serial_no="商户证书序列号"其中的signature是对下面这个字符串做 SHA256 摘要后再用商户私钥做 RSA(PKCS1v15)签名得到的:
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.baseURL+path, 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]any(Go的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:00+08: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?pr=xxxxxxxxx" }这个值不是给你前端用的链接,而是"微信支付协议"的串。收银台页面需要把它渲染成二维码。后端直接用开源二维码库生成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:00+08: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.nonce,associated_data对应用resource.associated_data:
func 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}?mchid=190000XXXX注意两点。第一,out_trade_no放进路径后要做URL编码,如果订单号里带了特殊符号,不编码会直接404。第二,这个URL带query参数,签名时构造的URL字符串必须包含?mchid=xxx这一整段:
path := "/v3/pay/transactions/out-trade-no/" + url.PathEscape(outTradeNo) + "?mchid=" + c.cfg.MchID // 签名用的是 path,HTTP请求用的也是 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_date=2024-05-06&bill_type=ALL响应里会给一个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_type=REFUND的退款账单,和退款状态机做一轮核对,避免出现"微信退了,本地订单还是已支付"的状态错位。
7. 实测踩坑清单:症状、根因、解法
7.1 按症状定位的高频问题表
以下都是我实际见过、修过的坑,按症状整理成表,排查时可以对着找:
| 症状 | 根因 | 解决办法 |
|---|---|---|
| 请求返回401,提示invalid serial | Authorization里的serial_no填成证书序号、或者填了平台证书序列号 | 用openssl x509 -in apiclient_cert.pem -noout -serial读取商户证书序列号,去掉冒号 |
| 401 invalid signature | 签名用的body和请求体不一致,比如对map二次Marshal | 签名和请求全程使用同一份[]byte |
| 401,提示时间戳问题 | 服务器时钟偏差太大,或者签名时timestamp和头里的timestamp不一致 | 同步NTP;签名函数里的时间戳必须和Header里填的保持一致 |
| 403,提示产品权限未开通 | 商户号没有开通对应支付产品 | 登录商户平台,在"产品中心"里申请开通Native/JSAPI等权限 |
| 回调返回200但本地订单没更新 | 幂等SQL的条件写错,比如少了status='PENDING' | 检查更新语句的WHERE条件;打印RowsAffected日志 |
| 回调解密失败 | 用了商户私钥去解AES,或者API v3密钥填错 | API v3密钥在商户平台"API安全"里设置,不是证书私钥 |
| 下载账单乱码 | 账单是GBK编码 | 用GBK解码器转UTF-8后再解析 |
| 查单返回404 | out_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,也一定要把本文这套原理过一遍——因为真出问题的时候,对着黑盒你连日志都看不懂,而支付这种场景,最贵的永远是排查时间。
接支付这段路我走下来的最大感受是:微信支付的接口不难,难的是把"签名、回调、幂等、对账"这个闭环想完整。只要状态机设计清楚、每个环节都考虑好兜底,后面基本就是复制粘贴换参数的事。先把订单状态机和回调幂等写扎实,再谈业务扩展,这是支付项目能不能睡得着觉的分水岭。