
大家好,我是小悟。
你做了一个小程序,内容做得不错,用户量也在涨。这时候产品经理走过来说:「该考虑商业化了吧,加个会员体系,让用户付费解锁内容。」
你想,这不就是接个支付嘛。微信支付文档那么多,照着抄一遍就行了。
然后你打开文档搜了一圈,发现一个尴尬的事实:iOS 用户在小程序里买虚拟商品,不能直接走普通微信支付。
苹果的 IAP(In-App Purchase)政策卡在那里:虚拟商品必须走苹果的内购通道,否则就有违规下架的风险。但小程序又不是原生 App,IAP 那套机制根本套不上。普通 wx.requestPayment 在 iOS 上对虚拟商品是受限的——你甚至看不到支付按钮。
进退两难的时候,你在微信开放文档的角落里发现了「虚拟支付」这个能力。
这篇文章就是关于这个东西怎么接的。我会把整个对接过程从头到尾走一遍,包括那些官方文档写得语焉不详、但你不去踩一遍就根本不知道的地方。
微信小程序里能用的支付方式,主要是两套体系。搞混了的话,后面所有努力都白费。
对比维度 | 普通微信支付 | 虚拟支付 |
|---|---|---|
适用商品 | 实物商品(电商、外卖等) | 虚拟商品(会员、课程、道具、解锁功能等) |
iOS 支持 | 不支持虚拟商品 | 支持(底层打通了苹果 IAP 通道) |
客户端 API | wx.requestPayment | wx.requestVirtualPayment |
费率(安卓) | 约 0.6% | 1% |
费率(iOS) | — | 较高(含苹果 + 腾讯两部分,标准费率 17%) |
结算周期 | T+1 | T+3 |
支付通道 | 微信支付直接处理 | 走米大师(Midas)中台 |
简单说:如果你卖的是实物,用普通支付;如果你卖的是虚拟商品——会员、课程、数字内容、功能解锁、打赏——就得用虚拟支付。这不是选不选的问题,是平台规定的。
虚拟支付最大的价值,是解决了 iOS 端虚拟商品的支付合规问题。它底层对接了苹果 IAP,让 iOS 用户也能在小程序里直接付钱买虚拟东西,不用跳出去。
💡 适用场景判断 如果你的小程序有以下任何一种功能,就需要虚拟支付:付费解锁文章/视频、购买虚拟道具/代币、开通会员/订阅、付费功能解锁、打赏/虚拟礼物。判断标准很简单——用户付了钱之后拿到的是「数字内容」而不是「快递到家」的东西,那就是虚拟支付。
虚拟支付不是想用就能用的,需要先开通。这个过程说复杂也不复杂,但需要耐心等。
首先,你的小程序主体必须是以下类型之一:
个人主体的小程序不能开通虚拟支付。如果你是个人开发者,得先升级主体类型。

在小程序管理后台左侧栏找到【虚拟支付】,点击【开通】,然后走这六步:
步骤 | 内容 | 耗时 |
|---|---|---|
1. 阅读协议 | 阅读并勾选同意《微信小程序虚拟支付功能开启及使用须知》 | 即时 |
2. 提交资料 | 营业执照、经营者/法人身份证信息、提现账户、支付管理员信息 | 即时 |
3. 等待审核 | 微信审核你的资质材料 | 1-7 个工作日 |
4. 账户验证 | 验证支付管理员身份(法人本人可跳过) | 即时 |
5. 扫码签约 | 管理员扫码完成电子签约 | 1-2 个工作日确认 |
6. 进入商户后台 | 配置代币/道具,管理资金 | 即时 |

开通完成后,在 MP 后台 → 虚拟支付 → 基础配置里,你会看到四个东西,这是后续所有代码的基石:
配置项 | 说明 | 用途 |
|---|---|---|
AppID | 小程序的 AppID | 身份标识 |
OfferID | 米大师分配的应用 ID | 支付请求的核心参数 |
现网 AppKey | 正式环境的支付密钥 | 计算支付签名 paySig |
沙箱 AppKey | 测试环境的支付密钥 | 沙箱测试时使用 |
⚠️ AppKey 千万不要泄露 现网 AppKey 是计算支付签名的密钥,一旦泄露,别人可以伪造你的支付请求。请只放在服务端环境变量里,绝不要硬编码进客户端代码,更不要提交到 Git 仓库。

