微信小程序
微信小程序虚拟支付道具模式详解
06-15 08:21虚拟支付概述
虚拟支付就是在小程序中购买一些非实物的商品,比如课程、小说、音视频、VIP会员、直播打赏、游戏金币等
在这之前,微信内的一直使用由微信提供的普通微信支付,但由于苹果要求对 iOS 用户的支付进行交税(苹果税),所以推出了微信支付,并由微信推广至全平台使用(包括安卓和鸿蒙),但个平台之间支付后被扣款项与比例不同,只有 iOS 用户才会被苹果扣掉百分之十几的税,其它的技术服务费,各平台之间一致。
虚拟支付接口列表:https://developers.weixin.qq.com/miniprogram/dev/server/API/VirtualPayment/
普通支付 or 虚拟支付
做普通微信支付时,需要后端开发做大量工作,排列参数生成签名 sign,调用微信“统一下单”接口,拿到 prepay_id 返回给小程序前端,小程序端通过 wx.requestPayment 发起微信支付。
普通支付完成,微信发起回调时,回调地址由调用“统一下单”接口时通过 notify_url 参数传给微信,服务端通过接收微信回传的参数签名 sign,与再次生成签名 sign 对比做合法验证。
虚拟支付和普通支付有较大区别,首先服务端无需再调用微信的接口,只需将发起虚拟支付所需的参数准备好即可,后端加密方式也由之前的 sha1 或 md5 变为 sha256,小程序端拿到后端返回的参数后,调用 wx.requestVirtualPayment 方法发起虚拟支付。
wx.requestVirtualPayment API文档:https://developers.weixin.qq.com/miniprogram/dev/api/payment/wx.requestVirtualPayment.html
虚拟支付的回调,需要在微信公众平台中配置,验证时有两种方案,下面会有专门的详解。
官方文档注意事项
微信官方文档对支付功能进行分开描述,有些并不仔细,比如后端需要做的部分,在虚拟支付文档里并没有体现,更多参数需要参考 wx.requestVirtualPayment 文档,很多类似的情形,需要多仔细读文档,结合 AI 做查询参考(现阶段ai并不精确)
开发准备
在小程序后台申请开通虚拟支付是必须的,但仅这一步还不够,还需要在微信支付中绑定虚拟支付的商户,若还存在实物交易的普通微信支付,已关联商户会显示两个及以上

开发流程
发起需要支付,有个必须的参数 session_key,它通过 wx.login 方法,拿到 code 后发送给后端,后端通过和 https://api.weixin.qq.com/sns/jscode2session 接口交换 code 获取 session_key,需要注意的是,session_key 会失效,具体有效时间微信并未说明,但可以确定的是,用户经常活跃,那么 session_key 便会一直有效,但为防止失效,要做好 session_key 失效的应对方案。因 session_key 有获取次数限制,文档中的描述是每日次数不多于打开小程序的用户,所以建议获取到 session_key 后做好缓存。
wx.login文档:https://developers.weixin.qq.com/miniprogram/dev/api/open-api/login/wx.login.html
获取 session_key 前端代码示例:

后端获取 session_key 代码示例:

还需要两个生成签名的必要参数:OfferID(支付应用ID)、AppKey,都在微信小程序后台的虚拟支付中获取。

