微信小程序虚拟支付接入全流程:从双签名到发货闭环

举报
yd_238049558 发表于 2026/10/24 19:37:35 2026/10/24
【摘要】 微信小程序虚拟支付接入全流程:从双签名到发货闭环如果你在小程序里售卖会员、课程、解锁功能或虚拟道具,普通 wx.requestPayment 在 iOS 上甚至看不到支付按钮,强行接入会触发“支付能力被限制”。虚拟支付(Virtual Payment / Midas)是微信给出的合规方案,底层打通了 Apple IAP,让 iOS 用户也能在小程序内直接完成虚拟商品付款。但虚拟支付和普通微信...

微信小程序虚拟支付接入全流程:从双签名到发货闭环

如果你在小程序里售卖会员、课程、解锁功能或虚拟道具,普通 wx.requestPayment 在 iOS 上甚至看不到支付按钮,强行接入会触发“支付能力被限制”。虚拟支付(Virtual Payment / Midas)是微信给出的合规方案,底层打通了 Apple IAP,让 iOS 用户也能在小程序内直接完成虚拟商品付款。

但虚拟支付和普通微信支付是两套完全不同的体系:签名算法、商户号、下单方式、回调协议全部不同。下面从开通配置、双签名、前端拉起、发货闭环到避坑清单,走一遍完整的接入流程。

一、先确认适用场景与开通条件

适用判断:用户付钱后拿到的是“数字内容”(会员到期时间、课程解锁、虚拟道具、代币),就必须走虚拟支付;拿到的是实物包裹,走普通 JSAPI。费率差异也值得注意:虚拟支付安卓约 1%,iOS 较高(含苹果分成),普通 JSAPI 约 0.6%。

主体门槛:小程序主体必须是企业、事业单位或个体工商户,个人主体不能开通虚拟支付。开通入口在 MP 后台左侧栏「虚拟支付」,提交资质、法人/经营者信息、提现账户,审核约 1–7 个工作日,签约后再等 1–2 个工作日。

开通完成后,在 MP → 虚拟支付 → 基础配置 拿到后续代码必需的几项配置:

配置项 用途
OfferId 虚拟支付商户号,signData 中必须传字符串,传数字会验签失败
AppKey 计算 paySig 的密钥,现网与沙箱各一套,只放服务端
道具 productId 在「道具管理」中预先创建,金额单位为分
发货推送 URL 在「发货推送配置」中填写,用于接收发货通知

基础库要求:wx.requestVirtualPayment 需 2.19.2 以上。

二、理解双签名:为什么需要两个签名

虚拟支付客户端拉起时,需要服务端提前算好两个 HMAC-SHA256 签名,和 signData 一起传给前端:

signData:一个 JSON 字符串,包含订单的全部关键字段。道具直购模式下的标准结构:

{
  "offerId": "你的OfferID",
  "buyQuantity": 1,
  "env": 0,
  "currencyType": "CNY",
  "productId": "monthly_vip",
  "goodsPrice": 990,
  "outTradeNo": "ORD2026070612345",
  "attach": "{\"uid\":10086}"
}

关键字段含义:env 为 0 正式 / 1 沙箱;goodsPrice 单位是分,9.9 元写 990;productId 和 goodsPrice 必须与 MP 后台道具配置逐字一致,不一致会直接报 -15013;outTradeNo 为 8–32 位业务订单号。

paySig:用 AppKey 作密钥,证明请求来自开发者服务端,防伪造。算法为:

paySig = hex(hmac_sha256(appKey, "requestVirtualPayment" + "&" + signData))

signature:用当前登录用户的 session_key 作密钥,证明操作由该用户发起,防冒用。算法为:

signature = hex(hmac_sha256(session_key, signData))

这意味着用户必须先完成微信登录,服务端能拿到有效的 session_key,才能生成 signature。

三、后端 Java 签名实现

以下是一个可直接复用的 Spring Boot 核心方法。注意 signData 的序列化结果必须与传给前端的字符串逐字节一致,否则签名必然失败。

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;

public class VirtualPaySigner {

    private static final String HMAC_SHA256 = "HmacSHA256";

