做了好几年微信生态开发,微信小程序支付和微信浏览器支付这两个词几乎每次做商城类项目都会被一起提出来。我自己的体会是,大部分新手踩坑不是因为代码写错,而是压根没搞清楚这两者到底是不是同一个东西。
先说结论:小程序支付和微信内置浏览器里的网页支付,本质都叫 JSAPI 支付,走的是同一套下单逻辑;而外部手机浏览器里的支付,才是真正独立的 H5 支付。三种场景的调起方式、参数要件、域名配置各不相同,把它们当成一个东西来开发,轻则控制台报 signature 错误,重则线上订单全部卡死。
这篇文章我打算把三个场景完整拆一遍,从商户号、证书、APIv3 密钥这些前置配置,到统一下单、前端调起、回调验签,最后落到老生常谈的签名错误、回调丢单排查,顺带聊聊 uniapp 多端支付和支付网关设计。无论你是刚接手支付功能的后端,还是被"微信公众号里付不了款"折腾的前端,都可以直接对照着排查。
1. 微信小程序支付和微信浏览器支付,先分清这三件事
1.1 小程序支付和公众号网页支付,其实都是 JSAPI
微信支付的官方文档里,有一个接口叫"JSAPI 支付",它的定位是:在微信客户端内的支付场景。它覆盖了两大块:
- 微信小程序内,通过
wx.requestPayment调起收银台; - 微信公众号(服务号)内的 H5 页面,通过微信 JS-SDK 的
chooseWXPay(旧版是WeixinJSBridge.invoke('getBrandWCPayRequest'))调起收银台。
这两块的共同点是:必须拿到用户的 openid。小程序里可以用wx.login换取 openid,公众号网页里要通过 OAuth2 网页授权获取 openid。下单接口同样是/v3/pay/transactions/jsapi(APIv3)或/pay/unifiedorder(APIv2),返回的prepay_id是前端调起支付的唯一凭证。
很多第一次接触支付的同学会困惑:"我小程序里都拿到 code 了,为什么还要传 openid?"原因很简单,微信支付是按用户维度结算和风控的,服务端需要知道这单是哪个人付的钱,所以下单请求里payer.openid是必填项。
1.2 外部浏览器里的 H5 支付,才是真正独立的场景
当用户用Safari、Chrome、华为浏览器等手机浏览器打开你的商城网页时,微信支付提供了另一种方案,叫H5 支付(也常被叫 MWEB 支付)。它的核心特征是:
- 不需要 openid;
- 不需要用户登录微信授权;
- 下单接口是
/v3/pay/transactions/h5(APIv3),返回一个h5_url; - 网页跳转到
h5_url后,会拉起微信客户端收银台完成支付。
从流程上看,H5 支付更像"把人带到微信里付钱,再跳回来"。它必须满足两个硬性条件:商户号开通 H5 支付权限,并且支付域名已完成 ICP 备案。很多朋友遇到"外部浏览器打开网页点支付没反应",十有八九是域名没有在商户平台配置成 H5 支付域名。
我把三个场景的关键差异整理成表格,方便你对照:
| 场景 | 接口 | 核心前置条件 | 调起方式 | 是否需要openid |
|---|---|---|---|---|
| 微信小程序支付 | /v3/pay/transactions/jsapi | 小程序 AppID 已绑定商户号 | wx.requestPayment | 必须 |
| 微信内置浏览器网页支付(公众号支付) | /v3/pay/transactions/jsapi | 服务号 AppID、JS接口安全域名、OAuth2授权 | wx.chooseWXPay/WeixinJSBridge | 必须 |
| 外部浏览器 H5 支付 | /v3/pay/transactions/h5 | H5支付域名(ICP备案) | 跳转h5_url | 不需要 |
记住这张表,后面所有代码和配置都不会跑偏。
2. 支付接入前,先把四把钥匙配齐
2.1 商户号、AppID、APIv3 和证书各管什么事
微信支付(APIv3)的接入体系里,有几个概念特别容易混淆,我见过不少人在这一步就开始懵:
- 商户号(mchid):你在微信商户平台申请的"收款账户",所有资金都进这个账户,所有接口请求都带着它。
- AppID:小程序的 AppID 或者公众号(服务号)的 AppID。它代表"谁在收款",商户号和 AppID 必须完成关联绑定,否则下单接口会直接报
APPID_MCHID_NOT_MATCH。 - APIv3 密钥:一串 32 字符的密钥,主要用来解密回调通知里的敏感数据(AES-256-GCM 解密)。它不是签名密钥,千万别拿它去做 RSA 签名。
- 商户 API 证书:包含
apiclient_cert.pem(证书)和apiclient_key.pem(私钥)。商户发请求时的签名用的是这把私钥。 - 微信支付平台证书:用于验证微信发给你的回调通知的签名。
我举个例子帮你理解:商户 API 证书相当于你家的门禁卡,你进门(发请求)时要用它证明身份;微信支付平台证书相当于物业的工牌,物业来敲门(发回调)时你要看它的工牌确认身份;APIv3 密钥则相当于家里保险柜的密码,物业送来的文件是加密的,得用保险柜密码才能打开看。
很多"用户态签名 signature 错误"的排查到最后,发现是配置层面搞混了:有人把 APIv3 密钥填到了私钥的位置,有人把证书序列号填成了商户号。这些错误在签名计算时必然失败。
2.2 申请流程与配置易错点
规范流程大概是:
- 用营业执照申请微信支付商户号,完成对公账户验证;
- 在商户平台"产品中心"开通对应的支付产品(JSAPI 支付 / H5 支付);
- 在"APPID 账号管理"里关联小程序或公众号 AppID;
- 在"账户中心 - API 安全"里设置 APIv3 密钥、申请 API 证书、下载微信支付平台证书;
- 在"开发配置"里配置支付目录和 H5 支付域名。
这套流程里我踩过的坑是:API 证书有效期只有一年,到期后没有及时续期,线上接口突然全部签名失败。虽然日志里错误很明显(证书过期),但当时第一反应是查代码,绕了很大一圈才发现是证书过期。所以建议在日历上设立证书到期提醒,或者做一个每周定时任务去检查证书有效期。
另外还有一个易错点:APIv3 密钥一旦设置,微信不会明文展示,只能重置。如果项目组多人协作,一定要把密钥放到统一的配置中心或环境变量里,不要写在代码仓库,否则离职交接时你根本拿不回密钥,只能重置并同步更新所有环境。
下面是我比较推荐的配置清单:
| 配置项 | 获取位置 | 用途 | 易错点 |
|---|---|---|---|
| mchid(商户号) | 商户平台首页 | 所有接口必传 | 填成 AppID |
| AppID | 小程序/公众号后台 | 标识应用主体 | 和小程序 AppID 混淆 |
| APIv3 密钥 | 商户平台自主设置 | AES-256-GCM 解密回调 | 误用于签名 |
| 商户API证书私钥 | API安全-证书管理 | RSA-SHA256 请求签名 | 泄露或过期 |
| 平台证书序列号 | API安全-平台证书 | 回调验签 | 与商户证书混淆 |
| 支付目录/H5域名 | 产品中心-开发配置 | 限制支付发起来源 | 漏配、配错域名 |
3. 微信小程序支付完整落地:从下单到回调
3.1 后端调 JSAPI 下单接口
小程序支付的前端其实非常"轻",真正复杂的是后端。后端核心工作只有一个:调微信支付的统一下单接口,拿到prepay_id。
APIv3 的下单接口地址是:
POST https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi请求体大概是这样的(以 PHP 数组转 JSON 为例):
{ "appid": "wx1234567890abcdef", "mchid": "1900001234", "description": "测试商品-微信小程序支付", "out_trade_no": "20250601123000123", "notify_url": "https://api.example.com/wechat/pay/notify", "amount": { "total": 100, "currency": "CNY" }, "payer": { "openid": "oUpF8uMuAJO_M2pxb1Q9zNjWeS6o" } }这里有个很多新手忽略的重点:金额单位是分,而且是整数。total: 100代表 1 元,不是 0.1 元。如果前端传的是元,后端没有乘以 100,用户付 1 元商品,微信会扣 1 分钱,这种线上事故非常尴尬。
我在开发时一般会在下单接口做一道"金额格式校验":
// 前端传的金额单位是元(最多两位小数),后端统一转分 $total = (int) round((float) $amount * 100); if ($total <= 0 || $total > 1000000000) { throw new \Exception('金额不合法'); }请求而不只是 JSON 对了就行,APIv3 要求每个请求都要在 Header 里带上签名。这是整个接入流程里最容易让人崩溃的部分。签名规范如下:
- 构造签名串,格式是:
{HTTP请求方法}\n{URL}\n{请求时间戳}\n{请求随机串}\n{请求报文主体}\n以我们上面这个 JSAPI 下单请求为例,它实际是这样:
POST /v3/pay/transactions/jsapi 1727088000 5K8264ILTKCH16CQ2502SI8ZNMTM67VS {"appid":"wx1234567890abcdef","mchid":"1900001234",...}- 用商户 API 私钥对这个签名串做 SHA256withRSA 签名,结果 Base64 编码;
- 组装
Authorization请求头:
WECHATPAY2-SHA256-RSA2048 mchid="1900001234",nonce_str="5K8264ILTKCH16CQ2502SI8ZNMTM67VS",signature="BASE64签名串",timestamp="1727088000",serial_no="1000AABBCCDD"这里面的serial_no是商户 API 证书的序列号,不是商户号!我见过不少人把mchid当serial_no传,微信直接就报签名错误。
我的 PHP 签名函数一般长这样:
private function sign(string $message, string $privateKeyPath): string { $privateKey = openssl_pkey_get_private(file_get_contents($privateKeyPath)); if (!$privateKey) { throw new \RuntimeException('私钥读取失败'); } openssl_sign($message, $signature, $privateKey, OPENSSL_ALGO_SHA256); return base64_encode($signature); } private function buildAuthorization(string $method, string $urlPath, string $body): string { $timestamp = time(); $nonce = bin2hex(random_bytes(16)); $message = $method . "\n" . $urlPath . "\n" . $timestamp . "\n" . $nonce . "\n" . $body . "\n"; $signature = $this->sign($message, config('pay.private_key_path')); return sprintf( 'WECHATPAY2-SHA256-RSA2048 mchid="%s",nonce_str="%s",signature="%s",timestamp="%d",serial_no="%s"', config('pay.mchid'), $nonce, $signature, $timestamp, config('pay.serial_no') ); }下单成功之后,微信返回的响应里有两个核心字段:prepay_id和expires_in。prepay_id就是一个支付订单的凭证,前端调起收银台全靠它,有效期大概 2 小时。
3.2 前端 wx.requestPayment 调起支付
后端把prepay_id返回给小程序前端之后,前端需要做一件事:用预支付 ID 组装调起参数,并再次签名。
有些团队会把一步直接在后端把paySign也算好,前端只负责wx.requestPayment。前端代码大致是:
wx.requestPayment({ timeStamp: '1727088000', nonceStr: '5K8264ILTKCH16CQ2502SI8ZNMTM67VS', package: 'prepay_id=wx201410272009395522572a690389285100', signType: 'RSA', paySign: '签名值', success: function (res) { // 支付成功,但最终要以服务端回调为准 }, fail: function (err) { // 用户取消或支付失败 } })这里最容易踩的坑是paySign 的签名串与统一下单的签名串不一样。
后端统一下单时签名的是请求报文(POST\n/v3/pay/transactions/jsapi\n...);而在前端调起支付时,paySign 签名的是下面这段字符串(注意末尾的换行):
appId timeStamp nonceStr package signType每行一个参数,共五行,末尾各带一个\n。package的值要写成prepay_id=xxx的完整形式,signType填RSA。很多项目在统一下单签名成功后,直接拿统一下单的签名逻辑去算 paySign,怎么调都提示"支付参数不正确",就是忽略了这两处签名串构造的差异。
小程序端调起支付后,前端的 success 回调只能作为 UI 提示,真正确定订单是否支付成功,必须以服务端收到微信回调通知为准。否则用户可能支付成功了,但因为微信回调延迟或丢失,订单状态还停留在待支付状态。
3.3 notify_url 回调验签与解密
微信支付回调通知是整个流程的"闭环"环节。微信在用户付款成功后,会往你下单时填的notify_url发一个 POST 请求,请求体大概是这样:
{ "id": "EV-20250601123000", "create_time": "2025-06-01T12:30:00+08:00", "resource_type": "encrypt-resource", "event_type": "TRANSACTION.SUCCESS", "resource": { "original_type": "transaction", "algorithm": "AEAD_AES_256_GCM", "ciphertext": "加密后的报文数据", "associated_data": "transaction", "nonce": "C6D2F3D9E5A1B4" } }回调处理必须做两件套:验签和解密。
验签的目的是确认"这个回调真的是微信发来的",不是别人伪造的。微信会把请求签名放在 HTTP 头里,比如 APIv3 下是Wechatpay-Signature、Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Serial。你需要用微信支付平台证书(根据Wechatpay-Serial找到对应证书)去验签。
验签通过后,再用 APIv3 密钥解密密文。AES-256-GCM 解密需要使用ciphertext、nonce、associated_data三个参数,解密出来的才是真正的订单数据:
{ "out_trade_no": "20250601123000123", "transaction_id": "4200001234202506011000001234", "trade_state": "SUCCESS", "amount": { "total": 100, "payer_total": 100, "currency": "CNY" }, "success_time": "2025-06-01T12:30:01+08:00" }我把验签和解密的 PHP 核心代码放在一起:
// 读取请求头和原始body $body = file_get_contents('php://input'); $headers = getallheaders(); $timestamp = $headers['Wechatpay-Timestamp']; $nonce = $headers['Wechatpay-Nonce']; $signature = $headers['Wechatpay-Signature']; $serial = $headers['Wechatpay-Serial']; // 1. 构造验签原文,注意格式:时间戳\n随机串\n请求体\n $message = $timestamp . "\n" . $nonce . "\n" . $body . "\n"; // 2. 用微信支付平台证书公钥验签 $platformCert = file_get_contents(config('pay.platform_cert_path')); $verified = openssl_verify($message, base64_decode($signature), $platformCert, OPENSSL_ALGO_SHA256); if ($verified !== 1) { // 验签失败,直接记录日志并返回非200状态码 http_response_code(401); exit; } // 3. 解密 resource $json = json_decode($body, true); $resource = $json['resource']; $apiV3Key = config('pay.api_v3_key'); $decrypted = openssl_decrypt( base64_decode($resource['ciphertext']), 'aes-256-gcm', $apiV3Key, OPENSSL_RAW_DATA, $resource['nonce'], $resource['associated_data'] ); // $decrypted 就是真实的订单JSON $payResult = json_decode($decrypted, true); // 4. 业务处理:幂等校验 + 更新订单 + 给微信返回成功 if ($payResult['trade_state'] === 'SUCCESS') { // 判断 out_trade_no 是否已处理,防止重复回调 // 更新订单状态、加余额、发货等 } // 5. 返回微信规定格式,response code 200, body: {"code":"SUCCESS","message":"成功"} http_response_code(200); echo '{"code":"SUCCESS","message":"成功"}';很多人处理回调时会犯一个错误:拿到订单后直接改金额。正确做法是拿回调里的amount.total与你自己订单表里的应付金额比对,如果不一致,说明被篡改或者业务异常,必须记录下来人工处理。
3.4 完整接入时的关键顺序
标准的小程序支付流程,用文字描述是这样的:
- 小程序前端调用
wx.login拿 code; - 后端拿 code 换 openid;
- 前端把商品信息发给后端创建订单;
- 后端校验库存和金额,调用 JSAPI 下单接口拿
prepay_id; - 后端计算出
paySign,把timeStamp/nonceStr/package/signType/paySign返回给前端; - 前端调
wx.requestPayment,用户输密码/指纹完成支付; - 微信异步通知后端
notify_url,后端验签、解密、更新订单状态; - 前端在
success回调里提示"支付成功",并向后端查询订单状态刷新页面。
这个顺序里,步骤 6 和步骤 7 在时间上"几乎同时发生",但前端 success 不一定是支付成功的最终依据。我在项目里会额外提供一个"查询订单状态"的接口,前端在支付回调成功后主动轮询一次,避免用户在支付成功后页面还显示待付款。
4. 微信浏览器支付:内置浏览器和外部浏览器两套玩法
4.1 微信内置浏览器里的公众号支付
如果你在微信里打开一个网页(比如公众号菜单里的商城),这个网页要支付,走的还是 JSAPI 支付。它和小程序支付最大的区别是:openid 获取方式不一样。
小程序里获取 openid 靠wx.login+ 后端code2Session;公众号网页里获取 openid 必须走 OAuth2 网页授权:
- 前端跳转到微信授权 URL:
https://open.weixin.qq.com/connect/oauth2/authorize?appid=APPID&redirect_uri=REDIRECT_URI&response_type=code&scope=snsapi_base&state=STATE#wechat_redirect- 授权后回调你的
redirect_uri,带上code; - 后端用
code请求https://api.weixin.qq.com/sns/oauth2/access_token?appid=APPID&secret=SECRET&code=CODE&grant_type=authorization_code,拿到 openid。
拿到 openid 之后,下单逻辑跟小程序完全一致,仍然是/v3/pay/transactions/jsapi。区别在调起支付这一步:公众号网页调起收银台,需要先引入微信 JS-SDK,做wx.config注入配置,然后调用:
wx.chooseWXPay({ timestamp: '1727088000', nonceStr: '5K8264ILTKCH16CQ2502SI8ZNMTM67VS', package: 'prepay_id=wx201410272009395522572a690389285100', signType: 'RSA', paySign: '签名值', success: function (res) {} });这里有两个细节要特别提醒:wx.chooseWXPay参数名是timestamp(小写开头),而小程序wx.requestPayment是timeStamp(驼峰)。如果前后端共用一套参数生成逻辑,很可能会因为键名不同导致调起失败或签名串不一致。
另外,wx.config需要用到公众号的jsapi_ticket,后端要封装一个获取 ticket 并缓存的接口。签名用的nonceStr和timestamp与支付参数无关,是 JS-SDK 自己的签名,很多团队会在这里二次懵。
4.2 外部浏览器里的 H5 支付
如果你的网页被别人在浏览器里打开,比如从百度、QQ、抖音等 App 的浏览器跳过来,这时候就不能用 JSAPI 支付了,必须走 H5 支付。H5 支付的核心接口是:
POST https://api.mch.weixin.qq.com/v3/pay/transactions/h5请求体与 JSAPI 下单类似,但需要额外传scene_info:
{ "appid": "wx1234567890abcdef", "mchid": "1900001234", "description": "测试商品-浏览器支付", "out_trade_no": "20250601123000130", "notify_url": "https://api.example.com/wechat/pay/notify", "amount": { "total": 100, "currency": "CNY" }, "scene_info": { "payer_client_ip": "14.23.150.211", "h5_info": { "type": "Wap", "app_name": "xx商城", "wap_url": "https://mall.example.com" } } }下单成功后返回:
{ "h5_url": "https://wx.tenpay.com/cgi-bin/mmpayweb/...?token=..." }后端拿到h5_url后,前端把这个链接塞进location.href或window.location.replace(),用户会先跳到一个中间页,然后自动拉起微信 App 收银台。支付完成后会重新跳回你的网页(通过redirect_url参数控制,需要在 H5 支付配置里设置)。
我特别想提醒:H5 支付的h5_url不能在微信内置浏览器里直接跳转。如果你把 H5 支付的链接发给用户,用户在微信里点开,微信会拦截并提示"请在外部浏览器打开"。因为微信不希望用户在微信内部用 H5 拉起再跳转,那样会绕开 JSAPI 的授权流程。所以在做 H5 支付前,服务端要注意判断来源;如果检测到MicroMessenger的 UA,就引导用户使用公众号支付或复制链接到外部浏览器。
4.3 域名备案、UA 判定与常见配置
H5 支付开通后,必须在商户平台"产品中心 - H5支付 - 开发配置"里添加H5 支付域名,这个域名必须已完成 ICP 备案,且是当前下单请求的 referer 域名。我在实践中的经验是,平台对域名的校验非常严格,如果请求里的Referer域名和配置不一致,会直接报H5_REFERER_NOT_ALLOWED。
开发调试时怎么判断当前页面是不是在微信内置浏览器?前端可以这样检测 UA:
function isWechatBrowser() { var ua = navigator.userAgent.toLowerCase(); return ua.indexOf('micromessenger') !== -1; }但要注意:UA 只是浏览器自报家门,可以被修改。服务端判断是否来自微信内置浏览器,不能只信 UA。比如做支付时,我更建议用微信提供的JS-SDK能力探测,或者在后端维护一份"可信 UA 特征 + 来源 IP + 请求路径"的综合判断。网上有些人讨论通过改 UA 来让网页误以为自己在微信里,这种方法在支付场景下不推荐,也不可靠——微信支付的风控会校验 referer、订单行为、支付环境等多维数据,伪装 UA 很容易被判定为风险交易,反而影响正常用户。
5. 常见问题与排查技巧实录
5.1 用户态签名 signature 错误
这个报错是微信支付开发中出镜率最高的问题,没有之一。它本质是"微信验签没过",常见原因有三个:
原因一:签名串构造错误。少了换行符、多了空格、URL 写了带域名的完整地址而要求只写路径,都会导致签名不一致。正确格式我再强调一遍:方法\nURL路径\n时间戳\n随机串\n请求体\n,每个\n都不能少。
原因二:使用的密钥不对。请求签名必须用商户 API 证书私钥(apiclient_key.pem),不是 APIv3 密钥。如果你误把 APIv3 密钥当成签名私钥,生成的签名必然验签失败。原因三:时间戳偏差。微信要求请求时间戳与服务器时间偏差不能超过 5 分钟,如果你服务器时间不准(比如云主机时区没设对),也会报签名错误。
排查时可以先把签名串打印出来,放到微信官方提供的"签名校验工具"里比对,一般很快能定位是哪一环错了。
5.2 支付目录、域名白名单导致调不起
小程序支付常见的报错是"url not in domain list",这表示小程序后台的 request 合法域名没有加上你的 API 域名。公众号网页支付常见的报错是"config:invalid signature"或"chooseWXPay:fail",这时候优先检查公众号的JS 接口安全域名是否配置正确。
有一个配置细节:JS 接口安全域名填写的是域名,不需要带https://,也不带路径。比如你接口是https://api.example.com/wechat,那域名配置为api.example.com。我在项目里见过前端把完整 URL 填进去,结果怎么调都无效。
另外,微信开发者工具里的"不校验合法域名"只在开发阶段可用,真机预览时不会生效。测试小程序支付,最好用真机扫码预览,否则你会遇到"开发者工具里一切正常,手机上全部失败"的尴尬。
5.3 回调丢失与订单状态不同步
回调丢失是支付系统里最考验"工程素养"的问题。微信会重试通知,但重试间隔会拉长。如果业务系统没有做好幂等,且回调又偶尔延迟,订单状态就会不一致。
我在项目里的做法是:查询接口兜底。用户支付成功后,前端调用"查询订单状态"接口;如果该接口发现订单还是待支付,后端主动调微信的查单接口(/v3/pay/transactions/out-trade-no/{out_trade_no})去确认最终状态。这套机制能覆盖回调延迟、回调丢失、回调被防火墙拦截等大部分情况。
回调处理还有一个常见坑:服务器防火墙或网关把微信回调 IP 拦截了。腾讯云的服务器默认安全组一般没问题,但如果你自建机房或有严格的白名单策略,一定要把微信支付回调的 IP 段放行。这个问题的典型表现是:订单已经支付,用户也看到扣款了,但系统一直显示待付款。
5.4 金额、openid、iOS 兼容等细节坑
- 金额单位:微信支付所有金额都是"分",用整数表示。如果你用浮点数存金额,
1.1 * 100可能等于110.00000000000001,入库前记得取整。 - openid 不匹配:小程序 AppID 和公众号 AppID 不同,用公众号网页授权拿到的 openid 去小程序下单,会报
payer_openid_error。 - iOS 虚拟商品限制:小程序里卖虚拟商品(会员、课程、道具等),iOS 端不允许使用微信支付,苹果要求走 IAP 内购。如果硬上微信支付,审核会被驳回,严重时小程序会被下架。这块属于平台合规问题,立项阶段就要确认业务属性。
- iOS 网络请求失败率偏高:小程序在 iOS 机型上偶发网络请求失败,根据我看到的分析,有一部分是
wx.request使用了 HTTP/2、IPv6 等特性,老版本微信客户端兼容不好,可以尝试让后端支持 HTTP/1.1 并检查 TLS 配置,同时设置合理的超时重试策略。
下面是一张排查速查表,建议收藏:
| 现象 | 可能原因 | 优先排查项 |
|---|---|---|
| 下单报签名错误 | 签名串错、密钥错、时间偏差 | 签名工具比对签名串 |
| 前端调起支付无反应 | 参数键名错、paySign算错 | 核对 timeStamp/timestamp |
| 小程序报 url not in domain | 域名白名单没配置 | 小程序后台 request 合法域名 |
| 公众号 config invalid signature | JS接口安全域名或jsapi_ticket错误 | 检查域名配置与ticket缓存 |
| H5 支付被拦截 | 域名未备案/未配置/H5场景限制 | 商户平台H5支付域名配置 |
| 回调收不到 | 防火墙拦截、回调地址不可外网访问 | 先模拟POST到notify_url |
6. 多端支付与支付网关设计的一点思路
6.1 uniapp / App / 小程序 / H5 支付参数为什么不能通用
很多用 uniapp 开发的朋友会问:"我同一套代码,打包成 App、H5 和小程序,支付流程和参数是否相同?"答案很明确:不相同。
uniapp 里uni.requestPayment确实做了一层抽象,但它内部实现取决于编译到哪个平台:
- 编译到微信小程序时,它调用的是
wx.requestPayment,参数就是上面说的timeStamp/nonceStr/package/paySign; - 编译到 App 时,它走的是 App 支付 SDK,比如微信 App 支付或支付宝 App 支付,参数是
provider/orderInfo,需要后端生成 App 端的订单串; - 编译到 H5 时,如果是微信浏览器,走 JSAPI;如果是外部浏览器,走 H5 支付。
所以你在后端设计接口时,不能让前端把支付参数写死,而是要根据端类型返回不同的支付参数结构。这也是为什么前端抱怨"为什么我 H5 端和 App 端支付代码不能复用"的原因——底层协议完全不同。
6.2 微信支付与支付宝支付的抽象对齐
很多项目不止接入微信支付,还要接入支付宝支付。支付宝的接口风格和微信差别很大:支付宝用的是app_id + sign_type + charset + timestamp + version + method + biz_content这种"公共参数 + 业务参数"的形式,签名是 RSA2(SHA256withRSA);微信 APIv3 则是 restful 风格 + Authorization 头签名。它们不是同一个套路,但你可以在业务层做对齐。
比如定义一个统一的"创建支付订单"接口:
$request = [ 'channel' => 'wx_jsapi', // 渠道标识:wx_jsapi / wx_h5 / alipay_wap 'order_no' => '20250601123000123', 'amount' => 100, 'subject' => 'xx商品', 'openid' => 'oUpF8uMuAJO_M2pxb1Q9zNjWeS6o' // 可能为空 ];后端根据channel分发给不同的渠道适配器,每个适配器输出统一的响应结构:pay_params(前端支付参数)、pay_url(跳转链接)、prepay_id等。
6.3 一个简单支付网关的路由设计
如果你的项目以后要扩展多个支付渠道,我建议从第一天就把支付逻辑抽象成网关。一个最小的支付网关至少有四个核心能力:下单(pay)、回调(notify)、查单(query)、退款(refund)。
我简单画一下路由的表结构设计(不是让你照抄,而是提供思路):
| 能力 | 统一接口 | 内部派发 |
|---|---|---|
| 下单 | /api/pay/create | 根据 channel 调微信JSAPI / 微信H5 / 支付宝 |
| 回调 | /api/pay/notify/{channel} | 验签后转发给业务处理器 |
| 查单 | /api/pay/query | 分别调微信查单、支付宝查单 |
| 退款 | /api/pay/refund | 分别调微信退款、支付宝退款 |
这个抽象带来的好处是:业务方永远只对着你自己的四个接口,不需要关心微信和支付宝的签名差异、接口差异。每次新渠道接入,只需要新增一个 adapter,业务代码不动。
我在实际项目里还加了一层"支付渠道配置表",把商户号、AppID、证书路径、密钥都存成配置,方便多商户场景下动态切换。常见的小程序知识付费平台,就是靠这张配置表去支持"平台方小程序里一个个子商户"的支付能力——虽然那会涉及分账,但配置思路是通用的。
我个人做了多年代码后最大的体会是:支付接入难不在"调通",而在"想清楚"。你愿意花半小时把场景归类清楚、把签名原理弄明白,后面调接口就快得多。上面这些坑我基本都踩过一遍,把它们写下来,是希望你在做微信小程序支付和微信浏览器支付的时候少走几步冤枉路。真要再遇到诡异的签名问题,先别急着改代码,把签名串打印出来,不信你对不出来。