后端代码(以PHP为例):
// 1. 定义基础参数
$appKey = "NAiYDSaDVskVSCGCvUNVgzeOmacoU5pu";
$session_key=""; //session_key切勿解码,在用于签名验证时,需使用原始的字符串,不要进行 base64_decode() 或其他额外处理
$out_trade_no = "ORDER_20240604_001" . strval(rand(100,999)); //订单号,signData 和 signature 都会用到,两个位置的参数值必须完全一致
$uri = 'requestVirtualPayment'; //固定填 requestVirtualPayment
// 2. 组装 signData 数组(必须按字母排序),转成 JSON 字符串(不能有多余空格)
$signData = json_encode([
//"activitySellingPrice" => '', //非必填,道具优惠价格(分),用于优惠活动(如使用优惠券后),实际支付金额需低于goodsPrice,该字段需与goodsPrice一起传入。如用户使用优惠券、积分等,需要以低于道具价格下单时可传入,传入后该价格即为实际下单价格。
"attach" => json_encode(['uid' => 11111, 'type' => 'normal'], JSON_UNESCAPED_UNICODE), //透传数据,希望回调时原样收到的信息(如用户ID),可作为JSON格式字符串传入,发货通知时会透传给开发者
"buyQuantity" => 1, //购买数量,通常为1
"currencyType" => 'CNY', //币种,人民币是 "CNY"
"env" => 0, //非必填:环境配置, 0正式环境, 1沙箱环境, 默认为0
"goodsPrice" => 100, //非必填,道具单价(分), 该字段仅mode=short_series_goods(道具直购)时需要必填, 用来校验价格与后台道具价格是否一致, 避免用户在业务商城页看到的价格与实际价格不一致导致投诉。终端特殊限制:对于 iOS 端,整个虚拟支付系统的业务规则要求订单金额最低为 1 元,因此即使后台能设置到0.01元,在iOS设备上也无法生效
'mode' => 'short_series_goods', //支付类型,有两个值:1、short_series_goods:道具/内容直购(短剧、网课、会员、虚拟物品),网课业务应使用这个值;2、short_series_coin代币充值(如游戏币、平台积分“钻石”)
"offerId" => '1451234567', //申请虚拟支付后,在后台获得的唯一米大师应用ID
"outTradeNo" => $out_trade_no, //业务系统生成的唯一订单号,每个订单号只能使用一次, 重复使用会失败(极端情况不保证唯一, 不建议业务强依赖唯一性). 要求8-32个字符内, 只能是数字、大小写字母、符号 _-|*@组成, 不能以下划线(_)开头
"productId" => 14334562115' //非必填,道具ID, 该字段仅mode=short_series_goods(道具直购)时需要必填。mode=为short_series_coin(代币充值)时不适用,改为传代币数量
, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
// 3. 拼接待签名字符串,生成支付签名(HMAC-SHA256)
$pay_sig = hash_hmac('sha256', $uri . '&' . $signData, $appKey);
// 4. 生成用户态签名
$signature = hash_hmac('sha256', $signData, $session_key);
// 5. 传给前端
echo json_encode([
'mode' => 'short_series_goods', //道具直购
'signData' => $signData,
'paySig' => $pay_sig,
'signature' => $signature
]);
以上面的代码为例,说几个注意的点:
1、goodsPrice 这个字段是商品价格,正常交易以这个字段为准(若存在activitySellingPrice则优先级更高),单位是分, iOS 最低支持100分,即1元,其它系统可以低于1元,0.01 元也支持
2、offerId 一定要检查好,不要填错,另外在小程序后台中,仅开通虚拟支付是不满足交易条件的,还需要在微信支付中绑定虚拟支付这个商户,这是必做项,否则会在小程序中发起支付时,报错-15001,这是最基本的错误,可以理解为支付通道尚未打通,无法进入验签环节
3、productId 在小程序中被称作道具id,需要在小程序后台的【虚拟支付】->【基本配置】->【道具配置】中提前设置好,可以理解为在道具配置中添加我们所售卖的虚拟商品信息,包括名称和价格,这些信息都将展示给用户,名称会在拉起支付时,收银台上面显示,交易成功后查看交易详情也会显示。
道具价格最高设置10000元,也就是说,虚拟支付单笔价格上限1万元,但这只是微信给的设置,有说这个价格虽然设置了10000,但 iOS 封顶并不能到达这个数字,有说法最高6000,但我实际测试7000可以拉起支付。
道具价格要和实际发起支付时严格匹配,也就是上面的 goodsPrice 字段,若数目不一致,微信会返回 -15013 的错误。
还有一个注意点,道具只能添加不能删除,所以建议考虑好再添加。


4、对于 env 这个字段,虽提供了沙箱选项,并且也提供了沙箱的 AppKey,但实际支付时,沙箱并没有生效,多方查验,这个沙箱目前可以忽略
5、attach 字段的参数,会在微信发起回调时,原封不动发回给服务端,可通过次参数做一些其它的验证等
6、activitySellingPrice 这个字段很重要,它表示商品经过优惠后的价格,如果这个字段存在,那么支付时的实际金额,就是 activitySellingPrice 的值,这时 goodsPrice 的唯一作用即是限制 activitySellingPrice 不能高于 goodsPrice
开端开发案例
小程序端通过拿到参数后,发起支付,代码如下:
wx.requestVirtualPayment({
signData: '',
mode: '',
paySig: '',
signature: '',
success(r) {
wx.showToast({
title:'支付成功',
icon: 'success'
})
//...
},
fail({ errMsg, errCode }) {
console.error(errMsg, errCode)
//错误消息列表
const errMsgMap = { '1001': '参数错误', '-1': '支付失败', '-2': '支付取消', '-4': '风控拦截', '-5': '开通签约结果未知', '-15001': '参数错误:' + errMsg, '-15002': '订单号重复使用,请更换新订单号重试', '-15003': '系统错误,请稍后重试', '-15004': '币种错误,只能为CNY', '-15005': '用户态签名(signature)错误,请联系客服处理', '-15006': '支付签名(paySig)错误', '-15007': 'session_key已过期,请重新登录', '-15008': '二级商户进件未完成', '-15009': '代币未发布', '-15010': '道具productId未发布', '-15011': '正式环境env不能为1(沙箱环境)', '-15012': '调用米大师失败订单已关闭,请更换订单号重试', '-15013': 'goodsPrice道具价格错误', '-15014': '道具/代币发布未生效,请稍后约10分钟再试', '-15016': 'signData格式有问题', '-15017': '商家涉嫌违规,收款功能受限,请登录商户平台查看', '-15018': '代币或道具productId审核不通过', '-15019': '商户受限,请登录商户平台查看详情', '-15020': '操作过快,请稍后再试', '-15021': '小程序被限频交易,请稍后重试', 'default': '支付失败,请稍后重试' };
//提示错误
const title = errMsgMap[errCode] || errMsgMap.default || errMsg;
wx.showToast({title, icon:'none'})
},
complete(){}
})
wx.requestVirtualPayment API文档:https://developers.weixin.qq.com/miniprogram/dev/api/payment/wx.requestVirtualPayment.html
错误码对应的错误信息,可在上面的文档中查询。
微信支付收银台注意
苹果端小程序使用虚拟支付时,拉起的是系统原生的 Apple Pay 支付界面,和刷公交/地铁时看到的弹窗是同一个东西,而不是微信支付的界面。
为了确保正常调起 Apple Pay,用户需要满足以下条件:
设备需装有 iOS 15 或更高版本、微信客户端需升级至 8.0.68 或更高版本、单笔支付金额最低为 1 元
安卓和鸿蒙没有变,还是微信支付老样子

前端的注意事项:
一定要先拿到 session_key 之后在发起支付,否则会失败,这里建议做个 session_key 的验证,即后端返回关于用户信息失效的错误后,多走一次 wx.login 方法,然后再回调继续支付,切记这里要做次数限制,防止因一些错误导致进入死循环。
微信在前后端都提供了对于会话有效性的验证方法,比如前端是 wx.checkSessionKey 方法,后端需要先拿到access_token再做会话验证,请求这些方法会增加额外的 http 开销,所以可以去请求官方 api,也可以自己做一下验证的方法。
回调
虚拟支付的回调,需要在微信公众平台中配置,配置步骤:
登录微信公众平台,进入小程序后台,然后依次进入:「开发」→「开发设置」→「消息推送」,填写以下三个关键信息:
URL (服务器地址):您自己的服务器上,用于接收微信支付结果回调的接口地址,必须以 https:// 开头。
Token:您可以任意填写,用作生成签名。微信会把 Token 和 timestamp、nonce 三个参数进行加密,您收到请求后可以用同样的方式校验,以确认请求是来自微信,而不是第三方伪造的。
EncodingAESKey:用于消息体加密,可以手动或随机生成。
请注意:这个 “消息推送”配置是接收虚拟支付结果的唯一入口。支付成功后,需要解析收到的参数,根据 Event 字段判断是哪种通知(例如发货通知 xpay_goods_deliver_notify),然后更新订单状态。
参考文档:
https://developers.weixin.qq.com/minigame/dev/guide/base-ability/message-push.html
保存消息推送配置时,微信会向填写的地址发送请求,此时将接受到的参数 echostr 输出给微信,这样即可视作合法,正常保存,下面是收到的微信发送的信息:

以 PHP 为例,可以这样保存:
//消息推送时,保存:
if(isset($_GET['echostr']) && !empty($_GET['echostr'])) {
// 设置纯文本响应头,避免任何 HTML 包装
header('Content-Type: text/plain; charset=utf-8');
$filename = 'wx_' . date('Ymd', time()) . ".txt";
$file = fopen('../../logs/' . $filename, 'a+');
fwrite($file, var_export($_GET, true));
fwrite($file, PHP_EOL.PHP_EOL.PHP_EOL);
fclose($file);
echo $_GET['echostr']; //输出echostr
exit;
}
消息推送的请求有两种格式,一种是 xml,一种是 json,就是微信要发送的数据格式,以及回调接口响应的格式,两个都属于 v3版 api,但合法性验证有较大区别,这里重要说一下 xml。
保存回调的数据到 txt 文件中,以 PHP 为例:
//保存回调信息:
$postRawData = file_get_contents('php://input');
$filename = 'wx_' . date('Ymd', time()) . ".txt";
$file = fopen('../../logs/' . $filename, 'a+');
fwrite($file, '--- ' . date('Y-m-d H:i:s') . " ---\n");
fwrite($file, $postRawData . "\n\n");
fwrite($file, var_export($_SERVER, true) . "\n\n");
fclose($file);
看一下回调的 XML 格式参数:

格式化之后:

虽然参数已经不少,但这些参数大部分是用来处理后端业务的,真正和签名有关的,在 header 中,以 PHP 为例,打印一下 $_SERVER:

可以看到 QUERY_STRING 和 REQUEST_URI 两个参数包涵 signature、timestamp、nonce、openid 四个参数,其中的 signature 就是签名信息。将小程序后台消息推送中设置的 token,和接收到的 timestamp、 nonce 组成一个数组,排序并通过 import 函数将数组拼接为字符串,再通过 sha1 加密后,即可得到签名,如果与 signature 值一致,则签名验证通过,如果代码:
$tmpArr=array('小程序消息推送中设置的token', $_GET['timestamp'], $_GET['nonce']);
sort($tmpArr, SORT_STRING);
if (sha1(implode($tmpArr)) === $_GET['signature']) {
// 签名验证通过,证明回调来自微信
// 这里再解析 XML 里的 order_no、attach 等字段,完成发货
// 告诉微信结果
echo '<xml><return_code><![CDATA[SUCCESS]]></return_code><return_msg><![CDATA[OK]]></return_msg></xml>';
} else {
// 签名验证失败,拒绝处理
// 告诉微信结构
echo '<xml><return_code><![CDATA[FAIL]]></return_code></xml>';
}
对于道具价格的补充说明
虚拟支付的道具模式,需要在小程序后台的虚拟支付中创建道具后才可以进行,因为道具id是必传项,这里有个问题,就是小程序后台设置的道具价格,必须和实际支付的价格对得上,也就是说,每一个商品若价格不同,就面临需要创建与之对应的道具的可能。
假设有很多的商品,并且运营还是不定时调价格,上新品,这种情况如果每次都创建道具,操作会比较繁琐。
对于这种情形,可以考虑使用 activitySellingPrice 这个字段,它本意是表示商品经过优惠后的价格,所以低于小程序后台设置的道具价格都可以。
具体做法是,创建一个10000元(如果 iOS 不支持,就尝试七八千)的道具,然后低于这个价格的商品,都使用这个道具id,goodsPrice 正常传道具设置的价格,实际价格使用 activitySellingPrice 这个字段(单位:分),注意 activitySellingPrice 一定要小于 goodsPrice。
获取当前操作系统
如果要把苹果税转移到用户,可以获取并区分用户的操作系统:
const deviceInfo = wx.getDeviceInfo();
console.log(deviceInfo.platform);
//苹果:ios
//安卓:android
//开发者工具:devtools