1. 从一笔68元的订单说起:Native支付到底在解决什么问题
很多人第一次接触微信支付开发,都是从“Native下单”这个场景开始的。原因很简单:PC网站、收银台大屏、自助点餐机、停车场缴费终端,这些场景下用户没法直接扫码跳转到微信App,必须由商户系统生成一个二维码,让用户掏出手机扫。这个“生成二维码让用户扫”的链路,对应的就是微信支付V2版本里的Native下单API。
我之所以想把这套东西完整梳理一遍,是因为在实际项目里踩过的坑实在太多了。表面上看,Native下单就是调一个接口、拿一个code_url、把它转成二维码图片就完事了。但真正上线之后你会发现:签名报错、回调收不到、订单状态对不上、二维码过期、退款和查询接口串不起来……每一个环节都能让你加班到凌晨。
这篇文章面向的是需要对接微信支付V2 Native下单的开发者,不管你用的是Java、PHP、Python还是Go,底层的逻辑都是一样的。我会从协议规范、签名机制、下单流程、回调处理、订单查询与关闭、常见报错排查这几个维度,把整个链路拆开讲透。文章里涉及的所有代码示例都是示意性的,重点在于讲清楚“为什么这么做”,而不是给你一个复制粘贴就能跑的完整项目——因为每个项目的技术栈和业务逻辑都不一样,授人以鱼不如授人以渔。
先明确一个前提:微信支付V2版本使用的是XML格式的请求和响应体,签名算法是MD5或HMAC-SHA256,这和V3版本的JSON+证书签名机制完全不同。很多新手看到网上教程混着讲,结果把V2的签名方式套到V3上,或者反过来,直接导致签名校验失败。所以第一步,你得先确认自己对接的到底是哪个版本。本文只讲V2,V3的内容不在讨论范围内。
提示:微信支付官方文档中,V2版本的接口统称为“基础支付API”,Native下单是其中“扫码支付”这一产品形态下的核心接口。接口地址为
https://api.mch.weixin.qq.com/pay/unifiedorder,所有V2接口都走这个域名。
2. 调通Native下单之前,先把这几个参数和签名机制吃透
2.1 商户号、AppID、API密钥三者的关系
在写任何一行代码之前,你必须先搞清楚三个核心凭证:
| 参数名 | 来源 | 作用 | 注意事项 |
|---|---|---|---|
| appid | 微信公众平台/开放平台 | 标识你的应用身份 | 必须是已认证的服务号或已绑定的开放平台账号 |
| mch_id | 微信商户平台 | 标识你的商户身份 | 与appid必须存在绑定关系 |
| key | 商户平台API安全设置 | 用于请求签名和回调验签 | 32位字符串,绝对不能泄露到前端 |
这三者的绑定关系是最容易出问题的地方。我见过不少案例:开发者拿着A公众号的appid去配B商户号的mch_id,结果下单接口直接返回“appid和mch_id不匹配”。还有一种情况是,appid和mch_id确实绑定了,但绑定的是另一个同主体的账号,导致支付成功后回调里的openid对不上。
API密钥(key)的设置路径在商户平台“账户中心-API安全”里。注意,这个key不是你登录商户平台的密码,也不是API证书的私钥,而是一个专门用于签名的32位字符串。设置之后只能修改不能查看,所以第一次设置的时候一定要保存好。如果忘了,只能重置,重置后所有正在使用的服务都会受影响,必须同步更新。
2.2 签名算法的完整推导过程
V2版本的签名逻辑说起来不复杂,但细节特别多。核心步骤是:
- 将所有非空参数按照参数名的ASCII码从小到大排序(字典序)
- 拼接成
key=value&key=value的格式 - 在末尾拼接
&key=你的API密钥 - 对拼接后的字符串做MD5(或HMAC-SHA256)运算
- 将结果转为大写
举个例子,假设你有这些参数:
appid=wx1234567890 mch_id=1500000000 nonce_str=abc123 body=测试商品 out_trade_no=ORDER20240101001 total_fee=68 spbill_create_ip=127.0.0.1 notify_url=https://yourdomain.com/notify trade_type=NATIVE排序后拼接(注意空值不参与签名):
appid=wx1234567890&body=测试商品&mch_id=1500000000&nonce_str=abc123¬ify_url=https://yourdomain.com/notify&out_trade_no=ORDER20240101001&spbill_create_ip=127.0.0.1&total_fee=68&trade_type=NATIVE&key=你的API密钥然后对这个字符串做MD5,结果转大写,就是sign字段的值。
这里有几个极其容易踩的坑:
- 空值处理:如果某个参数的值为空字符串,不参与签名拼接。但如果是
sign字段本身,当然也不参与。 - 编码问题:如果参数值包含中文(比如
body字段),必须确保整个字符串的编码是UTF-8。有些老项目用GBK编码,签名结果就会和微信服务器算出来的不一致。 - 参数名大小写:微信V2接口的参数名全部是小写加下划线,比如
out_trade_no,不要写成outTradeNo。 - key的位置:
key是拼接在最后面的,格式是&key=xxx,不是作为参数参与排序。
注意:HMAC-SHA256的签名方式在拼接完字符串后,使用API密钥作为HMAC的key进行运算,结果同样转大写。两种方式选一种即可,但必须和商户平台设置的一致。
2.3 请求XML的组装与发送
V2接口的请求体是XML格式,根节点是<xml>。把上面所有参数(包括sign)按照XML格式拼装:
<xml> <appid>wx1234567890</appid> <mch_id>1500000000</mch_id> <nonce_str>abc123</nonce_str> <sign>ABCDEF1234567890ABCDEF1234567890</sign> <body>测试商品</body> <out_trade_no>ORDER20240101001</out_trade_no> <total_fee>68</total_fee> <spbill_create_ip>127.0.0.1</spbill_create_ip> <notify_url>https://yourdomain.com/notify</notify_url> <trade_type>NATIVE</trade_type> </xml>发送请求时,Content-Type设置为application/xml或text/xml。微信服务器返回的也是XML,解析后重点看这几个字段:
return_code:通信标识,SUCCESS表示请求到达了微信服务器result_code:业务标识,SUCCESS表示下单成功code_url:当return_code和result_code都为SUCCESS时返回,这就是二维码的内容err_code和err_code_des:失败时的错误码和描述
很多人只看return_code就以为成功了,结果result_code是FAIL,白白浪费时间排查。记住:两个都为SUCCESS才算真正下单成功。
3. 二维码生成与订单状态流转:下单之后发生了什么
3.1 code_url到二维码图片的转换
拿到code_url之后,你需要把它转换成一个二维码图片展示给用户。code_url的格式通常是weixin://wxpay/bizpayurl?pr=xxxxx,有效期默认是2小时。
生成二维码的方式有很多种,后端生成或前端生成都可以:
- 后端生成:用Python的
qrcode库、Java的ZXing、PHP的phpqrcode等,生成图片后返回给前端展示。 - 前端生成:把
code_url传给前端,用JavaScript的qrcode.js等库在浏览器里直接渲染。
我个人更推荐后端生成,原因是:前端生成需要把code_url暴露在页面上,虽然code_url本身不包含敏感信息,但多一层暴露就多一层风险。而且后端生成可以控制二维码的尺寸、容错率、logo嵌入等细节。
import qrcode def generate_qrcode(code_url, save_path): qr = qrcode.QRCode( version=1, error_correction=qrcode.constants.ERROR_CORRECT_M, box_size=10, border=2, ) qr.add_data(code_url) qr.make(fit=True) img = qr.make_image(fill_color="black", back_color="white") img.save(save_path) return save_path这段代码里,error_correction选择M级别(约15%容错)就够了,太高会导致二维码过于密集,影响扫描速度。box_size控制每个小方块的像素大小,一般10左右比较合适。border是二维码四周的留白,微信官方建议至少留2个模块的宽度。
3.2 用户扫码后的完整链路
用户用微信扫描二维码后,整个链路是这样的:
- 微信客户端解析
code_url,向微信支付服务器发起支付请求 - 微信支付服务器验证订单信息,展示支付确认页面给用户
- 用户确认支付,输入密码或使用指纹/面容
- 微信支付服务器完成扣款,向商户的
notify_url发送异步通知 - 商户服务器接收通知,验证签名,处理业务逻辑,返回成功响应
- 微信支付服务器收到成功响应后,停止重发通知
这个链路里,第4步的异步通知是最关键的一环。很多开发者以为用户支付成功后前端会收到回调,但实际上微信支付的异步通知是发给商户服务器的,不是发给前端的。前端需要通过轮询订单查询接口来获取支付结果。
3.3 异步通知的接收与验签
接收异步通知的接口,需要做以下几件事:
from flask import Flask, request import hashlib import xml.etree.ElementTree as ET app = Flask(__name__) @app.route('/notify', methods=['POST']) def notify(): xml_data = request.data root = ET.fromstring(xml_data) result = {child.tag: child.text for child in root} # 第一步:验证签名 sign = result.pop('sign', None) if not verify_sign(result, API_KEY): return '<xml><return_code><![CDATA[FAIL]]></return_code></xml>' # 第二步:验证return_code和result_code if result.get('return_code') != 'SUCCESS' or result.get('result_code') != 'SUCCESS': return '<xml><return_code><![CDATA[SUCCESS]]></return_code></xml>' # 第三步:处理业务逻辑(更新订单状态、发货等) out_trade_no = result.get('out_trade_no') transaction_id = result.get('transaction_id') total_fee = result.get('total_fee') # ... 你的业务处理 # 第四步:返回成功响应 return '<xml><return_code><![CDATA[SUCCESS]]></return_code><![CDATA[OK]]></return_code></xml>'验签的逻辑和请求签名一样:去掉sign字段,剩余参数按字典序排序拼接,末尾加&key=API密钥,做MD5后转大写,与通知里的sign对比。
提示:异步通知可能会重复发送,所以你的业务处理必须保证幂等性。通常的做法是用
out_trade_no作为唯一键,在数据库里做唯一约束,重复通知直接返回成功即可。
4. 订单查询、关闭与退款:支付之后的那些接口
4.1 订单查询接口的正确用法
用户扫码后,前端怎么知道支付成功了?答案是轮询订单查询接口。查询接口的地址是https://api.mch.weixin.qq.com/pay/orderquery,请求参数只需要out_trade_no或transaction_id二选一,加上appid、mch_id、nonce_str和sign。
查询结果里的trade_state字段是关键:
| trade_state | 含义 | 建议操作 |
|---|---|---|
| SUCCESS | 支付成功 | 更新订单状态,触发发货 |
| REFUND | 转入退款 | 检查退款记录 |
| NOTPAY | 未支付 | 继续轮询或提示用户 |
| CLOSED | 已关闭 | 订单已关闭,不可支付 |
| REVOKED | 已撤销 | 订单被撤销 |
| USERPAYING | 用户支付中 | 继续轮询 |
| PAYERROR | 支付失败 | 提示用户重新支付 |
轮询的频率建议是前30秒每秒一次,之后每3秒一次,最多轮询2分钟。因为二维码的有效期是2小时,但用户通常会在几分钟内完成支付。如果超过2分钟还没支付成功,可以提示用户刷新二维码。
4.2 关闭订单的时机与注意事项
如果用户下单后一直不支付,你需要主动关闭订单,释放库存。关闭订单的接口是https://api.mch.weixin.qq.com/pay/closeorder,只需要传out_trade_no。
但关闭订单有几个限制:
- 订单状态必须是
NOTPAY才能关闭 - 如果用户正在支付中(
USERPAYING),关闭请求会返回错误 - 订单关闭后,用户再扫码会提示“订单已关闭”
我一般建议在订单创建后15分钟自动关闭,这个时间可以根据业务场景调整。比如生鲜电商可能5分钟就够了,而定制商品可能需要30分钟。
4.3 退款接口的调用逻辑
退款接口是https://api.mch.weixin.qq.com/secapi/pay/refund,注意这个接口必须使用API证书,不能只用API密钥。API证书的下载路径在商户平台“账户中心-API安全”里。
退款请求需要额外传out_refund_no(商户退款单号)、total_fee(原订单金额)、refund_fee(退款金额)。退款结果也是异步通知,通知地址是notify_url,但和支付通知不同,退款通知的return_code和result_code含义类似,重点看refund_status字段。
<xml> <appid>wx1234567890</appid> <mch_id>1500000000</mch_id> <nonce_str>xyz789</nonce_str> <sign>...</sign> <out_trade_no>ORDER20240101001</out_trade_no> <out_refund_no>REFUND20240101001</out_refund_no> <total_fee>68</total_fee> <refund_fee>68</refund_fee> </xml>退款是原路返回的,用户不需要做任何操作。退款到账时间取决于银行处理速度,一般是1-3个工作日。
5. 那些年我踩过的坑:Native支付常见报错与排查思路
5.1 签名错误的五种典型原因
签名错误是Native支付最高频的问题,没有之一。根据我的经验,90%的签名错误都逃不出以下五种情况:
第一种:参数排序错误。有些人用TreeMap排序,有些人用Arrays.sort,但微信要求的是按参数名的ASCII码从小到大排序。注意,是参数名的ASCII码,不是参数值的。比如appid排在body前面,因为a的ASCII码小于b。
第二种:空值参与了签名。微信的规则是“参数值为空不参与签名”,但有些人把空字符串也拼进去了,导致签名结果不一致。正确的做法是:在拼接之前,先过滤掉值为null或空字符串的参数。
第三种:编码不一致。请求XML的编码必须是UTF-8,签名拼接的字符串也必须是UTF-8。如果你的项目默认编码是GBK,中文参数就会出问题。解决办法是在拼接前统一转成UTF-8。
第四种:key用错了。有些人把API证书的私钥当成了API密钥,或者把商户平台的登录密码当成了API密钥。记住,API密钥是32位的字符串,在“API安全”页面设置。
第五种:sign字段本身参与了签名。签名的时候要把sign字段排除在外,验签的时候也一样。有些人忘了排除,导致算出来的签名和微信返回的不一样。
5.2 回调收不到的排查链路
回调收不到是第二高频的问题。排查思路应该是这样的:
- 确认notify_url是否可公网访问。微信服务器需要能访问到你的回调地址,内网地址、localhost、带端口的地址都可能有问题。
- 确认notify_url是否返回了正确的XML。微信要求回调接口返回
<xml><return_code><![CDATA[SUCCESS]]></return_code></xml>,如果返回了其他内容,微信会认为通知失败,继续重发。 - 确认服务器防火墙是否放行了微信的IP。微信支付的回调IP段是固定的,可以在官方文档里查到。如果你的服务器有IP白名单,需要把这些IP加进去。
- 确认是否被框架拦截了。有些框架会对POST请求做CSRF校验,或者对XML请求做特殊处理,导致回调还没到你的业务代码就被拦截了。
- 查看微信商户平台的交易记录。如果交易记录里显示“已支付”但你的系统没收到通知,那基本可以确定是回调地址的问题。
5.3 订单状态不一致的处理经验
有时候会出现这种情况:微信那边显示支付成功,但你的系统里订单还是“待支付”。原因通常是回调处理失败了,但微信重发了几次之后就不再发了。
这时候你需要一个主动补偿机制:定时任务扫描“待支付”状态的订单,调用订单查询接口确认实际状态。如果查询到trade_state是SUCCESS,就手动更新订单状态。
我一般会设置一个每5分钟跑一次的定时任务,扫描最近30分钟内创建的“待支付”订单。这样即使回调丢了,也能在5分钟内补偿回来。
注意:补偿机制和回调处理必须共用同一套业务逻辑,并且保证幂等性。否则可能出现回调处理了一次、补偿又处理了一次,导致重复发货。
6. 从能用到好用:Native支付的稳定性优化建议
6.1 请求超时与重试策略
调用微信支付接口时,网络超时是不可避免的。我的建议是:
- 连接超时设置为5秒,读取超时设置为10秒
- 下单接口失败时,不要立即重试,因为可能是参数问题,重试也没用
- 查询接口失败时,可以重试2-3次,每次间隔1秒
- 所有请求都要记录日志,包括请求参数、响应内容、耗时
import requests from requests.exceptions import Timeout, ConnectionError def call_wechat_api(url, xml_data, cert=None): try: resp = requests.post( url, data=xml_data.encode('utf-8'), headers={'Content-Type': 'application/xml'}, timeout=(5, 10), cert=cert ) return resp.content except Timeout: # 记录日志,根据业务决定是否重试 return None except ConnectionError: return None6.2 日志记录的关键字段
排查支付问题时,日志是唯一的线索。我建议在日志里记录以下字段:
out_trade_no:商户订单号,贯穿整个链路transaction_id:微信订单号,退款和查询时用trade_state:订单状态total_fee:订单金额time_start和time_expire:订单创建时间和过期时间- 请求和响应的完整XML(注意脱敏,不要记录API密钥)
有了这些日志,即使出了问题,也能快速定位是哪个环节出了错。
6.3 二维码过期与刷新机制
code_url的有效期是2小时,但用户可能在你生成二维码后很久才去扫。如果二维码过期了,用户扫码会提示“二维码已过期”。
处理方式有两种:
- 前端定时刷新:在二维码展示页面上加一个倒计时,比如显示“二维码有效期剩余XX分钟”,到期后自动调用后端接口重新生成。
- 后端预生成:在订单创建时就生成二维码,但设置一个较短的过期时间(比如15分钟),过期后重新下单。
我倾向于第一种方式,因为用户体验更好,而且不需要频繁调用下单接口。
6.4 对账与异常订单处理
每天定时下载微信支付的对账单,和你的系统订单做比对。对账单的下载接口是https://api.mch.weixin.qq.com/pay/downloadbill,需要传bill_date(对账日期)和bill_type(账单类型)。
对账的目的是发现“微信有但系统没有”和“系统有但微信没有”的订单。前者通常是回调丢失导致的,需要补偿;后者可能是系统重复下单或者测试数据,需要清理。
对账文件是CSV格式的,字段包括交易时间、商户订单号、微信订单号、交易金额、交易状态等。解析的时候注意编码是UTF-8,而且文件开头有统计信息,需要跳过。
7. 写在最后:一些个人体会
Native支付这套东西,说难不难,说简单也不简单。接口就那几个,但每个接口背后都有很多细节。我见过太多项目在测试环境跑得好好的,一上线就各种问题。归根结底,还是对签名机制、回调处理、订单状态流转这三个核心环节的理解不够深入。
如果你刚开始对接,我的建议是:先用微信提供的API调试工具把下单接口调通,拿到code_url,确认签名没问题。然后再去处理回调,用内网穿透工具把本地服务暴露到公网,模拟微信服务器的通知。最后再处理查询、关闭、退款这些接口。一步一步来,不要想着一次性把所有功能都做完。
还有一点很重要:不要在生产环境用测试的API密钥。我见过有人把测试环境的key配到了生产环境,结果所有订单都支付失败,排查了半天才发现是key的问题。环境隔离这件事,在支付系统里怎么强调都不为过。
最后分享一个小技巧:微信支付的所有接口都有沙箱环境,可以在沙箱里模拟支付成功、支付失败、退款等场景。虽然沙箱环境的签名逻辑和正式环境略有不同,但用来验证业务流程是足够的。沙箱环境的接口地址是在正式地址后面加/sandboxnew,比如https://api.mch.weixin.qq.com/sandboxnew/pay/unifiedorder。不过要注意,沙箱环境的API密钥是固定的,不是你在商户平台设置的那个。