代码 微信小程序虚拟支付接入:双签名对不上、iOS 没沙箱,这两处最容易卡

2026-09-14 09:01:28

微信小程序虚拟支付接入:双签名对不上、iOS 没沙箱,这两处最容易卡

小程序内的虚拟商品必须走「小程序虚拟支付」,这是平台要求,不是可选项。下面按接入顺序把文档明确的部分和社区踩过的坑分开写清楚。

背景与适用范围

文档明确:小程序内提供的虚拟商品——虚拟货币、解锁功能、订阅内容、付费功能、打赏、购买虚拟礼物——购买和支付都需要接入虚拟支付。它使用一个独立的微信支付商户号,在 MP 后台的【虚拟支付】模块开通。

客户端只有两个 API:实物/服务用 wx.requestPayment,虚拟商品用 wx.requestVirtualPayment。设备路由上,Android / 鸿蒙 / Windows 走微信支付;iOS 自 2.x 起路由到 Apple 支付(IAP),底层对接苹果通道。这也是它存在的主要价值——过去 iOS 禁止小程序内虚拟商品用普通支付。

适用于 Android、iOS、鸿蒙、Windows 等终端,iOS 端需微信客户端 8.0.68 及以上。

个人主体另有一份独立文档「虚拟支付:个人」:要求服务类目含「工具」、已认证备案,全终端月支付限额 10 万元。

不适用场景:实物、线下服务、物流类订单继续走普通微信支付,两套体系并行。商户号不可混用,有开发者反馈原来的普通微信支付商户号不能直接用于虚拟支付。

开通与基础配置

文档明确的接入条件:已认证小程序;主体为企业 / 事业单位 / 个体工商户;主体信息完备。

入口是 MP 左边栏【虚拟支付】→【开通】。流程为:阅读协议 → 提交营业执照 / 提现账户 / 支付管理员 → 审核(一般 1–7 个工作日)→ 账户验证(支付管理员是法人可跳过)→ 扫码签约 → 再等约 1–2 个工作日 → 进入商户管理模块。

iOS 端有一个额外前置条件:必须配置「小程序简称」,因为 Apple 支付要求展示 display name(MP - 虚拟支付 - 基础配置)。没配简称,iOS 端开不了 Apple 支付。

基础配置页里能拿到:appidofferidappkey,以及沙箱 AppKey现网 AppKey 两套密钥——后面签名的 env 决定用哪一套,别用混。

代币配置:设置代币名称与兑换比例,一旦发布不可修改。道具管理:上传道具到开发版本、发布至现网版本,可编辑保存后发布,并能查看发货推送配置。道具、代币是 Android / iOS 双端互通的,配置一次两端可用。

双签名:paySig 与 signature

这是接入的第一个卡点。两套签名都是 HMAC-SHA256 后转十六进制:

paySig   = to_hex(hmac_sha256(appKey,     uri + '&' + signData))
signature = to_hex(hmac_sha256(sessionKey, signData))

appKeyenv 选沙箱或现网(env=0 现网,env=1 沙箱)。

signData 的取值分两种:wx.* API 用基础库给的 signData 字段;服务端 API 用整个 POST body。

uri 也分两种:服务端 API 填接口路径且不带 query_string——即去掉 ? 及后面所有内容,例如 /xpay/query_user_balance;调 wx.requestVirtualPaymenturi 固定填 requestVirtualPayment

sessionKey 来自 auth.code2Session

官方给了 Python 的 calc_pay_sig / calc_signature 示例,并给了固定断言值用于自检:

uri      = '/xpay/query_user_balance'
appkey   = '12345'
post_body= '{"openid": "xxx", "user_ip": "127.0.0.1", "env": 0}'
→ pay_sig 应为 c37809f27c6d7fd1837ad2500a04512b66b34fd793a39a385fade56dca89a4b5

