1. 概述
1.1 这是什么
云轩之音我做的一款日记类小程序,通过微信虚拟支付向用户售卖两类「一次性道具」额度:
| 额度 | 英文名(creditKey) | 用途 | 免费额度 |
|---|---|---|---|
| 文档导入额度 | docImportCredits | 文档解析/机器翻译(docimport) | 5 次 |
| 创意工坊生图额度 | imggenCredits | AI 生图(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.js | HTTP 推送接收:验签、解包 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_VPAY、IMG_FREE_GENS、DOC_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_10 | 600 | 10 次文档导入 |
doc_parse_pack_30 | 1600 | 30 次文档导入 |
image_pack_10 | 900 | 10 次创意生图 |
image_pack_50 | 3200 | 50 次创意生图 |
改价/加档优先用环境变量
VPAY_PRODUCTS覆盖(cloudbaserc.json或控制台),不要改代码重新部署。priceFen与后台不一致会报-15013,未发布报-15010,刚发布需等约 10 分钟生效(-15014)。
2.3 消息推送 + HTTP 访问服务(发货回调)
- 云开发控制台 → 云函数
vpaynotify→ HTTP 访问服务 → 新建路由(如/vpay)→ 得到类似下方的URLhttps://<envId>.service.tcloudbase.com/vpay。 - MP 后台 → 开发与服务 → 开发管理 → 消息推送 → URL 填上面地址。
- Token 与环境变量
PUSH_TOKEN保持一致(控制台vpaynotify配置);加解密方式选「明文」或「安全」均可,安全模式需再配PUSH_AES_KEY(EncodingAESKey)。 - 配置后会有一条 GET 探测(原样回显
echostr)验证签名,vpaynotify已实现回显。
2.4 小程序 AppSecret
WX_APPSECRET(MP 后台 → 开发管理 → 开发设置)需配置到 virtualpay 环境变量,用于:
jscode2session换session_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 数量与校验
quantity在create端clamp(1, 99)(virtualpay/index.js:298)。- 商品必须命中商品表,否则
NO_PRODUCT拒绝。
4. 发货流程(推送回调 deliver)
4.1 vpaynotify 接收
微信支付成功 → 推送 xpay_goods_deliver_notify 到 vpaynotify(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.Encrypt,vpaynotify 用 decryptMessage 解 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)
支付成功但推送可能延迟/丢失,前端 onBuyPackage 在 requestVirtualPayment 成功后主动调一次 recheck:
// vpay.js
pay(productId, 1)
.then((order) => vpay.recheck(order.outTradeNo).then(() => order, () => order))
.then(() => this.loadQuota()); // 刷新余额后提示「购买成功」
recheck 逻辑(virtualpay/index.js:381-422):
- 本地已
delivered→ 直接返回。 - 否则用订单自身记录的
envVersion调官方query_order(带access_token+pay_sig),保证与create时 env 一致(即便用户之后切版本)。 - 官方状态 ∈
{2,3,4}(已支付待发货/发货中/已发货)→ 信任根,补发货。 - 金额对账只在官方返回
paid_fee时做(skipPriceCheck: !hasPaidFee),缺失则放行以免误伤。
6. 安全防护基线
6.1 发货接口零鉴权 → fail closed(guard.checkDeliverAccess)
| 防线 | 规则 |
|---|---|
| ① 拒绝用户端直调 | wx.cloud.callFunction 必带 OPENID;云函数内部转发不带。带 OPENID 一律拒绝。 |
| ② 内部密钥必配 | VPAY_INTERNAL_TOKEN 未配置 → 拒绝(旧实现「未配置就放行」= 零鉴权 P0)。 |
| ③ 常量时间比对 | __token 用 crypto.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 | 行为 |
|---|---|---|
| 开发版 develop | 1 | 沙箱,不真扣款 |
| 体验版 trial | 1 | 沙箱 |
| 正式版 release | 0 | 现网,真实付款 |
8. 商品表与额度模型
8.1 商品定义来源
优先级:VPAY_PRODUCTS(环境变量 JSON)> DEFAULT_PRODUCTS(virtualpay/index.js:55)。
8.2 creditKey 多额度隔离
grantCredits(openid, credits, creditKey) 按 creditKey 泛化写 profiles:
docImportCredits—— 文档导入imggenCredits—— 创意工坊生图
旧商品不带 creditKey 时默认按 docImportCredits 发货(向后兼容)。
8.3 免费额度与计费口径
| 功能 | 免费 | 已用字段 | 付费字段 | 计费口径 |
|---|---|---|---|---|
| 文档导入 | 5(DOC_FREE_IMPORTS) | docImportCount(docparse 侧) | docImportCredits | 解析成功即扣 |
| 创意生图 | 10(FREE_IMGENES/IMG_FREE_GENS) | imggenCount | imggenCredits | 成功生成 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 后台 → 小程序订单中心 → path 填
pages/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_TOKEN、PUSH_AES_KEY(安全模式)、VPAY_INTERNAL_TOKEN。
10.3 超时与内存(云函数参考配置)
| 云函数 | timeout | memorySize | 备注 |
|---|---|---|---|
virtualpay | 20s | 256MB | 含 query_order 网络调用 |
vpaynotify | 10s | 128MB | HTTP 访问服务 |
ai | 900s | 512MB | 生图最慢 60s+,必须 ≥900s |
10.4 上线前一键核对
- 控制台
VPAY_SANDBOX不是'1'(正式版应走现网) virtualpay/vpaynotify/ai三个云函数已 CLI 部署vpaynotifyHTTP 路由已绑,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 处理 |
具体的效果可以扫描下面的小程序码进行体验



暂无评论
要发表评论,您必须先 登录