1. 从一个签名报错说起:为什么v3接口让这么多人栽跟头
第一次对接微信支付v3接口的人,十有八九会在某个深夜被一个401 Unauthorized或者签名验证失败卡住。我见过不少开发者,v2版本用得挺顺手,一升级到v3就懵了——明明参数都对着文档填了,怎么就是过不去?问题往往不在业务逻辑,而在于v3这套全新的认证体系:它不再用MD5拼串那一套,而是换成了基于非对称密钥的SHA256withRSA签名机制,配合平台证书、敏感信息加密、回调验签等一整套流程。
这篇文章要聊的,就是围绕微信支付WechatPay-API-v3这套接口的完整落地案例。我会把从商户号配置、密钥生成、请求签名、应答验签,到回调处理、敏感字段加解密这一整条链路拆开揉碎讲清楚。不管你是第一次接支付的新手,还是从v2迁移过来的老手,都能从里面找到可以直接抄作业的代码结构和踩坑经验。核心关键词就几个:APIv3、商户私钥、平台证书、签名、验签、AES-GCM解密、回调通知。搞懂这几个,v3基本就通了。
需要先说明的是,v3相比v2最大的设计变化,是把"信任"建立在了证书体系上。v2时代,你只要有个API密钥,大家对称加密互相认;v3时代,请求方用自己商户私钥签名,微信侧用你上传的公钥验签,微信返回的数据用平台私钥签名,你用平台证书里的公钥验签。这套机制安全性高得多,但代价就是——你得先把证书和密钥这摊子事理顺,否则后面每一步都是坑。
2. 整体设计思路:v3到底改了哪些东西
2.1 从对称密钥到非对称密钥的信任模型
v2的信任模型很简单:商户平台设置一个32位的API密钥,请求时把参数按规则拼成字符串,末尾加上这个密钥做MD5,微信侧用同样的密钥算一遍,对得上就认。这套东西的问题在于,密钥一旦泄露,别人就能伪造任意请求,而且密钥在双方之间是对称共享的,谁也没法证明"这条请求确实是你发的"。
v3换成了非对称体系。你生成一对RSA密钥(商户私钥+商户公钥),私钥自己留着签名,公钥上传到商户平台。发请求时,你用商户私钥对"请求方法+URL+时间戳+随机串+请求体"这个拼接串做SHA256withRSA签名,微信侧拿你上传的公钥验签。反过来,微信返回数据时,用微信平台私钥签名,你下载平台证书拿到平台公钥来验签。这样一来,双方各自持有私钥,谁也无法冒充对方,安全性直接上了一个台阶。
这个设计带来的直接后果是:你必须先搞定证书和密钥,才能发第一个请求。很多新手卡住就是因为跳过了这一步,直接去调接口,结果签名怎么都过不了。
2.2 请求签名串的构造规则
v3的签名串不是随便拼的,它有严格的格式要求,五行,每行一个字段,用\n分隔,最后一行也要有换行符:
HTTP请求方法\n URL路径\n 请求时间戳\n 请求随机串\n 请求报文主体\n这里有几个容易出错的点。第一,URL路径要带query string,比如/v3/pay/transactions/native?mchid=123,不能只写/v3/pay/transactions/native。第二,请求时间戳是秒级,不是毫秒。第三,请求报文主体如果是GET请求,就是空字符串,但那一行和换行符不能省。第四,随机串建议用UUID去掉横线,32位。
签名算法用SHA256withRSA,签名结果做Base64编码,放到Authorization头里,格式是:
WECHATPAY2-SHA256-RSA2048 mchid="商户号",nonce_str="随机串",timestamp="时间戳",serial_no="证书序列号",signature="签名值"这个头拼错一个字符,微信侧就直接返回401,而且错误信息很含糊,不会告诉你具体哪里错了。
2.3 平台证书与应答验签的必要性
很多人觉得,我发请求签名就行了,为什么还要验签应答?答案是:防止中间人篡改。如果只签名不验签,攻击者可以在你和服务端之间拦截响应,把"支付成功"改成"支付失败",或者篡改金额。v3要求你对所有微信返回的应答做验签,就是为了堵住这个口子。
验签的逻辑是:从响应头里拿Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Signature、Wechatpay-Serial,用同样的规则拼成验签串(时间戳\n随机串\n响应体\n),然后用平台证书里的公钥验签。平台证书需要你主动下载并定期更新,微信会在证书到期前通过回调通知你。
这里有个坑:平台证书的序列号和你商户证书的序列号是两回事。请求签名用商户证书序列号,应答验签用平台证书序列号,别搞混了。
3. 核心细节解析:密钥、证书与加解密实操
3.1 商户密钥与证书的生成流程
第一步是生成商户私钥。用OpenSSL就行:
openssl genrsa -out apiclient_key.pem 2048这条命令生成一个2048位的RSA私钥。注意,v3要求密钥长度至少2048位,1024位的会被拒绝。生成后,从私钥导出公钥:
openssl rsa -in apiclient_key.pem -pubout -out apiclient_pub.pem然后拿着这个公钥去商户平台申请API证书。申请完会下载到一个apiclient_cert.pem,这就是你的商户证书。用下面这条命令查看证书序列号:
openssl x509 -in apiclient_cert.pem -noout -serial输出的那串十六进制就是证书序列号,请求签名时要用到。这里提醒一句:证书序列号是十六进制字符串,但拼到Authorization头里时要用大写,小写微信侧认不出来。
3.2 平台证书的下载与更新策略
平台证书不能手动下载,必须通过接口获取。调用GET /v3/certificates,返回的是一个加密的证书列表,每个证书用AES-256-GCM加密,密钥是APIv3密钥(在商户平台设置的32位字符串)。解密后拿到证书的PEM内容,再解析出公钥。
这里的关键点是:平台证书会定期轮换,微信会提前通过回调通知你新证书的序列号。你的代码里必须实现自动更新逻辑,否则证书过期后所有验签都会失败。我的做法是:启动时下载一次,缓存到本地;每次收到回调通知时检查是否有新证书;同时设置一个定时任务,每天检查一次证书有效期,快到期时主动刷新。
解密平台证书的代码大概长这样:
from cryptography.hazmat.primitives.ciphers.aead import AESGCM import base64 def decrypt_certificate(associated_data, nonce, ciphertext, apiv3_key): key = apiv3_key.encode('utf-8') nonce_bytes = nonce.encode('utf-8') ciphertext_bytes = base64.b64decode(ciphertext) associated_data_bytes = associated_data.encode('utf-8') aesgcm = AESGCM(key) plaintext = aesgcm.decrypt(nonce_bytes, ciphertext_bytes, associated_data_bytes) return plaintext.decode('utf-8')注意associated_data就是证书里的associated_data字段,nonce是nonce字段,ciphertext是ciphertext字段,别传错顺序。
3.3 敏感信息加密:AES-GCM的正确用法
v3里有些字段是敏感信息,比如回调通知里的payer信息、退款时的refund_account等,需要用微信平台公钥做RSA加密。但还有一类场景,比如你主动查询某些接口,返回的敏感字段是用AES-GCM加密的,这时候要用APIv3密钥解密。
AES-GCM的坑在于:nonce必须是12字节,associated_data可以是空字符串但不能是None。我见过有人把nonce写成16字节,结果解密直接报错。另外,解密后的明文是JSON字符串,需要再解析一次。
加密的代码逻辑类似,只是方向反过来:
def encrypt_sensitive_info(plaintext, platform_public_key): from cryptography.hazmat.primitives.asymmetric import padding from cryptography.hazmat.primitives import hashes ciphertext = platform_public_key.encrypt( plaintext.encode('utf-8'), padding.OAEP( mgf=padding.MGF1(algorithm=hashes.SHA1()), algorithm=hashes.SHA1(), label=None ) ) return base64.b64encode(ciphertext).decode('utf-8')注意这里用的是OAEP填充,不是PKCS1v15,v3明确要求OAEP。
4. 完整实操流程:从下单到回调的闭环
4.1 统一下单接口的请求构造
以Native支付为例,请求POST /v3/pay/transactions/native,请求体大概是这样:
{ "mchid": "1900000001", "out_trade_no": "ORDER_20240101_001", "appid": "wx1234567890abcdef", "description": "测试商品", "notify_url": "https://yourdomain.com/notify", "amount": { "total": 100, "currency": "CNY" } }构造签名的步骤:先把请求体转成JSON字符串(注意不要有多余空格),然后拼签名串:
POST\n /v3/pay/transactions/native\n 1704067200\n abc123def456\n {"mchid":"1900000001",...}\n用商户私钥签名,Base64编码,拼到Authorization头。发送请求时,Content-Type必须是application/json,Accept也是application/json。
这里有个细节:请求体里的字段顺序不影响签名,因为签名是对整个字符串做的,但JSON序列化时如果用了不同的库,可能产生不同的字符串(比如空格、换行),所以建议统一用json.dumps(data, separators=(',', ':'))这种紧凑格式。
4.2 应答验签与结果解析
微信返回的响应头里有四个关键字段:
| 响应头字段 | 说明 |
|---|---|
| Wechatpay-Timestamp | 应答时间戳 |
| Wechatpay-Nonce | 应答随机串 |
| Wechatpay-Signature | 应答签名值 |
| Wechatpay-Serial | 平台证书序列号 |
验签串的构造规则和请求签名一样,只是把请求体换成响应体:
时间戳\n 随机串\n 响应体\n用Wechatpay-Serial对应的平台证书公钥验签。验签通过后,再解析响应体里的code_url,这就是Native支付的二维码链接。
如果验签失败,先检查平台证书是否过期,再检查响应体是否被修改过(比如中间件做了格式化)。我遇到过一次,是因为网关把响应体的换行符改了,导致验签失败,排查了半天。
4.3 回调通知的验签与解密
支付成功后,微信会向notify_url发送POST请求,请求体是加密的:
{ "id": "EV-20240101-001", "create_time": "2024-01-01T12:00:00+08:00", "resource_type": "encrypt-resource", "event_type": "TRANSACTION.SUCCESS", "resource": { "algorithm": "AEAD_AES_256_GCM", "ciphertext": "...", "nonce": "...", "associated_data": "transaction" } }处理流程分三步:第一,验签,用平台证书公钥验证请求头里的签名;第二,解密resource里的ciphertext,用APIv3密钥做AES-GCM解密;第三,解析解密后的JSON,拿到订单号和支付状态。
解密后的内容大概是这样:
{ "mchid": "1900000001", "out_trade_no": "ORDER_20240101_001", "transaction_id": "4200001234202401011234567890", "trade_state": "SUCCESS", "amount": { "total": 100, "payer_total": 100 } }处理完业务逻辑后,必须返回HTTP 200,否则微信会重试。重试策略是:15秒、15秒、30秒、3分钟、10分钟、20分钟、30分钟、30分钟、30分钟、60分钟,共10次。所以你的回调接口必须幂等,同一个out_trade_no重复通知时不能重复发货。
4.4 退款接口的调用与状态查询
退款接口是POST /v3/refund/domestic/refunds,请求体:
{ "out_trade_no": "ORDER_20240101_001", "out_refund_no": "REFUND_20240101_001", "amount": { "refund": 100, "total": 100, "currency": "CNY" } }退款是异步的,提交后返回status: PROCESSING,需要你主动查询或者等回调。查询接口是GET /v3/refund/domestic/refunds/{out_refund_no}。这里注意,退款金额不能超过订单总金额,而且同一个订单多次退款时,累计退款金额不能超过总金额。
退款回调的event_type是REFUND.SUCCESS,处理逻辑和支付回调类似,验签、解密、更新订单状态。
5. 常见问题与排查技巧实录
5.1 签名失败的五大原因
签名失败是v3最高频的问题,我整理了一个排查表:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 401 Unauthorized | 证书序列号错误 | 用openssl查看证书序列号,确认大写 |
| 401 Unauthorized | 签名串格式错误 | 打印签名串,检查每行末尾是否有\n |
| 401 Unauthorized | 私钥与证书不匹配 | 用私钥导出公钥,和证书里的公钥对比 |
| 400 Bad Request | 请求体JSON格式错误 | 用紧凑格式序列化,避免多余空格 |
| 403 Forbidden | 商户号与证书不匹配 | 确认证书是在该商户号下申请的 |
我踩过最坑的一次是:证书序列号用了小写,微信侧一直返回401,但错误信息只说"签名验证失败",没提序列号的事。后来把序列号转成大写才通过。
5.2 平台证书过期的应急处理
平台证书有效期一般是5年,但微信会提前轮换。如果你的代码没有自动更新逻辑,证书过期后所有验签都会失败。应急处理方法是:手动调用GET /v3/certificates下载新证书,更新到本地缓存。
长期方案是:在回调处理逻辑里,检查Wechatpay-Serial是否在当前缓存的证书列表里,如果不在,说明有新证书,主动下载并更新。同时,每天定时检查证书有效期,剩余天数少于30天时主动刷新。
5.3 回调重复通知的幂等设计
微信的回调重试机制意味着同一个通知可能到达多次。如果你的回调接口没有幂等设计,可能会重复发货。我的做法是:在数据库里建一张payment_notify_log表,用out_trade_no做唯一索引,每次处理前先插入,如果插入失败说明已经处理过,直接返回200。
另外,回调处理要快,最好在1秒内返回。如果业务逻辑复杂,可以先把通知存到队列里,异步处理,接口直接返回200。但要注意,异步处理失败时要有补偿机制,不能丢了通知。
5.4 金额单位的坑:分与元
微信支付所有金额单位都是分,不是元。100元要写成10000。我见过有人把100元写成100,结果用户只付了1元。这个坑在测试环境不容易发现,因为测试时往往用1分钱,上线后才发现金额不对。
建议在代码里统一用Decimal类型处理金额,避免浮点数精度问题。展示给用户时再除以100转成元。
6. 工程化建议:把v3封装成可复用的SDK
6.1 签名与验签的公共模块设计
不要把签名逻辑散落在各个业务代码里,应该封装成一个独立的模块。我的做法是定义一个WechatPaySigner类,提供sign_request和verify_response两个方法,内部处理证书加载、签名串构造、Base64编码等细节。业务代码只需要传入请求方法和URL,拿到签名后的Authorization头。
证书加载建议用单例模式,避免每次请求都读文件。同时,证书内容缓存在内存里,定期刷新。
6.2 证书自动更新与缓存策略
平台证书的缓存策略是:内存缓存+本地文件缓存。启动时从本地文件加载,如果文件不存在或过期,调用接口下载。每次验签时,如果Wechatpay-Serial不在缓存里,触发一次下载。下载时加锁,避免并发重复下载。
本地文件缓存的好处是:即使接口暂时不可用,也能用旧证书继续验签(只要没过期)。但要注意,旧证书过期后必须强制刷新。
6.3 日志与监控的关键埋点
支付相关的日志必须详细,但要注意脱敏。建议记录:请求URL、请求体(脱敏后)、响应体(脱敏后)、签名串、验签结果、耗时。关键埋点包括:签名失败次数、验签失败次数、回调处理耗时、证书剩余有效期。
监控告警方面,签名失败率超过1%就要告警,回调处理耗时超过3秒也要告警。证书剩余有效期少于30天时,每天发一次提醒。
7. 我个人在实际操作中的几点体会
对接v3接口这段时间,最大的感受是:文档要看三遍,代码要写两遍。第一遍看文档,知道有哪些接口;第二遍看文档,理解签名和验签的规则;第三遍看文档,抠细节,比如时间戳是秒还是毫秒、序列号是大写还是小写。代码写两遍,第一遍跑通流程,第二遍重构封装。
另一个体会是:测试环境一定要用真实证书。有些人图省事,测试时用假证书,结果上线后各种问题。微信的测试环境(沙箱)和正式环境的证书是分开的,但流程完全一样,建议一开始就用正式环境的流程来测试,只是把商户号换成沙箱的。
最后分享一个小技巧:如果签名一直失败,可以把签名串打印出来,用在线工具(比如一些RSA签名验证工具)手动验一遍,确认签名值本身是对的。如果签名值对,但微信侧还是报错,那问题一定在Authorization头的格式上。这个排查思路帮我省了不少时间。