session_key = '9hAb/NEYUlkaMBEsmFgzig=='
→ signature 应为 089d9e8dc5d308977360c4b79ec600a93d736802802a807d634192328032f6c7

排查顺序按这个走:

  1. 先用写死参数确认自己的签名算法输出与示例结果完全一致;
  2. 核对 uri 是否误带了 query;
  3. 核对 post_body 是否与真正发出去的 HTTP body 逐字节一致——不同语言的 JSON 序列化顺序、空格都会改变结果;
  4. 核对 appkey 是否与 env 对应。

有开发者反馈,post_body 的 JSON 序列化差异是最高频的坑。建议服务端把参与签名的字符串原样保存并直接作为请求体发出,不要序列化两次。

服务端接口

路径均以 /xpay/ 开头,文档明确。

  • 代币:/xpay/query_user_balance(查代币余额)、/xpay/currency_pay(扣减代币)、/xpay/cancel_currency_pay(代币支付退款)、/xpay/present_currency(代币赠送)。赠送当前不支持按单号查询,可重试至返回成功 0 或重复操作 268490004
  • 道具:/xpay/start_upload_goods/xpay/query_upload_goods/xpay/start_publish_goods/xpay/query_publish_goods。每次仅支持一个道具,多个需分多次调用。
  • 订单:/xpay/query_order(查现金单,非代币单)、/xpay/refund_order(对 JSAPI 下的单退款,仅启动退款任务,需再用 query_order 轮询退款单状态直至完成)、/xpay/notify_provide_goods(通知已发货,只能通知现金单)、/xpay/start_download_order/xpay/query_download_order
  • 账单:/xpay/download_bill(按日汇总结算账单,首次调用触发生成下载 URL,之后间隔轮询取最终 URL)、/xpay/download_ios_settlement_bill(下载指定月份苹果 IAP 月账单)。
  • 资金:/xpay/create_withdraw_order/xpay/query_withdraw_order/xpay/query_biz_balance
  • 投诉 / 管控:/xpay/get_complaint_list/xpay/get_complaint_detail/xpay/get_negotiation_history/xpay/response_complaint/xpay/complete_complaint/xpay/upload_vp_file/xpay/get_upload_file_sign/xpay/query_punishment_reasons
  • 广告金:/xpay/query_transfer_account/xpay/query_adver_funds/xpay/create_funds_bill/xpay/bind_transfer_accout/xpay/query_funds_bill/xpay/query_recover_bill/xpay/download_adverfunds_order

消息推送与发货幂等

文档明确的推送 Event:

  • xpay_goods_deliver_notify:现金买道具支付成功
  • xpay_coin_pay_notify:代币扣减成功
  • xpay_refund_notify:退款完成
  • xpay_complaint_notify:用户投诉
  • xpay_wxpay_callback_notify:风控事件,due_diligence 尽职调查 / punishment 管控流水
  • xpay_subscribe_ios_refund_query_notify:iOS 退款问询

应答格式:XML 用 0,JSON 用 {"ErrCode":0,"ErrMsg":"success"},返回空或 success 等价成功。应答不对微信会重推,最多 15 次,间隔 2/4/8/16… 秒。

发货推送字段:ToUserName(小程序原始 ID)、FromUserName(道具场景固定微信官方 openid)、CreateTimeMsgType=eventEventOpenIdOutTradeNo(业务订单号)、Env(0 现网 / 1 沙箱)、WeChatPayInfo{MchOrderNo, TransactionId, PaidTime}GoodsInfo{ProductId, Quantity, OrigPrice, ActualPrice, Attach}TeamInfo{ActivityId, TeamId, TeamType, TeamAction}

代币支付推送是同样的外壳加 CoinInfo{Quantity, OrigPrice, ActualPrice, Attach}

退款推送字段:WxRefundIdMchRefundIdWxOrderIdMchOrderIdRefundFeeRetCode(0 成功)、RetMsgRefundStartTimestampRefundSuccTimestampWxpayRefundTransactionIdRetryTimes

