微信小程序虚拟支付实战:从「支付能力被限制」到沙箱调通的全过程

微信小程序虚拟支付实战:从「支付能力被限制」到沙箱调通的全过程

背景

最近在开发一个虾苗 AI 计数小程序(数虾苗神器),核心功能是用户拍照上传、AI 识别虾苗数量。商业模式是会员订阅制(9.9/月、99/年、155/两年)。

第一版支付直接用了 wx.requestPayment + 微信支付 V3 JSAPI,开发工具和体验版测试一切正常。但提交审核后,真机体验版始终提示:

"小程序对应的支付能力已经被限制"

排查了一圈------商户号、AppID 关联、结算验证、JSAPI 产品开通------全部正确。最后在微信支付商户平台提交资料,审核通过了一个米大师虚拟支付专用商户号

原因很简单:会员订阅属于虚拟商品,必须走虚拟支付通道,不能用实物支付的 JSAPI。

这篇文章记录完整的迁移过程,包括后端签名、前端调用、以及踩过的所有坑。


一、虚拟支付 vs 传统 JSAPI 支付

维度 wx.requestPayment(JSAPI) wx.requestVirtualPayment(虚拟支付)
适用场景 实物商品(电商、外卖) 虚拟商品(会员、课程、道具、数字内容)
iOS 支持 不支持虚拟商品 支持(底层走 Apple IAP)
支付后端 微信支付 V3 API 米大师(Midas)
商户号 普通商户号 虚拟支付专用商户号
签名方式 RSA-SHA256 证书签名 HMAC-SHA256 对称签名
费率 (Android) ~0.6% ~1%
费率 (iOS) N/A ~12%(含苹果 30% 分成)

核心原则:只要用户花钱买的是数字内容而非实体包裹,就必须用虚拟支付。


二、虚拟支付整体流程

markdown 复制代码
1. 小程序端:用户选择套餐,请求后端创建订单
        ↓
2. 后端:构建 signData JSON → 计算 paySig + signature → 返回三要素
        ↓
3. 小程序端:调用 wx.requestVirtualPayment({ signData, paySig, signature, mode })
        ↓
4. 微信客户端:弹出 Midas 支付弹窗(iOS 走 Apple IAP)
        ↓
5. 支付成功后:Midas 推送 xpay_goods_deliver_notify 到后端回调地址
        ↓
6. 后端:验签 → 更新订单状态 → 延长用户会员到期时间

三、前端实现

3.1 wx.requestVirtualPayment 参数

javascript 复制代码
wx.requestVirtualPayment({
  signData: params.signData,    // JSON 字符串,包含所有支付参数
  paySig: params.paySig,        // HMAC-SHA256(AppKey, "requestVirtualPayment&" + signData)
  signature: params.signature,   // HMAC-SHA256(session_key, signData)
  mode: 'short_series_goods',    // 道具直购模式
  success: (res) => { /* 支付成功 */ },
  fail: (err) => { /* 支付失败 */ }
})

3.2 signData 结构

json 复制代码
{
  "offerId": "1450595140",       // Midas 应用ID(小程序后台获取)
  "buyQuantity": 1,               // 购买数量
  "env": 0,                       // 0=正式环境,1=沙箱环境
  "currencyType": "CNY",          // 币种
  "productId": "monthly_vip",     // 道具ID(Midas 道具管理配置)
  "goodsPrice": 990,              // 价格(分)
  "outTradeNo": "AT17843356...",  // 商户订单号
  "attach": ""                    // 透传数据
}

3.3 完整前端代码

javascript 复制代码
pay() {
  if (!this.data.selectedPlan) {
    wx.showToast({ title: '请选择套餐', icon: 'none' })
    return
  }
  this.setData({ paying: true })

  let orderInfo = null
  request('/subscription/create-virtual-order', {
    method: 'POST',
    data: { planType: this.data.selectedPlan }
  }).then(params => {
    // 保存订单号,支付成功后用于主动确认
    orderInfo = params
    return new Promise((resolve, reject) => {
      wx.requestVirtualPayment({
        signData: params.signData,
        paySig: params.paySig,
        signature: params.signature,
        mode: params.mode || 'short_series_goods',
        success: (res) => resolve(res),
        fail: (err) => reject(err)
      })
    })
  }).then(() => {
    // 主动通知后端确认支付(补充 Midas 回调延迟)
    return request('/subscription/confirm-virtual-order', {
      method: 'POST',
      data: { outTradeNo: orderInfo.outTradeNo }
    })
  }).then(() => {
    wx.showToast({ title: '支付成功', icon: 'success' })
    getApp().login().then(() => {
      setTimeout(() => wx.navigateBack(), 1200)
    })
  }).catch(err => {
    if (err.errMsg && err.errMsg.includes('cancel')) {
      wx.showToast({ title: '已取消支付', icon: 'none' })
    } else {
      wx.showToast({ title: '支付失败,请重试', icon: 'none' })
    }
  }).finally(() => {
    this.setData({ paying: false })
  })
}