    public static String hmacSha256Hex(String key, String data) {
        try {
            Mac mac = Mac.getInstance(HMAC_SHA256);
            mac.init(new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), HMAC_SHA256));
            byte[] raw = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
            StringBuilder sb = new StringBuilder();
            for (byte b : raw) {
                sb.append(String.format("%02x", b));
            }
            return sb.toString();
        } catch (Exception e) {
            throw new RuntimeException("HMAC-SHA256 签名失败", e);
        }
    }

    public static PayParams buildPayParams(OrderRequest req,
                                           String offerId,
                                           String appKey,
                                           String sessionKey) {
        // 1. 构建 signData(保持字段顺序固定,推荐用 LinkedHashMap)
        String signData = buildSignDataJson(req, offerId);

        // 2. paySig:AppKey + "requestVirtualPayment&" + signData
        String paySig = hmacSha256Hex(appKey, "requestVirtualPayment&" + signData);

        // 3. signature:session_key + signData
        String signature = hmacSha256Hex(sessionKey, signData);

        return new PayParams(signData, paySig, signature, "short_series_goods");
    }

    private static String buildSignDataJson(OrderRequest req, String offerId) {
        // 务必使用与最终返回前端完全一致的 JSON 序列化方式
        // 推荐 Jackson / fastjson 固定字段顺序
        return String.format(
            "{\"offerId\":\"%s\",\"buyQuantity\":1,\"env\":%d,"
          + "\"currencyType\":\"CNY\",\"productId\":\"%s\","
          + "\"goodsPrice\":%d,\"outTradeNo\":\"%s\",\"attach\":\"%s\"}",
            offerId, req.getEnv(), req.getProductId(),
            req.getGoodsPrice(), req.getOutTradeNo(), req.getAttach()
        );
    }
}

最容易踩的坑:不要用 JSON.toJSONString(map) 直接生成 signData 后又在返回前重新序列化一次。两次序列化的空格、字段顺序、转义处理可能不一致,导致 paySig 校验通过但 signature 报错,或反过来。签的是哪个字符串,传给前端的就必须是哪个字符串。

四、前端调用 wx.requestVirtualPayment

服务端返回 signData、paySig、signature 和 mode 后,前端原样透传拉起支付:

// pages/vip/vip.js
Page({
  data: {
    selectedPlan: null,
    loading: false
  },

  async pay() {
    if (!this.data.selectedPlan) {
      wx.showToast({ title: "请选择套餐", icon: "none" });
      return;
    }

    this.setData({ loading: true });

    try {
      // 1. 请求后端,获取签名三要素
      const res = await wx.cloud.callFunction({
        name: "createVirtualOrder",
        data: {
          productId: this.data.selectedPlan.productId,
          goodsPrice: this.data.selectedPlan.price,
          env: 0 // 正式环境;联调时改为 1
        }
      });

      const { signData, paySig, signature, mode, outTradeNo } = res.result;

      // 2. 拉起虚拟支付
      wx.requestVirtualPayment({
        signData: signData,
        paySig: paySig,
        signature: signature,
        mode: mode, // 道具直购:"short_series_goods"
        success: () => {
          // 注意:前端 success 仅代表支付流程完成,发货以服务端推送为准
          wx.showToast({ title: "支付完成,权益到账中", icon: "success" });
          this.pollOrder(outTradeNo); // 可选:轮询业务侧订单状态
        },
        fail: (err) => {
          console.error("虚拟支付失败", err);
          const msg = this.mapPayError(err);
          wx.showToast({ title: msg, icon: "none" });
        },
        complete: () => this.setData({ loading: false })
      });
    } catch (e) {
      this.setData({ loading: false });
      wx.showToast({ title: "下单失败,请重试", icon: "none" });
    }
  },

  mapPayError(err) {
    const code = err.errCode || err.errno;
    const map = {
      "-1": "支付未完成",
      "-2": "支付被取消",
      "-15005": "用户态签名错误,请重新登录后重试",
      "-15013": "商品信息与后台配置不一致",
      "-15014": "道具尚未生效,请稍后再试"
    };
    return map[String(code)] || `支付失败(${code || "未知"})`;
  },

  pollOrder(outTradeNo) {
    // 建议 1s / 2s / 3s 递增轮询,最多 5 次,查到业务侧权益到账即停止
    let attempts = 0;
    const timer = setInterval(async () => {
      attempts++;
      const res = await wx.cloud.callFunction({
        name: "queryBusinessOrder",
        data: { outTradeNo }
      });
      if (res.result.status === "DELIVERED") {
        clearInterval(timer);
        this.setData({ vipActive: true });
      }
      if (attempts >= 5) clearInterval(timer);
    }, 1500);
  }
});

前端 success 不能作为发货依据。虚拟支付的可靠性确认在服务端的发货推送和主动查单,前端只负责交互提示。

五、发货闭环:推送 + 查单兜底 + 幂等

