扫码支付这个能力,放在五年前还是个新鲜玩意儿,现在几乎成了线上收银台的标配。我最早接触 Native 支付是在一个电商后台项目里,当时需求方只提了一句“用户要在电脑上扫码付钱”,剩下的全得自己啃文档。那会儿踩的坑现在想起来还挺有意思——签名报错查了一整天,回调地址配错导致订单状态永远停在“未支付”,还有一次把商户证书路径写成了相对路径,本地跑得好好的,一上服务器就崩。所以看到“微信扫码(Native)支付示例源码”这个标题,我特别有共鸣,这玩意儿看着简单,真要从零跑通,细节多得像牛毛。
这篇内容我打算按“一个完整可复现的 Native 支付 Demo”来拆,从整体设计思路讲到核心参数怎么算、代码怎么组织、回调怎么处理,再到实际联调时那些文档里不会写的坑。适合谁看?如果你是刚接手支付模块的后端开发,或者想在自己的小项目里接一个扫码收款能力,再或者你只是好奇“扫个码钱怎么就过去了”这件事背后的链路,那这篇应该能帮你省下不少翻文档的时间。下面所有内容都基于常见的 Native 支付接入实践来展开,示例代码用 Java 和 Python 两种语言穿插着给,方便不同技术栈的朋友对照。
1. 整体方案设计与技术选型思路
1.1 为什么是 Native 支付而不是别的模式
微信支付下面挂了好几种产品形态,JSAPI 适合公众号内网页,H5 适合手机浏览器,APP 支付适合原生应用,而 Native 支付专门解决“PC 端网页或者线下大屏展示二维码”这个场景。它的核心特征就一句话:商户后台调用统一下单接口,拿到一个二维码链接,前端把这个链接渲染成二维码图片,用户掏出手机扫一下,在手机上完成付款,商户后台通过回调或者主动查询拿到支付结果。
选 Native 的理由很直接。第一,它不依赖用户的浏览器环境,PC 端 Chrome、Safari、甚至 IE 都能用,只要能把字符串画成二维码就行。第二,支付动作发生在用户手机上,PC 端不需要处理任何敏感信息,安全边界清晰。第三,二维码链接本身有时效性,过期自动失效,不用商户自己维护状态。对比 JSAPI 需要用户授权拿 openid,Native 完全不需要,接入成本低了一大截。
不过有一点要提前说清楚:Native 支付拿到的是code_url,这个链接不是给用户直接点的,而是给前端库(比如 qrcode.js)转成图片的。很多人第一次接的时候会试图把这个链接丢到浏览器里打开,结果发现是一串weixin://wxpay/bizpayurl?pr=xxx的协议头,浏览器根本不认。这个设计是有意为之,weixin://协议只有微信客户端能识别,等于从源头上防止了链接被恶意转发或者钓鱼。
1.2 示例源码的整体分层结构
一个能跑起来的 Native 支付 Demo,我习惯把它拆成四层:配置层、签名层、接口调用层、回调处理层。配置层管商户号、API 密钥、证书路径这些敏感信息;签名层负责把参数按规则拼成待签名字符串再算摘要;接口调用层封装统一下单、查询订单、关闭订单这几个核心接口;回调处理层处理微信服务器推过来的支付结果通知。
这么分层的好处是每块职责单一,调试的时候能快速定位问题出在哪一层。比如签名报错,你只需要盯着签名层看,不用在业务代码里翻来翻去。示例源码里我建议把签名逻辑单独抽成一个工具类,参数组装用有序 Map,这样能避免“参数顺序不对导致签名失败”这种低级错误——微信的签名规则要求参数按 ASCII 码从小到大排序,用TreeMap天然满足这个条件。
技术选型上,HTTP 客户端我倾向用 OkHttp 或者 Apache HttpClient,前者 API 简洁,后者生态成熟,看团队习惯。JSON 序列化用 Jackson 或者 Fastjson 都行,但要注意微信接口对空值和字段顺序有要求,序列化的时候别把 null 字段带进去。二维码生成前端用 qrcode.js 就够了,后端不需要生成图片,省一层依赖。
1.3 支付链路的完整时序梳理
把时序理清楚,后面写代码就是填空。完整链路是这样的:用户在 PC 页面点“去支付”,前端请求商户后端下单接口;商户后端组装参数(商户号、订单号、金额、商品描述、回调地址、交易类型 Native)调用微信统一下单接口;微信返回code_url;商户后端把code_url返回给前端;前端用二维码库渲染成图片展示;用户手机扫码,微信客户端拉起支付确认页;用户确认付款,微信服务器向商户配置的回调地址 POST 支付结果;商户后端验签、核对金额、更新订单状态,返回成功应答;同时前端可以轮询商户后端的订单查询接口,作为回调的兜底。
这里有个关键点:回调不是百分百可靠的。网络抖动、商户服务器短暂不可用都可能导致通知丢失,所以必须配一个主动查询的兜底机制。我的做法是前端展示二维码后每 3 秒调一次商户后端的订单状态接口,后端再去查微信或者查本地库,查到已支付就跳转成功页。这样即使回调丢了,用户也不会卡在“支付中”的界面上干等。
2. 核心参数与签名机制的细节拆解
2.1 统一下单接口的必填参数清单
Native 支付调统一下单,参数看着不多,但每个都有讲究。下面这张表是我整理的必填项和常见填错点:
| 参数名 | 含义 | 常见错误 |
|---|---|---|
| appid | 应用唯一标识 | 和商户号不匹配,导致签名通过但下单报错 |
| mch_id | 商户号 | 抄错一位数字,报“商户不存在” |
| nonce_str | 随机字符串 | 长度超过 32 位或者含特殊字符 |
| body | 商品描述 | 含中文未做编码,或者超长被截断 |
| out_trade_no | 商户订单号 | 重复使用,第二次下单直接报错 |
| total_fee | 总金额(分) | 单位搞错,把元当成分传进去 |
| spbill_create_ip | 终端 IP | 传了内网 IP 或者格式不对 |
| notify_url | 回调地址 | 必须是公网可访问的 http/https 地址 |
| trade_type | 交易类型 | Native 场景必须传 NATIVE |
total_fee这个字段我单独拎出来说,因为它是最容易出问题的地方。微信要求单位是分,整数,不能带小数点。很多业务系统里金额存的是BigDecimal的元,直接toString传过去就变成"99.00",接口直接报参数格式错误。正确做法是先乘 100 再取整,用amount.multiply(new BigDecimal("100")).intValue(),注意别用double做乘法,浮点精度问题会让你在几分钱上栽跟头。
out_trade_no的唯一性也要强调。同一个商户订单号重复下单,微信会返回“订单已存在”的错误。所以订单号生成规则要保证全局唯一,常见做法是“业务前缀 + 时间戳 + 随机数”或者直接用雪花算法。示例源码里我用的是NATIVE+yyyyMMddHHmmss+ 6 位随机数,够用且可读。
2.2 签名算法的逐步推演
微信支付的签名用的是 MD5 或者 HMAC-SHA256,示例里我用 MD5 讲,因为最直观。步骤是这样的:先把所有非空参数按参数名 ASCII 码从小到大排序,然后拼成key1=value1&key2=value2的形式,最后在末尾拼上&key=商户API密钥,对整个字符串做 MD5,结果转大写。
举个例子,假设参数是appid=wxdemo123、mch_id=1900000、nonce_str=abc123、total_fee=100,API 密钥是testkey123。排序后拼接得到appid=wxdemo123&mch_id=1900000&nonce_str=abc123&total_fee=100&key=testkey123,对这个字符串算 MD5,得到 32 位小写再转大写,就是sign字段的值。
这里有几个坑必须点出来。第一,sign字段本身不参与签名,空值参数也不参与,但0和空字符串要区分清楚,total_fee=0是参与签名的。第二,参数值里如果有特殊字符,不需要做 URL 编码,直接原样拼。第三,API 密钥是在商户平台单独设置的,不是商户号也不是 appsecret,很多人第一次接会拿 appsecret 去签,怎么都对不上。第四,MD5 结果一定要转大写,微信校验是大小写敏感的。
Python 版本的签名实现大概长这样:
import hashlib def build_sign(params, api_key): filtered = {k: v for k, v in params.items() if v not in (None, '') and k != 'sign'} sorted_keys = sorted(filtered.keys()) raw = '&'.join(f'{k}={filtered[k]}' for k in sorted_keys) raw += f'&key={api_key}' return hashlib.md5(raw.encode('utf-8')).hexdigest().upper()Java 版本用TreeMap天然有序,遍历拼接即可。注意字符串拼接时用StringBuilder,别在循环里用+,参数多了性能差异明显。
2.3 证书与密钥的安全存放
Native 支付本身调统一下单不需要证书,只有退款、企业付款这类敏感接口才要双向证书。但示例源码里我还是建议把证书加载逻辑预留出来,方便后续扩展。证书文件(apiclient_cert.p12)的密码默认是商户号,加载的时候用KeyStore读取,别把证书路径硬编码在代码里,放配置文件或者环境变量。
API 密钥的存放更是重中之重。绝对不要提交到代码仓库,示例源码里我用的是配置文件占位符,实际部署时通过环境变量注入。我见过有团队把密钥写在application.properties里然后推到了公开仓库,虽然及时删了,但历史记录里还能翻出来,这种事故处理起来非常麻烦。
提示:本地调试时可以用一个专门的测试商户号和测试密钥,和生产环境完全隔离。测试环境的回调地址可以用内网穿透工具临时映射一个公网地址,但要注意这类工具只用于开发调试,正式环境必须用备案过的域名。
3. 从下单到回调的完整实操流程
3.1 统一下单接口的调用与响应解析
下单接口的地址是固定的,请求体是 XML 格式。对,你没看错,微信支付 v2 接口用的是 XML,不是 JSON。虽然现在 v3 接口已经全面 JSON 化,但 Native 支付的很多存量示例还是 v2,示例源码里我两种都给,先讲 v2 因为资料多、排查方便。
请求体组装好之后,用 HTTP POST 发出去,Content-Type设为text/xml。响应也是 XML,解析出return_code、result_code、code_url这几个关键字段。return_code是通信层面的成功标识,result_code是业务层面的,两个都是SUCCESS才算真正下单成功。很多人只看return_code,结果业务报错(比如签名错误、参数错误)的时候拿不到code_url,还以为是网络问题。
Java 里解析 XML 可以用dom4j或者 JDK 自带的DocumentBuilder,示例里我用dom4j,代码短。解析完把code_url取出来返回给前端,同时把prepay_id存起来,后续查单或者对账会用到。
响应里还有个err_code和err_code_des,业务失败的时候这两个字段会告诉你具体原因,比如OUT_TRADE_NO_USED表示订单号重复,SIGNERROR表示签名错误。调试阶段把这两个字段打到日志里,能省很多猜测时间。
3.2 二维码的生成与前端展示
后端把code_url返回给前端之后,前端用 qrcode.js 渲染。这里有个细节:code_url的有效期默认是 2 小时,过期后扫码会提示“二维码已失效”。所以前端展示的时候最好加一个倒计时,让用户知道还剩多少时间,体验会好很多。
二维码的尺寸也有讲究。太小了手机扫不出来,太大了页面布局难看。我一般用 200x200 到 260x260 像素之间,容错级别用 M 或者 Q,保证打印或者屏幕反光的情况下也能识别。qrcode.js 的配置大概是这样:
new QRCode(document.getElementById('qrcode'), { text: codeUrl, width: 240, height: 240, correctLevel: QRCode.CorrectLevel.M });如果是在线下大屏场景,比如自助收银机,二维码要做得更大,同时加一句“请使用微信扫一扫”的提示文案。别小看这句文案,实测能显著降低用户的操作困惑。
前端展示二维码的同时,要启动轮询。轮询间隔我建议 2 到 3 秒,太频繁会给后端和微信接口造成压力,太慢用户感知差。轮询到已支付状态就停止,并跳转结果页。轮询接口要做频率限制,防止恶意刷接口。
3.3 支付结果回调的验签与幂等处理
回调是整条链路里最需要小心的地方。微信服务器会向notify_urlPOST 一段 XML,里面包含return_code、result_code、out_trade_no、transaction_id、total_fee、sign等字段。收到之后第一件事是验签,用同样的签名算法把收到的参数重新算一遍,和sign比对,不一致直接丢弃。
验签通过之后,核对total_fee和本地订单金额是否一致。这一步很多人会漏,觉得验签过了就万事大吉。实际上验签只能证明请求来自微信,不能证明金额没被篡改(虽然概率极低,但防御性编程要求我们核对)。金额不一致的订单要标记为异常,人工介入。
然后是幂等处理。微信的回调可能会重复推送,同一个transaction_id可能来好几次。所以更新订单状态之前要先查,如果已经是“已支付”状态就直接返回成功应答,不要重复处理。数据库层面可以用transaction_id做唯一索引,插入冲突就说明重复了。
回调处理成功后,必须返回一段特定的 XML 给微信,告诉它“我收到了”:
<xml> <return_code><![CDATA[SUCCESS]]></return_code> <return_msg><![CDATA[OK]]></return_msg> </xml>如果返回的不是这个格式,微信会认为通知失败,隔一段时间重试,重试策略是 15 秒、15 秒、30 秒、3 分钟、10 分钟、20 分钟、30 分钟、30 分钟、30 分钟,一共 9 次。所以回调接口一定要做快速响应,业务逻辑能异步就异步,别在回调里做耗时操作。
3.4 主动查询作为回调的兜底
前面说了回调可能丢,所以主动查询必须有。查询接口传out_trade_no或者transaction_id,返回订单状态。前端轮询的时候,后端先查本地库,如果本地还是“待支付”,就调微信查询接口,查到已支付就更新本地状态并返回给前端。
查询接口的调用频率要控制,微信对查询有频次限制,具体数值看官方文档。我的做法是前端轮询间隔 3 秒,后端对同一个订单的微信查询做 10 秒缓存,避免短时间内重复调微信。缓存用本地ConcurrentHashMap加过期时间就够了,示例项目不需要上 Redis。
还有一种情况:用户扫码后关闭了支付页面,没付款。这时候订单会一直停在“待支付”。所以需要一个关单机制,超过一定时间(比如 30 分钟)的未支付订单,调关闭订单接口把微信侧的订单关掉,本地标记为“已关闭”。这样能避免订单号被长期占用,也方便对账。
4. 联调排查与常见问题实录
4.1 签名错误的排查路径
签名错误是 Native 支付联调阶段出现频率最高的问题,没有之一。报错信息通常是SIGNERROR,但不会告诉你具体哪里错了。我的排查顺序是这样的:先确认 API 密钥对不对,是不是拿成了 appsecret;再确认参数排序是不是 ASCII 升序,中文参数有没有被转码;然后确认空值参数有没有被排除;最后确认 MD5 结果有没有转大写。
有个特别隐蔽的坑:参数值里如果包含&或者=,拼接的时候会破坏key=value的结构,导致签名对不上。这种情况要么在业务层限制参数值不能含这些字符,要么在拼接前做转义。微信文档里没明说这一点,但实际遇到过好几次。
还有一个坑是字符编码。签名用的字符串必须是 UTF-8 编码,如果你的项目默认编码是 GBK,body字段里的中文在算 MD5 的时候字节序列不一样,签名必然错。统一在算签名的地方显式指定 UTF-8,别依赖系统默认。
4.2 回调收不到的几种可能
回调收不到,先分清楚是微信没发还是你没收。查微信商户平台的交易记录,看订单状态是不是已支付,如果是,说明钱收到了,问题出在通知环节。然后检查notify_url是不是公网可访问,用浏览器或者 curl 直接 POST 一下你的回调地址,看能不能通。常见原因有这么几个:地址填的是内网 IP、端口没开放、防火墙拦截、HTTPS 证书链不完整、回调接口返回了非预期格式导致微信认为失败。
HTTPS 证书链这个问题值得单独说。有些证书只配了站点证书,没配中间证书,浏览器能正常访问(因为浏览器会自动补全),但微信服务器不会,握手直接失败。用在线工具检测一下证书链完整性,缺中间证书就补上。
还有一种情况是回调接口本身抛异常了,比如数据库连接超时、空指针,导致返回了 500。微信收到 500 会重试,但如果你的接口一直不稳定,重试 9 次之后就不再推了。所以回调接口的日志一定要打全,收到什么、处理到哪一步、返回什么,都要有记录。
4.3 金额与订单状态的典型异常
金额相关的异常主要有两类:单位搞错和精度丢失。单位搞错前面说过了,元当成分传,用户付了 100 倍的钱,这种事故很严重。精度丢失是double运算导致的,比如0.1 + 0.2不等于0.3,金额计算一律用BigDecimal,并且指定RoundingMode。
订单状态异常最常见的是“用户已付款但订单显示未支付”。原因可能是回调丢了、回调处理失败、或者本地事务回滚了。排查的时候先看微信侧订单状态,再看本地库,再看回调日志。如果确认是回调丢了,手动触发一次查询补单。示例源码里我加了一个管理接口,输入订单号就能触发补单,运维排查很方便。
还有一个容易被忽略的点:退款和支付的订单状态是联动的。如果一笔订单已经退款,再收到支付回调(极端情况下的乱序),不能把状态改回已支付。所以状态机要设计好,已退款是终态,不允许回退。
4.4 常见问题速查表
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 下单返回 SIGNERROR | 密钥错误、排序错误、编码错误 | 逐项核对签名四要素 |
| 下单返回 OUT_TRADE_NO_USED | 订单号重复 | 检查订单号生成规则 |
| 二维码扫不出来 | code_url 过期或二维码尺寸过小 | 检查有效期,调整尺寸和容错级别 |
| 回调收不到 | notify_url 不可达或返回格式错误 | curl 自测,检查证书链和返回体 |
| 订单状态不更新 | 回调丢失或处理异常 | 查日志,用主动查询补单 |
| 金额对不上 | 单位错误或浮点精度问题 | 统一用分和 BigDecimal |
| 重复发货 | 回调未做幂等 | 用 transaction_id 做唯一约束 |
注意:联调阶段建议把微信返回的原始报文完整打到日志里,包括请求和响应。出问题的时候这份日志就是最直接的证据。但生产环境要注意脱敏,别把密钥、证书密码这些打进去。
5. 源码组织与可复用的工程实践
5.1 配置项的集中管理与环境隔离
示例源码里我把所有配置项抽到一个WxPayConfig类里,字段包括 appid、mchId、apiKey、notifyUrl、certPath、certPassword。这个类从配置文件读取,配置文件分dev、test、prod三套,通过启动参数指定激活哪套。这样本地调试和线上部署用的是同一份代码,只是配置不同,避免“本地能跑线上不行”的经典问题。
配置项里notifyUrl建议做成可配置而不是硬编码,因为不同环境的域名不一样。certPath用绝对路径或者 classpath 相对路径都行,但要在启动时校验文件是否存在,不存在直接启动失败,比运行到一半报错要好排查。
5.2 接口调用的统一封装与重试策略
统一下单、查询、关闭这三个接口的调用逻辑高度相似:组装参数、签名、发 HTTP 请求、解析响应、验签(查询和关闭的响应也带签名)。所以我抽了一个WxPayClient类,内部用一个doPost方法统一处理 HTTP 和 XML 解析,上层方法只关心业务参数。
重试策略要谨慎。下单接口不建议自动重试,因为可能造成重复下单(虽然订单号唯一能挡住,但会给用户造成困惑)。查询接口可以重试,失败后隔 1 秒重试一次,最多 2 次。回调处理不重试,失败了就靠微信自己的重试机制。
HTTP 超时时间设置也有讲究。连接超时 5 秒,读取超时 10 秒,太短了网络抖动就失败,太长了用户等待体验差。示例里我用的是 OkHttp,配置如下:
OkHttpClient client = new OkHttpClient.Builder() .connectTimeout(5, TimeUnit.SECONDS) .readTimeout(10, TimeUnit.SECONDS) .build();5.3 日志与对账的配套设计
支付系统的日志不是“打个 log.info 就完事”,要能支撑事后对账和问题追溯。我的做法是每次接口调用记录一条结构化日志,包含请求流水号、接口名、请求参数(脱敏后)、响应码、耗时。请求流水号用 UUID,贯穿下单、回调、查询全链路,排查的时候一个流水号就能串起来。
对账是另一个容易被忽视的环节。每天定时拉取微信侧的账单,和本地订单逐笔比对,发现差异(微信有本地无、本地有微信无、金额不一致)就告警。示例源码里我只留了对账的骨架,实际落地还需要处理账单文件的下载和解压(账单是 gzip 压缩的 CSV)。
5.4 安全加固的几个实操建议
第一,回调接口要做来源校验,虽然验签已经能挡住伪造请求,但再加一层 IP 白名单更稳妥。第二,code_url不要在前端日志或者埋点里明文记录,虽然它有时效性,但泄露了总归不好。第三,商户平台的 API 密钥定期轮换,轮换期间新旧密钥并行一段时间,避免影响存量业务。第四,所有涉及金额的接口都要做权限校验,防止越权查询他人订单。
我在实际项目里还遇到过一个情况:测试环境的密钥不小心配到了生产环境,导致生产下单全部签名失败。后来我们加了一个启动自检,用配置的密钥调一次查询接口(查一个不存在的订单),如果返回签名错误就拒绝启动。这个自检逻辑很简单,但能挡住配置错误这类低级事故。
6. 个人实操体会与后续扩展方向
Native 支付这个能力本身不复杂,复杂的是围绕它的工程细节。我踩过的坑里,真正跟支付逻辑相关的不到三成,剩下七成都是配置、编码、网络、证书这些外围问题。所以我的建议是,第一次接的时候别急着写业务代码,先把官方示例跑通,把签名、下单、回调这条最小链路走顺,再往自己的项目里搬。
示例源码的价值不在于代码本身多精妙,而在于它把那些散落在文档各处的细节串成了一条能跑通的线。你可以直接拿它当脚手架,改改配置就能用,也可以只挑其中某一块(比如签名工具类、回调处理模板)嵌到现有项目里。
后续如果要扩展,我建议往两个方向走。一是接入 v3 接口,v3 用 JSON、用平台证书、用 AES 加密回调,安全性更高,也是官方主推的方向。二是把支付能力抽象成独立的服务,下单、查询、退款、对账都通过 RPC 暴露,业务方不用关心支付细节,这样多业务线复用的时候会清爽很多。不过这是后话了,先把 Native 这条链路跑稳,比什么都实在。