关键点signData 必须原样传递,不能再 JSON.stringify 一次。因为签名是基于服务端序列化的确切字符串计算的,前端重新序列化会改变字段顺序,导致签名验证失败。


四、后端实现(Java Spring Boot)

4.1 核心签名算法

java 复制代码
/**
 * 构建虚拟支付参数
 * paySig = HMAC-SHA256(AppKey, "requestVirtualPayment&" + signData)
 * signature = HMAC-SHA256(session_key, signData)
 */
@Transactional
public PayParamDTO createOrder(Long userId, Integer planType) {
    // 1. 查询套餐和用户
    PriceConfig plan = priceConfigMapper.selectOne(
        new LambdaQueryWrapper<PriceConfig>().eq(PriceConfig::getPlanType, planType));
    User user = userMapper.selectById(userId);
    
    // 2. 生成商户订单号
    String outTradeNo = "AT" + System.currentTimeMillis()
        + UUID.randomUUID().toString().replace("-", "").substring(0, 8);

    // 3. 构建 signData(TreeMap 保证 JSON key 字母序)
    Map<String, Object> signMap = new LinkedHashMap<>();
    signMap.put("buyQuantity", 1);
    signMap.put("currencyType", "CNY");
    signMap.put("env", env);  // 0=生产,1=沙箱
    signMap.put("goodsPrice", plan.getPrice().multiply(new BigDecimal("100")).intValue());
    signMap.put("offerId", offerId);
    signMap.put("outTradeNo", outTradeNo);
    signMap.put("productId", plan.getProductId());
    
    TreeMap<String, Object> sorted = new TreeMap<>(signMap);
    String signData = objectMapper.writeValueAsString(sorted);

    // 4. 计算两个签名
    String paySig = hmacSha256(appKey, "requestVirtualPayment&" + signData);
    String signature = hmacSha256(user.getSessionKey(), signData);

    // 5. 插入待支付订单
    Subscription subscription = new Subscription();
    subscription.setUserId(userId);
    subscription.setPlanType(planType);
    subscription.setAmount(plan.getPrice());
    subscription.setOutTradeNo(outTradeNo);
    subscription.setPayStatus(0);
    subscription.setCreatedAt(LocalDateTime.now());
    subscriptionMapper.insert(subscription);

    // 6. 返回给前端
    return PayParamDTO.builder()
        .outTradeNo(outTradeNo)
        .signData(signData)
        .paySig(paySig)
        .signature(signature)
        .mode("short_series_goods")
        .build();
}

4.2 HMAC-SHA256 工具方法

