首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >用WorkBuddy制作流程图解,微信小程序虚拟支付对接全流程实战

用WorkBuddy制作流程图解,微信小程序虚拟支付对接全流程实战

原创
作者头像
悟空码字
发布2026-07-06 14:45:16
发布2026-07-06 14:45:16
1060
举报
文章被收录于专栏:编程技术编程技术

大家好,我是小悟。

目录

  1. 先搞清楚你要的是哪种支付
  2. 开通条件与准备工作
  3. 支付全流程图解
  4. 服务端实现:构建参数与双签名
  5. 客户端实现:拉起支付弹窗
  6. 回调处理:钱到账了怎么办
  7. 踩坑实录:那些文档没写的事
  8. 上线前检查清单

你做了一个小程序,内容做得不错,用户量也在涨。这时候产品经理走过来说:「该考虑商业化了吧,加个会员体系,让用户付费解锁内容。」

你想,这不就是接个支付嘛。微信支付文档那么多,照着抄一遍就行了。

然后你打开文档搜了一圈,发现一个尴尬的事实:iOS 用户在小程序里买虚拟商品,不能直接走普通微信支付。

苹果的 IAP(In-App Purchase)政策卡在那里:虚拟商品必须走苹果的内购通道,否则就有违规下架的风险。但小程序又不是原生 App,IAP 那套机制根本套不上。普通 wx.requestPayment 在 iOS 上对虚拟商品是受限的——你甚至看不到支付按钮。

进退两难的时候,你在微信开放文档的角落里发现了「虚拟支付」这个能力。

这篇文章就是关于这个东西怎么接的。我会把整个对接过程从头到尾走一遍,包括那些官方文档写得语焉不详、但你不去踩一遍就根本不知道的地方。


1. 先搞清楚你要的是哪种支付

微信小程序里能用的支付方式,主要是两套体系。搞混了的话,后面所有努力都白费。

对比维度

普通微信支付

虚拟支付

适用商品

实物商品(电商、外卖等)

虚拟商品(会员、课程、道具、解锁功能等)

iOS 支持

不支持虚拟商品

支持(底层打通了苹果 IAP 通道)

客户端 API

wx.requestPayment

wx.requestVirtualPayment

费率(安卓)

约 0.6%

1%

费率(iOS)

较高(含苹果 + 腾讯两部分,标准费率 17%)

结算周期

T+1

T+3

支付通道

微信支付直接处理

走米大师(Midas)中台

简单说:如果你卖的是实物,用普通支付;如果你卖的是虚拟商品——会员、课程、数字内容、功能解锁、打赏——就得用虚拟支付。这不是选不选的问题,是平台规定的。

虚拟支付最大的价值,是解决了 iOS 端虚拟商品的支付合规问题。它底层对接了苹果 IAP,让 iOS 用户也能在小程序里直接付钱买虚拟东西,不用跳出去。

💡 适用场景判断 如果你的小程序有以下任何一种功能,就需要虚拟支付:付费解锁文章/视频、购买虚拟道具/代币、开通会员/订阅、付费功能解锁、打赏/虚拟礼物。判断标准很简单——用户付了钱之后拿到的是「数字内容」而不是「快递到家」的东西,那就是虚拟支付。


2. 开通条件与准备工作

虚拟支付不是想用就能用的,需要先开通。这个过程说复杂也不复杂,但需要耐心等。

资质门槛

首先,你的小程序主体必须是以下类型之一:

  • 企业
  • 事业单位
  • 个体工商户

个人主体的小程序不能开通虚拟支付。如果你是个人开发者,得先升级主体类型。

开通六步走

在小程序管理后台左侧栏找到【虚拟支付】,点击【开通】,然后走这六步:

步骤

内容

耗时

1. 阅读协议

阅读并勾选同意《微信小程序虚拟支付功能开启及使用须知》

即时

2. 提交资料

营业执照、经营者/法人身份证信息、提现账户、支付管理员信息