在商户管理后台,你需要把你要卖的商品配成「道具」。每个道具有一个 productId 和一个价格(单位是分,不是元)。这个 productId 后面在客户端调用支付时会用到,而且必须跟后台配置的完全一致——否则支付会被拒绝。

使用WorkBuddy制作虚拟支付全流程交互图。在写代码之前,先从全局视角理解整个支付流程。虚拟支付涉及四个角色:用户、小程序客户端、你的服务器、微信支付系统。数据在它们之间来回传递,流程比普通支付多几步。

整个流程可以拆成三个阶段:下单准备(步骤 1-4)、支付执行(步骤 5-6)、发货回调(步骤 7-9)。
跟普通微信支付最大的区别在于中间这步——你的服务器需要自己构建 signData 并计算两个签名,然后把签名结果传给客户端,客户端再拿着这些参数去调微信的 API。普通支付只需要后端调微信统一下单接口拿到 prepay_id 就行,虚拟支付多了一层「参数自己构造」的逻辑。
这是整个对接过程中最容易出错的地方。微信虚拟支付要求客户端在调用支付 API 时传入两个签名:paySig 和 signature。这两个签名都由你的服务端计算,缺一不可。
signData 是一个 JSON 字符串,包含了这笔订单的所有关键信息。在道具直购模式下,它的结构是这样的:
{
"offerId": "你的 OfferID",
"buyQuantity": 1,
"env": 0,
"currencyType": "CNY",
"productId": "vip_monthly",
"goodsPrice": 2500,
"outTradeNo": "ORD2026070612345",
"attach": "{\"uid\":10086,\"pid\":\"vip\"}"
}每个字段的含义:
字段 | 类型 | 说明 |
|---|---|---|
offerId | string | MP 后台虚拟支付配置中的 OfferID |
buyQuantity | number | 购买数量,道具直购一般填 1 |
env | number | 0 = 正式环境,1 = 沙箱环境 |
currencyType | string | 币种,目前只支持 CNY |
productId | string | 道具 ID,必须与后台配置一致 |
goodsPrice | number | 道具单价,单位是分,必须与后台配置一致 |
outTradeNo | string | 业务订单号,8-32 位,不可以下划线开头 |
attach | string | 透传数据,回调时原样返回 |
🔴 两个最容易踩的雷 1. goodsPrice 的单位是「分」——25 元就写 2500,不是 25。搞反了要么用户付 2500 元,要么微信报价格不一致。 2. productId 和 goodsPrice 必须与后台配置完全一致——微信会校验这两个值,不一致直接返回
-15013错误码。
使用WorkBuddy制作双签名机制图解。虚拟支付要求两个签名,分别用不同的密钥、对同一个 signData 做加密。理解为什么需要两个签名很重要:

paySig 用你的 appKey 做密钥,证明这个支付请求确实是你发起的,防止第三方伪造请求。
signature 用用户的 session_key 做密钥,证明这个操作确实是当前登录用户发起的,防止别人冒用。这就要求你的服务端在构建支付参数时,必须先拿到用户的 session_key——也就是用户必须先完成微信登录。
下面是 Spring Boot 风格的完整实现,包含签名工具类、支付参数构建接口。
package com.example.vpay.util;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
/**
* 虚拟支付签名工具类
*/
public class VpSignUtil {
private static final String HMAC_SHA256 = "HmacSHA256";
/**
* 计算支付签名 paySig
*
* @param uri 固定值 "requestVirtualPayment"
* @param signDataStr signData 的 JSON 字符串
* @param appKey 对应环境的 AppKey(现网 / 沙箱)
* @return HMAC-SHA256 十六进制签名
*/
public static String calcPaySig(String uri, String signDataStr, String appKey) {
// 拼接规则:uri + "&" + signDataStr
String signStr = uri + "&" + signDataStr;
return hmacSHA256(appKey, signStr);
}
/**
* 计算用户态签名 signature
*
* @param signDataStr signData 的 JSON 字符串
* @param sessionKey 用户 session_key
* @return HMAC-SHA256 十六进制签名
*/
public static String calcSignature(String signDataStr, String sessionKey) {
return hmacSHA256(sessionKey, signDataStr);
}
/**
* HMAC-SHA256 计算
*
* @param key 密钥
* @param message 待签名内容
* @return 十六进制字符串
*/
private static String hmacSHA256(String key, String message) {
try {
Mac mac = Mac.getInstance(HMAC_SHA256);
SecretKeySpec secretKeySpec = new SecretKeySpec(
key.getBytes(StandardCharsets.UTF_8), HMAC_SHA256);
mac.init(secretKeySpec);
byte[] hash = mac.doFinal(message.getBytes(StandardCharsets.UTF_8));
return bytesToHex(hash);
} catch (Exception e) {
throw new RuntimeException("HMAC-SHA256 计算失败", e);
}
}
/**
* 字节数组转十六进制字符串
*/
private static String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
}package com.example.vpay.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
/**
* 虚拟支付配置
* 对应 application.yml 中的 vpay.* 配置项
*/
@Configuration
public class VpConfig {
@Value("${vpay.app-id}")
private String appId;
@Value("${vpay.offer-id}")
private String offerId;
@Value("${vpay.app-key}") // 现网密钥
private String appKey;
@Value("${vpay.sandbox-key}") // 沙箱密钥
private String sandboxKey;
@Value("${vpay.sandbox:false}") // 是否沙箱环境
private boolean sandbox;
public String getAppId() { return appId; }
public String getOfferId() { return offerId; }
/** 根据环境返回对应的 AppKey */
public String getCurrentAppKey() {
return sandbox ? sandboxKey : appKey;
}
/** env 参数:0 = 现网,1 = 沙箱 */
public int getEnv() {
return sandbox ? 1 : 0;
}
}对应的 application.yml:
vpay:
app-id: wx_your_appid
offer-id: "1234567890"
app-key: ${VP_APP_KEY} # 从环境变量读取
sandbox-key: ${VP_SANDBOX_KEY} # 从环境变量读取
sandbox: false # 上线时改为 falsepackage com.example.vpay.controller;
import com.example.vpay.config.VpConfig;
import com.example.vpay.entity.Product;
import com.example.vpay.entity.Order;
import com.example.vpay.service.ProductService;
import com.example.vpay.service.OrderService;
import com.example.vpay.service.UserService;
import com.example.vpay.util.VpSignUtil;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.*;
@RestController
@RequestMapping("/api/vp")
public class VpPaymentController {
@Autowired
private VpConfig vpConfig;
@Autowired
private ProductService productService;
@Autowired
private OrderService orderService;
@Autowired
private UserService userService;
@Autowired
private ObjectMapper objectMapper; // Jackson JSON 序列化
/**
* 构建支付参数接口
* POST /api/vp/create
*
* 请求体:{ "productId": "vip_monthly", "uid": 10086 }
* 响应体:{ "code": 0, "data": { "mode", "signData", "paySig", "signature" } }
*/
@PostMapping("/create")
public Map<String, Object> createPayment(@RequestBody Map<String, Object> body) {
String productId = (String) body.get("productId");
Long uid = Long.valueOf(body.get("uid").toString());
Map<String, Object> resp = new HashMap<>();
// 1. 查商品信息(你的业务逻辑)
Product product = productService.getProduct(productId);
if (product == null) {
resp.put("code", -1);
resp.put("msg", "商品不存在");
return resp;
}
// 2. 生成订单号(8-32 位,字母数字 + _-*@)
String outTradeNo = "ORD" + System.currentTimeMillis()
+ new Random().nextInt(10000);
// 3. 构建 signData(用 LinkedHashMap 保证字段顺序)
Map<String, Object> signData = new LinkedHashMap<>();
signData.put("offerId", vpConfig.getOfferId());
signData.put("buyQuantity", 1);
signData.put("env", vpConfig.getEnv()); // 0=现网, 1=沙箱
signData.put("currencyType", "CNY");
signData.put("productId", product.getCode());
signData.put("goodsPrice", product.getPrice()); // 单位:分
signData.put("outTradeNo", outTradeNo);
// attach 是透传数据,回调时原样返回
Map<String, Object> attachData = new HashMap<>();
attachData.put("uid", uid);
attachData.put("productId", productId);
signData.put("attach", objectMapper.writeValueAsString(attachData));
// 4. 序列化为字符串(关键步骤!)
// 使用 Jackson 序列化,确保格式统一
String signDataStr;
try {
signDataStr = objectMapper.writeValueAsString(signData);
} catch (Exception e) {
throw new RuntimeException("signData 序列化失败", e);
}
// 5. 获取用户 session_key
String sessionKey = userService.getSessionKey(uid);
if (sessionKey == null || sessionKey.isEmpty()) {
resp.put("code", -2);
resp.put("msg", "登录态已过期,请重新登录");
return resp;
}
// 6. 计算双签名
String appKey = vpConfig.getCurrentAppKey();
String paySig = VpSignUtil.calcPaySig(
"requestVirtualPayment", signDataStr, appKey);
String signature = VpSignUtil.calcSignature(signDataStr, sessionKey);
// 7. 创建本地订单
Order order = new Order();
order.setOutTradeNo(outTradeNo);
order.setUid(uid);
order.setProductId(productId);
order.setAmount(product.getPrice());
order.setStatus("pending");
order.setCreatedAt(new Date());
orderService.createOrder(order);
// 8. 返回给客户端
Map<String, Object> data = new HashMap<>();
data.put("mode", "short_series_goods");
data.put("signData", signDataStr); // 必须是字符串!
data.put("paySig", paySig);
data.put("signature", signature);
resp.put("code", 0);
resp.put("data", data);
return resp;
}
}写个单元测试验证签名逻辑是否正确,避免上线后才发现签名算错:
package com.example.vpay.util;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class VpSignUtilTest {
@Test
void testCalcPaySig() {
String uri = "requestVirtualPayment";
String signDataStr = "{\"offerId\":\"1234567890\",\"buyQuantity\":1}";
String appKey = "test_app_key_123456";
String paySig = VpSignUtil.calcPaySig(uri, signDataStr, appKey);
// 签名应该是 64 位十六进制字符串
assertNotNull(paySig);
assertEquals(64, paySig.length());
assertTrue(paySig.matches("[0-9a-f]{64}"));
}
@Test
void testCalcSignature() {
String signDataStr = "{\"offerId\":\"1234567890\",\"buyQuantity\":1}";
String sessionKey = "test_session_key";
String signature = VpSignUtil.calcSignature(signDataStr, sessionKey);
assertNotNull(signature);
assertEquals(64, signature.length());
}
@Test
void testSameInputSameResult() {
// 相同输入应产生相同签名
String key = "same_key";
String msg = "same_message";
String sig1 = VpSignUtil.calcSignature(msg, key);
String sig2 = VpSignUtil.calcSignature(msg, key);
assertEquals(sig1, sig2);
}
}⚠️ 签名排查要点 1. uri 不可带参数(即
?及后面的部分全部舍去) 2.signDataStr必须和真正用于签名的那份字符串完全一致——包括空格、顺序、转义。如果你的框架在序列化时自动加了空格或换了顺序,签名就对不上。 3.appKey必须与环境匹配:env=0用现网 AppKey,env=1用沙箱 AppKey。混了必错。
客户端这边相对简单。拿到服务端返回的参数后,调一个 API 就行。但有几个兼容性细节要注意。
虚拟支付 API wx.requestVirtualPayment 从基础库 2.19.2 开始支持。版本不够的用户调用不了,你得先判断:
// 版本号比较工具函数
function compareVersion(v1, v2) {
const parts1 = v1.split('.');
const parts2 = v2.split('.');
const len = Math.max(parts1.length, parts2.length);
while (parts1.length < len) parts1.push('0');
while (parts2.length < len) parts2.push('0');
for (let i = 0; i < len; i++) {
const n1 = parseInt(parts1[i], 10);
const n2 = parseInt(parts2[i], 10);
if (n1 > n2) return 1;
if (n1 < n2) return -1;
}
return 0;
}
// 检查是否支持虚拟支付
function isVPSupported() {
const { SDKVersion } = wx.getSystemInfoSync();
return compareVersion(SDKVersion, '2.19.2') >= 0
|| wx.canIUse('requestVirtualPayment');
}// pages/payment/payment.js
/**
* 发起虚拟支付
* @param {string} productId - 商品 ID
*/
async function handlePay(productId) {
// 1. 检查版本支持
if (!isVPSupported()) {
wx.showModal({
title: '提示',
content: '当前微信版本过低,请更新到最新版本后再试',
showCancel: false,
});
return;
}
wx.showLoading({ title: '准备中...' });
try {
// 2. 请求服务端获取支付参数
const res = await request({
url: '/api/vp/create',
method: 'POST',
data: { productId },
});
wx.hideLoading();
if (res.code !== 0) {
wx.showToast({ title: res.msg || '支付准备失败', icon: 'none' });
return;
}
const { mode, signData, paySig, signature } = res.data;
// 3. 调用虚拟支付 API
wx.requestVirtualPayment({
mode, // 'short_series_goods' 道具直购
signData, // 字符串!不是对象!
paySig,
signature,
success(res) {
console.log('支付成功', res);
wx.showToast({ title: '购买成功' });
// 刷新用户权益状态
refreshUserVip();
},
fail({ errMsg, errCode }) {
console.error('支付失败', errMsg, errCode);
if (errCode === -2) {
// 用户取消,不用提示
return;
}
const msg = ERROR_MAP[errCode] || errMsg || '支付失败';
wx.showToast({ title: msg, icon: 'none' });
},
});
} catch (err) {
wx.hideLoading();
wx.showToast({ title: '网络错误', icon: 'none' });
}
}
// 常见错误码映射
const ERROR_MAP = {
-15001: '参数错误',
-15002: '订单号重复,请重试',
-15003: '系统繁忙,请稍后重试',
-15005: '用户签名错误,请重新登录',
-15006: '支付签名错误',
-15007: '登录态已过期,请重新登录',
-15010: '商品未发布,请联系客服',
-15013: '商品价格异常',
-15014: '商品配置未生效,请稍后再试',
-15020: '操作过快,请稍后再试',
};错误码 | 含义 | 处理建议 |
|---|---|---|
-2 | 用户取消支付 | 静默处理,不提示 |
-4 | 风控拦截 | 提示用户联系客服 |
-15001 | 参数错误 | 检查 signData 字段 |
-15002 | 订单号重复 | 重新生成订单号 |
-15003 | 系统错误 | 重试或提示稍后再试 |
-15005 | 用户签名错误 | session_key 过期,需重新登录 |
-15006 | 支付签名错误 | 检查 appKey 和签名算法 |
-15007 | session_key 过期 | 重新调用登录流程 |
-15010 | 道具未发布 | 检查后台道具配置 |
-15011 | 现网环境 env 不能为 1 | 上线前确认 env=0 |
-15013 | 道具价格错误 | goodsPrice 与后台不一致 |
-15014 | 道具/代币发布未生效 | 等待约 10 分钟 |
-15020 | 操作过快 | 前端限流 |
💡 signData 传字符串还是对象? 这是一个高频踩坑点。
signData参数必须以 字符串 形式传递,不能传 JSON 对象。微信内部会用这个字符串做验签,如果你传了对象,微信会自动序列化,但序列化后的字符串可能跟你服务端签名用的字符串不一致(空格、顺序等差异),导致-15006签名错误。 正确做法:signData: param.signData(服务端返回的已经是字符串)。错误做法:signData: JSON.parse(param.signData)。
用户付完钱之后,微信会向你的服务器推送一条消息,告诉你「这笔订单付款成功了,可以发货了」。这个消息叫回调(callback),也叫消息推送。
这里有一个关键认知:wx.requestVirtualPayment 的 success 回调只能告诉你「微信那边处理完了」,但不能作为唯一依据。 因为微信可能在极端情况下退出、崩溃,导致 success 回调不触发。所以你必须实现服务端回调来确认支付结果。
虚拟支付有四种回调推送:
Event 值 | 触发时机 | 你要做什么 |
|---|---|---|
xpay_goods_deliver_notify | 道具直购支付成功 | 发放道具/权益 |
xpay_coin_pay_notify | 代币扣减成功 | 更新代币余额 |
xpay_refund_notify | 退款完成 | 回收权益 |
xpay_complaint_notify | 用户发起投诉 | 处理投诉 |
使用WorkBuddy制作回调处理流程交互图。

