微信支付V3的API对接,是不少后端开发在项目进入支付环节时绕不开的一道坎。我前后在几个项目里接过这套接口,从最早被签名报错折腾到凌晨,到后来能比较从容地处理各种回调与对账,中间踩的坑确实不少。这篇内容就围绕“用Get和Post请求调用微信支付V3接口”这件事,把签名、请求构造、证书处理、响应验签、常见报错这些环节拆开讲清楚。不管你是第一次接微信支付,还是接过V2想迁移到V3,或者只是想把手里那套调用逻辑理顺,下面这些内容应该都能直接用上。
微信支付V3和V2最大的区别,在于它全面转向了RESTful风格的接口设计,同时用上了SHA256-RSA的签名机制和AES-GCM的敏感信息加密。这意味着你不能再像V2那样拼个XML、做个MD5就完事,而是要真正理解一套基于非对称加密的请求与验签流程。Get请求和Post请求在V3里承担的角色也不一样:Get多用于查询类操作,比如查订单、查退款、下载对账单;Post则用于创建类操作,比如下单、退款、转账。两者在签名构造上的逻辑是一致的,但请求体的处理方式有差异,这也是很多人第一次接的时候容易混淆的地方。
1. 先把V3的请求骨架搭明白
在动手写任何一行调用代码之前,得先搞清楚一个V3请求到底由哪些部分组成。很多人一上来就复制别人的工具类,结果出了错完全不知道从哪查,就是因为对请求骨架没有建立起清晰的认知。V3的每一个HTTP请求,本质上都是在标准HTTP协议之上,叠加了一套签名与验签规则。你把它拆开看,其实就四块:请求方法、请求URL、请求头、请求体。
1.1 请求方法与URL的拼装逻辑
V3的接口URL有固定的格式,通常是https://api.mch.weixin.qq.com/v3/加上具体的业务路径。比如创建订单是/v3/pay/transactions/jsapi,查询订单是/v3/pay/transactions/out-trade-no/{out_trade_no}。这里要注意,Get请求的参数是直接拼在URL路径里的,而不是像很多人习惯的那样放在query string里。比如查询订单,商户订单号是作为路径的一部分传进去的,这一点和很多RESTful接口的设计习惯不太一样,第一次接的时候容易写成?out_trade_no=xxx的形式,结果就是404。
Post请求则不同,它的业务参数是放在请求体里的,而且必须是JSON格式。请求体的构造有一个关键点:你用来签名的字符串,必须和你实际发送的请求体完全一致,一个空格、一个换行都不能差。我见过太多人因为序列化JSON时用了不同的库、或者中间做了一次格式化,导致签名和实际请求体对不上,服务端直接返回签名错误。所以我的习惯是,请求体序列化一次,存到一个变量里,签名和发送都用这同一个字符串,绝不重新序列化。
1.2 请求头里必须带的那几个字段
V3的请求头是整个调用流程的核心,签名信息全部藏在里面。必须携带的字段包括Authorization、Accept、Content-Type、User-Agent。其中Authorization是最关键的,它的格式是WECHATPAY2-SHA256-RSA2048加上一串签名参数。这串参数里包含了mchid(商户号)、nonce_str(随机字符串)、signature(签名值)、timestamp(时间戳)、serial_no(证书序列号)。
这里有一个非常容易踩的坑:serial_no是你商户API证书的序列号,不是平台证书的序列号。很多人第一次配的时候把平台证书序列号填进去了,结果一直报签名错误。商户证书序列号在你申请证书的时候会给你,或者你可以通过openssl命令从证书文件里提取出来。这个序列号在后续所有请求里都要用,建议直接配置到配置文件里,不要硬编码在代码里。
nonce_str是一个随机字符串,长度没有严格限制,但建议用UUID或者随机生成的32位字符串。它的作用是防止重放攻击,每次请求都必须不一样。timestamp是当前时间戳,单位是秒。微信支付服务端会校验这个时间戳,如果和服务器时间相差太大(一般是5分钟),请求会被拒绝。所以你的服务器时间一定要同步,最好配上NTP。
1.3 签名串的构造规则
签名串的构造是V3里最容易出错的地方,没有之一。它的格式是固定的五行,每行以换行符\n结尾,最后一行也要有换行符。具体格式是:请求方法、URL、时间戳、随机字符串、请求体。注意,如果是Get请求,请求体那一行是空字符串,但换行符不能省。
我举个例子,假设你要发起一个Post请求创建订单,请求方法是POST,URL是/v3/pay/transactions/jsapi,时间戳是1554208460,随机字符串是593BEC0C930BF1AFEB40B4A08C8FB242,请求体是{"appid":"wx123","mchid":"123456"}。那么签名串就是:
POST /v3/pay/transactions/jsapi 1554208460 593BEC0C930BF1AFEB40B4A08C8FB242 {"appid":"wx123","mchid":"123456"}注意最后一行后面也有一个换行符。这个细节很多人会忽略,导致签名结果对不上。我建议你在构造完签名串之后,先打印出来看一眼,确认每一行都对,特别是换行符的位置。如果是Get请求,最后一行就是空的,但那个换行符依然要在。
构造好签名串之后,用你的商户私钥对它进行SHA256-RSA签名,然后把签名结果做Base64编码,就得到了signature。私钥的格式必须是PKCS#8,如果你拿到的是PKCS#1的私钥,需要先转换一下。这个转换用openssl就能做,命令是openssl pkcs8 -topk8 -inform PEM -in pkcs1.pem -outform PEM -nocrypt -out pkcs8.pem。
2. Get请求的实战细节与查询类接口
Get请求在V3里主要用于查询类操作,比如查询订单状态、查询退款、下载对账单、查询商户信息等。虽然它不涉及请求体的构造,但签名逻辑和Post完全一致,只是在签名串的最后一行留空。很多人觉得Get简单,反而容易在细节上翻车。
2.1 查询订单接口的完整调用链路
以查询订单为例,接口路径是/v3/pay/transactions/out-trade-no/{out_trade_no}?mchid={mchid}。注意这里的mchid是作为query参数传的,而out_trade_no是作为路径参数传的。构造签名串的时候,URL部分要包含query string,也就是说,你签名用的URL必须和实际请求的URL完全一致,包括?mchid=xxx这部分。
我见过有人签名的时候只用了路径部分,没带query参数,结果一直报签名错误。这个坑很隐蔽,因为报错信息不会告诉你具体哪里对不上,只会说签名验证失败。所以我的建议是,在构造签名串之前,先把完整的请求URL拼好,存到一个变量里,签名和发送都用这个变量,避免不一致。
还有一个细节是URL编码。如果你的out_trade_no里包含了特殊字符,比如空格、中文、斜杠等,需要先做URL编码。但签名的时候用的是编码前的还是编码后的?答案是编码后的。也就是说,你实际请求的URL是什么样,签名就用什么样。这一点在微信支付的官方文档里没有特别强调,但实测下来必须这样做。
2.2 下载对账单的特殊处理
下载对账单这个接口比较特殊,它返回的不是JSON,而是一个文件流。而且它的签名逻辑和普通Get请求一样,但响应处理不一样。你拿到响应之后,不能直接按JSON解析,而是要按流处理,保存成文件。
这个接口还有一个坑:它支持gzip压缩,如果你在请求头里带了Accept-Encoding: gzip,返回的就是压缩后的数据,需要先解压。我建议在下载对账单的时候,不要带这个头,直接拿原始数据,省去解压的麻烦。另外,对账单文件是TXT格式的,每行一条记录,字段之间用逗号分隔,解析的时候要注意字段顺序和含义。
还有一点,下载对账单的接口有频率限制,不能频繁调用。一般建议每天调用一次,而且要在账单生成之后。账单的生成时间通常是第二天上午,具体时间不固定,所以最好做个定时任务,在上午10点之后去拉取。
2.3 Get请求的响应验签
V3的所有响应都带有签名,你需要用微信支付的平台证书公钥去验签。验签的步骤是:从响应头里取出Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Signature、Wechatpay-Serial,然后用平台证书公钥对timestamp\nnonce\nbody\n这个字符串进行验签。
这里有一个关键点:body是响应体的原始字符串,不是你解析后的对象。所以你在处理响应的时候,要先拿到原始字符串,验签通过之后再解析成JSON。很多人直接用HTTP客户端自动解析成对象了,导致验签的时候拿不到原始字符串,只能跳过验签。跳过验签虽然不影响功能,但存在安全风险,不建议这么做。
平台证书的获取也是一个环节。V3的平台证书需要通过专门的接口下载,而且会定期轮换。你不能把平台证书写死在代码里,而是要定期更新。我的做法是,启动的时候拉一次平台证书,然后每天定时更新一次,缓存到本地。验签的时候根据Wechatpay-Serial找到对应的证书。
3. Post请求的请求体构造与签名一致性
Post请求是V3里用得最多的,创建订单、申请退款、发起转账都是Post。它的核心难点在于请求体的构造和签名的一致性。前面提过,签名用的请求体必须和实际发送的完全一致,但实际开发中,由于序列化库的差异、字符编码的问题,很容易出现不一致的情况。
3.1 JSON序列化的坑与规避方法
不同的JSON库序列化出来的结果可能不一样。比如有的库会把中文转成Unicode转义,有的不会;有的库会调整字段顺序,有的不会;有的库会在冒号和逗号后面加空格,有的不会。这些差异都会导致签名和实际请求体对不上。
我的做法是,统一用一个JSON库,并且在序列化的时候指定不转义中文、不调整字段顺序、不加多余空格。以Java为例,用Jackson的话,可以配置JsonGenerator.Feature.WRITE_NUMBERS_AS_STRINGS之类的特性,但更简单的做法是直接用ObjectMapper的writeValueAsString方法,然后手动确认输出格式。Python的话,用json.dumps的时候指定separators=(',', ':')和ensure_ascii=False。
还有一个更稳妥的做法:序列化一次,把结果存到一个字符串变量里,签名和发送都用这个变量。这样即使序列化库有差异,也不会影响签名的一致性。我现在的项目里都是这么做的,没有再出现过签名不一致的问题。
3.2 敏感字段的加密处理
V3里有些字段是敏感信息,比如用户的姓名、身份证号、手机号等,这些字段在请求体里需要加密传输。加密用的是AES-GCM算法,密钥是微信支付平台证书里的公钥对应的私钥?不对,这里要分清楚:敏感信息加密用的是微信支付平台证书的公钥,也就是说,你用平台证书的公钥加密,微信支付用对应的私钥解密。
加密的步骤是:生成一个随机的16字节的AES密钥,用这个密钥对敏感信息做AES-GCM加密,然后用平台证书的公钥对这个AES密钥做RSA加密,最后把加密后的AES密钥、随机串、密文拼成一个字符串,格式是<encrypted_key>|<nonce>|<ciphertext>。这个字符串就是最终传输的敏感字段值。
这里有一个容易忽略的点:AES-GCM加密的时候需要一个additional data,微信支付规定这个值是certificate或者空字符串。实测下来,传空字符串是可以的,但官方推荐传certificate。另外,加密后的密文是Base64编码的,拼接的时候要注意格式。
3.3 创建订单接口的完整示例
以JSAPI下单为例,接口路径是/v3/pay/transactions/jsapi,请求方法是Post。请求体里必须包含appid、mchid、description、out_trade_no、notify_url、amount、payer等字段。其中amount是一个对象,包含total和currency,total的单位是分。payer也是一个对象,包含openid。
构造好请求体之后,按照前面说的签名规则生成签名,然后发送请求。响应里会返回一个prepay_id,这个ID是后续调起支付的关键。拿到prepay_id之后,你还需要生成一个支付参数给前端,这个参数也需要签名,但签名方式和API请求不一样,用的是另一种规则。
这里有一个坑:notify_url必须是HTTPS的,而且不能带参数。很多人图省事用了HTTP,结果回调一直收不到。另外,notify_url的域名必须是你配置在商户平台里的域名,否则也会被拒绝。
4. 证书管理与签名报错的排查思路
证书管理是V3里另一个容易出问题的环节。V3涉及两种证书:商户API证书和微信支付平台证书。商户证书用于请求签名,平台证书用于响应验签和敏感信息加密。这两种证书的用途不同,不能混用。
4.1 商户证书与平台证书的区别与获取
商户API证书是你自己在商户平台申请的,包含一个证书文件和一个私钥文件。证书文件里包含公钥和证书序列号,私钥文件用于签名。申请的时候会给你一个压缩包,里面有三个文件:apiclient_cert.pem、apiclient_key.pem、apiclient_cert.p12。其中apiclient_key.pem就是私钥,apiclient_cert.pem是证书。
平台证书是微信支付提供的,用于验签和加密。它不能直接从商户平台下载,而是要通过API接口获取。获取平台证书的接口是/v3/certificates,请求方法是Get。返回的证书是加密的,需要用商户私钥解密。解密后的证书里包含公钥和序列号,你需要把这些证书缓存起来,后续验签的时候用。
平台证书会定期轮换,一般是几个月换一次。所以你不能把证书写死在代码里,而是要定期更新。我的做法是,启动的时候拉一次,然后每天定时拉一次,缓存到本地文件或者内存里。验签的时候根据响应头里的Wechatpay-Serial找到对应的证书。
4.2 签名报错的常见原因与排查步骤
签名报错是V3里最常见的错误,没有之一。报错信息通常是签名验证失败或者401 Unauthorized。排查的时候,我一般按以下步骤来:
第一步,检查签名串的构造是否正确。把签名串打印出来,逐行核对,特别是换行符的位置和最后一行是否为空。如果是Post请求,检查请求体是否和签名时用的一致。
第二步,检查私钥是否正确。确认私钥是PKCS#8格式的,而且和证书是配对的。可以用openssl命令验证一下:openssl rsa -in apiclient_key.pem -check。
第三步,检查证书序列号是否正确。确认serial_no是商户证书的序列号,不是平台证书的。可以用openssl x509 -in apiclient_cert.pem -noout -serial命令查看。
第四步,检查时间戳和随机字符串。确认时间戳是当前时间,而且和服务器时间同步。随机字符串每次都要不一样。
第五步,检查请求头里的Authorization格式是否正确。确认WECHATPAY2-SHA256-RSA2048这个前缀没有拼错,各个参数之间用逗号分隔,参数值用双引号包裹。
如果以上都检查了还是报错,那可能是编码问题。确认签名串的编码是UTF-8,而且没有BOM头。BOM头是一个很隐蔽的坑,特别是在Windows上编辑的文件,很容易带上BOM。可以用十六进制编辑器查看文件开头是否有EF BB BF。
4.3 响应验签失败的排查
响应验签失败虽然不影响功能,但说明你的验签逻辑有问题,需要排查。常见的原因有:平台证书不对、签名串构造不对、响应体被修改过。
排查的时候,先把响应头里的Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Signature、Wechatpay-Serial取出来,然后构造验签串:timestamp\nnonce\nbody\n。注意body是原始响应体,不是你解析后的对象。然后用Wechatpay-Serial对应的平台证书公钥验签。
如果验签失败,先确认平台证书是否是最新的。平台证书会轮换,如果你用的是旧证书,验签就会失败。然后确认验签串的构造是否正确,特别是换行符。最后确认响应体是否被修改过,比如被HTTP客户端自动解析了。
5. 回调通知的处理与对账逻辑
支付成功之后,微信支付会向你配置的notify_url发送回调通知。回调通知也是Post请求,但它的处理方式和主动调用API不一样。回调通知的请求体是加密的,你需要先解密,然后验签,最后处理业务逻辑。
5.1 回调通知的解密与验签
回调通知的请求体是一个JSON,里面包含resource字段,resource里又有ciphertext、nonce、associated_data等字段。你需要用APIv3密钥(不是商户私钥,也不是平台证书)对ciphertext做AES-GCM解密,得到真正的业务数据。
解密的时候,nonce和associated_data从resource里取,密钥是你在商户平台设置的APIv3密钥。这个密钥是32位的字符串,设置之后要妥善保管,不要泄露。解密后的数据是一个JSON,包含订单号、金额、支付状态等信息。
解密之后,还要验签。验签的方式和普通响应验签一样,用平台证书公钥对timestamp\nnonce\nbody\n验签。注意这里的body是回调通知的原始请求体,不是你解密后的数据。
处理完业务逻辑之后,你需要返回一个JSON给微信支付,格式是{"code": "SUCCESS", "message": "成功"}。如果不返回或者返回格式不对,微信支付会重复发送回调通知,最多发送多次。所以你的回调处理逻辑必须保证幂等,同一个订单号多次回调不能重复处理。
5.2 对账与异常订单的处理
对账是支付系统里必不可少的一环。微信支付提供了对账单下载接口,你可以每天下载前一天的账单,和自己的订单数据做比对。对账的目的是发现异常订单,比如支付成功但系统没记录、退款成功但系统没更新等。
对账单的格式是TXT,每行一条记录,字段包括交易时间、商户订单号、微信订单号、交易状态、金额等。解析的时候要注意字段顺序和含义,特别是金额字段,单位是元,不是分。对账的逻辑是:遍历对账单里的每一笔交易,和自己的订单数据比对,如果发现不一致,就记录下来,人工排查。
异常订单的处理一般分几种情况:如果是对账单里有但系统里没有,说明回调可能丢了,需要手动补单;如果系统里有但对账单里没有,说明可能是重复订单或者测试数据,需要核实;如果金额不一致,那问题就比较严重了,需要立即排查。
5.3 幂等性与重试机制的设计
支付系统的幂等性非常重要。不管是主动调用API还是处理回调通知,都要保证同一个请求多次执行的结果是一样的。实现幂等性的常见做法是:用订单号作为唯一键,在处理之前先查一下这个订单是否已经处理过,如果处理过就直接返回成功。
重试机制也是必须的。调用微信支付API的时候,可能会因为网络抖动、服务端超时等原因失败,这时候需要重试。但重试不能无脑重试,要有策略。我的做法是:对于查询类接口,可以重试3次,每次间隔1秒;对于创建类接口,重试要谨慎,因为可能会重复创建订单。如果创建订单超时了,不要直接重试,而是先查询订单状态,确认是否创建成功,再决定是否重试。
回调通知的处理也要考虑重试。如果处理失败,要返回失败给微信支付,让它重新发送。但要注意,微信支付的重试是有频率限制的,不是无限重试。所以你的处理逻辑要尽量健壮,避免因为小问题导致回调失败。
6. 我踩过的几个典型坑与经验总结
接微信支付V3的这段时间,踩过的坑确实不少,有些是文档里没写的,有些是文档里写了但没注意的。这里挑几个典型的分享一下,希望能帮你少走弯路。
第一个坑是签名串的换行符。我一开始以为最后一行不需要换行符,结果一直报签名错误。后来打印出来对比才发现,最后一行也要有换行符。这个细节在文档里其实有写,但很容易被忽略。我的建议是,构造完签名串之后,先打印出来,用十六进制查看器看一下,确认每一行都以\n结尾。
第二个坑是平台证书的更新。我一开始把平台证书写死在代码里,结果运行了几个月之后,突然开始报验签失败。排查了半天才发现是平台证书轮换了。后来改成了定时更新,就再也没出过这个问题。所以平台证书一定要动态获取,不要写死。
第三个坑是JSON序列化的差异。我用的是Java,一开始用Fastjson序列化,结果发现它会把字段顺序打乱,导致签名和实际请求体对不上。后来换成了Jackson,并且手动控制序列化格式,问题就解决了。所以JSON库的选择很重要,最好用同一个库,并且固定序列化格式。
第四个坑是回调通知的幂等性。我一开始没做幂等处理,结果微信支付重复发送回调的时候,同一个订单被处理了两次,导致数据错乱。后来加了幂等判断,用订单号作为唯一键,问题就解决了。所以回调处理一定要做幂等,这是支付系统的基本要求。
第五个坑是时间戳同步。我的服务器时间一开始没同步,结果时间戳和微信支付服务器相差太大,请求被拒绝。后来配了NTP,问题就解决了。所以服务器时间一定要同步,这是很多支付问题的根源。
第六个坑是敏感信息加密的additional data。我一开始不知道这个参数是干嘛的,随便传了个值,结果解密失败。后来查了文档才知道,这个值必须是certificate或者空字符串。所以加密的时候要注意这个参数,不要随便传。
这些坑说起来都不复杂,但真正踩进去的时候,排查起来还是很费时间的。我的经验是,遇到问题先看文档,文档里没有的再搜索,搜索不到的再自己调试。调试的时候要善于打印日志,把签名串、请求体、响应体都打印出来,对比着看,问题往往就藏在细节里。
另外,微信支付提供了一个签名验证工具,可以在线验证你的签名是否正确。这个工具在排查签名问题的时候非常有用,可以帮你快速定位是签名串的问题还是私钥的问题。不过这个工具需要你上传证书和私钥,所以要注意安全,不要在公共网络上使用。
最后再说一点,微信支付的接口文档更新比较频繁,建议定期看一下文档的更新日志,了解有哪些变化。特别是一些接口的字段增减、参数调整,如果不注意,可能会导致调用失败。我一般每个月看一次更新日志,有变化就及时调整代码。