即时

3. 等待审核

微信审核你的资质材料

1-7 个工作日

4. 账户验证

验证支付管理员身份(法人本人可跳过)

即时

5. 扫码签约

管理员扫码完成电子签约

1-2 个工作日确认

6. 进入商户后台

配置代币/道具,管理资金

即时

你需要拿到手的四个关键配置

开通完成后,在 MP 后台 → 虚拟支付 → 基础配置里,你会看到四个东西,这是后续所有代码的基石:

配置项

说明

用途

AppID

小程序的 AppID

身份标识

OfferID

米大师分配的应用 ID

支付请求的核心参数

现网 AppKey

正式环境的支付密钥

计算支付签名 paySig

沙箱 AppKey

测试环境的支付密钥

沙箱测试时使用

⚠️ AppKey 千万不要泄露 现网 AppKey 是计算支付签名的密钥,一旦泄露,别人可以伪造你的支付请求。请只放在服务端环境变量里,绝不要硬编码进客户端代码,更不要提交到 Git 仓库。

道具配置

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


3. 支付全流程图解

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

整个流程可以拆成三个阶段:下单准备(步骤 1-4)、支付执行(步骤 5-6)、发货回调(步骤 7-9)。

跟普通微信支付最大的区别在于中间这步——你的服务器需要自己构建 signData 并计算两个签名,然后把签名结果传给客户端,客户端再拿着这些参数去调微信的 API。普通支付只需要后端调微信统一下单接口拿到 prepay_id 就行,虚拟支付多了一层「参数自己构造」的逻辑。


4. 服务端实现:构建参数与双签名

这是整个对接过程中最容易出错的地方。微信虚拟支付要求客户端在调用支付 API 时传入两个签名:paySigsignature。这两个签名都由你的服务端计算,缺一不可。

signData 是什么

signData 是一个 JSON 字符串,包含了这笔订单的所有关键信息。在道具直购模式下,它的结构是这样的:

代码语言:javascript
复制
{
  "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——也就是用户必须先完成微信登录。

Java 服务端代码实现

下面是 Spring Boot 风格的完整实现,包含签名工具类、支付参数构建接口。

4.1 签名工具类
代码语言:javascript
复制
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();
    }
}
4.2 配置类
代码语言:javascript
复制
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

代码语言:javascript
复制
vpay:
  app-id: wx_your_appid
  offer-id: "1234567890"
  app-key: ${VP_APP_KEY}          # 从环境变量读取
  sandbox-key: ${VP_SANDBOX_KEY}  # 从环境变量读取
  sandbox: false                   # 上线时改为 false
4.3 支付参数构建接口
代码语言:javascript
复制
package 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;
    }
}
4.4 签名验证测试

写个单元测试验证签名逻辑是否正确,避免上线后才发现签名算错:

代码语言:javascript
复制
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。混了必错。


5. 客户端实现:拉起支付弹窗

客户端这边相对简单。拿到服务端返回的参数后,调一个 API 就行。但有几个兼容性细节要注意。

版本检查

虚拟支付 API wx.requestVirtualPayment 从基础库 2.19.2 开始支持。版本不够的用户调用不了,你得先判断:

代码语言:javascript
复制
// 版本号比较工具函数
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');
}

发起支付

代码语言:javascript
复制
// 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)


6. 回调处理:钱到账了怎么办

用户付完钱之后,微信会向你的服务器推送一条消息,告诉你「这笔订单付款成功了,可以发货了」。这个消息叫回调(callback),也叫消息推送。

这里有一个关键认知:wx.requestVirtualPaymentsuccess 回调只能告诉你「微信那边处理完了」,但不能作为唯一依据。 因为微信可能在极端情况下退出、崩溃,导致 success 回调不触发。所以你必须实现服务端回调来确认支付结果。

回调类型

虚拟支付有四种回调推送:

Event 值

触发时机

你要做什么