package com.example.vpay.controller;
import com.example.vpay.service.OrderService;
import com.example.vpay.service.GoodsService;
import com.example.vpay.service.ComplaintService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.*;
/**
* 虚拟支付回调处理
* 路由:POST /api/vp/callback
* 需要在 MP 后台配置此地址为消息推送 URL
*/
@RestController
@RequestMapping("/api/vp")
public class VpCallbackController {
private static final Logger log = LoggerFactory.getLogger(VpCallbackController.class);
@Autowired
private OrderService orderService;
@Autowired
private GoodsService goodsService;
@Autowired
private ComplaintService complaintService;
@Autowired
private ObjectMapper objectMapper;
/**
* 回调统一入口
* 微信推送的 body 是 JSON 格式,包含 Event 字段区分回调类型
*/
@PostMapping("/callback")
public Map<String, Object> handleCallback(@RequestBody Map<String, Object> body) {
String event = (String) body.get("Event");
String outTradeNo = (String) body.get("OutTradeNo");
log.info("收到虚拟支付回调: event={}, outTradeNo={}", event, outTradeNo);
// ---- 道具直购发货推送 ----
if ("xpay_goods_deliver_notify".equals(event)) {
return handleGoodsDeliver(body);
}
// ---- 代币支付推送 ----
if ("xpay_coin_pay_notify".equals(event)) {
return handleCoinPay(body);
}
// ---- 退款推送 ----
if ("xpay_refund_notify".equals(event)) {
return handleRefund(body);
}
// ---- 投诉推送 ----
if ("xpay_complaint_notify".equals(event)) {
return handleComplaint(body);
}
// ---- 首次验证回调地址 ----
// 配置回调 URL 时微信会发验证请求,直接返回传入的 echostr
if (event == null || "weapp_audit".equals(event)) {
Map<String, Object> resp = new HashMap<>();
resp.put("echostr", body.getOrDefault("echostr", "success"));
return resp;
}
// 未知事件,返回成功避免重试
return successResp();
}
/**
* 道具直购发货
*/
private Map<String, Object> handleGoodsDeliver(Map<String, Object> body) {
Map<String, Object> goodsInfo = (Map<String, Object>) body.get("GoodsInfo");
Map<String, Object> weChatPayInfo = (Map<String, Object>) body.get("WeChatPayInfo");
String outTradeNo = (String) body.get("OutTradeNo");
String productId = (String) goodsInfo.get("ProductId");
int quantity = (int) goodsInfo.get("Quantity");
int actualPrice = (int) goodsInfo.get("ActualPrice");
String attach = (String) goodsInfo.get("Attach");
String transactionId = (String) weChatPayInfo.get("TransactionId");
long paidTime = (long) weChatPayInfo.get("PaidTime"); // 秒级时间戳
try {
// 1. 解析透传数据
Map<String, Object> attachData = objectMapper.readValue(
attach, Map.class);
Long uid = Long.valueOf(attachData.get("uid").toString());
// 2. 查询本地订单
Order order = orderService.getByOutTradeNo(outTradeNo);
if (order == null) {
log.error("订单不存在: {}", outTradeNo);
return errorResp("order not found");
}
// 3. 幂等检查:已处理过的订单直接返回成功
if ("success".equals(order.getStatus())) {
log.info("订单已处理,幂等返回: {}", outTradeNo);
return successResp();
}
// 4. 更新订单状态
order.setStatus("success");
order.setTransactionId(transactionId);
order.setPaidTime(new Date(paidTime * 1000)); // 秒 → 毫秒
order.setPaidAmount(actualPrice);
orderService.updateOrder(order);
// 5. 发放商品 / 权益(你的业务逻辑)
goodsService.deliverGoods(uid, productId, quantity, outTradeNo);
// 6. 返回成功,告诉微信「我收到了」
log.info("发货成功: outTradeNo={}, productId={}", outTradeNo, productId);
return successResp();
} catch (Exception e) {
log.error("回调处理异常: outTradeNo={}", outTradeNo, e);
// 返回失败,微信会重试(最多 15 次)
return errorResp("internal error");
}
}
/**
* 代币支付
*/
private Map<String, Object> handleCoinPay(Map<String, Object> body) {
Map<String, Object> coinInfo = (Map<String, Object>) body.get("CoinInfo");
// 处理代币扣减逻辑...
log.info("代币支付回调: {}", coinInfo);
return successResp();
}
/**
* 退款
*/
private Map<String, Object> handleRefund(Map<String, Object> body) {
String mchRefundId = (String) body.get("MchRefundId");
int refundFee = (int) body.get("RefundFee");
int retCode = (int) body.get("RetCode");
if (retCode == 0) {
// 退款成功,回收用户权益
goodsService.revokeGoods(mchRefundId);
log.info("退款成功,已回收权益: {}", mchRefundId);
}
return successResp();
}
/**
* 投诉
*/
private Map<String, Object> handleComplaint(Map<String, Object> body) {
String complaintId = (String) body.get("ComplaintId");
String complaintDetail = (String) body.get("ComplaintDetail");
// 存入投诉表,通知运营处理
complaintService.createRecord(complaintId, complaintDetail);
log.info("收到投诉: id={}", complaintId);
return successResp();
}
// ---- 响应工具方法 ----
private Map<String, Object> successResp() {
Map<String, Object> resp = new HashMap<>();
resp.put("ErrCode", 0);
resp.put("ErrMsg", "success");
return resp;
}
private Map<String, Object> errorResp(String errMsg) {
Map<String, Object> resp = new HashMap<>();
resp.put("ErrCode", -1);
resp.put("ErrMsg", errMsg);
return resp;
}
}推送有可能丢失,建议加一个定时任务兜底:
package com.example.vpay.task;
import com.example.vpay.entity.Order;
import com.example.vpay.service.OrderService;
import com.example.vpay.service.WxQueryService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 订单状态轮询兜底任务
* 每 5 分钟查询近 30 分钟内 pending 状态的订单
*/
@Component
public class OrderPollingTask {
private static final Logger log = LoggerFactory.getLogger(OrderPollingTask.class);
@Autowired
private OrderService orderService;
@Autowired
private WxQueryService wxQueryService;
/**
* 每 5 分钟执行一次
*/
@Scheduled(fixedRate = 300_000)
public void pollPendingOrders() {
List<Order> pendingOrders = orderService.getRecentPendingOrders(30);
for (Order order : pendingOrders) {
try {
// 调用微信 /xpay/query_order 接口查询订单状态
WxQueryService.QueryResult result =
wxQueryService.queryOrder(order.getOutTradeNo());
if (result.isPaid()) {
// 已支付但未发货 → 补发商品
log.info("轮询发现已支付未发货订单: {}", order.getOutTradeNo());
orderService.markAsSuccess(order, result.getTransactionId());
goodsService.deliverGoods(
order.getUid(),
order.getProductId(),
1,
order.getOutTradeNo()
);
}
} catch (Exception e) {
log.warn("轮询订单失败: {}", order.getOutTradeNo(), e);
}
}
}
}⚠️ 回调响应格式不对会被疯狂重试 如果你的回调接口返回的格式不正确,微信会认为你没收到,然后重试——最多重试 15 次,间隔从 2 秒指数增长到 4、8、16 秒……你会收到一堆重复推送。所以务必确保返回
{"ErrCode": 0, "ErrMsg": "success"}这个格式(JSON 或 XML 均可)。💡 推送 + 轮询,双保险 官方建议至少实现推送或轮询中的一个,两者结合最可靠。推送负责实时发货,轮询作为兜底——万一推送丢了,你的定时任务可以主动调用
/xpay/query_order接口查询订单状态,发现已支付但未发货的订单就补发。建议每 5 分钟轮询一次近 30 分钟内 pending 状态的订单。
以下经验来自实际对接过程中真实遇到过的问题,它们能让你少走几天弯路。
用户签名 signature 依赖 session_key,而 session_key 会过期。小程序每次启动时通过 wx.login 获取的 code 换取的 session_key 有效期有限(官方没公布确切时间,实测大约几小时到一天不等)。如果用户打开小程序后放了很久再点支付,session_key 可能已经失效,导致 -15007 错误。
解法:在请求支付参数之前,先调 wx.login 刷新登录态,拿到新的 code 传给后端换取新的 session_key。
iOS 端的虚拟支付走的是苹果 IAP 通道,苹果不提供沙箱测试环境给小程序。所以你在 iOS 上测试时,env=1(沙箱)是调不起来的。iOS 只能用 env=0(现网环境)测试。但现网环境会产生真实扣款和真实技术服务费。
解法:iOS 端测试用低价商品,测完通过后台退款。安卓端可以正常用沙箱。
iOS 端走 IAP 时有最低金额限制。实测 1 分钱(goodsPrice=1)在 iOS 上会报错,但 1 元(goodsPrice=100)或更高金额可以。具体阈值微信没有明确文档说明,建议 iOS 测试金额不低于 1 元。
在商户后台配置或修改道具后,大概需要 10 分钟才生效。在生效之前发起支付会返回 -15014。如果你改了道具价格就立即去测试,会以为代码写错了。
解法:改完道具配置后去喝杯咖啡,10 分钟后再测。
这是最隐蔽的坑。JSON.stringify() 默认不加空格,但有些框架或中间件会「美化」JSON 输出,在键值对之间加空格:{"a": 1} 变成 { "a": 1 }。你的服务端用美化后的字符串算签名,客户端传给微信的却是原始字符串(或者反过来),签名就对不上了。
解法:确保 signDataStr 从生成到签名到传给客户端,全程使用同一个字符串实例。不要中间 JSON.parse 再 JSON.stringify。在 Java 中,Jackson 默认不加多余空格,但如果你自定义了序列化器或用了 SerializationFeature.INDENT_OUTPUT,要确保关闭它。
安卓用户的订单可以在小程序商户后台直接操作退款,但 iOS 端走 IAP 的订单,退款需要走苹果的退款流程。如果用户找你退款,iOS 订单你可能需要引导用户去苹果申请。
微信小程序现在也支持 Windows 端。如果你的判断逻辑只写了 platform === 'android',Windows 用户就调不了支付。需要加上 Windows 判断:
const { platform } = wx.getSystemInfoSync();
if (platform === 'android' || platform === 'windows') {
// 可以发起虚拟支付
}在对完代码、测完沙箱之后,切到现网之前,对照这份清单逐项检查。能帮你避免 90% 的上线事故。
productId 和价格(分)与代码中的一致,已发布并等待 10 分钟生效。env=0(现网),不要忘了从沙箱切回来。现网 env 填 1 会直接报错。wx.requestVirtualPayment 的 signData 是 JSON 字符串,不是对象。{"ErrCode":0,"ErrMsg":"success"}。虚拟支付的对接不算特别难,但它有一些跟普通微信支付不同的设计理念——双签名、米大师中台、回调推送。理解了这些差异,对接过程就会顺畅很多。
最大的心智负担在于「双签名」机制:你得确保服务端构建的 signData 字符串、签名计算用的字符串、传给客户端的字符串、最终传给微信的字符串,这四者是同一个字符串。任何一个环节重新序列化了 JSON,签名就对不上。把这一点守住,其他都是水到渠成的事。
希望这篇文章能帮你少踩几个坑。祝上线顺利,用户付费如流水。

谢谢你看我的文章,既然看到这里了,如果觉得不错,随手点个赞、转发、在看三连吧,感谢感谢。那我们,下次再见。
您的一键三连,是我更新的最大动力,谢谢
山水有相逢,来日皆可期,谢谢阅读,我们再会
我手中的金箍棒,上能通天,下能探海
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。