ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

微信小程序虚拟支付接入全指南:开通、签名、收银台与iOS避坑

微信小程序虚拟支付接入全指南:开通、签名、收银台与iOS避坑 微信小程序里做虚拟支付我见过太多人卡在第一周后台找不到开通入口文档翻半天不知道用哪个接口好不容易调到收银台又发现 iOS 上根本不弹最后只能对着报错干瞪眼。微信小程序虚拟支付本身不算复杂但“从 0 到 1”这一步的门槛全藏在细节里——类目、主体、签名、回调、平台差异任何一个没搞清楚都能让你白忙一整天。这篇文章就是一份宝宝级接入教程我把整个接入过程按真实开发顺序拆开从开通权限、服务端下单到客户端拉起收银台、uniapp 项目适配再到 iOS 限制、跳转链接weixin://dl/business的避坑流程和抓包排错。适合刚接手小程序虚拟支付、或者正在从 H5 支付转到小程序支付的同学照着做基本能少踩一半坑。1. 开通虚拟支付前先把这三件事搞清楚很多人一上来就问“代码怎么写”其实虚拟支付头号拦路虎不是代码而是权限。我在实际项目里见过好几个团队前后端接口都联调完了结果发现小程序后台根本没有虚拟支付的开关整个排期直接崩掉。1.1 主体类型直接决定你能不能开通虚拟支付和普通微信支付最大的区别是它走的是小程序平台自己的支付能力所以对主体和类目的审核非常严格。个人主体基本不用想。微信小程序的虚拟支付目前面向的是企业、个体工商户等非个人主体。个人开发者在后台翻遍所有菜单也找不到“虚拟支付”这个入口即使你做了非常合规的虚拟商品也不具备开通条件。类目要匹配。虚拟支付开放的类目通常集中在游戏、在线教育、知识付费、文娱内容、工具会员这类“卖虚拟商品或服务”的领域。如果你的类目是实物电商那应该走普通微信支付不需要虚拟支付。小游戏和普通小程序要区分开。小游戏里的道具、皮肤、金币走的是米大师支付体系也就是wx.requestMidasPayment和普通小程序的wx.requestVirtualPayment不是一回事。如果你做的是小游戏搜索资料时直接搜“微信小游戏虚拟支付”或“米大师支付”别在普通小程序文档里绕。这里提醒一句网上有些“个人也能开通虚拟支付”的服务基本都是钻空子或者改主体风险极高。个人开发者如果确实需要变现可以考虑合规的替代方案比如引导用户到公众号或 H5 完成付费流程而不是硬碰虚拟支付。1.2 商户号、类目和后台开通主体和类目满足之后开通流程大致是这样的在微信支付商户平台申请一个商户号完成微信支付的功能开通和结算账户绑定。在小程序后台找到“支付”或“交易”相关的菜单申请开通虚拟支付能力提交时需要选择经营类目和提供相关资质证明。审核通过后在小程序后台绑定之前申请的商户号。确保小程序已完成微信认证未认证的小程序即使主体是企业也很多功能不可用。审核周期一般不固定快的话一两天慢的话一周以上。强烈建议项目立项第一天就把这个申请提交了千万不要等开发完再去走流程。另外有个很容易忽略的点开发环境和生产环境用的是同一个虚拟支付开关但测试过程中建议使用测试商户号或者小额真实支付避免因为回调地址、商户号配置不一致导致线上事故。2. 服务端统一下单金额、代币和签名客户端拉起的收银台只是一个展示层真正可信的订单金额和商品信息必须在服务端生成。这不仅是安全要求也是微信支付体系的基本约定客户端传上来的钱数永远不可信。2.1 金额永远是“分”代币数量可以用小数点吗虚拟支付的金额单位是“币种最小单位”人民币就是“分”。也就是说你传给微信的amount永远是一个整数单位是分而不是元更不是浮点数。很多做游戏或内容平台的同学会问我的代币数量想支持小数点比如充值 0.5 个钻石或者会员时长按天折算这能行吗这个问题要拆成两部分看你业务侧的商品数量比如代币个数、会员天数可以支持小数因为这只是你自己系统里的展示和计算逻辑。传给微信的支付金额必须是整数分。假设 100 个代币卖 6 元用户买 350 个代币后端计算逻辑应该是350 * 6 / 100 21 元 2100 分一定不要在服务端用浮点数直接做乘法比如350 * 0.06这种写法很容易出现 20.999999 之类的精度问题。正确做法是把单价也换算成分用整数计算单价分 6 * 100 / 100 6 分/代币 // 等价于 0.06 元/代币 总价分 350 * 6 / 100 先算金额再乘100如果出现除不尽的情况比如单价 3.99 元 100 个代币买 350 个要明确四舍五入规则而且前端展示的实付金额必须和实际传给微信的分值一致否则用户会对不上账。我的建议是服务端负责所有金额计算客户端只展示从服务端拿到的订单号和金额。前端写死任何价格都可能被篡改也因此会被判为不安全实现。2.2 用户态签名到底怎么回事以及签名报错怎么查虚拟支付下单接口普遍要求“用户态签名”。第一次看到这个词的时候我也愣了一下后来总结下来它其实就是把用户的 openid 和订单参数一起参与签名用来证明这个订单确实是由当前登录用户发起的。签名原理和普通微信支付大同小异一般步骤是把 appid、openid、orderNo、currency、amount、timestamp 等参数按参数名字典序排列。拼接成keyvaluekeyvalue形式。使用商户密钥APIv2 一般是 MD5 或 HMAC-SHA256部分接口要求 RSA计算签名。把签名放在请求参数里一起提交。伪代码大概是这样的const params { appid, openid, orderNo, currency: CNY, amount, timestamp }; const str Object.keys(params) .sort() .map(key ${key}${params[key]}) .join(); const sign hmacSHA256(str, apiKey);实际接入时最常见的签名报错原因我列一下参数拼接顺序错了。记得按字典序排序不是按你代码里的书写顺序。URL 编码导致签名不一致。如果参数里有中文或特殊字符签名时用原始值请求传输时再 encode绝对不要用编码后的值去签名。timestamp 过期。很多签名接口要求请求时间在 5 分钟以内服务器时间偏差过大也会报错。openid 传错。用户态签名里 openid 必须和当前登录态一致如果你拿到的是其他小程序的 openid签名必挂。排查签名问题最快的方式是把参与签名的字符串打印出来和官方示例里的格式逐字对比空格、换行、大小写都会影响结果。3. 客户端拉起收银台的全流程服务端把订单创建好之后客户端就可以拉起微信的收银台了。这一段看起来只是调一个 API但涉及用户点击、收银台弹出、页面状态恢复踩坑点一点不比后端少。3.1 wx.requestVirtualPayment 参数与回调普通小程序拉起虚拟支付收银台的接口是wx.requestVirtualPayment一个标准的调用大概长这样wx.requestVirtualPayment({ orderNo: ORDER20250101001, currency: CNY, amount: 100, goods: [ { title: 100钻石, quantity: 1, price: 100 } ], extInfo: 来自商品页A-01, success(res) { // 用户完成了支付流程但最终结果以服务端回调为准 }, fail(err) { // 用户取消、未开通、参数错误都会走这里 }, complete() { // 收银台关闭后必触发 } });几个字段要特别注意orderNo必须是服务端创建订单时生成的唯一单号客户端不要自己拼。currency币种常见的是 CNY也有 USD 等取决于你的业务和商户号配置。amount单位是分整数必须和服务端订单金额一致。goods商品列表里面的title、quantity、price字段格式以官方文档为准price同样是分。success回调只代表“用户从收银台完成了一次支付动作”并不百分百等于支付成功。微信支付体系里最终结果要以服务端收到的支付回调通知为准。这也是新手最容易误解的地方后面专门讲回调时再展开。需要特别提醒的是wx.requestVirtualPayment在小游戏里不适用小游戏请用wx.requestMidasPayment。混用会导致“接口不存在”或“当前环境不支持”之类的报错。3.2 收银台弹出前后的加载页面与状态刷新很多团队做完支付功能后会发现一个小问题用户支付完成回到小程序页面还是刚才那个“未支付”的状态过一会儿才刷新甚至要手动下拉刷新才正常。这个问题就出在收银台关闭后的页面状态恢复上。一个比较稳的流程是这样的用户点击“立即支付”立刻把按钮置为 loading 和 disabled防止重复点击。在调用wx.requestVirtualPayment之前可以先弹一个轻量的 loading 遮罩让用户知道正在拉起收银台。收银台弹出后微信会自己接管界面此时把 loading 隐藏不然遮罩会盖在收银台上层影响操作。收银台关闭触发complete回调后不要急着把按钮恢复成可点击状态而是先显示“支付确认中”然后主动查单。根据查单结果再更新页面状态成功显示支付成功失败/取消则恢复按钮。查单一般就是请求你自己的后端接口查询这个订单的最终状态。可以做一个简单的轮询比如每 1.5 秒查一次最多查 10 次避免无限循环。这里有一个来自 iOS 的坑小程序在 iOS 上的生命周期触发和 Android 不完全一样收银台关闭回到页面时onShow不一定会按照你预期的时间点触发有些复杂页面还会因为scroll-view里嵌套了太多组件导致渲染卡顿甚至出现支付按钮点击事件被吞掉的情况。遇到这种问题优先把“开始支付”按钮放到页面根节点层级避免放在scroll-view深层嵌套里如果实在挪不动就用catchtap代替click再给按钮加一个hover-class反馈至少能让用户感知到点击。4. uniapp 项目里的虚拟支付封装现在很多小程序项目是用 uniapp 开发的好处是一套代码跑多端但虚拟支付这种强微信能力的接口在 uniapp 里没有直接封装还是得通过wx原生对象去调。这就需要我们自己做好封装和平台隔离。4.1 用条件编译隔离平台差异uniapp 的写法本身会在 H5、App、小程序之间做转换但wx.requestVirtualPayment只有微信小程序端存在。如果在 H5 或 App 端直接调用wx一定会报错。我习惯封装一个统一方法export function requestVirtualPayment(params) { return new Promise((resolve, reject) { // #ifdef MP-WEIXIN wx.requestVirtualPayment({ ...params, success: resolve, fail: reject }); // #endif // #ifndef MP-WEIXIN reject(new Error(当前平台不支持虚拟支付)); // #endif }); }这样业务层调用时不需要关心平台差异。如果将来要兼容支付宝小程序或其他端可以在同一个函数里增加分支。App 端如果需要“跳转到微信小程序完成支付”的引导场景可以配合uni.openEmbeddedMiniProgram或wx.openEmbeddedMiniProgram从小程序里拉起微信小程序后再走支付流程。但注意这种方式只是“打开小程序”并不能绕过平台对虚拟支付的限制iOS 上该不能支付的还是不能支付。4.2 支付结果查询与页面恢复uniapp 页面里的状态管理建议单独抽一个usePayResult之类的组合式函数把查单逻辑和页面 loading 状态统一管理而不是在每个页面复制粘贴。伪代码思路export function usePayResult(orderNo) { const status ref(pending); async function queryOrder() { const res await api.queryOrder(orderNo); status.value res.status; // success | failed | pending return res.status; } async function waitForResult(maxCount 10) { for (let i 0; i maxCount; i) { const result await queryOrder(); if (result ! pending) return result; await sleep(1500); } return timeout; } return { status, queryOrder, waitForResult }; }查单成功之后再触发 uni.showToast、页面跳转或订单列表刷新。这样做的好处是无论用户是从收银台正常返回、还是中途切走再回来页面都能通过onShow里的queryOrder兜底刷新避免出现“钱付了但页面没变化”的尴尬。5. iOS 上的限制与 weixin://dl/business 跳转链接做小程序虚拟支付绕不开 iOS 这个特殊案例。很多新手在 Android 上测试没问题一到 iPhone 就发现收银台根本拉不起来第一反应是代码写错了查半天才发现是平台限制。5.1 iOS 为什么不能直接拉起收银台由于苹果对虚拟支付的严格限制微信在 iOS 端的wx.requestVirtualPayment是没有完整收银台能力的。调用后大概率直接走fail回调错误信息会提示当前环境不支持或非 Android 环境。这不是你代码的问题而是平台规则。常见的处理方案有iOS 端隐藏虚拟商品购买入口。比如会员、道具、钻石这类虚拟商品在 iOS 端直接不展示购买按钮引导用户前往其他端购买。展示“前往公众号充值”之类的引导文案。把用户引导到 H5 或其他合规渠道完成支付。部分业务会通过服务端生成特定跳转链接比如weixin://dl/business这类 scheme尝试唤起微信内的业务页面完成支付引导。需要强调不要尝试在 iOS 上用任何“绕过”手段强行拉起收银台轻则功能失效重则被处罚。合规优先先想清楚 iOS 上你到底要给用户提供什么体验。5.2 weixin://dl/business 从生成到触发的完整避坑流程weixin://dl/business是微信客户端识别的一种业务跳转协议常见于唤起微信内的支付页、业务页或客服会话。在虚拟支付场景里它经常被用来做“外部跳转支付引导”。如果你打算在项目里用这类链接下面的避坑流程一定要看完第一步生成链接链接一般由服务端调用微信相关接口生成或者按照微信约定的规则拼接。携带的参数通常包括业务标识、签名、时间戳等。需要注意参数里有中文或特殊字符一定要encodeURIComponent。生成后先核对签名签名错了客户端根本无法识别。链接通常有时效性超过有效期会直接跳转失败。第二步测试环境验证生成的链接不要在开发者工具里测试开发者工具对weixin://scheme 的支持很弱。正确姿势是把链接放到一个临时页面在真机上打开微信通过扫码或分享进入页面后点击跳转。不同环境的 appid 生成的链接大概率不通用测试环境链接不要直接用于生产环境。第三步触发跳转在微信内置浏览器或小程序 web-view 中一般通过location.href触发function openBusinessLink(url) { if (!url) return; const ua navigator.userAgent.toLowerCase(); if (ua.indexOf(micromessenger) -1) { // 外部浏览器提示用户“请使用微信打开” return; } window.location.href url; }不要直接在a hrefweixin://...里写死链接很多情况下 scheme 跳转会丢失或被 WebView 拦截用 JS 动态跳转并在 try-catch 里兜底更可控。第四步跳转返回后的状态恢复跳转到微信业务页再返回小程序时页面会重新触发onShow。建议在onShow里做一次查单或状态恢复避免用户支付完成回来看到旧页面。这类链接最容易踩的坑是“生成时一切正常点击后没反应”。我遇到过的情况包括参数编码错误、链接过期、当前 WebView 不允许 scheme 跳转、用户微信版本太低。排查顺序一般是先用手机浏览器看能否唤起微信再用微信内置浏览器测试最后才查参数和签名。6. 支付回调、退款和订单闭环收银台关掉只是开始真正决定系统是否可靠的是支付后的订单闭环。这里包括服务端回调、订单状态同步、退款处理和日常对账。6.1 服务端回调验签与幂等微信服务器在用户支付成功后会向你的notifyUrl发送支付结果通知。这个回调是最终判定支付成功的依据。回调处理有几个硬性要求验签。对回调参数做签名校验防止伪造通知。校验金额。回调里的实付金额必须和本地订单金额一致不一致直接返回失败。幂等。微信回调可能会多次推送服务端要保证同一条通知处理多次结果一致。通常以订单号作为唯一键处理成功后标记状态重复消息直接返回成功。回调响应也很讲究处理成功后需要返回微信规定的成功应答格式错了微信会一直重试。如果服务端接口在公网不可访问或者返回超时回调就会不断重推直到达到上限。6.2 掉单、退款和对账处理掉单是支付系统里最常见的问题。用户明明付了钱但订单状态还是“未支付”。原因很多客户端没有正确处理收银台关闭后的状态。服务端回调没有及时到达或被验证失败。用户支付后立即杀掉了微信进程。我的处理经验是“双保险”客户端查单 服务端主动对账。客户端查单解决“用户已经回到页面但状态没更新”的问题服务端定时任务主动调用订单查询接口把长时间处于“支付中”的订单捞出来向微信确认最终状态再更新本地订单。退款方面虚拟支付的退款一般也有对应接口处理投诉和异常订单时直接走系统退款不要私下转账。退款时要原路退回并且保留退款单号和对账记录。对账建议每天拉一份支付流水和本地订单表逐笔核对差异订单单独告警。支付系统不出事则已一出事基本都出在账对不上。7. 抓包排查实录虚拟支付联调时最常见的需求就是“看看到底请求有没有发出去、返回了什么”。这时候抓包是最高效的手段。7.1 用 Charles 抓电脑端小程序请求电脑端微信小程序可以直接通过代理抓包步骤大致如下打开 Charles在 Proxy 菜单里开启 HTTP Proxy默认端口 8888。在 Proxy → SSL Proxying Settings 里开启 SSL Proxying并添加要抓的域名或直接填*。安装 Charles 根证书并在系统钥匙串里信任。电脑版微信设置里把网络代理指向127.0.0.1:8888。重新打开小程序Charles 里就能看到请求了。抓包主要看统一下单请求是否发出、请求参数是否完整、签名是否正确、微信返回了什么错误码。很多“签名错误”“订单不存在”的问题一看请求体就知道原因。7.2 真机 iOS 小程序流量为什么抓不到很多人在电脑端抓包顺利真机上一抓就懵手机明明配置了代理微信小程序也能正常打开但 Charles 里就是看不到请求。真机 iOS 小程序有一个特点流量不一定归在“微信”进程下而是可能出现在nsurlsessiond这个系统进程里。所以不要死盯着进程名字直接在 Charles 里按域名搜索或者关掉进程过滤条件。另外还要确保手机端证书安装完整并且到“设置 → 通用 → 关于本机 → 证书信任设置”里手动信任 Charles 证书否则 HTTPS 请求是看不到内容的。如果以上都做了还是抓不到检查一下手机和电脑是否在同一局域网以及系统是否开启了“私有 Wi-Fi 地址”或代理类工具这些都可能导致代理失效。7.3 高频报错速查表现象可能原因解决方法调起收银台直接 fail未开通虚拟支付、类目不符、当前环境不支持后台检查虚拟支付开关和主体资质提示非 Android 环境在 iOS 上调用了收银台 APIiOS 走引导方案或隐藏入口签名校验失败openid 错误、参数拼接顺序错、编码不一致打印签名前字符串和官方示例对比订单不存在服务端没有成功创建订单或客户端单号拼错后端创建订单并统一管理 orderNo金额不一致前端传入金额和服务端订单金额不一致后端校验一切金额前端只展示收银台返回后状态未刷新只依赖回调没有主动查单增加 complete 后查单兜底用户支付成功但订单未更新回调通知没收到或验签失败检查 notifyUrl 可访问性增加主动对账跳转链接点击无反应scheme 过期、编码错误、WebView 拦截用真机微信测试改用 location.href 动态跳转这套速查表基本覆盖了我接入虚拟支付过程中遇到的大部分问题。如果有一天你发现某个报错不在表里第一件事永远是去翻微信官方文档的最新版本然后再看自己的代码因为虚拟支付这类能力更新频率不低很多细节会随版本变化。最后分享一个个人习惯接入支付功能前先建一张“支付环境清单”表格把主体类型、类目、商户号、回调地址、测试 appid、测试订单号全部列清楚。支付开发最大的成本从来不是写代码而是环境配置和问题定位这张表能帮你省下大量来回确认的时间。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进