H5 页面在微信浏览器里调起微信支付,这个需求听起来就是“一个按钮加一笔订单”,但真正动手的时候,JSAPI、openid、prepay_id、paySign、回调验签、平台证书这些词会一个个冒出来把你绕晕。这篇博文我用一份完整的 demo,把微信内置浏览器里 H5 支付从授权到回调的整条链路拆开讲清楚,包含可以直接抄的后端 Node.js 代码和前端页面代码,同时把每个环节为什么要这么做也一并说明。适合第一次接微信支付的前后端同学,也适合想弄清楚某些诡异报错的老手。
1. 先搞清楚:微信里的 H5 支付到底走的哪条链路
1.1 JSAPI 支付和 H5 支付别搞混
很多人一搜“H5 支付”就去翻微信支付官方文档里的“H5 支付”章节,结果发现流程完全对不上,这是最常见的第一坑。微信支付官方文档里的“H5 支付”,指的是用户在手机浏览器(比如 Safari、Chrome,但不是微信内置浏览器)里访问网页时发起的支付,流程是后端下单后返回一个mweb_url,前端跳转到这个链接去完成付款。
而我们标题里说的“H5 页面在微信浏览器里调用微信支付”,走的是另一条路,叫JSAPI 支付,也叫公众号支付。它的特点是通过微信内置浏览器提供的 JSBridge 直接拉起微信收银台,用户不离开微信就能完成支付。微信浏览器里用 H5 支付反而会被官方限制,体验也不对。
所以接到这类需求,第一步不是写代码,是先确认页面跑在什么环境里:
| 页面运行环境 | 使用的支付方式 | 关键标识 |
|---|---|---|
| 微信内置浏览器 | JSAPI 支付(公众号支付) | 需要 openid,拉起原生收银台 |
| 微信外的手机浏览器 | H5 支付 | 返回 mweb_url 跳转 |
| 小程序内嵌 web-view | JSAPI 支付变体 | 要结合小程序支付参数 |
本文 demo 以 JSAPI 支付为准,这也是微信里 H5 页面最主流的支付方案。如果你确认产品场景主要就是“用户在微信里打开网页、下单、付款”,那就盯准这条线。
1.2 一次完整支付的链路梳理
整个流程可以分成 9 个关键节点,任何一个断了都付不了款:
- 用户在微信里打开 H5 页面。
- 页面先判断是不是微信浏览器,如果是,就触发网页授权,微信会跳转到一个授权页或直接静默跳回。
- 微信携带
code跳回你的回调地址,后端拿着 code 去微信接口换openid。 - 用户点击“立即支付”,前端把订单信息发给后端。
- 后端带着 openid 调用微信支付 JSAPI 下单接口,拿到
prepay_id。 - 后端用
prepay_id生成前端拉起支付所需的参数,包括paySign。 - 前端调用
WeixinJSBridge.invoke或wx.chooseWXPay,拉起微信收银台。 - 用户完成支付后,微信服务器异步通知你配置的
notify_url回调地址。 - 后端验签、解密回调数据,更新订单状态,前端再根据业务轮询或跳转进入结果页。
这里面最容易理解错的是第 8 步。很多人以为前端收到支付成功回调就算完事,实际上前端支付回调只是“用户操作反馈”,真正能信的是微信服务器异步通知你后端的那条回调。我们做支付对账、订单状态流转,必须以这个回调为准。
2. 动手前先把账号和开发环境备好
2.1 商户号、AppID、APIv3 密钥和证书
跑通这个 demo 需要先有微信支付商户号和对应的公众号。公众号要服务号且完成认证,商户号要完成微信支付开通,然后和这个公众号绑定关联。关联以后你会用到几个关键凭证:
- AppID:公众号的唯一标识,在公众号后台“设置与开发”里能看到。
- 商户号 mchid:在商户平台首页能看到,一串数字,也就是微信支付商户号。
- APIv3 密钥:在商户平台“账户中心 -> API安全”里自己设置的 32 位字符串,用于回调数据解密等场景,别泄露。
- 商户 API 证书:在商户平台申请,会生成
apiclient_cert.pem(证书)和apiclient_key.pem(私钥)。请求微信支付接口时要用商户私钥做签名。 - 证书序列号:在证书详情里能看到,也用于请求头标识。
注意私钥文件apiclient_key.pem是敏感文件,只能放在后端服务器上,绝不能暴露到前端页面或者上传到公开代码仓库。很多项目出安全事故就是私钥泄露导致别人能伪造支付请求。
2.2 域名配置:授权域名、JS安全域名、回调地址
代码没开始写,先要配好三个地方:
- 网页授权域名:在公众号后台“接口权限 -> 网页授权”里设置。这里只填域名,比如
yourdomain.com,不填路径。 - JS 接口安全域名:如果前端要用
wx.chooseWXPay,需要把页面域名配进“JS 接口安全域名”。 - JSAPI 支付授权目录:在商户平台的产品中心里配置,比如
https://yourdomain.com/。如果页面 URL 不在授权目录下,拉起支付时会报“当前页面 URL 未注册”。
这三个域名配置是初学者踩坑重灾区。经常出现授权回调正常、下单也正常,但最后拉起支付时白屏或者报错,一查才发现是支付授权目录没配。建议动手前就把域名确认好,开发阶段用测试域名,上线前再改成正式域名。
2.3 本地开发如何调试
微信支付回调要求notify_url必须能被微信服务器公网访问到。本地开发的服务器通常在内网,微信服务器访问不了,这时候可以用内网穿透工具(比如 ngrok、cpolar)把本地端口映射到一个公网域名。我平时开发是本地起 Node 服务,然后开一条穿透隧道,把回调地址临时指向这个公网域名,测试完再关掉。
页面调试方面,微信浏览器里的页面可以用以下方式:
- 微信开发者工具:可以模拟微信浏览器的 UA 和一些 API,但支付按钮的完整弹出不一定模拟得了,很多情况还是需要真机。
- Chrome 自定义 UA:在 DevTools 的设备模拟里,把 User-Agent 改成微信内置浏览器的 UA。这样可以绕过一些“请在微信客户端打开”的限制,方便调试页面样式和接口,但真正拉起支付还是要回真机。
- vConsole:在页面引入 vConsole,能在微信里直接看 console 日志和网络请求,排查前端问题非常管用。
3. 拿 openid:网页授权这一步卡住很多人
3.1 授权 URL 怎么拼
JSAPI 支付的payer.openid字段直接决定了下单能不能成功,而这个 openid 必须是通过网页授权拿到的,不能随便从第三方接口拿一个。为什么?因为微信要根据 openid 判断当前用户是不是在对应 AppID 下完成了支付授权,拿错了不可信。
网页授权分两种:
snsapi_base:静默授权,用户无感知,能拿到 openid。snsapi_userinfo:用户需要手动点授权,除了 openid 还能拿昵称、头像。
做支付场景,绝大多数只用snsapi_base就够了,因为只要 openid。用户没有感知,体验最好。而且要在支付前最少步骤拿到 openid,否则用户点支付才发现要授权,流程就断了。
授权 URL 拼法如下:
https://open.weixin.qq.com/connect/oauth2/authorize?appid=APPID&redirect_uri=ENCODED_URL&response_type=code&scope=snsapi_base&state=STATE#wechat_redirect注意redirect_uri必须经过 URL 编码,并且这个地址的域名必须和你在公众号后台配置的“网页授权域名”一致。state参数可以放你自己的标识,微信回调时会原样带回来,用来防跨站请求伪造。
前端写起来大概是这样的跳转:
const appid = '你的AppID'; const redirectUri = encodeURIComponent('https://yourdomain.com/api/auth/callback'); window.location.href = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${appid}&redirect_uri=${redirectUri}&response_type=code&scope=snsapi_base&state=test#wechat_redirect`;3.2 code 换 openid 的后端实现
微信跳回你的回调地址时,会在 URL 上带上code参数,这个 code 只能用一次,有效期大约 5 分钟。后端拿着 code 去换 openid。
const axios = require('axios'); const appid = '你的AppID'; const secret = '你的公众号AppSecret'; async function code2openid(code) { const url = `https://api.weixin.qq.com/sns/oauth2/access_token?appid=${appid}&secret=${secret}&code=${code}&grant_type=authorization_code`; const { data } = await axios.get(url); if (data.errcode) { throw new Error(`换取 openid 失败: ${data.errcode} ${data.errmsg}`); } return data.openid; }拿到 openid 后,把这个用户身份记到你的登录态里,比如写入 session 或者签发你自己的 token。之后的支付接口里需要带着这个 openid 一起过来。这里再强调一次:openid 必须和当前页面的 AppID 对应,也就是这个用户是在你这个公众号下打开页面的,换出来的 openid 才有效。如果你拿的是别的地方的 openid 来下单,微信会直接报“payer.openid 与 appid 不匹配”。
4. 后端下单与支付参数签名
4.1 v3 JSAPI 下单请求
现在新接入微信支付,官方主推的是APIv3接口。相比老的 v2 接口,v3 用 JSON 格式替代 XML,签名机制也改成了更安全的 RSA 签名。老接口已经进入下线周期,新项目建议直接上 v3。
下单接口是:
POST https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi这个请求头里必须带上微信支付要求的Authorization头,里面包含商户号、随机串、时间戳、证书序列号和签名。签名的目的是让微信确认请求确实来自你这个商户,防止别人伪造订单。我手写了一个简化版请求函数,方便你理解整个签名过程:
const crypto = require('crypto'); const fs = require('fs'); const axios = require('axios'); const mchid = '你的商户号'; const appid = '你的AppID'; const serialNo = '你的商户API证书序列号'; const privateKey = fs.readFileSync('apiclient_key.pem'); // 商户私钥 function buildAuthHeader(method, url, bodyStr) { const timestamp = Math.floor(Date.now() / 1000); const nonceStr = crypto.randomBytes(16).toString('hex'); const bodyHash = bodyStr ? crypto.createHash('sha256').update(bodyStr).digest('hex') : ''; const message = `${method}\n${url}\n${timestamp}\n${nonceStr}\n${bodyHash}\n`; const signature = crypto.createSign('RSA-SHA256').update(message).sign(privateKey, 'base64'); return `WECHATPAY2-SHA256-RSA2048 mchid="${mchid}",nonce_str="${nonceStr}",timestamp="${timestamp}",serial_no="${serialNo}",signature="${signature}"`; } async function requestV3(method, url, body) { const bodyStr = body ? JSON.stringify(body) : ''; const authorization = buildAuthHeader(method, url, bodyStr); const { data } = await axios({ method, url: `https://api.mch.weixin.qq.com${url}`, headers: { 'Authorization': authorization, 'Content-Type': 'application/json', 'Accept': 'application/json', }, data: bodyStr || undefined, }); return data; } async function createJsapiOrder(openid, totalFee) { const outTradeNo = 'DEMO' + Date.now(); const body = { appid, mchid, description: '测试商品', out_trade_no: outTradeNo, notify_url: 'https://yourdomain.com/api/pay/notify', amount: { total: totalFee, // 单位是分 currency: 'CNY' }, payer: { openid } }; const result = await requestV3('POST', '/v3/pay/transactions/jsapi', body); return result.prepay_id; }这里有几个参数要特别注意:
out_trade_no是商户订单号,6 到 32 位,只能包含数字、字母、-和_,同一商户号下不能重复。重复下单会直接报错。total单位是分,不是元。1 元等于 100 分,很多人第一次测试传了1,以为付 1 元,结果是 1 分钱。notify_url必须公网可访问,微信支付服务器会往这个地址发异步通知。
4.2 生成前端支付参数 paySign
拿到prepay_id后还不能直接给前端用,后端需要再生成一组调起支付参数。官方要求用商户私钥对一段固定格式的字符串做签名,生成paySign:
function buildPayParams(prepayId) { const timeStamp = Math.floor(Date.now() / 1000).toString(); const nonceStr = crypto.randomBytes(16).toString('hex'); const packageValue = `prepay_id=${prepayId}`; const message = `${appid}\n${timeStamp}\n${nonceStr}\n${packageValue}\n`; const paySign = crypto.createSign('RSA-SHA256').update(message).sign(privateKey, 'base64'); return { appId: appid, timeStamp, // 注意这里是 timeStamp,驼峰写法 nonceStr, package: packageValue, signType: 'RSA', paySign }; }最后后端返回给前端的就是这组 JSON 参数。前端拿到它,就可以去调用微信的 JSAPI 拉起收银台了。
4.3 签名串为什么要这么拼
你可能好奇,为什么paySign要把appId、timeStamp、nonceStr、package按顺序用换行拼起来?因为微信服务端收到前端调用后,也会用同样的规则拼一段字符串,然后用你的公钥验签。这中间任何字段大小写、顺序、换行符不一致,都会导致验证失败。
这也是我看很多线上问题的重点排查区域:后端生成的参数和前端实际传的参数不一致。比如后端返回timeStamp,前端却传了timestamp;或者package前面漏了prepay_id=前缀。这些都是最典型的报错原因。
4.4 v2 接口为什么不要再用了
老项目里很多代码还是 v2 的unifiedorder接口,用的是 XML 格式,签名用的是 MD5 或者 HMAC-SHA256,通过 API 密钥来签名。这个方案的问题是摘要算法不够强、密钥管理也比较粗糙。微信官方已经把这套老接口逐步下线,新项目如果再按 v2 写,很可能过段时间就不能用了。建议直接按 v3 来做,虽然请求头签名看起来复杂一点,但官方文档和 SDK 支持都更全,后面升级维护也方便。
5. 前端拉起微信支付
5.1 WeixinJSBridge 方式和 JSSDK 方式
微信浏览器里拉起支付,有两种主流写法:
- WeixinJSBridge.invoke('getBrandWCPayRequest', ...):微信内置浏览器天生支持的 bridge,不需要提前加载额外 JS 文件,也不需要配置 JSSDK。写法直接,很多老项目在用。
- wx.chooseWXPay:微信官方 JSSDK 提供的方法,需要先引入
jweixin-1.6.0.js,并且要先执行wx.config注入配置,配置里需要用到jsapi_ticket生成的签名。流程更完整,也是官方推荐的新方式。
实际开发里,如果产品只要求“必须在微信里打开”,用 WeixinJSBridge 方式最省事,少配一个 JSSDK 签名环节。但如果你想统一管理微信前端能力,或者后续还要在页面里调用微信分享、扫一扫这些功能,那用 JSSDK 方式更好,反正wx.config早晚要配。
5.2 一块完整的前端支付 demo
这里给一个能直接跑的前端页面逻辑,两种方式都列出来。页面里有一个支付按钮,点击后先向后端请求支付参数,然后拉起收银台。
<script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script> <script> async function pay() { const res = await fetch('/api/pay/params', { method: 'POST' }); const params = await res.json(); // params 包含 appId, timeStamp, nonceStr, package, signType, paySign // 方案一:WeixinJSBridge 方式 if (typeof WeixinJSBridge !== 'undefined') { invokeBridgePay(params); } else { document.addEventListener('WeixinJSBridgeReady', () => invokeBridgePay(params)); } // 方案二:JSSDK 方式 // 这种方式需要你额外准备 wx.config 的签名参数 if (typeof wx !== 'undefined' && wx.chooseWXPay) { const configRes = await fetch('/api/jssdk/config', { method: 'POST' }); const config = await configRes.json(); wx.config({ debug: false, appId: config.appId, timestamp: config.timestamp, nonceStr: config.nonceStr, signature: config.signature, jsApiList: ['chooseWXPay'] }); wx.ready(() => { wx.chooseWXPay({ timestamp: params.timeStamp, // 注意这里传给 chooseWXPay 的是 timestamp nonceStr: params.nonceStr, package: params.package, signType: params.signType, paySign: params.paySign, success: function (res) { // 用户操作成功,但真正要以后端回调为准 }, fail: function (err) { console.error('支付失败', err); } }); }); } } function invokeBridgePay(params) { WeixinJSBridge.invoke('getBrandWCPayRequest', { appId: params.appId, timeStamp: params.timeStamp, nonceStr: params.nonceStr, package: params.package, signType: params.signType, paySign: params.paySign }, function (res) { if (res.err_msg === 'get_brand_wcpay_request:ok') { // 支付成功,但最终以后端回调为准 } else { console.error('支付失败', res.err_msg); } }); } </script>5.3 前端收到按钮回调后别急着信
注意两处细节:
一是WeixinJSBridge.invoke要在WeixinJSBridgeReady事件后才能调用,不然可能拿到一个未定义对象。我写代码时会判断typeof WeixinJSBridge === 'undefined',如果没准备好就等事件,这个习惯能帮你少踩一个白屏坑。
二是wx.chooseWXPay成功回调里的success,说明的是用户这一侧操作顺利完成,并不代表钱一定到账。真实订单状态必须在后端回调里确认。所以前端回调里不要直接跳转“支付成功页”,最稳妥的做法是拿到结果后,轮询你自己的订单接口,等后端确认后再跳转。不然会出现用户支付成功但页面还停在待支付状态,或者反过来显示支付失败的情况。
6. 支付回调:真正决定订单状态的地方
6.1 回调要求
下单时填的notify_url就是微信异步通知你的地址。用户支付完成后,微信支付服务器会向这个地址发送 POST 请求,请求体是一段 JSON,里面包含id、event_type、resource_type、resource等字段。这个接口需要满足几个硬性要求:
- 必须是公网可以访问的 URL,微信服务器要能连上。
- 建议使用标准的 HTTPS 或 HTTP 端口,回调地址不要带自定义端口。
- 接口处理要快,不要在这个接口里做太多耗时逻辑,比如同步调用外部系统、发大量邮件,这些操作应该丢到消息队列或者异步任务里。
6.2 验签和解密
回调数据默认是加密的,你不能直接读resource里的明文。需要做两步处理:验签和解密。
验签用的不是商户私钥,而是微信支付平台证书的公钥。请求头里会带上Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Signature、Wechatpay-Serial四个字段。验签的签名串格式是:
时间戳\n 随机串\n 请求体原文\n然后用平台证书公钥做 RSA-SHA256 验签。解密则用 APIv3 密钥,按 AES-256-GCM 算法解密resource里的ciphertext。下面是一个参考实现,展示核心逻辑:
const crypto = require('crypto'); const apiV3Key = '你的APIv3密钥,32位字符串'; function verifyWechatSign(timestamp, nonce, body, signature, platformPublicKey) { const message = `${timestamp}\n${nonce}\n${body}\n`; return crypto.createVerify('RSA-SHA256').update(message).verify(platformPublicKey, signature, 'base64'); } function decryptResource(resource) { const { ciphertext, nonce, associated_data } = resource; const buf = Buffer.from(ciphertext, 'base64'); const tag = buf.slice(buf.length - 16); // GCM 认证标签在密文末尾 const data = buf.slice(0, buf.length - 16); const decipher = crypto.createDecipheriv('aes-256-gcm', apiV3Key, nonce); decipher.setAuthTag(tag); if (associated_data) { decipher.setAAD(Buffer.from(associated_data)); } let decoded = decipher.update(data, null, 'utf8'); decoded += decipher.final('utf8'); return JSON.parse(decoded); }解密后的内容里能看到out_trade_no、transaction_id、trade_state、amount.total这些关键字段。拿到trade_state为SUCCESS时,才说明这笔订单真正支付成功了。
关于平台证书,很多新手会报“无可用的平台证书”。这是因为你在代码里没有加载微信支付平台证书,或者加载的证书已经过期。可以通过调用GET /v3/certificates接口下载最新的平台证书,或者直接从商户平台下载后配置到代码里。证书过期、不匹配,验签和解密都会出问题。
6.3 应答与幂等
处理完回调后,你必须给微信服务器一个明确的响应,否则微信会认为通知失败并持续重试。正确应答是返回 HTTP 200,响应体是:
{ "code": "SUCCESS", "message": "成功" }如果处理失败,也要返回一个非 SUCCESS 的状态,微信会按照一定的时间间隔重试,重试策略大约是 15 秒、15 秒、30 秒、3 分钟、10 分钟…… 所以回调接口一定是幂等的。
幂等的意义在于:同一笔订单可能收到多次通知,你后端处理时需要先判断订单状态,如果已经更新为支付成功,直接返回 SUCCESS,不要再重复处理。我见过现场问题:发货接口写在回调里,没做幂等,结果同一订单被回调三次,货发了三遍。这个锅很真实。
7. 常见问题与排查技巧实录
7.1 报错速查表
整理了支付对接过程里最高频的一些问题,遇到可以直接对照排查:
| 报错或现象 | 最常见原因 | 排查方向 |
|---|---|---|
| 页面提示“请在微信客户端打开” | 浏览器不是微信内置浏览器 | 用真机微信打开,或用微信开发者工具/自定义 UA 模拟 |
| 授权回调后 code 换 openid 报错 | redirect_uri 域名和网页授权域名不一致 | 检查公众号后台“网页授权域名”配置 |
| wx.config 报 invalid signature | JSSDK 签名错误 | 检查签名 URL 是否为当前页面完整 URL,且去掉 # 后面的部分;jsapi_ticket 是否过期 |
| 拉起支付时提示“当前页面 URL 未注册” | JSAPI 支付授权目录没配 | 到商户平台配置支付授权目录 |
| get_brand_wcpay_request:fail | 支付参数有问题 | 重点检查 timeStamp 大小写、package 前缀、paySign 生成格式 |
| 下单接口报“请确认支付目录正确” | 商户号与 AppID 绑定关系错误 | 检查商户平台和公众号是否已关联 |
| 回调验签失败 | 平台证书不对或过期、签名串拼错 | 更新平台证书,重新核对 timestamp nonce body 的拼接顺序 |
| 回调解密失败 | APIv3 密钥配置错误或 ciphertext 截取错误 | 确认 APIv3 密钥 32 位,确认 GCM 的 tag 和密文切分 |
| 回调一直收不到 | notify_url 不可达 | 公网访问测试,确认端口、域名、防火墙 |
| openid 与 appid 不匹配 | 使用了其他公众号的 openid | 确认授权获取 openid 时使用的 AppID 与下单 AppID 一致 |
7.2 我踩过的几个典型坑
第一个坑是timeStamp和timestamp的大小写问题。后端生成参数时我用的是驼峰timeStamp,这是 WeixinJSBridge 要求的写法。但 JSSDK 的wx.chooseWXPay接收的参数名是timestamp。如果后端统一返回timeStamp,前端直接拿到就传给chooseWXPay,微信会一直报参数错误。解决方法是前端在传参时手动转一下字段名,或者后端针对两种调用方式各返回一套参数。
第二个坑是订单金额单位。我之前有个 demo 测试时想支付 1 分钱,后端写total: 1,用户点支付后微信收银台显示 0.01 元,看起来没问题。但后来改成正式金额时,业务方传进来的是“元”,我忘了转成分,结果出现一笔 100 元的订单只付了 1 元。这属于低级但致命的错误,建议后端在下单前统一做一次金额校验,检查金额范围并明确单位是分。
第三个坑是回调里的幂等。我之前自己写回调处理时也觉得“不就是更新个订单状态”,没太在意重复通知。结果压力测试时同一笔订单回调重试了三五次,索引冲突、状态错乱全来了。后来老老实实加了订单状态判断和唯一索引,问题才消停。
第四个坑是证书私钥管理。本地调试时我把apiclient_key.pem放到工程根目录,有一次不小心提交到了 git 仓库。虽然仓库是私有的,但这件事让我出了一身冷汗。后来我把私钥文件加进.gitignore,并使用环境变量或密钥管理服务来加载。
最后再说两句实在话
支付功能不是“页面能弹窗”就算完,钱、订单、用户身份三者要对得上,整个链路才稳。对比下来,微信浏览器里的 H5 支付虽然流程长,但只要你把授权、下单、拉起支付、回调这四段摸熟,后面再做小程序支付或者 App 支付,会发现套路很像,只是入口和参数不同。我的做法是先跑通一笔 1 分钱订单,确认回调能收到、订单状态能更新,再逐步完善页面和业务逻辑。这样既安全又高效,你也能在出问题的时候,清晰地定位到环节,而不是一团乱麻。