微信小程序虚拟支付接入全流程:从双签名到发货闭环
微信小程序虚拟支付接入全流程:从双签名到发货闭环
如果你在小程序里售卖会员、课程、解锁功能或虚拟道具,普通 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 … 直到你的服务端返回明确成功。
服务端处理原则:
- 先验签:用请求中的
PayEventSig对事件内容做校验,确认推送来自微信。 - 幂等:同一个
outTradeNo可能收到多次推送,必须以outTradeNo为唯一键做幂等。重复请求不能重复发货,且回包要与第一次一致返回成功。 - 返回正确格式:虚拟支付回调的响应体通常要求
{"ErrCode":0,"ErrMsg":"success"},与普通支付 V3 的{"code":"SUCCESS"}不同。 - 查单兜底:推送可能丢失,定时任务或前端轮询触发
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 本身,而在双签名的字节一致性和发货闭环的幂等兜底。把这两个环节做扎实,剩下的就是配置和联调。建议先用沙箱环境把“下单 → 拉起 → 推送 → 发货 → 查单”整条链路跑通,再切换正式环境,能避免大部分上线后的掉单和重复发货问题。
- 点赞
- 收藏
- 关注作者
评论(0)