风控通知字段:AppIdNickNameMerchantCodeMerchantCompanyNameBusinessTimeBusinessCodeBusinessStateRemarkEventTypeRetryTimes

关键一点:官方注明【用户支付成功】由 wx.requestVirtualPayment 的 success 回调触发,可能丢失(例如微信异常退出)。所以发货推送分支与发货轮询分支至少实现一个,两者结合更可靠。也就是说,客户端 success 不能当作发货依据,应以服务端推送或 query_order 轮询为准。

发货幂等:以 wx_order_id / OutTradeNo 作幂等键去重(个人文档的检查清单写的是「以 wx_order_id 去重」)。

错误码对照

来源为官方文档、官方错误码页与社区整理,接入以官方最新为准。

  • 90010signature 签名错误,或用户登录态 session_key 已过期,需重新获取 code 刷新。
  • 90011pay_sig 签名错误。
  • 90012:订单号重复(相同 bill_no 但其他参数不一致)。
  • 90013:余额不足(小游戏 midas 文档口径)。
  • 90017:没有调用接口的权限。
  • 90018:参数错误,具体看 errmsg
  • 268490002:微信侧订单数据不存在,检查 order_id、环境是否一致、用户是否通过微信 API 消费。
  • 268490004:重复操作,按幂等忽略。
  • 268490009:登录态已过期,需重新打开小程序取 code。

注意虚拟支付与小游戏 midas 的错误码口径略有差异,不要把一套码直接套到另一套上。

iOS 相关

文档明确:Apple 支付不支持沙箱环境,仅支持现网。iOS 端用户下单条件为 iPhone / iPad + iOS 15 及以上、微信 8.0.68 及以上、最低支付金额 1 元、仅支持中国大陆 App Store 账户。

Apple 支付不支持开发者主动向用户发起退款。用户在 App Store 申请退款后,Apple 会向开发者发起重复三次的退款问询;连续 3 次、3 秒内均未应答,微信平台向 Apple 返回「不确定」,决定权交给苹果。应答用 IosRefundQueryResponseresult_code 0 表示放过(建议退款)、1 表示拦截(拒绝退款),evidence 必填,作为决策凭据用于退款审计。若苹果最终退款成功,平台仍通过 xpay_refund_notify 通知。

以下几条有开发者反馈,未在官方文档明确,接入前自行验证:

  1. iOS 上 env=1 沙箱调不起来,只能用 env=0 现网测试,会真实扣款,建议用低价商品测完走退款。
  2. iOS 有最低支付金额限制,goodsPrice=1(0.01 元)会报错,一般不低于 1 元。
  3. MP 后台配置或修改道具后约 10 分钟生效,未生效时下单可能返回 -15014,改完价格立刻测容易误判成代码问题。

结算与对账

文档明确:结算周期 T+3,订单完成后资金冻结,3 日后分账。「待结算金额」指尚未分账(未扣技术服务费)的金额。

iOS 端:苹果在自然月结束后 45~60 天内结算,扣除 Apple 佣金后打给腾讯,腾讯再划转到虚拟支付账户,到账后可提现。

退款方面,支付时间 180 天以内的退款平台退还手续费,超过 180 天不退还。

对账用 /xpay/download_bill(按日汇总)与 /xpay/download_ios_settlement_bill(苹果月账单)。iOS 订单可在虚拟支付 - 交易订单页切换「普通支付 / Apple 支付」查看,也可用 query_order 查。

有开发者反馈:代币余额以微信侧为权威账本,本地余额只做缓存与展示,需与微信侧对账纠偏;且 query_user_balance 有调用频率限制,不宜每次查询都打一次。这一条未在官方文档明确。

官方文档:

个人主体见「虚拟支付:个人」文档;错误码可参考小游戏 midas.pay / midas.getBalance 文档中的 errCode 表:

推荐文章

程序员茄子在线接单