适用读者 :已有小程序与云开发基础, 但从未接入过微信支付。(文章末尾有代码示例)
目标 :基于 CloudBase 完成 JSAPI 支付 接入,并在 体验版真机完成支付验证(含回调)。
目录
[1. 前置准备](#1. 前置准备)
[2. 私钥转换与安全管理](#2. 私钥转换与安全管理)
[3. 配置支付回调(HTTP 入口)](#3. 配置支付回调(HTTP 入口))
[4. 云函数](#4. 云函数)
[5. 小程序前端调起支付](#5. 小程序前端调起支付)
[6. 回调](#6. 回调)
[7. 部署与真机验证 Checklist](#7. 部署与真机验证 Checklist)
[8. 常见问题与排查(FAQ)](#8. 常见问题与排查(FAQ))
[9. 后续增强建议](#9. 后续增强建议)
[10. 附录:](#10. 附录:)完整代码清单与目录结构
1. 前置准备
开通商户与能力
-
小程序开通微信支付,并与商户号 mchid 绑定。
-
开通 API v3,记录 APIv3 密钥(32 字符)。
下载证书(商户证书)
-
商户平台 → 账户中心 → API 安全 → 下载 API 证书:得到 apiclient_cert.pem、apiclient_key.pem。
-
若生成证书时设置了密码,务必记住,后续用于私钥解密。
OpenSSL
- macOS / Linux 默认自带;Windows 建议使用 Git Bash 或 WSL。
获取证书序列号(serial_no)
# 输出大写 HEX(无冒号)即为 serial_no
openssl x509 -in apiclient_cert.pem -noout -serial | cut -d= -f2 | tr '[:lower:]' '[:upper:]' | tr -d ':'
2. 私钥转换与安全管理
微信导出的 apiclient_key.pem 常为 加密 PKCS#1 。Node.js 18 在云函数环境下使用更便利的是 无密码 PKCS#8。
# 若原始私钥有密码,执行时会提示输入
openssl pkcs8 -topk8 -inform PEM -outform PEM \
-in apiclient_key.pem \
-out apiclient_key_plain.pem \
-nocrypt
环境变量建议(.env 示例):
WECHAT_APPID=wx_your_appid
WECHAT_MCHID=your_mchid
WECHAT_API_V3_KEY=32_chars_api_v3_key
WECHAT_SERIAL_NO=YOUR_MCH_CERT_SERIAL
WECHAT_NOTIFY_URL=https://<cloudbase-domain>/pay/wechat/notify
PAY_PRODUCT_DESCRIPTION=...
PAY_PRODUCT_ID=quit-plan-annual
# 二选一:以 Base64 保存内容,或提供云环境内的安全路径
WECHAT_PRIVATE_KEY_BASE64=<可选:apiclient_key_plain.pem 的 Base64>
WECHAT_PRIVATE_KEY_PATH=/secure/path/apiclient_key_plain.pem
✅ 务必将 .env 加入 .gitignore,私钥只保存在部署环境。
✅ CloudBase 推荐使用" 环境变量 / 密钥管理服务"保存敏感信息。
❌ 不要把 .pem / 密钥明文提交到仓库。
3. 配置支付回调(HTTP 入口)
-
在 cloudfunctions/wechatPayNotify 目录新建云函数(HTTP 触发),先返回占位 JSON:{code:"SUCCESS"}。
-
CloudBase 控制台 → HTTP 访问服务,启用并添加路由:
-
路径:/pay/wechat/notify
-
资源:云函数 wechatPayNotify
-
-
浏览器访问 https://<云开发域名>/pay/wechat/notify,看到 JSON 即通。
说明:真实环境需 验签 + 解密 + 幂等写库,第 6 节会给出占位骨架与待办。
4. 云函数
createCheckout
(JSAPI 下单)
4.1 关键点
-
由后端云函数完成下单 与签名,前端仅调用 wx.requestPayment。
-
Authorization 头 需按 WECHATPAY2-SHA256-RSA2048 规范自行构造。
-
签名串格式(下单请求):
HTTP_METHOD + "\n" +
HTTP_PATH_WITH_QUERY + "\n" +
TIMESTAMP + "\n" +
NONCE_STR + "\n" +
REQUEST_BODY + "\n" -
- 例如:POST\n/v3/pay/transactions/jsapi\n1699999999\n随机串\n{"amount":...}\n
-
JSAPI 前端二次签名(paySign):
message = appId + "\n" + timeStamp + "\n" + nonceStr + "\n" + package + "\n"
paySign = RSA-SHA256(privateKey, message)
4.2 云函数实现(Node.js 18,无外部依赖)
见文末"附录:完整代码"。核心流程:
-
校验环境变量(缺失则返回 skipPayment:true,用于体验期防误触)。
-
读取私钥(优先 WECHAT_PRIVATE_KEY_BASE64,否则读取 WECHAT_PRIVATE_KEY_PATH)。
-
生成 out_trade_no、nonceStr、timestamp。
-
构造下单请求体(金额单位:分 ,payer.openid 必传)。
-
计算 Authorization,请求 https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi。
-
拿到 prepay_id 后,生成前端 timeStamp/nonceStr/package/paySign 返回。
5. 小程序前端调起支付
// pages/pay/index.js
Page({
data:{},
async onPayTap() {
try {
const res = await wx.cloud.callFunction({
name: 'createCheckout',
data: { planId: 'quit-plan-annual' }, // 你的业务参数
})
const { skipPayment, paymentParams, message } = res.result || {}
if (skipPayment) {
wx.showToast({ title: '当前为体验模式,已跳过支付', icon: 'none' })
return
}
if (!paymentParams) {
throw new Error(message || '获取支付参数失败')
}
const payRes = await wx.requestPayment(paymentParams)
console.log('pay success', payRes)
wx.showToast({ title: '支付成功' })
} catch (err) {
console.error(err)
wx.showToast({ title: err.message || '支付失败', icon: 'none' })
}
}
})
注意:wx.cloud.callFunction 默认会注入 wxContext,后端可以从 context.OPENID 获取用户 openid。
6. 回调
wechatPayNotify:占位骨架与幂等
正式环境必做:
-
读取回调请求头 Wechatpay-Signature / Wechatpay-Timestamp / Wechatpay-Nonce / Wechatpay-Serial,用 微信支付平台证书验签。
-
解密 resource.ciphertext(AES-256-GCM,密钥为 WECHAT_API_V3_KEY)得到订单资源。
-
根据 out_trade_no / amount.total / 交易状态做 幂等更新(避免重复通知造成多次发货)。
本文先给一个可运行的占位实现,返回"SUCCESS"告知微信"已接收"。上线前把 // TODO 部分补齐(见附录代码)。
7. 部署与真机验证 Checklist
-
部署云函数:createCheckout / wechatPayNotify(微信开发者工具 → 上传并部署)。
-
环境变量:在 CloudBase 控制台补齐并确认生效。
-
商户平台:配置"支付结果通知 URL"为 https://<cloudbase-domain>/pay/wechat/notify。
-
体验版真机:扫码预览 → 进入支付页 → 测试。
-
结果观察:支付成功页、云函数日志是否打印到 prepay_id 与回调;回调函数是否触发成功。
-
常见报错:见下节 FAQ。
8. 常见问题与排查(FAQ)
| 现象 / 报错 | 可能原因 | 排查与解决 |
|---|---|---|
| error:1E08010C:DECODER routines::unsupported | 私钥仍为加密 PKCS#1或粘贴导致换行错乱 | 用 openssl pkcs8 -topk8 -nocrypt 转换为 无密码 PKCS#8;检查 BEGIN/END 与换行。 |
| mchid is not defined | 构造 Authorization 时变量名与传参不一致 | 签名函数参数与模板字面量使用同名变量(mchIdvs mchid)。 |
| 弹窗提示"测试环境"或无法调起 | 云函数返回 skipPayment:true 或前端参数为空 | 检查环境变量、确认已成功拿到 prepay_id 并返回 paymentParams。 |
| 403 / 401 | Authorization 签名错误 / serial_no 不匹配 | 重核签名串格式 与时间戳/随机串;确认使用的是商户证书的 serial_no。 |
| openid 为空 | 前端未带上 wxContext | wx.cloud.callFunction 默认自动注入;云函数读取 context.OPENID。 |
| 无法安装 wx-server-sdk | 云函数网络限制 | 本文代码不依赖该 SDK;HTTP 调用与签名使用内置 https/crypto。 |
9. 后续增强建议
-
完善回调 :平台证书拉取与缓存、验签、解密、幂等写库、业务状态流转。
-
订单后台:下单/退款/续费/对账工具;导出报表。
-
通知与运营:成功订单发送服务通知 / 企业微信提醒;失败重试与预警。
-
安全治理:周期性轮换 API v3 密钥与证书;日志脱敏;最小权限。
-
灰度与风控:白名单/黑名单、下单频控、设备指纹与风控评分。
10. 附录:
完整代码清单与目录结构
10.1 目录结构
miniapp/
├─ cloudfunctions/
│ ├─ createCheckout/
│ │ ├─ index.js
│ │ └─ package.json # 可空(无第三方依赖)
│ └─ wechatPayNotify/
│ ├─ index.js
│ └─ package.json
├─ miniprogram/
│ └─ pages/pay/index.js
└─ .env (仅本地与云环境;请勿入库)
10.2
cloudfunctions/createCheckout/index.js
/* 云函数:createCheckout --- JSAPI 下单 + 二次签名(RSA) */
const https = require('https')
const crypto = require('crypto')
function readPrivateKey() {
const b64 = process.env.WECHAT_PRIVATE_KEY_BASE64
if (b64 && b64.trim()) {
const pem = Buffer.from(b64, 'base64').toString('utf8')
return normalizePem(pem)
}
const fs = require('fs')
const p = process.env.WECHAT_PRIVATE_KEY_PATH
if (!p) throw new Error('Missing WECHAT_PRIVATE_KEY_{BASE64|PATH}')
return normalizePem(fs.readFileSync(p, 'utf8'))
}
function normalizePem(pem) {
// 兼容单行 / 多行,确保换行正确
const body = pem
.replace(/\r/g, '')
.replace(/-----(BEGIN|END) PRIVATE KEY-----/g, m => `\n${m}\n`)
.replace(/[\t ]+/g, ' ')
.trim()
// 粗略归一化
return body.includes('BEGIN PRIVATE KEY') ? body : pem
}
function rsaSign(privateKey, message) {
const sign = crypto.createSign('RSA-SHA256')
sign.update(message)
sign.end()
return sign.sign(privateKey, 'base64')
}
function randomStr(len = 16) {
return crypto.randomBytes(Math.ceil(len / 2)).toString('hex').slice(0, len)
}
function requestWechatPay(path, body, auth) {
const data = JSON.stringify(body)
const options = {
hostname: 'api.mch.weixin.qq.com',
port: 443,
path,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': auth,
'User-Agent': 'CloudBase-Miniprogram-Checkout',
'Content-Length': Buffer.byteLength(data)
}
}
return new Promise((resolve, reject) => {
const req = https.request(options, (res) => {
let chunks = ''
res.on('data', d => (chunks += d))
res.on('end', () => {
try {
const json = JSON.parse(chunks || '{}')
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(json)
} else {
reject(new Error(`HTTP ${res.statusCode}: ${chunks}`))
}
} catch (e) {
reject(new Error(`Bad JSON: ${chunks}`))
}
})
})
req.on('error', reject)
req.write(data)
req.end()
})
}
exports.main = async (event, context) => {
// 1) 关键环境变量校验
const {
WECHAT_APPID: appId,
WECHAT_MCHID: mchId,
WECHAT_API_V3_KEY: apiV3Key, // 回调解密会用到
WECHAT_SERIAL_NO: serialNo,
WECHAT_NOTIFY_URL: notifyUrl,
PAY_PRODUCT_DESCRIPTION: description = '商品描述',
} = process.env
// 可根据你的发布策略,体验版/开发环境允许跳过支付
const allowSkip = !appId || !mchId || !serialNo || !notifyUrl
if (allowSkip) {
return { skipPayment: true, message: '支付环境变量未就绪,跳过支付(体验模式)' }
}
// 2) 读取 openid(需 wx.cloud 默认 wxContext)
const openid = context.OPENID || event.OPENID || event?.userInfo?.openId
if (!openid) {
return { message: '无法获取 OPENID' }
}
// 3) 私钥读取
const privateKey = readPrivateKey()
// 4) 业务参数(示例:年费 99 元)
const totalFen = 9900 // 单位分;根据 event.planId 或后端定价表映射
const outTradeNo = `QP${Date.now()}${randomStr(6).toUpperCase()}`
const body = {
appid: appId,
mchid: mchId,
description,
out_trade_no: outTradeNo,
notify_url: notifyUrl,
amount: { total: totalFen, currency: 'CNY' },
payer: { openid }
}
// 5) 构造下单签名(WECHATPAY2-SHA256-RSA2048)
const timestamp = String(Math.floor(Date.now() / 1000))
const nonceStr = randomStr(32)
const method = 'POST'
const path = '/v3/pay/transactions/jsapi'
const message = [
method,
path,
timestamp,
nonceStr,
JSON.stringify(body)
].join('\n') + '\n'
const signature = rsaSign(privateKey, message)
const authorization = `WECHATPAY2-SHA256-RSA2048 mchid="${mchId}",nonce_str="${nonceStr}",timestamp="${timestamp}",serial_no="${serialNo}",signature="${signature}"`
// 6) 发起下单
const resp = await requestWechatPay(path, body, authorization)
const prepayId = resp?.prepay_id
if (!prepayId) {
return { message: '未获取到 prepay_id' }
}
// 7) 生成前端调起参数(JSAPI 二次签名)
const timeStamp = String(Math.floor(Date.now() / 1000))
const nonceStr2 = randomStr(16)
const pkg = `prepay_id=${prepayId}`
const payMsg = [appId, timeStamp, nonceStr2, pkg].join('\n') + '\n'
const paySign = rsaSign(privateKey, payMsg)
return {
skipPayment: false,
order: {
outTradeNo: outTradeNo,
total: totalFen
},
paymentParams: {
timeStamp,
nonceStr: nonceStr2,
package: pkg,
signType: 'RSA',
paySign
}
}
}
10.3
cloudfunctions/wechatPayNotify/index.js
(占位,可运行)
/* 云函数:wechatPayNotify --- 回调入口(占位版本) */
exports.main = async (event, context) => {
// CloudBase HTTP 触发时:
// - event.headers 回调请求头
// - event.body 原始 JSON 字符串
// - event.httpMethod HTTP 方法
try {
// 1) 读取头部(验签材料)
const headers = event.headers || {}
const wechatSig = headers['Wechatpay-Signature'] || headers['wechatpay-signature']
const wechatTs = headers['Wechatpay-Timestamp'] || headers['wechatpay-timestamp']
const wechatNonce = headers['Wechatpay-Nonce'] || headers['wechatpay-nonce']
const wechatSerial = headers['Wechatpay-Serial'] || headers['wechatpay-serial']
// 2) 解析 body
const body = typeof event.body === 'string' ? JSON.parse(event.body) : (event.body || {})
// body.resource.algorithm === 'AEAD_AES_256_GCM'
// body.resource.ciphertext / nonce / associated_data
// TODO:使用【微信支付平台证书】公钥进行验签
// TODO:使用 APIv3 Key (process.env.WECHAT_API_V3_KEY) 对 resource 进行 AES-256-GCM 解密
// TODO:获取 out_trade_no, total, trade_state 等,做幂等更新
// 先返回占位成功,表示"已接收",避免微信重试
return {
statusCode: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: 'SUCCESS', message: 'OK' })
}
} catch (e) {
console.error('notify error:', e)
return {
statusCode: 500,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: 'FAIL', message: e.message })
}
}
}
10.4
miniprogram/pages/pay/index.js
(示例页面)
Page({
async onPayTap() {
wx.showLoading({ title: '发起支付...' })
try {
const res = await wx.cloud.callFunction({
name: 'createCheckout',
data: { planId: 'quit-plan-annual' },
})
const { skipPayment, paymentParams, message } = res.result || {}
if (skipPayment) {
wx.hideLoading()
return wx.showToast({ title: '体验模式,未发起支付', icon: 'none' })
}
if (!paymentParams) {
throw new Error(message || '获取支付参数失败')
}
await wx.requestPayment(paymentParams)
wx.hideLoading()
wx.showToast({ title: '支付成功' })
} catch (err) {
wx.hideLoading()
console.error(err)
wx.showToast({ title: err.message || '支付失败', icon: 'none' })
}
}
})
10.5
package.json
(两处函数均可为空依赖)
{
"name": "cloudfunction",
"version": "1.0.0",
"main": "index.js",
"dependencies": {}
}