个人主体微信小程序接入虚拟支付功能

1. 概述

1.1 这是什么

云轩之音我做的一款日记类小程序,通过微信虚拟支付向用户售卖两类「一次性道具」额度:

额度英文名(creditKey)用途免费额度
文档导入额度docImportCredits文档解析/机器翻译(docimport)5 次
创意工坊生图额度imggenCreditsAI 生图(studio)10 次

虚拟支付走 wx.requestVirtualPayment,与个人主体的「实物电商微信支付」是两套独立体系——
虚拟支付只用于虚拟道具/知识付费内容

1.2 能力边界与资质约束

  • 个人主体开通虚拟支付受类目限制,道具类型固定为 short_series_goods(一次性道具直购),不支持代币充值(虽然有这个设置选项),不支持订阅/连续包月。
  • 发货由微信服务器异步推送,必须做幂等,否则微信最多重推 15 次会重复发货或漏发货。
  • 所有下单/发货/查单的金额与签名均在服务端完成,前端只透传 signData 原串。

1.3 架构总览

┌────────────┐   wx.login(code)            ┌─────────────────────┐
│  小程序前端  │ ───────────────────────▶   │  virtualpay (云函数)  │
│ services/   │   action: create            │  · 算双签名 paySig    │
│  vpay.js    │ ◀───────────────────────   │  · signature          │
└────────────┘   返回 signData/paySig       └─────────────────────┘
      │  wx.requestVirtualPayment(signData)
      ▼
┌────────────┐   支付成功                  ┌─────────────────────┐
│ 微信支付平台 │ ── xpay_goods_deliver_notify ─▶ │  vpaynotify (云函数)  │
└────────────┘   (HTTP 消息推送)           │  · 验签 / 解密 XML    │
                                           │  · 转调 deliver       │
                                           └──────────┬──────────┘
                                                      │ cloud.callFunction
                                                      ▼
                                           ┌─────────────────────┐
                                           │  virtualpay.deliver   │
                                           │  · fail-closed 鉴权   │
                                           │  · 幂等发货 + 加额度   │
                                           └─────────────────────┘

文件清单

