1. 微信支付的场景选型与核心参数
1.1 五大支付场景,你到底该接哪一个
很多人第一次接触微信支付就懵了:公众号支付、小程序支付、Native支付、APP支付、H5支付,名字一大堆,文档更是看得眼花缭乱。其实微信支付的底层逻辑不复杂,核心就是"人在哪个场景里,就用哪个渠道让他付款"。
简单给你个对照表,看完就知道自己该接哪个:
| 支付场景 | 调用方式 | 典型使用场景 | 前置条件 |
|---|---|---|---|
| JSAPI支付 | 公众号内H5页面调起 | 服务号文章、公众号菜单里的商城 | 认证服务号、网页授权登录 |
| 小程序支付 | 小程序内wx.requestPayment | 小程序商城、预约、课程 | 认证小程序、用户登录openid |
| Native支付 | 生成二维码,用户扫码 | PC网站、线下扫码 | 不需要openid,商户号即可 |
| APP支付 | APP内调起微信 | 原生APP、React Native、Flutter | APP已上架、应用签名配置 |
| H5支付 | 微信外浏览器跳转 | 短信链接、浏览器下单 | 已备案域名、需申请H5支付 |
我见过最多的坑是什么?拿着一堆文档不知道选哪个,尤其是刚接触支付的同学,把JSAPI支付和小程序支付当成一回事。
其实JSAPI支付的核心是"公众号网页 + openid",它要求在微信内置浏览器里通过网页授权拿到用户的openid,然后调起支付。而小程序支付走的是wx.requestPayment,小程序天然有openid。两者虽然在下单接口上参数很像,但调起方式完全不同。如果你在小程序里调起了JSAPI的接口,大概率会报"当前商户需在对应平台使用对应支付方式"之类的错误。
1.2 商户平台里的这几个核心参数,配置错一个都白搭
选好场景,接下来就是去微信商户平台做配置。不管接哪个场景,有几个参数是绕不过去的:
- 商户号(mchid):微信支付分配给你商户身份的唯一编号。
- AppID:绑定的小程序或公众号的AppID,和商户号要完成绑定授权,否则下单必报"商户号与AppID不匹配"。
- API密钥(APIv3密钥 / APIv2密钥):这是签名用的,泄漏等于别人能用你的商户号下单,必须妥善保管。
- 证书:API证书(V3)、商户私钥、平台证书等,主要用于退款、转账等敏感操作和APIv3签名。
这里要特别提醒一下:微信支付现在主推APIv3接口,但市面上仍有大量老系统在用APIv2。v2用的是MD5/HMAC-SHA256签名,v3用的是RSA-SHA256签名,两者不能混用。如果你在一个v3项目里按v2的方式拼签名串,服务端直接给你一个"签名错误"或者"参数格式不正确"。
还有一件事经常被忽略:回调地址(notify_url)必须是公网可访问的HTTPS地址,并且不能带参数。用的时候要先去商户平台设置,也可以在调用统一下单接口时通过参数指定。回调地址配错是最常见的"支付成功但业务订单没更新"的原因。
1.3 签名到底是什么,为什么绕不开它
聊微信支付,签名是永远绕不开的话题。你可以这么理解:签名就是把你请求的所有参数加上你的API密钥,按照固定规则算出一串"指纹",微信根据这串指纹确认请求是你本人发的,并且参数没有被篡改。
签名做对,支付就成功了三分之一;签名做错,你会被各种"signature error"折磨到怀疑人生。所以我在下面的章节里专门讲签名算法和排查路径,这是微信支付所有接口的地基。
2. 统一下单到支付成功的完整流程
2.1 统一下单:参数组装与请求发送
微信支付的流程基本是固定的:后端调统一下单接口拿到prepay_id,把prepay_id和相关参数返回给前端,前端调起支付,微信回调你的notify_url,后端处理订单状态。
先看服务端统一下单。以APIv2为例,你需要构造这样一段XML(下文我会给出完整代码思路):
<xml> <appid>wxxxxxxxxxxxxxxxxx</appid> <mch_id>1600000000</mch_id> <nonce_str>5K8264ILTKCH16CQ2502SI8ZNMTM67VS</nonce_str> <body>测试商品</body> <out_trade_no>20250710001</out_trade_no> <total_fee>1</total_fee> <spbill_create_ip>127.0.0.1</spbill_create_ip> <notify_url>https://yourdomain.com/pay/notify</notify_url> <trade_type>JSAPI</trade_type> <openid>o8xxxxxxxxxxxxxxxxxxx</openid> <sign>这里填计算出来的签名</sign> </xml>重点参数解释一下:
total_fee的单位是分,不是元。传1代表1分钱。这是N多人踩过的坑,金额直接少两位。out_trade_no是商户订单号,由你自己生成,必须保证唯一。同一个商户号下重复的订单号会被拒绝。openid在JSAPI和小程序支付时必须传,Native支付不需要。nonce_str是随机字符串,每次请求都要变。
发送请求时,直接把这段XML POST到https://api.mch.weixin.qq.com/pay/unifiedorder,响应也是一段XML。拿到prepay_id后用包在return_code和result_code里面的判断,很多新手把这个搞混,最后看到SUCCESS就以为下单成功,结果直接把空字符串prepay_id传给了前端。
2.2 签名算法实现,手写一遍你就彻底懂了
签名的原理其实一句话能说清:把参数按ASCII字典序排序,拼成"key=value"的字符串,末尾拼接API密钥,再做MD5或HMAC-SHA256哈希,转大写。
以APIv2为例,完整的签名计算过程分为五步:
第一步,过滤空值:把参数中值为空或者为null的字段剔除。
第二步,ASCII字典序排序:按参数名的字母顺序从小到大排序(也就是a到z)。
第三步,拼接字符串:每个参数用key=value的形式,中间用&连接,形成stringA。
第四步,拼接密钥:在stringA末尾加上&key=你的API密钥,得到stringB。
第五步,计算签名:对stringB做MD5,结果转大写。
给你一份可直接用的Python实现:
import hashlib import hmac def sign_v2(params: dict, api_key: str, sign_type: str = "MD5") -> str: # 1. 过滤空值 filtered = {k: str(v) for k, v in params.items() if v != "" and v is not None} # 2. 按key的ASCII排序 sorted_keys = sorted(filtered.keys()) # 3. 拼接a=1&b=2 string_a = "&".join([f"{k}={filtered[k]}" for k in sorted_keys]) # 4. 拼接密钥 string_b = string_a + "&key=" + api_key # 5. 计算签名 if sign_type == "HMAC-SHA256": sign = hmac.new(api_key.encode(), string_b.encode(), hashlib.sha256).hexdigest().upper() else: sign = hashlib.md5(string_b.encode()).hexdigest().upper() return sign如果你用的是APIv3,签名方式完全不一样:v3要求把请求方法、请求路径、时间戳、随机串、请求体拼成一个字符串,用商户私钥做RSA-SHA256签名,然后在请求头里带上Authorization字段。
import time import uuid import base64 from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import padding def build_v3_authorization(method, url, body, merchant_id, cert_serial_no, private_key_path): timestamp = str(int(time.time())) nonce_str = uuid.uuid4().hex message = f"{method}\n{url}\n{timestamp}\n{nonce_str}\n{body}\n" with open(private_key_path, "rb") as f: private_key = serialization.load_pem_private_key(f.read(), password=None) signature = base64.b64encode( private_key.sign(message.encode(), padding.PKCS1v15(), hashes.SHA256()) ).decode() return ( f'WECHATPAY2-SHA256-RSA2048 ' f'mchid="{merchant_id}",nonce_str="{nonce_str}",' f'signature="{signature}",timestamp="{timestamp}",' f'serial_no="{cert_serial_no}"' )提示:v3签名里那个
url要带query string,比如/v3/pay/transactions/jsapi?a=b。请求体如果为空,签名串里对应位置就是空行。
网上有大量现成SDK帮你封装签名,比如官方sdk、wechatpay-apiv3等,建议直接引入。但如果项目不允许引第三方依赖,那就按上面的逻辑自己造轮子。
2.3 支付回调:验签、解密、幂等,一个都不能少
用户支付成功后,微信会向notify_url发一个异步通知,这个时候后端才算真正“收到钱”。回调处理是整个流程里最容易出问题的地方。
APIv2的回调数据是XML,其中return_code和result_code都是SUCCESS才代表支付成功,且每次回调里有sign字段,你需要拿sign和其余参数做一次签名比对,用来验签。
APIv3的回调则是JSON,而且内容做了AES-256-GCM加密,需要拿APIv3密钥解密:
import json from cryptography.hazmat.primitives.ciphers.aead import AESGCM def decrypt_callback(api_v3_key, associated_data, nonce, ciphertext): key = api_v3_key.encode() aesgcm = AESGCM(key) plaintext = aesgcm.decrypt(nonce, ciphertext, associated_data) return json.loads(plaintext)解密后的字段里面有一个transaction_id,这是微信支付订单号。我处理回调用一句话概括:先验签,再解密,再核金额,最后改订单状态。
这里最关键的坑是幂等处理。微信回调机制是重试制的,它会在几十秒内多次重复发送回调,直到你的接口返回成功响应。所以你的业务逻辑必须是幂等的——如果订单已经处理过了,直接返回成功,不要重复更新库存、不要重复发权益,否则用户买一件商品收到两件,财务那边直接炸。
2.4 前端拉起支付,二次签名别再错
后端拿到prepay_id后,前端才能真正调起支付。如果是小程序,用wx.requestPayment:
wx.requestPayment({ timeStamp: res.timeStamp, // 必须为字符串 nonceStr: res.nonceStr, package: res.package, // 格式:prepay_id=xxx signType: res.signType, // MD5 或 RSA paySign: res.paySign, success() {}, fail() {} });前端这步有一个“二次签名”:paySign要用appId、timeStamp、nonceStr、package这几个参数重新计算。很多人在这里栽跟头,报错“提示用户态签名signature错误”,或者paySign校验失败。
我的建议是:timeStamp、nonceStr、package这几个参数由后端生成并返给前端,paySign也在后端计算,前端不要自己改任何参数。前端拿到什么就传什么,这样能有效避免二次签名不一致的问题。
3. 高频报错:签名错误到底怎么排查
3.1 服务端统一下单报签名错误
“signature error”这条报错把你卡在原地,一般逃不过下面几个原因:
- API密钥不一致:签名时用的
key和商户平台里设置的不一样。这是最最常见的原因,尤其当你本地配置和线上配置不一致时。检查方法很简单,去商户平台重新复制API密钥,确保代码里用的和平台里的一字不差。 - 参与签名的参数和实际请求参数不一致:签名算的是A组参数,实际请求发的是B组参数,相当于“对不上号”。举个典型的例子,你签名时带了
out_trade_no,但构造XML请求时拼错了字段名,两者自然不一致。 - 值包含空格或大小写问题:参数值前后多了一个空格会导致签名失败。很多请求库在拼接XML时会把值格式化,这一点特别隐蔽。
- 使用了HTML实体编码:如果XML里的
&被转义成&,微信那边解析出来就是&,和你签名时用的&不一致,照样报错。 - 时间戳过期:timestamp超过微信允许的有效期(通常是几分钟到几小时),需要重新取服务器当前时间。
排查方法就一条:写一个签名校验函数,把微信返回的错误参数和你本地计算的结果print出来一行行对。我调试的时候会把参与签名的每个参数名和值都打印出来,然后手动拼一遍,基本十分钟内能找到问题。
3.2 拉起支付时提示签名错误,我踩过的坑
网上搜“微信支付 提示用户态签名signature错误”,大部分场景是前端拉不起支付。这里我把实际排查经验整理成清单:
- timeStamp必须是字符串。很多语言里时间戳是Long类型,序列化成JSON后变成数字,传给小程序时如果没转成字符串,微信那边解析就出错。前后端约定好:timeStamp统一用String类型。
- package参数必须是
prepay_id=xxx。有人只传了prepay_id后面的那一串,没有前面的前缀,必然报错。 - 前端传入的timeStamp、nonceStr必须和算paySign时用的一致。你先传给前端一个
nonceStr,再算签名时又生成了一个新的,当然对不上。 - 二次签名里的appId要用小程序的AppID,不要用商户号绑定的公众号AppID。这个错误在同时运营公众号和小程序的团队里特别常见。
- signType要对上。如果用MD5签名,paySign的算法就是MD5,可有人前端写
signType: 'RSA',后端却按MD5计算,一样过不了。 - 不要用前端本机时间。用户手机时间如果慢了5分钟,签名的时间戳和微信服务器时间差太大,会直接被拒。正确做法是后端取系统时间返回给前端。
3.3 其他高频错误速查表
| 报错信息 | 常见原因 | 解决办法 |
|---|---|---|
| 商户号与AppID不匹配 | 商户号和小程序/公众号未绑定 | 在商户平台产品中心完成AppID授权关联 |
| 无效的IP | 请求IP不在白名单 | 商户平台-API安全-配置IP白名单 |
| 订单号重复 | out_trade_no已存在 | 每个订单号全局唯一,加上时间戳或随机数 |
| 证书序列号错误 | 请求头里的serial_no有误 | 检查商户API证书序列号是否为当前证书的 |
| 金额不匹配 | 支付金额与下单金额不一致 | 回调里比对total_fee和业务订单金额,不符则告警 |
| 签名错误(回调验签) | 回调数据被篡改或参数拼错 | 用回调里除sign外的参数重新计算签名比对 |
注意:微信回调通知有验签失败的返回,如果你的接口返回了非200状态码,微信会认为是通知失败,持续重试。所以一定要保证回调接口返回
{"code":"SUCCESS"}或XML形式的成功标识。
4. 小程序里能不能接支付宝?多渠道支付架构怎么设计
4.1 先说结论:微信小程序内不建议直接接支付宝
很多商家都问过“微信小程序可以加入支付宝支付渠道吗”,这个问题的答案是在微信小程序的环境里,直接调起支付宝是行不通的。原因不复杂:微信小程序的运行环境不允许唤起支付宝App,也无法使用支付宝SDK,平台规则本身也限制小程序内使用其他支付方式。
但这不代表你的业务不能同时支持支付宝。合理的做法是分端设计:小程序端用微信支付,APP或H5端同时接微信和支付宝,服务端用一套订单系统统一管理。
还有的团队试图在小程序的web-view组件里加载H5页面,通过H5跳支付宝。这个方案体验很差,而且很容易被微信限制访问,不建议作为正式路径。
4.2 服务端支付渠道抽象层设计
既然要同时支持微信和支付宝,服务端就不能把支付代码写死成一家。我常用的思路是做一个支付渠道抽象层,核心是这四个部分:
第一,定义一个统一的支付渠道接口:
public interface PaymentChannel { // 创建支付单 PayOrderResult createPayOrder(PayRequest request); // 查询订单 PayQueryResult queryOrder(String outTradeNo); // 发起退款 RefundResult refund(RefundRequest request); }第二,每种渠道各自实现这个接口。微信渠道走微信支付的统一下单和回调验签,支付宝渠道走支付宝的alipay.trade.create和异步通知。
第三,用一个工厂类根据channel参数返回对应实现:
public PaymentChannel getChannel(String channel) { if ("wechat".equals(channel)) return wechatPaymentChannel; if ("alipay".equals(channel)) return alipayPaymentChannel; throw new UnsupportedOperationException("unsupported channel: " + channel); }第四,回调入口统一,但验签逻辑各走各的。微信回调验微信的签名,支付宝回调验支付宝的签名;验签通过后,根据outTradeNo把订单状态更新为已支付。这样前端无论从哪个端发起支付,落到服务端都是同一套订单状态流转。
这个设计的好处是,以后要新增渠道(比如云闪付、Apple Pay),只需加一个实现类,不影响现有逻辑。
4.3 小程序端和APP端的落地差异
小程序端由于只能用微支付,前端逻辑最简单:调用后端拿到paySign,再调wx.requestPayment即可。APP端则要判断用户设备上装了微信还是支付宝,优先调起用户最常用的支付App;如果都没装,要提示用户去下载,或者切换到H5收银台。
还有一个体验细节:APP端如果接的是原生SDK,需要在微信开放平台创建移动应用并配置应用签名,同时在支付宝开放平台配置APPID和密钥。两边的签名、证书体系完全不同,服务端做渠道抽象后,这一坨配置差异都被隔离在实现类里了。
4.4 对账和退款也要双渠道统一
支付不只是“收到钱”就完了,还有退款和对账。这一块更需要抽象设计。
退款接口在抽象层里已经定义,微信退款走/v3/refund/domestic/refunds,支付宝退款走alipay.trade.refund,但业务侧只需要传订单号、退款金额、退款原因,不用关心具体渠道。
对账就更有意思了。微信每天会给你一个对账单文件,支付宝也有对应的账单,但格式天差地别。我在项目里会把两份账单都拉下来,统一转成内部标准结构,然后和本地订单表逐笔核对。对账发现不平的情况,基本都是退款状态没同步、回调丢失或者支付成功但本地订单没更新导致的——这个环节最能暴露系统的隐藏bug。
5. 实战中的资金安全与体验优化
5.1 退款接口的安全细节
退款的接口比下单更敏感,尤其是退款申请需要保证幂等。原因很简单:你调用退款接口时如果网络超时,通常会重试;但如果第一次其实已经退款成功了,第二次重试又发起一次退款,就会出现重复退款。微信的out_refund_no机制本身就是幂等的,你只要保证同一个退款单号只对应一次退款请求就行。
退款还需要证书支持,APIv2是下载商户证书文件,APIv3也一样。所以很多初学项目把退款写成接口调试工具里手工触发,这也是不对的——你应该把退款能力做成后端服务,触发后记录退款流水,再查询微信退款结果更新状态。
5.2 支付核心链路必须做的三件事
做支付业务,时刻记住三件事:记录日志、核对金额、保证幂等。
日志方面,每笔下单、回调、退款都要有完整链路日志,至少包含订单号、金额、渠道、时间、来源IP、关键参数。出了问题能立刻定位。
金额核对不能只看数值相等,还要注意精度。微信的单位是分,支付宝的金额单位是元,而且可能有小数。内部统一用分做存储和比较,避免浮点误差。我在代码里见过不少因为“元转分”时用了float导致相差0.01的bug,正确做法是使用整数运算或Decimal。
幂等性主要在回调处理和退款重试两个入口。回调处理之前讲了,退款重试也需要用退款单号去查询是否已退款成功,避免重复打款。
5.3 移动端拉起支付的体验细节
真实项目里,用户最烦的几种支付体验问题,排名靠前的是这三个:
- 支付成功但页面一直处于“处理中”。
- 支付失败后找不到退款入口。
- 在弱网环境下重复点击支付按钮结果下了两个单。
第一个问题的根源往往是回调没到或者处理失败。前端轮询支付结果时,后端不要只查微信订单状态,也要查本地订单状态;本地订单没更新,说明回调处理有问题,光查微信状态容易误导。
第二个问题需要提供“取消订单并退款”的能力。用户支付了但业务没完成,运营能在后台看到差异并手动退,这是底线。
第三个问题要在下单入口做防重。比如前端按钮置灰、后端在短时间内拒绝相同用户ID和商品ID的重复下单请求。这个在后端用Redis加个短时分布式锁就能解决。
5.4 一套能直接抄的低成本监控方案
Snail,我简单说说我用的低成本监控。不整复杂系统,一个PaymentMonitorJob定时扫库就够了:找出“创建时间超过10分钟但状态仍是待支付”的订单,标记为支付超时;找出“用户已支付且微信侧已支付但本地订单未更新”的异常订单,自动告警。
告警渠道我习惯用企业微信机器人或者钉钉机器人,推一条带订单号的文本消息。这个监控配上回调日志,基本能覆盖99%的线上支付问题。
6. 绕开那些坑之后,我的一些经验
对接微信支付这件事,说难也难,说简单其实也简单。难在它牵扯签名、证书、回调、对账这些细节,任何一个点出错都会卡很久;简单在于它的流程是固定模板,你只要把签名做对、回调处理好、订单状态机理清楚,后面所有渠道都是套同样的壳。
我个人觉得最有价值的习惯是:第一次对接时把它当核心系统对待,不要用跑通流程的心态写代码。我见过太多项目在demo阶段没做幂等,上线后财务对账出问题,再回头补,代价大得多。
还有一个小技巧:调试支付时,把微信支付商户平台、APIv2和APIv3的文档地址都收藏了,每个接口的请求参数都先在文档里核对一遍再写代码,不要凭记忆。我身边不少同事“凭记忆写”最后发现少了某个必填字段,白白浪费一天。
如果你正在开发类似项目,建议优先做这几件事:先把统一下单-回调-查询这条主链路跑通,再做退款-对账,最后做多渠道抽象。主链路稳定了再扩展,问题排查起来才不心累。