java 复制代码
private static String hmacSha256(String key, String message) {
    try {
        Mac mac = Mac.getInstance("HmacSHA256");
        SecretKeySpec spec = new SecretKeySpec(
            key.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
        mac.init(spec);
        byte[] hash = mac.doFinal(message.getBytes(StandardCharsets.UTF_8));
        StringBuilder hex = new StringBuilder(hash.length * 2);
        for (byte b : hash) hex.append(String.format("%02x", b));
        return hex.toString();
    } catch (Exception e) {
        throw new BusinessException("支付签名异常");
    }
}

4.3 session_key 的获取与存储

虚拟支付的 signature 需要用户的 session_key,这个值来自微信登录时 code2session 接口的返回值。必须在登录时保存到数据库:

java 复制代码
// AuthServiceImpl.java --- wxLogin 方法
Map<String, Object> response = restTemplate.getForObject(
    "https://api.weixin.qq.com/sns/jscode2session" +
    "?appid={appid}&secret={secret}&js_code={code}&grant_type=authorization_code",
    Map.class, appId, appSecret, code);

String openid = (String) response.get("openid");
String sessionKey = (String) response.get("session_key");

// 新用户创建时保存,老用户每次登录更新
user.setSessionKey(sessionKey);
userMapper.insert(user);  // 或 updateById

注意session_key 绝对不能暴露给前端,只在服务端使用。

4.4 支付确认(补充 Midas 回调)

Midas 的发货通知(xpay_goods_deliver_notify)是异步推送的,可能存在延迟。所以我们增加了客户端主动确认机制:

java 复制代码
@Transactional
public void confirmOrder(Long userId, String outTradeNo) {
    Subscription sub = subscriptionMapper.selectOne(
        new LambdaQueryWrapper<Subscription>()
            .eq(Subscription::getOutTradeNo, outTradeNo));
    if (sub == null || !sub.getUserId().equals(userId))
        throw new BusinessException("订单不存在");
    if (sub.getPayStatus() == 1) return; // 幂等
    
    // 更新订单 + 延长会员
    sub.setPayStatus(1);
    sub.setPaidAt(LocalDateTime.now());
    subscriptionMapper.updateById(sub);
    
    PriceConfig plan = priceConfigMapper.selectById(sub.getPlanType());
    User user = userMapper.selectById(sub.getUserId());
    LocalDateTime base = user.getExpireAt() != null 
        && user.getExpireAt().isAfter(LocalDateTime.now())
        ? user.getExpireAt() : LocalDateTime.now();
    user.setExpireAt(base.plusDays(plan.getDurationDays()));
    userMapper.updateById(user);
}

五、踩坑记录

坑1:paySig 缺少 & 分隔符

现象 :始终报 PAY_SIG_INVALID

根因 :文档明确写了公式是 HMAC-SHA256(AppKey, uri + '&' + signData),我漏掉了中间的 &

arduino 复制代码
❌ "requestVirtualPayment" + signData
✅ "requestVirtualPayment&" + signData

这个 & 字符花了我整整一天才找到。

坑2:offerId 的类型

Midas 配置中 OfferID 是纯数字 1450595140,但 signData 中它必须是字符串类型 。如果序列化成数字 1450595140(不带引号),签名也会失败。

坑3:signData 的 JSON key 顺序

不同 JSON 库序列化时 key 顺序可能不同。Midas 验签对 key 顺序敏感。最稳妥的做法是用 TreeMap 保证字母序排列:

java 复制代码
TreeMap<String, Object> sorted = new TreeMap<>(signMap);
String signData = objectMapper.writeValueAsString(sorted);

坑4:沙箱环境不发回调

沙箱(env=1)支付弹窗能正常调起,但 Midas 不会推送 xpay_goods_deliver_notify 发货通知。如果完全依赖回调更新会员状态,沙箱测试时会发现"支付成功但会员没到账"。

解决方案:支付成功后客户端主动调 /confirm-virtual-order 确认订单。生产环境仍然保留 Midas 回调作为最终保障。

坑5:道具创建后有同步延迟

在 Midas 道具管理新建道具后,立即测试会报 COIN_OR_PRODUCT_ID_CREATED_IN_RECENTLY。需要等 10~15 分钟让 Midas 同步到支付网关。

坑6:session_key 会过期

微信 session_key 有时效性,用户长时间不登录后 session_key 可能失效。支付时如果 sessionKey 为 null,需要提示用户重新登录。所以每次 wx.login 成功后都要更新数据库里的 session_key


六、配置清单

小程序后台 → 功能 → 虚拟支付

  1. 基本配置 → 获取 OfferID、AppKey(沙箱和现网各一套)
  2. 道具管理 → 创建商品,道具 ID 和价格必须与后端代码一致

数据库迁移

sql 复制代码
-- user 表增加 session_key
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS session_key VARCHAR(255);

-- price_config 表增加 product_id
ALTER TABLE price_config ADD COLUMN IF NOT EXISTS product_id VARCHAR(64);
UPDATE price_config SET product_id = 'monthly_vip'  WHERE plan_type = 1;
UPDATE price_config SET product_id = 'yearly_vip'   WHERE plan_type = 2;
UPDATE price_config SET product_id = 'two_year_vip' WHERE plan_type = 3;

application.yml

yaml 复制代码
wechat:
  virtual-pay:
    offer-id: "1450595140"
    app-key: ${WECHAT_VIRTUAL_APP_KEY:}
    env: ${WECHAT_VIRTUAL_ENV:0}    # 0=生产,1=沙箱
    callback-url: https://your-domain.com/api/subscription/xpay-notify

七、总结

虚拟支付和传统 JSAPI 支付是两套完全独立的体系------不同的商户号、不同的 API、不同的签名算法、不同的回调格式。

迁移的核心工作就三件事:

  1. 登录时保存 session_key(虚拟支付签名必需)
  2. 构建 signData + 计算 paySig 和 signature (注意 & 分隔符和 key 顺序)
  3. 前端把 wx.requestPayment 替换为 wx.requestVirtualPayment

沙箱调通后切换到生产环境前,务必确认 Midas 道具管理的「现网环境」下也已创建商品,否则生产支付会报 PAY_SIG_INVALID


本文基于微信小程序基础库 3.16.2、Spring Boot 3.2.5、米大师虚拟支付接口。

相关推荐
武子康1 小时前
vLLM 0.25.1:服务没有报错,为什么仍会生成垃圾 Token(5 级正确性门禁 + 自动回滚条件)
前端·人工智能·后端
Conan在掘金1 小时前
鸿蒙 ArkUI V2 装饰器:@ObservedV2 + @Trace,嵌套对象深层重绘,告别 V1 的「重赋值才更新」
后端
卷无止境1 小时前
Python 的 exec 与 eval :动态代码执行的能力、风险与工程实践
后端·python
jyp201211071 小时前
Vue3 Diff 算法
前端·vue.js
胡萝卜术1 小时前
抽象的三级跳:从原生 DOM 到 React 组件树,我们到底在解决什么问题?
前端·javascript·面试
Conan在掘金1 小时前
鸿蒙 ArkUI V2 装饰器:AppStorageV2,应用级状态存取,告别 V1 的「set/get 手动同步」
后端
咕白m6251 小时前
通过 C++ 写入数据到 Excel 文档
c++·后端
黄敬峰1 小时前
从零理解React:事件、组件与响应式——一个WebGPU Demo的前端笔记
前端·面试
极光技术熊1 小时前
AI应用开发中的流式输出:从协议原理到工程实战的完整指南
后端·架构