这是虚拟支付与普通支付最大的机制差异之一:普通支付以支付回调为终点,虚拟支付多了“确认发货”环节,微信要确认你真的发货了,订单才算闭环。

支付成功后,微信会向你在 MP 后台配置的 URL 推送发货事件,事件类型通常为 xpay_goods_deliver_notify(道具直购场景也可能是 minigame_h5_goods_deliver_notify 等)。推送不是“尽力一次”,而是有限时间内多轮重试,周期大致为 15s / 15s / 30s / 3m / 10m / 20m / 30m … 直到你的服务端返回明确成功。

服务端处理原则:

  1. 先验签:用请求中的 PayEventSig 对事件内容做校验,确认推送来自微信。
  2. 幂等:同一个 outTradeNo 可能收到多次推送,必须以 outTradeNo 为唯一键做幂等。重复请求不能重复发货,且回包要与第一次一致返回成功。
  3. 返回正确格式:虚拟支付回调的响应体通常要求 {"ErrCode":0,"ErrMsg":"success"},与普通支付 V3 的 {"code":"SUCCESS"} 不同。
  4. 查单兜底:推送可能丢失,定时任务或前端轮询触发 POST /xpay/query_order 主动查询订单状态,查到已支付但未发货的订单,补发并幂等落库。

一个幂等发货的伪代码骨架:

@PostMapping("/virtual/deliver/callback")
public Map<String, Object> handleDeliver(@RequestBody DeliverNotify notify) {
    // 1. 验签(依据官方 PayEventSig 算法)
    if (!verifyEventSig(notify)) {
        return Map.of("ErrCode", -1, "ErrMsg", "sign error");
    }

    String outTradeNo = notify.getOutTradeNo();

    // 2. 幂等锁:Redis SETNX 或数据库唯一索引
    if (!orderLock.tryLock(outTradeNo)) {
        return Map.of("ErrCode", 0, "ErrMsg", "success"); // 已处理,直接确认
    }

    try {
        Order order = orderRepo.findByOutTradeNo(outTradeNo);
        if (order == null) {
            return Map.of("ErrCode", -1, "ErrMsg", "order not found");
        }
        if ("DELIVERED".equals(order.getStatus())) {
            return Map.of("ErrCode", 0, "ErrMsg", "success"); // 幂等
        }

        // 3. 发货:延长会员、发放道具等
        deliverService.deliver(order);

        // 4. 落库标记已发货
        order.setStatus("DELIVERED");
        orderRepo.save(order);

        return Map.of("ErrCode", 0, "ErrMsg", "success");
    } finally {
        orderLock.unlock(outTradeNo);
    }
}

查单兜底的定时任务可以简化为:扫描 PAID 但未 DELIVERED 且超过一定时间的订单,调用 /xpay/query_order,若返回已支付则走同一套幂等发货逻辑。

六、避坑清单

offerId 传字符串:signData 中 offerId 必须是字符串,传数字会直接验签失败。

goodsPrice 单位是分:9.9 元写 990,写 9.9 或 99 都会出问题;且必须与后台道具配置一致,否则 -15013。

signData 签名前后必须字节一致:算签名用的 JSON 字符串和传给前端的字符串不能经过不同的序列化流程。建议服务端生成一次 signData 后,算签名和返回前端使用同一个字符串变量。

AppKey 绝不进客户端:paySig 的 AppKey 只放服务端环境变量,泄露等于别人可以伪造你的支付请求。

前端 success ≠ 发货依据:发货必须由服务端推送 + 查单兜底 + 幂等完成。前端 success 只做 UI 提示。

道具发布后不是立即生效:MP 后台道具发布后约 10 分钟才生效,刚发布就下单可能报 -15014。

沙箱与正式环境分开:env: 1 走沙箱 AppKey,env: 0 走现网 AppKey,联调阶段建议先用沙箱跑通全链路,再切正式。

错误码做友好映射:-2 用户取消、-15005 用户态签名错误(常与 session_key 失效有关)等,直接透出原始 errno 对用户体验很差,建议在前端统一映射为可读文案。

虚拟支付的接入复杂度主要不在 API 本身,而在双签名的字节一致性和发货闭环的幂等兜底。把这两个环节做扎实,剩下的就是配置和联调。建议先用沙箱环境把“下单 → 拉起 → 推送 → 发货 → 查单”整条链路跑通,再切换正式环境,能避免大部分上线后的掉单和重复发货问题。

【版权声明】本文为华为云社区用户原创内容,未经允许不得转载,如需转载请自行联系原作者进行授权。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。