xpay_goods_deliver_notify

道具直购支付成功

发放道具/权益

xpay_coin_pay_notify

代币扣减成功

更新代币余额

xpay_refund_notify

退款完成

回收权益

xpay_complaint_notify

用户发起投诉

处理投诉

回调处理流程

使用WorkBuddy制作回调处理流程交互图。

回调处理代码

代码语言:javascript
复制
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;
    }
}
轮询兜底:定时查询订单状态

推送有可能丢失,建议加一个定时任务兜底:

代码语言:javascript
复制
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 状态的订单。


7. 踩坑实录:那些文档没写的事

以下经验来自实际对接过程中真实遇到过的问题,它们能让你少走几天弯路。

坑 1:session_key 过期是最常见的支付失败

用户签名 signature 依赖 session_key,而 session_key 会过期。小程序每次启动时通过 wx.login 获取的 code 换取的 session_key 有效期有限(官方没公布确切时间,实测大约几小时到一天不等)。如果用户打开小程序后放了很久再点支付,session_key 可能已经失效,导致 -15007 错误。

解法:在请求支付参数之前,先调 wx.login 刷新登录态,拿到新的 code 传给后端换取新的 session_key

坑 2:iOS 沙箱环境不支持

iOS 端的虚拟支付走的是苹果 IAP 通道,苹果不提供沙箱测试环境给小程序。所以你在 iOS 上测试时,env=1(沙箱)是调不起来的。iOS 只能用 env=0(现网环境)测试。但现网环境会产生真实扣款和真实技术服务费。

解法:iOS 端测试用低价商品,测完通过后台退款。安卓端可以正常用沙箱。

坑 3:iOS 最低支付金额限制

iOS 端走 IAP 时有最低金额限制。实测 1 分钱(goodsPrice=1)在 iOS 上会报错,但 1 元(goodsPrice=100)或更高金额可以。具体阈值微信没有明确文档说明,建议 iOS 测试金额不低于 1 元。

坑 4:道具发布后不是立即生效

在商户后台配置或修改道具后,大概需要 10 分钟才生效。在生效之前发起支付会返回 -15014。如果你改了道具价格就立即去测试,会以为代码写错了。

解法:改完道具配置后去喝杯咖啡,10 分钟后再测。

坑 5:JSON 序列化的空格问题

这是最隐蔽的坑。JSON.stringify() 默认不加空格,但有些框架或中间件会「美化」JSON 输出,在键值对之间加空格:{"a": 1} 变成 { "a": 1 }。你的服务端用美化后的字符串算签名,客户端传给微信的却是原始字符串(或者反过来),签名就对不上了。

解法:确保 signDataStr 从生成到签名到传给客户端,全程使用同一个字符串实例。不要中间 JSON.parseJSON.stringify。在 Java 中,Jackson 默认不加多余空格,但如果你自定义了序列化器或用了 SerializationFeature.INDENT_OUTPUT,要确保关闭它。

坑 6:苹果订单退款不方便

安卓用户的订单可以在小程序商户后台直接操作退款,但 iOS 端走 IAP 的订单,退款需要走苹果的退款流程。如果用户找你退款,iOS 订单你可能需要引导用户去苹果申请。

坑 7:Windows 端需要特殊处理

微信小程序现在也支持 Windows 端。如果你的判断逻辑只写了 platform === 'android',Windows 用户就调不了支付。需要加上 Windows 判断:

代码语言:javascript
复制
const { platform } = wx.getSystemInfoSync();
if (platform === 'android' || platform === 'windows') {
  // 可以发起虚拟支付
}

8. 上线前检查清单