文件职责
cloudfunctions/virtualpay/index.js下单签名、幂等发货、兜底查单、订单查询(核心)
cloudfunctions/virtualpay/guard.js发货守卫(纯逻辑、可单测):调用方鉴权 + 参数校验
cloudfunctions/vpaynotify/index.jsHTTP 推送接收:验签、解包 XML、转发发货
cloudfunctions/ai/index.js创意工坊生图配额(imgQuota/consumeImgCredit/FREE_IMGENES
cloudfunctions/docparse(云函数)文档导入配额(docImportCredits
miniprogram/services/vpay.js前端服务层:products/pay/recheck/myorders/envVersion
miniprogram/services/cloud.js云函数调用封装 callFn
miniprogram/utils/config.js常量:FN_VPAYIMG_FREE_GENSDOC_FREE_IMPORTS
miniprogram/pages/orders/orders.*订单中心页(审核必填的 path 落地页)
cloudbaserc.json云函数 timeout/memorySize/环境变量
数据库集合 vpay_orders订单(权限「仅管理端可读写」)
数据库集合 profiles用户额度(docImportCredits/imggenCredits/imggenCount

2. 前置准备(微信公众平台 + 云开发控制台)

2.1 开通虚拟支付

MP 后台 → 支付与交易 → 虚拟支付,按引导开通并确保小程序类目在支持范围内。开通后在「基本配置」拿到的关键信息:

  • OfferID → 环境变量 VPAY_OFFER_ID
  • 现网 AppKey → 环境变量 VPAY_APP_KEY
  • 沙箱 AppKey(开发版/体验版调试用)→ 环境变量 VPAY_APP_KEY_SANDBOX

2.2 道具管理(必须与代码商品表一致)

MP 后台 → 虚拟支付 → 基本配置→道具配置,创建并发布以下道具(商品 ID、价格分必须和代码一致):

productId价格(分)说明
doc_parse_pack_1060010 次文档导入
doc_parse_pack_30160030 次文档导入
image_pack_1090010 次创意生图
image_pack_50320050 次创意生图

改价/加档优先用环境变量 VPAY_PRODUCTS 覆盖cloudbaserc.json 或控制台),不要改代码重新部署。
priceFen 与后台不一致会报 -15013,未发布报 -15010,刚发布需等约 10 分钟生效(-15014)。

2.3 消息推送 + HTTP 访问服务(发货回调)

  1. 云开发控制台 → 云函数 vpaynotifyHTTP 访问服务 → 新建路由(如 /vpay)→ 得到类似下方的URL
    https://<envId>.service.tcloudbase.com/vpay
  2. MP 后台 → 开发与服务 → 开发管理 → 消息推送 → URL 填上面地址。
  3. Token 与环境变量 PUSH_TOKEN 保持一致(控制台 vpaynotify 配置);加解密方式选「明文」或「安全」均可,安全模式需再配 PUSH_AES_KEY(EncodingAESKey)。
  4. 配置后会有一条 GET 探测(原样回显 echostr)验证签名,vpaynotify 已实现回显。

2.4 小程序 AppSecret

WX_APPSECRET(MP 后台 → 开发管理 → 开发设置)需配置到 virtualpay 环境变量,用于:

  • jscode2sessionsession_key(算用户态签名 signature
  • access_token(官方 query_order 查单)

3. 下单流程(create)

3.1 完整时序

前端 studio/docimport.onBuyPackage
  → wx.login() 取 code
  → vpay.pay(productId, qty)
      → cloud.callFunction(virtualpay, {action:'create', code, productId, quantity, envVersion})
  ◀── { signData, paySig, signature, mode, outTradeNo }
  → wx.requestVirtualPayment({ signData, paySig, signature, mode })
  ◀── success / fail(用户取消 -2)
  → 成功即 vpay.recheck(outTradeNo) 兜底补发货

3.2 双签名算法(官方规范,逐字一致)

// virtualpay/index.js
const signData = JSON.stringify({          // 键顺序即本顺序,不可乱
  offerId: offer,
  buyQuantity: quantity,
  env: env(envVersion),                    // 1=沙箱 0=现网
  currencyType: 'CNY',
  productId: product.productId,
  goodsPrice: product.priceFen,
  outTradeNo: outTradeNo,
  attach: [openid, product.productId, product.credits].join('|'),
});

const paySig    = HMAC_SHA256(AppKey,    "requestVirtualPayment&" + signData);  // 现网/沙箱 AppKey
const signature = HMAC_SHA256(sessionKey, signData);                            // 用户态签名
  • paySig 的 key 是 env 对应的 AppKey(沙箱用沙箱 AppKey)。
  • signature 的 key 是下单时 jscode2session 换来的 session_key
  • signData 必须是服务端下发的原始字符串,前端 wx.requestVirtualPayment 必须原样透传,不可再 JSON.stringify / 格式化,否则前后端序列化不一致 → -15006

3.3 数量与校验

  • quantitycreateclamp(1, 99)virtualpay/index.js:298)。
  • 商品必须命中商品表,否则 NO_PRODUCT 拒绝。

4. 发货流程(推送回调 deliver)

4.1 vpaynotify 接收

微信支付成功 → 推送 xpay_goods_deliver_notifyvpaynotify(HTTP)。vpaynotify 只做三件事:
校验来源 → 解析推送 → 转调 virtualpay.deliver

安全基线(vpaynotify/index.js:105-116全部 fail closed):

if (!PUSH_TOKEN)        return 拒绝;   // 未配置 Token = 拒绝,而非放行
if (!INTERNAL_TOKEN)    return 拒绝;   // 未配置内部密钥 = 拒绝
// GET 探测与 POST 推送都先 verifySignature(sha1 排序拼接)

XML 解析注意点(vpaynotify/index.js:73-76):

// 微信推送 XML 带 <xml> 根,xmlToObj 会把内容套进 obj.xml
function parsePushXml(xml) {
  const o = xmlToObj(xml);
  return o && o.xml && typeof o.xml === 'object' ? o.xml : o;
}
// 不解这层:msg.Event 恒 undefined,发货推送会被当未知事件忽略

安全模式(EncodingAESKey)推送走 wrapper.EncryptvpaynotifydecryptMessage 解 AES-256-CBC 后同样 parsePushXml 解包。

4.2 virtualpay.deliver 发货

vpaynotify 转发 {action:'deliver', __token, openid, outTradeNo, wxOrderId, productId, quantity, paidFee}

virtualpay.deliver 先过 guard.checkDeliverAccess(三条防线,见第 6 节),再过 deliverOrder
wxOrderId 幂等去重,商品表校验 + 金额对账 + 数量上限,最后 grantCredits 写入 profiles

发货顺序(virtualpay/index.js:255-277)——先落 pending 单、再加额度、再标 delivered

旧顺序是「先标 delivered 再加额度」,一旦 grantCredits 失败,重推会命中 delivered 幂等分支永久少发货
新顺序下 grant 失败订单停在 pending,重推/查单会走补发分支自动重试(虚拟商品宁可多发不可少发)。


5. 兜底查单(recheck)

支付成功但推送可能延迟/丢失,前端 onBuyPackagerequestVirtualPayment 成功后主动调一次 recheck

// vpay.js
pay(productId, 1)
  .then((order) => vpay.recheck(order.outTradeNo).then(() => order, () => order))
  .then(() => this.loadQuota());   // 刷新余额后提示「购买成功」

recheck 逻辑(virtualpay/index.js:381-422):

  1. 本地已 delivered → 直接返回。
  2. 否则用订单自身记录的 envVersion 调官方 query_order(带 access_token + pay_sig),保证与 create 时 env 一致(即便用户之后切版本)。
  3. 官方状态 ∈ {2,3,4}(已支付待发货/发货中/已发货)→ 信任根,补发货。
  4. 金额对账只在官方返回 paid_fee 时做(skipPriceCheck: !hasPaidFee),缺失则放行以免误伤。

6. 安全防护基线

6.1 发货接口零鉴权 → fail closed(guard.checkDeliverAccess

防线规则
① 拒绝用户端直调wx.cloud.callFunction 必带 OPENID;云函数内部转发不带。带 OPENID 一律拒绝。
② 内部密钥必配VPAY_INTERNAL_TOKEN 未配置 → 拒绝(旧实现「未配置就放行」= 零鉴权 P0)。
③ 常量时间比对__tokencrypto.timingSafeEqual 比较,防逐字节爆破。

6.2 消息推送零鉴权 → fail closed(vpaynotify

旧实现 if (PUSH_TOKEN && !verifySignature(...)):空串时条件短路 = 任何人可伪造推送发货。
改为 if (!PUSH_TOKEN) return 拒绝,未配置即拒绝服务。

6.3 密钥管理

  • cloudbaserc.json不要留空串环境变量——CLI 部署会把空串写上去覆盖控制台真值。
  • VPAY_INTERNAL_TOKEN 用 32 字节随机 hex(两端 virtualpay/vpaynotify 必须一致)。
  • 开发调试的临时密钥用完即删,不留常驻入口。

6.4 参数校验(guard.checkDeliverPayload

  • 商品必须命中商品表;绝不从 attach 解析额度attach 是客户端可见字段,任何「从 attach 取额度」都是伪造入口)。
  • 实付金额必须等于 priceFen × quantity,防低价单买高档位
  • quantity[1, 99],防数量放大。

7. 沙箱 / 现网自动判断

目标:开发版自动走沙箱、正式版自动走现网,发布后无需手动改环境变量。

// virtualpay/index.js
function resolveSandbox(envVersion) {
  const forced = process.env.VPAY_SANDBOX;
  if (forced === '1') return true;          // 强制沙箱(应急覆盖)
  if (forced === '0') return false;         // 强制现网(应急覆盖)
  const ev = String(envVersion || '').toLowerCase();
  return ev === 'develop' || ev === 'trial'; // 否则按运行版本
}
// vpay.js —— 前端自动取版本号并注入每个请求
function envVersion() {
  try {
    return wx.getAccountInfoSync().miniProgram.envVersion || 'release';
  } catch (e) { return 'release'; }
}
运行版本env行为
开发版 develop1沙箱,不真扣款
体验版 trial1沙箱
正式版 release0现网,真实付款

8. 商品表与额度模型

8.1 商品定义来源

优先级:VPAY_PRODUCTS(环境变量 JSON)> DEFAULT_PRODUCTSvirtualpay/index.js:55)。

8.2 creditKey 多额度隔离

grantCredits(openid, credits, creditKey)creditKey 泛化写 profiles

  • docImportCredits —— 文档导入
  • imggenCredits —— 创意工坊生图

旧商品不带 creditKey 时默认按 docImportCredits 发货(向后兼容)。

8.3 免费额度与计费口径

功能免费已用字段付费字段计费口径
文档导入5(DOC_FREE_IMPORTSdocImportCount(docparse 侧)docImportCredits解析成功即扣
创意生图10(FREE_IMGENES/IMG_FREE_GENSimggenCountimggenCredits成功生成 1 张扣 1 次

创意工坊「生成成功才扣」与文档导入「解析成功即扣」口径不同,务必区分。
前端 studio.loadPackages 只展示 creditKey==='imggenCredits'(或 productId 前缀 image_/imggen_ 兜底)的创意包。

8.4 配额查询与扣减(ai 云函数)

// cloudfunctions/ai/index.js
async function imgQuota(openid) {              // 剩余 = 免费 + 付费余额 − 已用
  const left = Math.max(0, FREE_IMGENES + paid - used);
  return { left, used, paid, free: FREE_IMGENES };
}
async function consumeImgCredit(openid) {      // 成功后 imggenCount +1
  db.collection('profiles').doc(_id).update({ data: { imggenCount: _.inc(1) } });
}
function remainingImgs(used, paid) {
  return Math.max(0, FREE_IMGENES + Number(paid || 0) - Number(used || 0));
}

image/image2image action:先 imgQuota 预检(left<=0 返回 NO_CREDIT),成功后才 consumeImgCredit


9. 订单中心页(审核必填)

pages/orders/orders主包,非分包):

  • MP 后台 → 小程序订单中心 → pathpages/orders/orders(勿带开头斜杠、勿带参数)。
  • 返回当前用户全部状态订单(myorders 不再只返回 delivered),前端 STATUS_MAP 映射:
    created 待支付 / pending 发放中 / delivered 已到账 / refunded 已退款 / failed 已失败
  • 客服/售后入口使用微信原生问题反馈组件 <button open-type="feedback">
    不占用「消息推送 URL」、不依赖企业微信客服,规避与消息推送的冲突。

10. 部署清单

10.1 云函数部署

上传并部署:云端安装依赖(不上传node_modules)

virtualpay
vpaynotify   # 部署后到控制台开 HTTP 访问服务并绑路由
ai           # 免费额度/配额改了需重部署

10.2 环境变量(virtualpay)

变量必填说明
VPAY_OFFER_ID虚拟支付 OfferID
VPAY_APP_KEY现网 AppKey
VPAY_APP_KEY_SANDBOX⚠️沙箱 AppKey(开发/体验版调试)
VPAY_INTERNAL_TOKEN内部发货密钥(与 vpaynotify 一致)
WX_APPID小程序 AppID
WX_APPSECRET小程序 AppSecret(换 sessionKey/token)
VPAY_PRODUCTS商品表 JSON 覆盖(改价加档用)
VPAY_SANDBOX强制覆盖:'1'沙箱 / '0'现网 / 不设为自动
VPAY_SKIP_PRICE_CHECK测试才需设置'1' 临时跳过金额对账(勿常态开)

vpaynotify 还需:PUSH_TOKENPUSH_AES_KEY(安全模式)、VPAY_INTERNAL_TOKEN

10.3 超时与内存(云函数参考配置)

云函数timeoutmemorySize备注
virtualpay20s256MBquery_order 网络调用
vpaynotify10s128MBHTTP 访问服务
ai900s512MB生图最慢 60s+,必须 ≥900s

10.4 上线前一键核对

  • 控制台 VPAY_SANDBOX 不是 '1'(正式版应走现网)
  • virtualpay / vpaynotify / ai 三个云函数已 CLI 部署
  • vpaynotify HTTP 路由已绑,MP 消息推送 URL + Token 已配
  • vpay_orders 集合已建,权限「仅管理端可读写」
  • MP 道具已发布,priceFen 与商品表一致
  • 订单中心 path pages/orders/orders 已填
  • 正式版实付一笔验证 env=0、额度到账

11. 常见错误码(前端映射见 vpay.js

含义排查
-15006签名校验失败AppKey 与 env 不匹配 / signData 被前端二次序列化
-15010道具未发布MP 后台发布道具
-15013道具价格与后台不一致校准 priceFen
-15014道具刚发布未生效等约 10 分钟
-15011现网 env 只能为 0关掉沙箱模式(确认 VPAY_SANDBOX/envVersion
-2用户取消支付正常,按 CANCEL 处理

具体的效果可以扫描下面的小程序码进行体验

相关推荐

小米路由器AX3600 解锁SSH

小米AIoT路由器AX3600,是一台标准的Wi-Fi 6路由,采用高通第二代Wi-Fi 6方案,更加成熟。全套高通芯片,2.4GHz频段支持2T2R ...

暂无评论