微信小程序

微信小程序虚拟支付道具模式详解

06-15 08:21

虚拟支付概述

虚拟支付就是在小程序中购买一些非实物的商品,比如课程、小说、音视频、VIP会员、直播打赏、游戏金币等

在这之前,微信内的一直使用由微信提供的普通微信支付,但由于苹果要求对 iOS 用户的支付进行交税(苹果税),所以推出了微信支付,并由微信推广至全平台使用(包括安卓和鸿蒙),但个平台之间支付后被扣款项与比例不同,只有 iOS 用户才会被苹果扣掉百分之十几的税,其它的技术服务费,各平台之间一致。

虚拟支付官方文档:https://developers.weixin.qq.com/miniprogram/dev/platform-capabilities/business-capabilities/virtual-payment.html

虚拟支付接口列表: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/miniprogram/dev/wxcloudservice/wxcloud/guide/wechatpay/virtual-payment-callback.html

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


微信小程序
大潇博客 版权所有 Copyright ©2016~2026
京ICP备17004217号-6  合作QQ:284710375
天玺科技