在对完代码、测完沙箱之后,切到现网之前,对照这份清单逐项检查。能帮你避免 90% 的上线事故。

  • 1. 资质开通完成:小程序后台虚拟支付状态显示「已签约」,拿到了 AppID、OfferID、现网 AppKey、沙箱 AppKey。
  • 2. 道具配置正确:所有 productId 和价格(分)与代码中的一致,已发布并等待 10 分钟生效。
  • 3. env 参数确认:上线时 env=0(现网),不要忘了从沙箱切回来。现网 env 填 1 会直接报错。
  • 4. AppKey 确认:使用现网 AppKey,不是沙箱 AppKey。放在环境变量里,没有硬编码,没有提交到 Git。
  • 5. 签名算法正确:paySig = HMAC-SHA256(appKey, "requestVirtualPayment" + "&" + signDataStr);signature = HMAC-SHA256(session_key, signDataStr)。
  • 6. signData 是字符串:传给 wx.requestVirtualPayment 的 signData 是 JSON 字符串,不是对象。
  • 7. 回调地址已配置:在 MP 后台配置了消息推送 URL,且验证通过。回调接口正确返回 {"ErrCode":0,"ErrMsg":"success"}
  • 8. 幂等处理:回调中对已处理订单直接返回成功,不会重复发放商品。
  • 9. 轮询兜底:有定时任务查询 pending 订单状态,防止回调丢失导致用户付了钱没收到商品。
  • 10. 版本兼容:客户端做了基础库版本检查(≥ 2.19.2),低版本有友好提示。
  • 11. 平台兼容:支持 Android、iOS、Windows 端判断,不遗漏。
  • 12. 错误码处理:客户端有完整的错误码映射,用户取消(-2)静默处理,其他错误有友好提示。
  • 13. 日志完备:服务端记录了每笔订单的创建、签名计算、回调处理日志,出问题能快速定位。
  • 14. 退款流程:了解安卓退款(后台直接操作)和 iOS 退款(走苹果流程)的区别,有对应的客服话术。

虚拟支付的对接不算特别难,但它有一些跟普通微信支付不同的设计理念——双签名、米大师中台、回调推送。理解了这些差异,对接过程就会顺畅很多。

最大的心智负担在于「双签名」机制:你得确保服务端构建的 signData 字符串、签名计算用的字符串、传给客户端的字符串、最终传给微信的字符串,这四者是同一个字符串。任何一个环节重新序列化了 JSON,签名就对不上。把这一点守住,其他都是水到渠成的事。

希望这篇文章能帮你少踩几个坑。祝上线顺利,用户付费如流水。

谢谢你看我的文章,既然看到这里了,如果觉得不错,随手点个赞、转发、在看三连吧,感谢感谢。那我们,下次再见。

您的一键三连,是我更新的最大动力,谢谢

山水有相逢,来日皆可期,谢谢阅读,我们再会

我手中的金箍棒,上能通天,下能探海

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 目录
  • 1. 先搞清楚你要的是哪种支付
  • 2. 开通条件与准备工作
    • 资质门槛
    • 开通六步走
    • 你需要拿到手的四个关键配置
    • 道具配置
  • 3. 支付全流程图解
  • 4. 服务端实现:构建参数与双签名
    • signData 是什么
    • 双签名机制
    • Java 服务端代码实现
      • 4.1 签名工具类
      • 4.2 配置类
      • 4.3 支付参数构建接口
      • 4.4 签名验证测试
  • 5. 客户端实现:拉起支付弹窗
    • 版本检查
    • 发起支付
    • 完整错误码表
  • 6. 回调处理:钱到账了怎么办
    • 回调类型
    • 回调处理流程
    • 回调处理代码
      • 轮询兜底:定时查询订单状态
  • 7. 踩坑实录:那些文档没写的事
    • 坑 1:session_key 过期是最常见的支付失败
    • 坑 2:iOS 沙箱环境不支持
    • 坑 3:iOS 最低支付金额限制
    • 坑 4:道具发布后不是立即生效
    • 坑 5:JSON 序列化的空格问题
    • 坑 6:苹果订单退款不方便
    • 坑 7:Windows 端需要特殊处理
  • 8. 上线前检查清单
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档