微信小程序虚拟支付实战:从「支付能力被限制」到沙箱调通的全过程
背景
最近在开发一个虾苗 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。
六、配置清单
小程序后台 → 功能 → 虚拟支付
- 基本配置 → 获取 OfferID、AppKey(沙箱和现网各一套)
- 道具管理 → 创建商品,道具 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、不同的签名算法、不同的回调格式。
迁移的核心工作就三件事:
- 登录时保存 session_key(虚拟支付签名必需)
- 构建 signData + 计算 paySig 和 signature (注意
&分隔符和 key 顺序) - 前端把
wx.requestPayment替换为wx.requestVirtualPayment
沙箱调通后切换到生产环境前,务必确认 Midas 道具管理的「现网环境」下也已创建商品,否则生产支付会报 PAY_SIG_INVALID。
本文基于微信小程序基础库 3.16.2、Spring Boot 3.2.5、米大师虚拟支付接口。