
Payment Skill
- 1 installs
- 27 repo stars
- Updated June 18, 2026
- tencentcloudbase/awesome-miniprogram-skills
WeChat Mini Program skill for WeChat Pay integration: creating payment orders, invoking JSAPI payment, and querying payment status.
About
Adds WeChat Pay integration to a Mini Program via a cloud-function handler that creates orders, invokes JSAPI payment, and queries status. A developer uses it when wiring up payment in a WeChat Mini Program.
- Creates payment orders and invokes WeChat Pay JSAPI
- Uses a payment-skill-handler cloud function with amount validation
Payment Skill by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,980 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Jul 27, 2026 (Skillselion catalog sync)
npx skills add https://github.com/tencentcloudbase/awesome-miniprogram-skills --skill payment-skillAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 27 |
| Last updated | June 18, 2026 |
| Repository | tencentcloudbase/awesome-miniprogram-skills ↗ |
What it does
WeChat Mini Program skill for WeChat Pay integration: creating payment orders, invoking JSAPI payment, and querying payment status.
Files
payment-skill 微信支付集成
业务流程
业务方完成下单 → createPayment({ orderId, totalAmount, description })
│
├─ 调云函数 payment-skill-handler
│ ├─ 参数校验(金额单位转分)
│ ├─ 调微信支付 JSAPI 下单 → 获取 prepay_id
│ └─ 组装小程序调起支付参数
│
↓
客户端 wx.requestPayment(支付参数)
│
├─ 成功 → queryPayment 确认支付结果
└─ 失败 → 返回错误原子接口
createPayment
创建支付订单,获取小程序调起支付的参数。
参数:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| orderId | string | 是 | 业务方订单号 |
| totalAmount | number | 是 | 订单金额,单位:元 |
| description | string | 是 | 订单描述,展示在支付弹窗中 |
| attach | string | 否 | 附加数据,回调时原样返回 |
| skillName | string | 是 | 调用方 Skill 名称,用于回调时区分业务 |
返回值:
{
isError: false,
structuredContent: {
orderId: "Oxxx",
prepayId: "wx111111111111111",
payParams: {
timeStamp: "1717920000",
nonceStr: "abc123",
package: "prepay_id=wx111111111111111",
signType: "RSA",
paySign: "base64signature..."
},
totalAmount: 32.90
},
_meta: {
payParams
}
}queryPayment
查询支付结果。
参数:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| orderId | string | 是 | 业务方订单号 |
返回值:
{
isError: false,
structuredContent: {
orderId: "Oxxx",
status: "success", // success / fail / pending
payTime: "2026-06-09T10:30:00Z",
transactionId: "wx420000000000000000000"
}
}原子组件
| 组件 | 说明 |
|---|---|
components/payment-card/index | 支付状态卡片,展示支付进度和结果 |
组件行为: 1. 接收到 createPayment 返回结果后,自动调起 wx.requestPayment 2. 支付成功 → 自动调 queryPayment 确认 → 展示成功状态 3. 支付失败 → 展示失败状态,提供重试入口 4. 支付中 → 展示加载状态
原子接口依赖关系
| 接口 | 前置条件 | 后续接口 |
|---|---|---|
| createPayment | 业务方已完成下单 | 客户端调 wx.requestPayment → 组件自动调 queryPayment |
| queryPayment | createPayment 已调用 | 无 |
云函数
云函数名: payment-skill-handler
| action | 说明 |
|---|---|
| createPayment | 调微信支付 JSAPI 下单,返回 prepay_id 和支付参数 |
| queryPayment | 查单接口,返回支付状态 |
数据库集合: payment_records
| 字段 | 类型 | 说明 |
|---|---|---|
| openid | string | 用户标识 |
| orderId | string | 业务方订单号 |
| skillName | string | 调用方 Skill 名称 |
| totalAmount | number | 订单金额(分) |
| description | string | 订单描述 |
| prepayId | string | 微信 prepay_id |
| transactionId | string | 微信交易号 |
| status | string | pending / success / fail |
| createTime | date | 创建时间 |
| payTime | date | 支付时间 |
设计约束
1. 金额中台化:金额统一以"元"为输入单位,云函数内转"分"调微信支付 API,返回时再转回"元" 2. 幂等:同一 orderId 重复调用 createPayment 返回相同的 prepay_id,不重复下单 3. 回调安全:支付结果以云函数查单为准,不以客户端返回为准 4. 业务隔离:payment-skill 只负责支付,不感知业务方订单内容
// skills/payment-skill/apis/createPayment.js
const { isPreviewMode, errorResult, successResult, mockPayParams, callPayCommon } = require('../utils/util')
async function createPayment(params = {}) {
console.info('[ai-mode] createPayment 入口, params=', JSON.stringify(params))
const { orderId, totalAmount, description, attach, skillName } = params || {}
if (!orderId || totalAmount === undefined || totalAmount === null || !description || !skillName) {
return errorResult('缺少必填参数(orderId/totalAmount/description/skillName)。请确认已生成业务订单后调用。')
}
if (typeof totalAmount !== 'number' || totalAmount <= 0) {
return errorResult('订单金额无效,请确认 totalAmount 为正数(单位:元)。')
}
if (isPreviewMode()) {
console.info('[ai-mode] createPayment 预览模式')
const data = mockPayParams(orderId, totalAmount)
return successResult(
`已生成支付订单,金额 ¥${totalAmount.toFixed(2)}。请展示支付卡片引导用户确认支付。`,
{ orderId: data.orderId, prepayId: data.prepayId, payParams: data.payParams, totalAmount: data.totalAmount },
{ payParams: data.payParams }
)
}
try {
const amountInCents = Math.round(totalAmount * 100)
const result = await callPayCommon('wxpay_order', {
out_trade_no: orderId,
description,
amount: { total: amountInCents, currency: 'CNY' },
attach,
skill_name: skillName
})
if (result && result.code === 0 && result.data) {
const d = result.data
const payParams = {
timeStamp: d.timeStamp,
nonceStr: d.nonceStr,
package: d.package,
signType: d.signType || 'RSA',
paySign: d.paySign
}
return successResult(
`已生成支付订单,金额 ¥${totalAmount.toFixed(2)}。请展示支付卡片引导用户确认支付。`,
{ orderId, prepayId: d.prepay_id, payParams, totalAmount },
{ payParams }
)
}
return errorResult(result?.msg || result?.message || '创建支付订单失败')
} catch (err) {
console.error('[ai-mode] createPayment error:', err.message)
return errorResult('创建支付订单失败,请稍后重试')
}
}
module.exports = createPayment
// skills/payment-skill/apis/queryPayment.js
const { isPreviewMode, errorResult, successResult, mockQueryResult, callPayCommon } = require('../utils/util')
async function queryPayment(params = {}) {
console.info('[ai-mode] queryPayment 入口, params=', JSON.stringify(params))
const { orderId } = params || {}
if (!orderId) {
return errorResult('缺少 orderId。请从 createPayment 返回值获取。')
}
if (isPreviewMode()) {
console.info('[ai-mode] queryPayment 预览模式')
const data = mockQueryResult(orderId)
return successResult(`订单 ${orderId} 支付成功。`, data)
}
try {
const result = await callPayCommon('wxpay_query_order_by_out_trade_no', {
out_trade_no: orderId
})
if (result && result.code === 0 && result.data) {
const d = result.data
const status = d.trade_state === 'SUCCESS' ? 'success'
: d.trade_state === 'CLOSED' || d.trade_state === 'PAY_ERROR' ? 'fail'
: 'pending'
const statusText = status === 'success' ? '支付成功' : status === 'fail' ? '支付失败' : '支付中'
return successResult(
`订单 ${orderId} ${statusText}。`,
{
orderId,
status,
payTime: d.success_time || '',
transactionId: d.transaction_id || ''
}
)
}
return errorResult(result?.msg || result?.message || '查询支付状态失败')
} catch (err) {
console.error('[ai-mode] queryPayment error:', err.message)
return errorResult('查询支付状态失败,请稍后重试')
}
}
module.exports = queryPayment
const createError = require('http-errors');
const express = require('express');
const logger = require('morgan');
const config = require('./config/config');
const { validateConfig } = require('./config/config');
const app = express();
// 启动时校验配置
validateConfig();
// 证书模式启动时预热:提前拉取平台证书,避免首次回调时证书未就绪导致验签失败
if (config.signMode === 'sdk' && config.verifyMode === 'certificate') {
const SdkStrategy = require('./services/strategies/sdkStrategy');
const strategy = new SdkStrategy(config.payConfig);
strategy.wxPay.getCertificates?.().then(certs => {
const count = Array.isArray(certs) ? certs.length : 0;
console.log(`[Startup] 平台证书预加载成功,共 ${count} 张`);
}).catch(err => {
console.warn('[Startup] 平台证书预加载失败(将在首次验签时自动重试):', err.message);
});
}
// CORS:默认不开放跨域
// 如果前端与本服务不同源(如 H5 页面直接调用),
// 需设置环境变量 corsAllowOrigin,多个域名用逗号分隔
// 例:corsAllowOrigin=https://a.com,https://b.com
app.use((req, res, next) => {
const allowedOrigins = process.env.corsAllowOrigin
? process.env.corsAllowOrigin.split(',').map(s => s.trim())
: [];
const origin = req.headers.origin;
if (origin && allowedOrigins.includes(origin)) {
res.header('Access-Control-Allow-Origin', origin);
res.header('Access-Control-Allow-Methods', 'POST, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
if (req.method === 'OPTIONS') {
return res.sendStatus(204);
}
}
next();
});
app.use(logger('dev'));
app.use(express.json({
verify: (req, res, buf) => {
// 保存原始请求体,供微信支付回调验签使用
req.rawBody = buf.toString('utf8');
}
}));
app.use(express.urlencoded({ extended: false }));
// 支付路由(HTTP 访问服务方式,路径完整)
const payRouter = require('./routes/pay');
app.use('/wx-pay', payRouter);
// 云 API 网关适配
// 云 API 调用 HTTP 云函数时,Express 收到的 path 为 /,业务路由通过 body._action 传递
// 参考:https://docs.cloudbase.net/cloud-function/function-calls/
const ALLOWED_ACTIONS = new Set([
'wxpay_order', 'wxpay_order_h5', 'wxpay_order_native',
'wxpay_query_order_by_out_trade_no', 'wxpay_query_order_by_transaction_id',
'wxpay_close_order',
'wxpay_refund', 'wxpay_refund_query',
'wxpay_transfer', 'wxpay_transfer_bill_query', 'wxpay_transfer_bill_query_by_no',
'wxpay_transfer_batch_query',
'unifiedOrderTrigger', 'refundTrigger', 'transferTrigger'
]);
// 集成中心系统内置回调的 event_type 映射(body.ParsedNotify.event_type → 内部路由名)
const INTEGRATION_EVENT_MAP = {
'TRANSACTION.SUCCESS': 'unifiedOrderTrigger',
'TRANSACTION.FAIL': 'unifiedOrderTrigger',
'REFUND.SUCCESS': 'refundTrigger',
'REFUND.ABNORMAL': 'refundTrigger',
'REFUND.CLOSED': 'refundTrigger',
'MCHTRANSFER.TRANSFER.SUCCESS': 'transferTrigger',
'MCHTRANSFER.TRANSFER.FAIL': 'transferTrigger',
};
app.use((req, res, next) => {
// 从 body 中提取路由(四种方式兼容)
const action = req.body?._action; // 方式1: { _action: 'wxpay_order' }
const bodyPath = req.body?.path; // 方式2: { path: '/wx-pay/wxpay_order' }
// 方式3: 集成中心系统内置回调
// 优先用 rawData.event_type(微信原始字段),fallback 到 ParsedNotify.event_type(集成中心解析字段)
// 两者值一致,双来源保证兼容性
const eventType = req.body?.rawData?.event_type
|| req.body?.ParsedNotify?.event_type;
let actionName = null;
if (action) {
actionName = action.includes('/wx-pay/') ? action.split('/wx-pay/').pop() : action;
delete req.body._action;
} else if (bodyPath && bodyPath.includes('/wx-pay/')) {
actionName = bodyPath.split('/wx-pay/').pop();
delete req.body.path;
delete req.body.method;
} else if (eventType && INTEGRATION_EVENT_MAP[eventType]) {
// 集成中心系统内置回调:通过 event_type 判断路由
actionName = INTEGRATION_EVENT_MAP[eventType];
console.info('[集成中心] event_type 路由映射:', eventType, '→', actionName);
}
if (actionName) {
if (!ALLOWED_ACTIONS.has(actionName)) {
return res.status(400).json({ code: -1, msg: '不支持的操作: ' + actionName });
}
console.info('[云API适配] 路由分发:', '/' + actionName);
req.url = '/' + actionName;
req.method = 'POST';
return payRouter(req, res, next);
}
next();
});
// catch 404 and forward to error handler
app.use(function(req, res, next) {
next(createError(404));
});
// error handler
app.use(function(err, req, res, next) {
const status = err.status || 500;
res.status(status).json({
code: -1,
msg: req.app.get('env') === 'development' ? err.message : '服务内部错误'
});
});
module.exports = app;
#!/usr/bin/env node
/**
* Module dependencies.
*/
const app = require('../app');
const debug = require('debug')('pay-common:server');
const http = require('http');
/**
* Get port from environment and store in Express.
*/
const port = normalizePort(process.env.PORT || '3000');
app.set('port', port);
/**
* Create HTTP server.
*/
const server = http.createServer(app);
/**
* Listen on provided port, on all network interfaces.
*/
server.listen(port);
server.on('error', onError);
server.on('listening', onListening);
/**
* Normalize a port into a number, string, or false.
*/
function normalizePort(val) {
const port = parseInt(val, 10);
if (isNaN(port)) {
// named pipe
return val;
}
if (port >= 0) {
// port number
return port;
}
return false;
}
/**
* Event listener for HTTP server "error" event.
*/
function onError(error) {
if (error.syscall !== 'listen') {
throw error;
}
const bind = typeof port === 'string'
? 'Pipe ' + port
: 'Port ' + port;
// handle specific listen errors with friendly messages
switch (error.code) {
case 'EACCES':
console.error(bind + ' requires elevated privileges');
process.exit(1);
break;
case 'EADDRINUSE':
console.error(bind + ' is already in use');
process.exit(1);
break;
default:
throw error;
}
}
/**
* Event listener for HTTP server "listening" event.
*/
function onListening() {
const addr = server.address();
const bind = typeof addr === 'string'
? 'pipe ' + addr
: 'port ' + addr.port;
debug('Listening on ' + bind);
}
{
"$schema": "https://static.cloudbase.net/cli/cloudbaserc.schema.json",
"version": "2.0",
"envId": "{{env.TCB_ENV_ID}}",
"functions": [
{
"name": "pay-common",
"type": "HTTP",
"timeout": 30,
"runtime": "Nodejs18.15",
"handler": "index.main",
"memorySize": 256,
"installDependency": true,
"dir": "./",
"envVariables": {
"signMode": "{{env.SIGN_MODE}}",
"appId": "{{env.WX_APP_ID}}",
"service_app_id": "{{env.SERVICE_APP_ID}}",
"merchantId": "{{env.MCH_ID}}",
"merchantSerialNumber": "{{env.MCH_SERIAL_NO}}",
"apiV3Key": "{{env.API_V3_KEY}}",
"privateKey": "{{env.PRIVATE_KEY}}",
"wxPayPublicKey": "{{env.WX_PAY_PUBLIC_KEY}}",
"wxPayPublicKeyId": "{{env.WX_PAY_PUBLIC_KEY_ID}}",
"notifyURLPayURL": "{{env.NOTIFY_PAY_URL}}",
"notifyURLRefundsURL": "{{env.NOTIFY_REFUNDS_URL}}",
"transferNotifyUrl": "{{env.NOTIFY_TRANSFER_URL}}",
"corsAllowOrigin": "{{env.CORS_ALLOW_ORIGIN}}"
},
"ignore": [
"node_modules",
".git",
".env",
".env.*",
"!.env.example",
"tests",
"*.pem"
]
}
],
"cloudrun": {
"name": "pay-common"
}
}
/**
* pay-common 配置管理
* 优先从环境变量读取,回退到默认值
*/
const config = {
// 回调处理模式: 'sdk' | 'gateway'
// sdk: 回调自己验签 + AES-GCM 解密
// gateway: 回调由集成中心解密,转发明文(body.ParsedContent)
// 注意:两种模式下,主动请求(下单/退款/转账)都走 SDK 自签名直连微信
signMode: process.env.signMode || 'gateway',
// 验签方式:根据是否配置 wxPayPublicKey 自动推断(无需手动设置)
// 有公钥 → publickey(公钥验签,兼容旧配置)
// 无公钥 → certificate(SDK 内置证书管理,自动拉取/刷新平台证书)
verifyMode: (process.env.wxPayPublicKey || '') ? 'publickey' : 'certificate',
payConfig: {
// AppID(JSAPI/H5→公众号, 小程序→小程序, APP→移动应用)
// 小程序场景使用此 appId
appId: process.env.appId || '',
// 服务号 AppID(Web 端 JSAPI 支付用,同一个商户号绑定的服务号)
serviceAppId: process.env.service_app_id || '',
// 支付商户号
mchId: process.env.merchantId || '',
// 商户 API 证书序列号
mchSerialNo: process.env.merchantSerialNumber || '',
// API V3 密钥(32 字节,用于回调解密)
mchAPIv3Key: process.env.apiV3Key || '',
// 商户 API 证书私钥(PEM 格式,用于请求签名)
// Dockerfile ENV 中 \n 是字面量,需替换为真正换行符
mchPrivateKey: (process.env.privateKey || '').replace(/\\n/g, '\n'),
// 微信支付公钥(PEM 格式,用于验签)-- 注意:是"微信支付公钥",不是"商户公钥"
// Dockerfile ENV 中 \n 是字面量,需替换为真正换行符
mchWechatpayPublicKey: (process.env.wxPayPublicKey || '').replace(/\\n/g, '\n'),
// 微信支付公钥 ID(标识使用哪个公钥)
mchWechatpayPublicKeyId: process.env.wxPayPublicKeyId || '',
// 支付回调通知 URL(必须 https://,不能 localhost,不能带参数)
// SDK 模式:指向自己的服务地址
// 网关模式:指向集成中心分配的回调域名
jsapiNotifyUrl: process.env.notifyURLPayURL || '',
// 退款回调通知 URL
refundNotifyUrl: process.env.notifyURLRefundsURL || '',
// 商家转账回调通知 URL(Ext 字段无此项,pay-common 扩展)
transferNotifyUrl: process.env.transferNotifyUrl || '',
},
};
/**
* 校验配置完整性
* 根据 signMode 检查必填项,启动时调用
*/
function validateConfig() {
const errors = [];
const { payConfig, signMode } = config;
// 通用必填(两种模式都需要,因为主动请求都走 SDK 自签名)
// appId 和 serviceAppId 至少配置一个
if (!payConfig.appId && !payConfig.serviceAppId) {
errors.push('appId 或 serviceAppId 至少配置一个(小程序 / 服务号 AppID)');
}
if (!payConfig.mchId) errors.push('merchantId(支付商户号)未配置');
if (!payConfig.mchAPIv3Key) errors.push('apiV3Key(APIv3 密钥)未配置');
if (!payConfig.mchSerialNo) errors.push('merchantSerialNumber(商户证书序列号)未配置');
if (!payConfig.mchPrivateKey) errors.push('privateKey(商户私钥)未配置');
// wxPayPublicKey 仅在公钥模式下必填(证书模式由 SDK 自动管理)
// 回调 URL 格式校验
[
{ key: 'notifyURLPayURL', val: payConfig.jsapiNotifyUrl },
{ key: 'notifyURLRefundsURL', val: payConfig.refundNotifyUrl },
{ key: 'transferNotifyUrl', val: payConfig.transferNotifyUrl },
].forEach(({ key, val }) => {
if (val) {
if (!/^https:\/\//.test(val)) {
errors.push(`${key} 必须以 https:// 开头(微信支付要求回调 URL 必须 HTTPS)`);
}
if (/localhost|127\.0\.0\.1|0\.0\.0\.0/.test(val)) {
errors.push(`${key} 不能使用内网地址(localhost/127.0.0.1)`);
}
if (val.includes('?')) {
errors.push(`${key} 不能携带参数(不能包含 ?)`);
}
}
});
// signMode 值校验
if (!['sdk', 'gateway'].includes(signMode)) {
errors.push(`signMode 值无效: "${signMode}",仅支持 "sdk" 或 "gateway"`);
}
// 公钥模式:配置了公钥则公钥 ID 也必填
if (config.verifyMode === 'publickey') {
if (!payConfig.mchWechatpayPublicKeyId) {
errors.push('wxPayPublicKeyId 未配置(已配置 wxPayPublicKey 时必填)');
}
}
if (errors.length > 0) {
console.warn('⚠️ 配置校验警告(共 ' + errors.length + ' 项):');
errors.forEach((msg, idx) => console.warn(` ${idx + 1}. ${msg}`));
}
return errors;
}
module.exports = { ...config, validateConfig };
/**
* 支付控制器
* 处理路由请求,调用 payService,返回统一格式
*/
const PayService = require('../services/payService');
const config = require('../config/config');
const { validateOrderParams, validateRefundParams, validateTransferParams } = require('../utils/validator');
const { getOpenId } = require('../utils/cloudbaseAuth');
const payService = new PayService();
// ========== 统一响应格式 ==========
function success(res, data) {
res.status(200).json({ code: 0, msg: 'success', data });
}
function fail(res, msg, statusCode = 400) {
res.status(statusCode).json({ code: -1, msg, data: null });
}
// ========== 下单 ==========
/**
* 微信支付 - JSAPI/小程序下单
*/
exports.unifiedOrder = async (req, res) => {
try {
// 自动注入 payer.openid(如果前端没传,从 JWT / x-wx-openid header 中解析)
if (!req.body.payer?.openid) {
const openid = getOpenId(req);
if (openid) {
req.body.payer = { ...req.body.payer, openid };
console.info('[Controller] 自动注入 payer.openid:', openid);
}
}
const errors = validateOrderParams(req.body);
if (errors.length > 0) return fail(res, errors.join('; '));
const result = await payService.unifiedOrder(req.body, 'jsapi');
success(res, result);
} catch (err) {
console.error('[Controller] unifiedOrder error:', err);
fail(res, err.message || '下单失败');
}
};
/**
* 微信支付 - H5 下单
*/
exports.unifiedOrderH5 = async (req, res) => {
try {
const errors = validateOrderParams(req.body);
if (errors.length > 0) return fail(res, errors.join('; '));
const result = await payService.unifiedOrder(req.body, 'h5');
success(res, result);
} catch (err) {
console.error('[Controller] unifiedOrderH5 error:', err);
fail(res, err.message || 'H5 下单失败');
}
};
/**
* 微信支付 - Native 扫码下单
*/
exports.unifiedOrderNative = async (req, res) => {
try {
const errors = validateOrderParams(req.body);
if (errors.length > 0) return fail(res, errors.join('; '));
const result = await payService.unifiedOrder(req.body, 'native');
success(res, result);
} catch (err) {
console.error('[Controller] unifiedOrderNative error:', err);
fail(res, err.message || 'Native 下单失败');
}
};
// ========== 查询 ==========
/**
* 微信支付 - 通过商户订单号查询订单
*/
exports.queryOrderByOutTradeNo = async (req, res) => {
try {
if (!req.body.out_trade_no) return fail(res, 'out_trade_no 必填');
const result = await payService.queryOrderByOutTradeNo(req.body);
success(res, result);
} catch (err) {
console.error('[Controller] queryOrderByOutTradeNo error:', err);
fail(res, err.message || '查单失败');
}
};
/**
* 微信支付 - 通过微信订单号查询订单
*/
exports.queryOrderByTransactionId = async (req, res) => {
try {
if (!req.body.transaction_id) return fail(res, 'transaction_id 必填');
const result = await payService.queryOrderByTransactionId(req.body);
success(res, result);
} catch (err) {
console.error('[Controller] queryOrderByTransactionId error:', err);
fail(res, err.message || '查单失败');
}
};
// ========== 关闭订单 ==========
/**
* 微信支付 - 关闭订单
*/
exports.closeOrder = async (req, res) => {
try {
if (!req.body.out_trade_no) return fail(res, 'out_trade_no 必填');
const result = await payService.closeOrder(req.body);
success(res, result);
} catch (err) {
console.error('[Controller] closeOrder error:', err);
fail(res, err.message || '关闭订单失败');
}
};
// ========== 退款 ==========
/**
* 微信支付 - 退款
*/
exports.refund = async (req, res) => {
try {
const errors = validateRefundParams(req.body);
if (errors.length > 0) return fail(res, errors.join('; '));
const result = await payService.refund(req.body);
success(res, result);
} catch (err) {
console.error('[Controller] refund error:', err);
fail(res, err.message || '退款失败');
}
};
/**
* 微信支付 - 查询退款
*/
exports.queryRefund = async (req, res) => {
try {
if (!req.body.out_refund_no) return fail(res, 'out_refund_no 必填');
const result = await payService.queryRefund(req.body);
success(res, result);
} catch (err) {
console.error('[Controller] queryRefund error:', err);
fail(res, err.message || '退款查询失败');
}
};
// ========== 回调通知 ==========
/**
* 回调通知公共处理
* 解析 body.ParsedContent(集成中心解密明文)→ 构造 callbackParams → 调用对应 service 方法 → 统一应答
* @param {Object} req
* @param {Object} res
* @param {Function} handlerFn - payService 上的回调处理方法
* @param {string} triggerName - 日志标识
*/
async function _handleCallback(req, res, handlerFn, triggerName) {
try {
const headers = req.headers;
// 回调日志脱敏:只打印关键标识,避免泄露签名和密文
console.log(`[Controller] ${triggerName} 回调:`, {
timestamp: headers['wechatpay-timestamp'],
serial: headers['wechatpay-serial'],
event_type: req.body?.event_type
|| req.body?.rawData?.event_type
|| req.body?.ParsedNotify?.event_type,
has_ciphertext: !!req.body?.resource?.ciphertext,
is_integration: !!req.body?.ParsedContent,
});
// 集成中心网关模式:解密后明文在 body.ParsedContent
// SDK 模式:body 不含 ParsedContent,decryptedData 为 null,下游会走自验签 + 解密
const decryptedData = req.body?.ParsedContent || null;
const callbackParams = {
body: req.body,
rawBody: req.rawBody,
decryptedData,
signature: headers['wechatpay-signature'],
serial: headers['wechatpay-serial'],
nonce: headers['wechatpay-nonce'],
timestamp: headers['wechatpay-timestamp'],
};
const result = await handlerFn.call(payService, callbackParams);
if (result) {
res.status(200).json({ code: 'SUCCESS', message: '成功' });
} else {
res.status(400).json({ code: 'FAIL', message: '验签失败' });
}
} catch (err) {
console.error(`[Controller] ${triggerName} error:`, err);
res.status(500).json({ code: 'FAIL', message: '处理失败' });
}
}
/**
* 微信支付 - 支付回调通知
* SDK 模式:微信支付直接回调,需自己验签解密
* 网关模式(集成中心):网关已验签解密,明文在 header 中
*/
exports.unifiedOrderTrigger = (req, res) => _handleCallback(req, res, payService.handlePayCallback, 'unifiedOrderTrigger');
/**
* 微信支付 - 退款回调通知
*/
exports.refundTrigger = (req, res) => _handleCallback(req, res, payService.handleRefundCallback, 'refundTrigger');
// ========== 商家转账 ==========
/**
* 微信支付 - 发起商家转账(升级版 - 单笔模式)
* 文档:https://pay.weixin.qq.com/doc/v3/merchant/4012716434
*
* ⚠️ 注意:
* 1. 受理成功 ≠ 转账成功,必须查单或等回调确认最终状态
* 2. 转账金额 < 0.3元不填 user_name,≥ 2000元必填(需加密)
* 3. SYSTEM_ERROR/ACCEPTED/频率超限 → 必须用相同参数+相同单号重试,禁止换单
* 4. 同一单号重试期为 3 个自然日,超期需换新单号
* 5. 用户 24 小时内未确认收款,系统自动关单退款(实际关单时间可能略超 24 小时)
*/
exports.transfer = async (req, res) => {
try {
// 自动注入 openid(如果前端没传,从 JWT / x-wx-openid header 中解析)
if (!req.body.openid) {
const openid = getOpenId(req);
if (openid) {
req.body.openid = openid;
console.info('[Controller] 转账自动注入 openid:', openid);
}
}
const errors = validateTransferParams(req.body);
if (errors.length > 0) return fail(res, errors.join('; '));
const result = await payService.transfer(req.body);
// 把 mchId 注入 result.data 中透传给前端(wx.requestMerchantTransfer 需要)
if (result && result.data) {
result.data.mchId = config.payConfig.mchId;
}
success(res, result);
} catch (err) {
console.error('[Controller] transfer error:', err);
fail(res, err.message || '商家转账失败');
}
};
/**
* 微信支付 - 商户单号查询转账单(升级版)
*/
exports.queryTransferBill = async (req, res) => {
try {
if (!req.body.out_bill_no) return fail(res, 'out_bill_no(商户单号)必填');
const result = await payService.queryTransferBill(req.body);
success(res, result);
} catch (err) {
console.error('[Controller] queryTransferBill error:', err);
fail(res, err.message || '查询转账单失败');
}
};
/**
* 微信支付 - 微信单号查询转账单(升级版)
*/
exports.queryTransferBillByNo = async (req, res) => {
try {
if (!req.body.transfer_bill_no) return fail(res, 'transfer_bill_no(微信转账单号)必填');
const result = await payService.queryTransferBillByNo(req.body);
success(res, result);
} catch (err) {
console.error('[Controller] queryTransferBillByNo error:', err);
fail(res, err.message || '查询转账单失败');
}
};
/**
* 微信支付 - 商家转账回调通知
*/
exports.transferTrigger = (req, res) => _handleCallback(req, res, payService.handleTransferCallback, 'transferTrigger');
// ========== 鉴权中间件 ==========
const { parseCloudBaseAuth } = require('../utils/cloudbaseAuth');
/**
* 鉴权中间件
* 兼容多种部署方式:云 API 网关、集成中心网关、HTTP 云函数、云托管、本地开发
*/
exports.payMiddleware = (req, res, next) => {
// ⚠️ 开发环境提示(不跳过鉴权,仅打印日志辅助排查)
if (process.env.NODE_ENV === 'development') {
console.warn('[鉴权] 开发环境 — 鉴权仍然生效,如需测试请携带有效 header(Bearer token / X-WX-SOURCE 等)');
}
// 云 API 网关模式:请求带有 Bearer token(CloudBase Auth accessToken)
// 网关已验证 token 有效性,这里解析 JWT 获取用户信息
if (req.headers.authorization && req.headers.authorization.startsWith('Bearer ')) {
const authInfo = parseCloudBaseAuth(req);
if (authInfo) {
req.cloudbaseAuth = authInfo;
return next();
}
}
// 集成中心网关模式
// 安全前提:集成中心网关会剥离客户端传入的 x-tcb-integration-id,仅网关自身注入
if (req.headers['x-tcb-integration-id']) {
return next();
}
// 云托管模式(callContainer)
// 安全前提:云托管入口会剥离客户端传入的 X-WX-SOURCE / X-Authmethod
const authMethod = req.headers['x-authmethod'] || req.headers['X-Authmethod'];
const wxSource = req.headers['x-wx-source'];
if (wxSource === 'wx_devtools' || wxSource === 'wx_client' || authMethod === 'WX_SERVER_AUTH') {
return next();
}
// HTTP 云函数模式
// 安全前提:这些环境变量仅存在于 SCF 运行时,外部无法伪造
if (process.env.TENCENTCLOUD_RUNENV || process.env.SCF_RUNTIME) {
return next();
}
res.status(401).json({ code: -1, msg: '未授权访问' });
};
/**
* H5 安全中间件
* 先走 payMiddleware 鉴权,再做 H5 特有的安全校验(Origin 白名单等)
*/
exports.h5SecurityMiddleware = (req, res, next) => {
// 先经过通用鉴权
exports.payMiddleware(req, res, (err) => {
if (err) return next(err);
// H5 Origin 白名单校验(微信支付安全规范要求)
// 从环境变量 corsAllowOrigin 读取(与 app.js CORS 中间件共用同一配置)
const allowedOrigins = process.env.corsAllowOrigin
? process.env.corsAllowOrigin.split(',').map(s => s.trim())
: [];
const origin = req.headers.origin;
if (origin && allowedOrigins.length > 0 && !allowedOrigins.includes(origin)) {
console.warn('[H5Security] Origin 被拒绝:', origin, '白名单:', allowedOrigins);
return res.status(403).json({ code: -1, msg: 'Origin 不在白名单内' });
}
// 如果未配置白名单(allowedOrigins 为空),放行并打印警告
if (allowedOrigins.length === 0 && origin) {
console.warn('[H5Security] ⚠️ 未配置 Origin 白名单,所有来源均放行。建议设置环境变量 corsAllowOrigin');
}
next();
});
};
FROM node:20-alpine
# 容器默认时区为UTC,如需使用上海时间请启用以下时区设置命令
RUN apk add --no-cache tzdata ca-certificates \
&& cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
&& echo Asia/Shanghai > /etc/timezone
# # 指定工作目录
WORKDIR /app
# 暴露端口(云托管标准)
EXPOSE 80
ENV PORT=80
# ⚠️ 环境变量说明:
# 正式环境请在云托管控制台 → 服务设置 → 环境变量中配置,不要把密钥写在 Dockerfile 里。
# 本地开发请复制 .env.example 为 .env 并填入实际值。
# 以下仅为占位声明,实际值通过运行时注入。
ENV signMode=sdk
ENV corsAllowOrigin=""
ENV appId=""
ENV merchantId=""
ENV merchantSerialNumber=""
ENV apiV3Key=""
ENV privateKey=""
ENV wxPayPublicKey=""
ENV wxPayPublicKeyId=""
ENV notifyURLPayURL=""
ENV notifyURLRefundsURL=""
ENV transferNotifyUrl=""
# 拷贝包管理文件
COPY package*.json /app/
# npm 源,选用国内镜像源以提高下载速度
RUN npm config set registry https://mirrors.cloud.tencent.com/npm/
# RUN npm config set registry https://registry.npm.taobao.org/
# npm 安装依赖
RUN npm install
# 将当前目录(dockerfile所在目录)下所有文件都拷贝到工作目录下(.dockerignore中文件除外)
COPY . /app
# 执行启动命令
# 写多行独立的CMD命令是错误写法!只有最后一行CMD命令会被执行,之前的都会被忽略,导致业务报错。
# 请参考[Docker官方文档之CMD命令](https://docs.docker.com/engine/reference/builder/#cmd)
CMD ["npm", "start"]
// HTTP 云函数入口文件(CLI 部署校验用)
// 实际运行走 scf_bootstrap 启动 Express 服务
const app = require('./app');
exports.main = app;
{
"name": "pay-common",
"version": "1.0.0",
"private": true,
"description": "微信支付云函数/云托管通用模板,支持 JSAPI/H5/Native/APP 多平台,SDK/网关双模式",
"engines": {
"node": ">=16.0.0 <23.0.0"
},
"scripts": {
"start": "node ./bin/www",
"test": "node --test tests/"
},
"dependencies": {
"debug": "~4.4.0",
"express": "^4.21.2",
"http-errors": "~2.0.0",
"morgan": "~1.10.0",
"wechatpay-node-v3": "^2.2.1"
}
}
pay-common 微信支付通用模板
微信支付 V3 API 的 Express 通用模板,支持多种部署方式和签名模式。
特性
- 多平台支付:JSAPI(小程序/微信内H5)、H5、Native 扫码、APP
- 多部署方式:HTTP 云函数、云托管(Docker)、本地/自建服务器
- 双回调模式:SDK 自验签解密 / 集成中心解密(环境变量一键切换,主动请求均走 SDK 自签名)
- 安全:参数校验、回调验签、时间戳过期检查、幂等提示
- 回调规范:先应答后处理、JSON 格式应答、签名探测兼容
---
快速开始
📖 完整教程:skill/cloudbase-wechatpay/references/模板接入/quick-start.md
⚠️ 铁律(必须遵守)
金额单位 = 分(cents):所有金额字段单位为"分"而非"元"。1 元 =100,9.99 元 =999。禁止传入浮点数或元为单位。
订单号全局唯一:out_trade_no和out_refund_no必须全局唯一。退款失败重试必须复用原 `out_refund_no`。
下单与调起使用同一私钥:下单签名和调起支付签名必须使用同一把商户 API 私钥。
Step 1:获取模板 & 安装依赖
cp -r pay-common your-project-name
cd your-project-name
npm installStep 2:配置环境变量
| 创建方式 | 环境变量写入位置 | 需要 .env? |
|---|---|---|
| 控制台集成中心创建 | 自动填写 + 自动部署 | ❌ |
| CLI 部署到云函数 | cloudbaserc.json → envVariables | ❌ |
| 云托管 | 控制台 → 服务配置 → 环境变量 | ❌ |
| 本地开发 | export 或启动脚本注入 | 📄 .env.example 仅作参考 |
📖 环境变量完整配置:env-config.md
>
📖 SDK vs 网关模式:sign-mode.md
>
📖 公钥验签 vs 证书验签:verify-mode.md
环境变量完整配置
⚠️ 代码只读 `process.env`,没有 `dotenv`,不会自动加载 `.env` 文件! .env.example 仅作参考模板。全量配置项清单:
| 变量名 | 必填 | 示例值 | 说明 |
|---|---|---|---|
signMode | 是 | sdk / gateway | 签名模式。sdk=自己验签;gateway=集成中心代验签(默认值) |
appId | 是 | wx1234567890abcdef | 小程序 / 公众号 AppID,需已在商户平台绑定 |
merchantId | 是 | 1900009191 | 微信支付商户号(10 位数字) |
merchantSerialNumber | 是 | 40位十六进制 | API 证书序列号 |
apiV3Key | 是 | 32字节字符串 | APIv3 密钥,用于回调解密 |
privateKey | 是 | PEM 字符串,换行用 \n | 商户 API 证书私钥(最易出错项,见下方格式说明) |
wxPayPublicKey | 条件必填 | PEM 字符串,换行用 \n | 微信支付公钥。配了此项 → 自动启用公钥验签;不配 → 走证书自动下载模式 |
wxPayPublicKeyId | 条件必填 | YOUR_WX_PAY_PUBLIC_KEY_ID | 与 wxPayPublicKey 成对使用,配了公钥时必填 |
notifyURLPayURL | 是 | https://域名/wx-pay/unifiedOrderTrigger | 支付回调 URL(HTTPS,不能 localhost,不能带 ?) |
notifyURLRefundsURL | 是 | https://域名/wx-pay/refundTrigger | 退款回调 URL |
transferNotifyUrl | 是 | https://域名/wx-pay/transferTrigger | 转账回调 URL |
corsAllowOrigin | 否 | https://your-domain.com | CORS 允许域名,多个逗号分隔 |
集成中心用户:以上回调 URL 由平台自动生成注入,无需手动填写。
<details> <summary>🔑 privateKey 格式详解(最容易踩坑)</summary>
# 正确写法:整个 PEM 内容写在一行,换行用字面 \n(两个字符:反斜杠 + n)
privateKey=-----BEGIN PRIVATE KEY-----\nMIIEvgIBADA...\n-----END PRIVATE KEY-----代码 config.js 会执行 .replace(/\\n/g, '\n') 将字面 \n 还原为真换行。
| 错误写法 | 后果 |
|---|---|
| 多行换行写入 | .env 解析截断 |
用 \\n(双重转义) | \n 变成字面文本 |
| 复制了多余空格 | PEM 解析失败 |
</details>
<details> <summary>⚡ 最小可用配置(SDK 模式,复制即用)</summary>
signMode=sdk
appId=YOUR_APP_ID
merchantId=YOUR_MERCHANT_ID
merchantSerialNumber=YOUR_SERIAL_NUMBER
apiV3Key=YOUR_API_V3_KEY
privateKey=-----BEGIN PRIVATE KEY-----\nYOUR_KEY_CONTENT\n-----END PRIVATE KEY-----
wxPayPublicKey=-----BEGIN PUBLIC KEY-----\nYOUR_PUBLIC_KEY_CONTENT\n-----END PUBLIC KEY-----
wxPayPublicKeyId=YOUR_WX_PAY_PUBLIC_KEY_ID
notifyURLPayURL=https://<YOUR_HTTP_DOMAIN>/wx-pay/unifiedOrderTrigger
notifyURLRefundsURL=https://<YOUR_HTTP_DOMAIN>/wx-pay/refundTrigger
transferNotifyUrl=https://<YOUR_HTTP_DOMAIN>/wx-pay/transferTriggerCLI 部署时将这些值填入 cloudbaserc.json → envVariables;云托管在控制台填写。
</details>
<details> <summary>⚠️ 公钥陷阱:微信支付公钥 vs 商户公钥</summary>
| 类型 | 用途 | 来源 |
|---|---|---|
微信支付公钥 (wxPayPublicKey) | ✅ 验签微信回调 | 商户平台 → API 安全 → 微信支付公钥 |
| 商户 API 公钥 | ❌ 不用于本模板 | 申请 API 证书时生成 |
混淆后果:用商户公钥验签 → 签名验证永远失败。
</details>
签名模式速查:
| 模式 | 主动请求 | 回调处理 | 适用场景 |
|---|---|---|---|
sdk | SDK 自签名 → 直连微信 | 自己验签解密 | 自部署(必须开 HTTP 访问服务 + 关闭回调路由认证) |
gateway | SDK 自签名 → 直连微信 | 集成中心已解密 | 控制台集成中心创建(主流) |
Step 3:部署
| 方式 | 命令 | 详细文档 |
|---|---|---|
| HTTP 云函数 | tcb fn deploy pay-common | deploy-cloud-function.md |
| 云托管 | tcb cloudrun deploy pay-common --path . | deploy-cloud-run.md |
| 本地开发 | npm start → http://localhost:3000 | deploy-local.md |
⚠️cloudbaserc.json中"type": "HTTP"必须声明,否则无法通过 HTTP 访问服务访问。
Step 4:接入前端
| 场景 | 调用方式 | 详细文档 |
|---|---|---|
| 小程序 | callHTTPFunction(推荐) | miniprogram-cloud-api.md |
| 小程序(云托管) | callContainer | miniprogram-cloud-run.md |
| H5(微信外浏览器) | fetch → h5_url 跳转 | web-h5.md |
| JSAPI(微信内浏览器) | WeixinJSBridge.invoke | web-h5.md |
| Native 扫码 | fetch → 生成二维码 | web-native.md |
| APP | 各端 SDK 调起 | app.md |
| 微搭低码 | callHTTPFunction | weda-miniprogram.md |
完整可运行示例:`examples/miniprogram/`(云函数版)、`examples/miniprogram-cloudrun/`(云托管版)
小程序 callHTTPFunction 下单支付流程
推荐方式:通过 wx.cloud.callHTTPFunction 调用 HTTP 云函数,平台自动注入 openid,无需登录流程。调用链路:
用户点击"支付"
↓
1. wx.cloud.callHTTPFunction({ name, path, data })
↓
2. 平台自动注入 x-wx-openid header(客户端无法伪造)
↓
3. HTTP 云函数收到请求 → 从 header 获取 openid → 签名下单
↓
4. 返回 prepay_id + 签名参数
↓
5. wx.requestPayment({ timeStamp, nonceStr, package, signType, paySign })
↓
6. 用户在微信界面完成支付Step A:app.js 初始化
// app.js
const ENV_ID = 'YOUR_ENV_ID' // ⚠️ 替换为你的云开发环境 ID
const FUNCTION_NAME = 'pay-common' // HTTP 云函数名称(集成中心创建的以实际名称为准)
App({
globalData: { envId: ENV_ID, functionName: FUNCTION_NAME },
onLaunch() {
wx.cloud.init({ env: ENV_ID, traceUser: true })
},
})Step B:封装通用调用方法
// pages/pay/pay.js 顶部
const app = getApp()
function callPayCommon(action, data) {
const { functionName, envId } = app.globalData
return new Promise((resolve, reject) => {
wx.cloud.callHTTPFunction({
name: functionName,
config: { env: envId },
method: 'POST',
header: { 'Content-Type': 'application/json' },
path: `/wx-pay/${action}`, // 直接映射 Express 路由
data,
success(res) {
res.statusCode < 300 ? resolve(res.data) : reject({ code: -1, msg: `HTTP ${res.statusCode}` })
},
fail: reject,
})
})
}Step C:下单 + 调起支付
async handlePay() {
// 1️⃣ 下单(openid 由平台自动注入,无需传入)
const res = await callPayCommon('wxpay_order', {
description: '商品名称',
out_trade_no: 'ORDER' + Date.now(), // 全局唯一 ⚠️
amount: { total: 100, currency: 'CNY' }, // 单位=分 ⚠️
// ❌ 不需要传 payer.openid,后端自动从 x-wx-openid 获取
})
if (res.code !== 0) return wx.showToast({ title: res.msg, icon: 'none' })
// 2️⃣ 调起微信支付
const payData = res.data?.data || res.data
await wx.requestPayment({
timeStamp: String(payData.timeStamp),
nonceStr: payData.nonceStr,
package: payData.package || ('prepay_id=' + payData.prepay_id),
signType: 'RSA',
paySign: payData.paySign,
})
wx.showToast({ title: '支付成功', icon: 'success' })
// ⚠️ 建议支付成功后主动查单确认,不要仅依赖前端回调
}前置条件:
| 条件 | 说明 |
|---|---|
wx.cloud.init() 已调用 | 必须在 callHTTPFunction 前执行 |
| 基础库 ≥ 3.15.2 | project.config.json 中 libVersion 设为 3.15.2+ |
| 无需 npm 依赖 | 不需要 npm install、不需要构建 npm |
| 无需登录 / Token | 平台自动鉴权 + 注入 openid |
常见问题:
| 问题 | 原因 | 解决 |
|---|---|---|
callHTTPFunction is not a function | 基础库版本过低 | libVersion ≥ 3.15.2 |
| openid 为空 | 开发工具未设置 / 未真机调试 | 使用真机调试 |
requestPayment 签名错误 | 下单与调起用了不同私钥 | 确保同一套凭证 |
| 调用返回 404 | 函数名或路径错误 | 检查 name(集成中心创建的实际名称)和 path |
下单返回 -1 | 金额非整数等参数错误 | 检查 amount.total 是否为正整数(分) |
📖 完整文档:miniprogram-cloud-api.md
Step 5:接入业务逻辑
编辑 services/orderService.js,接入你的数据库。
📖 数据库集成方案:order-service.md
>
📖 安全红线 + 上线清单:security-checklist.md
自定义 orderService
services/orderService.js 是业务钩子层——所有方法当前都是空壳(仅打印日志 + return true)。上线前必须替换为你自己的数据库操作。
调用链路:
前端请求 → routes/pay.js → controllers/payController.js → services/payService.js → services/orderService.js
↑ 你只需改这里6 个方法一览:
| 方法 | 触发时机 | 你要做什么 | 幂等要求 |
|---|---|---|---|
handlerUnified(params) | 下单成功后 | 创建订单记录到数据库 | — |
handlerUnifiedTrigger(params) | 支付回调通知 | 更新订单为"已支付"、发货等 | 必须 |
handlerRefund(params) | 退款申请成功后 | 更新订单为"退款中" | — |
handlerRefundTrigger(params) | 退款回调通知 | 更新退款最终结果 | 必须 |
handlerTransfer(params, result) | 转账受理成功后 | 记录转账单 | — |
handlerTransferTrigger(params) | 转账回调通知 | 更新转账最终状态 | 必须 |
示例:以 CloudBase 数据库实现 `handlerUnifiedTrigger`(最关键的支付回调处理):
const cloudbase = require('@cloudbase/node-sdk');
const app = cloudbase.init({ env: process.env.ENV_ID });
const db = app.database();
const _ = db.command;
async handlerUnifiedTrigger(params) {
const { out_trade_no, transaction_id, trade_state, amount } = params;
// 1. 幂等检查:查询当前订单状态
const { data } = await db.collection('orders')
.where({ out_trade_no })
.get();
if (!data.length) {
console.error('[OrderService] 订单不存在:', out_trade_no);
return false;
}
const order = data[0];
// 已处理过 → 直接跳过(幂等)
if (order.status === 'paid') {
console.info('[OrderService] 重复回调,已跳过:', out_trade_no);
return true;
}
// 2. 金额校验(防篡改)
if (amount.total !== order.amount) {
console.error('[OrderService] 金额不匹配:', amount.total, '!=', order.amount);
return false;
}
// 3. 更新订单状态
if (trade_state === 'SUCCESS') {
await db.collection('orders').doc(order._id).update({
status: 'paid',
transaction_id,
paid_at: new Date(),
});
// 4. 执行后续业务(发货、通知等)
// await this.sendGoods(order);
}
return true;
}⚠️ 回调幂等三件事:① 查状态(已处理就跳过)→ ② 校金额(防篡改)→ ③ 再更新
每个方法接收的参数:
<details> <summary>handlerUnified(params) — 下单参数</summary>
{
out_trade_no: 'ORDER202604130001', // 商户订单号
description: '测试商品', // 商品描述
amount: { total: 100, currency: 'CNY' }, // 金额(分)
payer: { openid: 'oUpF8...' } // 支付者(JSAPI 必传)
}</details>
<details> <summary>handlerUnifiedTrigger(params) — 支付回调明文</summary>
{
out_trade_no: 'ORDER202604130001',
transaction_id: '4200001985...', // 微信支付订单号
trade_state: 'SUCCESS', // SUCCESS/REFUND/NOTPAY/CLOSED
trade_type: 'JSAPI', // JSAPI/NATIVE/APP/MWEB
amount: { total: 100, payer_total: 100, currency: 'CNY' },
payer: { openid: 'oUpF8...' }
}</details>
<details> <summary>handlerRefund(params) — 退款请求参数</summary>
{
out_trade_no: 'ORDER202604130001',
out_refund_no: 'REFUND202604130001', // 商户退款单号
amount: { total: 100, refund: 50 } // 原单总额 + 退款金额(分)
}</details>
<details> <summary>handlerRefundTrigger(params) — 退款回调明文</summary>
{
out_trade_no: 'ORDER202604130001',
out_refund_no: 'REFUND202604130001',
transaction_id: '4200001985...',
refund_id: '50000001982...', // 微信退款单号
refund_status: 'SUCCESS', // SUCCESS/CHANGE/REFUNDCLOSE
amount: { total: 100, refund: 50, payer_total: 100, payer_refund: 50 }
}</details>
<details> <summary>handlerTransfer(params, result) — 转账请求 + 微信返回</summary>
// params(你的请求参数)
{
out_bill_no: 'TRANS202604130001', // 商户转账单号
transfer_amount: 100, // 转账金额(分)
openid: 'oUpF8...' // 收款用户 openid
}
// result(微信返回)
{
transfer_bill_no: '1300001201...', // 微信转账单号
out_bill_no: 'TRANS202604130001',
create_time: '2024-04-13T10:00:00+08:00',
state: 'ACCEPTED'
}</details>
<details> <summary>handlerTransferTrigger(params) — 转账回调明文</summary>
{
mchid: '1900009191', // 商户号
out_bill_no: 'TRANS202604130001',
transfer_bill_no: '1300001201...',
state: 'SUCCESS' // SUCCESS/FAIL
}</details>
---
路由表
所有路由前缀:/wx-pay
下单
| 路由 | 方法 | 说明 | 鉴权 |
|---|---|---|---|
/wxpay_order | POST | JSAPI/小程序下单 | payMiddleware |
/wxpay_order_h5 | POST | H5 下单 | h5SecurityMiddleware |
/wxpay_order_native | POST | Native 扫码下单 | payMiddleware |
查询
| 路由 | 方法 | 说明 | 鉴权 |
|---|---|---|---|
/wxpay_query_order_by_out_trade_no | POST | 商户订单号查单 | payMiddleware |
/wxpay_query_order_by_transaction_id | POST | 微信订单号查单 | payMiddleware |
/wxpay_close_order | POST | 关闭订单 | payMiddleware |
退款
| 路由 | 方法 | 说明 | 鉴权 |
|---|---|---|---|
/wxpay_refund | POST | 申请退款 | payMiddleware |
/wxpay_refund_query | POST | 查询退款 | payMiddleware |
商家转账
| 路由 | 方法 | 说明 | 鉴权 |
|---|---|---|---|
/wxpay_transfer | POST | 发起商家转账 | payMiddleware |
/wxpay_transfer_bill_query | POST | 商户单号查询 | payMiddleware |
/wxpay_transfer_bill_query_by_no | POST | 微信单号查询 | payMiddleware |
📖 商家转账详解:transfer.md
回调(无鉴权,微信支付服务器直接调用)
| 路由 | 方法 | 说明 |
|---|---|---|
/unifiedOrderTrigger | POST | 支付回调通知 |
/refundTrigger | POST | 退款回调通知 |
/transferTrigger | POST | 商家转账回调通知 |
请求/响应格式
// 下单请求
{ "description": "商品名称", "out_trade_no": "ORDER202604130001",
"amount": { "total": 100, "currency": "CNY" },
"payer": { "openid": "oUpF8uMuAJO_M2pxb1Q9zNjWeS6o" } }
// 成功响应
{ "code": 0, "msg": "success", "data": { "prepay_id": "wx2014..." } }
// 失败响应
{ "code": -1, "msg": "amount.total(订单金额)必须为正整数(单位:分)", "data": null }
// 回调应答
{ "code": "SUCCESS", "message": "成功" }---
注意事项速查
| 事项 | 要点 |
|---|---|
| AppID 类型 | JSAPI 小程序用小程序 AppID;JSAPI 公众号用已认证服务号 AppID;H5/Native 两种均可 |
| 支付授权目录 | JSAPI/H5/Native 都要配。商户平台 → 产品中心 → 开发配置 → 支付授权目录 |
| prepay_id 有效期 | 2 小时 |
| h5_url 有效期 | 5 分钟 |
| 回调超时 | 必须 5 秒内应答,否则微信重试最多 15 次(本模板已实现先应答后处理) |
| 查单兜底 | 回调不保证 100% 送达,建议定时扫描"待支付"订单主动查单 |
📖 40+ 常见问题排查:troubleshooting.md
>
📖 错误模式深度分析:error-patterns.md
---
目录结构
pay-common/
├── index.js # HTTP 云函数入口(CLI 部署用)
├── app.js # Express 入口
├── bin/www # 启动脚本
├── scf_bootstrap # HTTP 云函数启动脚本(PORT=9000)
├── Dockerfile # 云托管构建
├── cloudbaserc.json # 云函数部署配置
├── .env.example # 环境变量模板
├── package.json
├── config/
│ └── config.js # 配置管理(双回调模式 + 校验)
├── controllers/
│ └── payController.js # 路由控制器(下单/查单/退款/回调)
├── services/
│ ├── payService.js # 支付服务(策略入口,SDK 签名 + 回调模式分叉)
│ ├── orderService.js # 订单服务(业务钩子,接入数据库)
│ └── strategies/
│ └── sdkStrategy.js # SDK 签名策略(签名/验签/解密)
├── routes/
│ └── pay.js # 路由定义
├── utils/
│ ├── validator.js # 参数校验
│ └── cloudbaseAuth.js # CloudBase Auth JWT 解析(获取 openid)---
更多资源
| 资源 | 路径 |
|---|---|
| 🏗️ 架构图 | assets/architecture.md |
| 🔐 商户凭证准备 | merchant-credentials.md |
| 📋 方案选型指南 | cloudbase-pay-overview.md |
| 🛡️ 安全红线 + 上线清单 | security-checklist.md |
| 🔧 诊断脚本 | scripts/(env 校验 / PEM 检查 / 部署配置对比 / 回调连通测试) |
const express = require('express');
const router = express.Router();
const payController = require('../controllers/payController');
// ========== 下单(鉴权保护)==========
// JSAPI / 小程序下单
router.post('/wxpay_order', payController.payMiddleware, payController.unifiedOrder);
// H5 下单(额外安全中间件:Origin 白名单 + 登录态)
router.post('/wxpay_order_h5', payController.h5SecurityMiddleware, payController.unifiedOrderH5);
// Native 扫码下单
router.post('/wxpay_order_native', payController.payMiddleware, payController.unifiedOrderNative);
// ========== 查询(鉴权保护)==========
// 通过商户订单号查询
router.post('/wxpay_query_order_by_out_trade_no', payController.payMiddleware, payController.queryOrderByOutTradeNo);
// 通过微信订单号查询
router.post('/wxpay_query_order_by_transaction_id', payController.payMiddleware, payController.queryOrderByTransactionId);
// ========== 关闭订单(鉴权保护)==========
router.post('/wxpay_close_order', payController.payMiddleware, payController.closeOrder);
// ========== 退款(鉴权保护)==========
router.post('/wxpay_refund', payController.payMiddleware, payController.refund);
router.post('/wxpay_refund_query', payController.payMiddleware, payController.queryRefund);
// ========== 商家转账(升级版,鉴权保护)==========
// 发起商家转账
router.post('/wxpay_transfer', payController.payMiddleware, payController.transfer);
// 商户单号查询转账单
router.post('/wxpay_transfer_bill_query', payController.payMiddleware, payController.queryTransferBill);
// 微信单号查询转账单
router.post('/wxpay_transfer_bill_query_by_no', payController.payMiddleware, payController.queryTransferBillByNo);
// 兼容旧路由(查询转账批次 → 重定向到查询转账单)
router.post('/wxpay_transfer_batch_query', payController.payMiddleware, payController.queryTransferBill);
// ========== 回调通知(无鉴权,微信支付服务器 / 集成中心网关直接调用)==========
// 支付回调
router.post('/unifiedOrderTrigger', payController.unifiedOrderTrigger);
// 退款回调
router.post('/refundTrigger', payController.refundTrigger);
// 商家转账回调
router.post('/transferTrigger', payController.transferTrigger);
module.exports = router;
#!/bin/bash
# SCF 云函数环境:平台固定使用 9000 端口,不可更改
export PORT=9000
node ./bin/www
/**
* 订单服务(业务钩子)
*
* 使用 CloudBase 数据库记录支付记录。
* 集合:payment_records
*
* 关键提醒:
* 1. handlerUnifiedTrigger 和 handlerRefundTrigger 必须做幂等检查
* 2. 回调用应核验支付金额与下单金额是否一致(防篡改)
*/
const cloud = require('wx-server-sdk')
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
const db = cloud.database()
class OrderService {
constructor() {}
/**
* 下单成功后记录订单
* @param {Object} params - { out_trade_no, description, amount, payer, attach, skill_name }
*/
async handlerUnified(params) {
const openid = params.payer?.openid || ''
const skillName = params.skill_name || ''
const amountCents = params.amount?.total || 0
try {
// 幂等:已存在则跳过
const existing = await db.collection('payment_records')
.where({ orderId: params.out_trade_no })
.limit(1)
.get()
if (existing.data && existing.data.length > 0) {
console.info('[OrderService] handlerUnified 跳过(已存在):', params.out_trade_no)
return true
}
await db.collection('payment_records').add({
data: {
_openid: openid,
orderId: params.out_trade_no,
skillName,
totalAmount: amountCents,
description: params.description || '',
status: 'pending',
createTime: db.serverDate()
}
})
console.info('[OrderService] handlerUnified 记录成功:', params.out_trade_no)
} catch (e) {
console.error('[OrderService] handlerUnified 写入失败:', e.message)
}
return true
}
/**
* 支付回调 - 更新订单状态为已支付
* 幂等实现:先查询状态,已支付则跳过
*/
async handlerUnifiedTrigger(params) {
const orderId = params.out_trade_no
const transactionId = params.transaction_id
const tradeState = params.trade_state
try {
const existing = await db.collection('payment_records')
.where({ orderId })
.limit(1)
.get()
if (!existing.data || existing.data.length === 0) {
console.warn('[OrderService] handlerUnifiedTrigger 订单不存在:', orderId)
return true
}
const record = existing.data[0]
if (record.status === 'paid') {
console.info('[OrderService] handlerUnifiedTrigger 幂等跳过(已支付):', orderId)
return true
}
// 金额校验
const paidAmount = params.amount?.total || 0
if (paidAmount > 0 && paidAmount !== record.totalAmount) {
console.error('[OrderService] handlerUnifiedTrigger 金额不匹配:',
'期望', record.totalAmount, '实际', paidAmount)
return true
}
if (tradeState === 'SUCCESS') {
await db.collection('payment_records')
.where({ orderId })
.update({
data: {
status: 'paid',
transactionId,
payTime: params.success_time || db.serverDate(),
updateTime: db.serverDate()
}
})
console.info('[OrderService] handlerUnifiedTrigger 更新成功:', orderId, transactionId)
}
} catch (e) {
console.error('[OrderService] handlerUnifiedTrigger 更新失败:', e.message)
}
return true
}
async handlerRefund(params) {
console.info('[OrderService] handlerRefund - 退款申请:', params.out_refund_no)
return true
}
async handlerRefundTrigger(params) {
console.info('[OrderService] handlerRefundTrigger - 退款结果:', params.out_refund_no, params.refund_status)
return true
}
async handlerTransfer(params, result) {
console.info('[OrderService] handlerTransfer - 转账受理:', params.out_bill_no)
return true
}
async handlerTransferTrigger(params) {
console.info('[OrderService] handlerTransferTrigger - 转账结果:', params.out_bill_no, params.state)
return true
}
}
module.exports = OrderService;
/**
* 支付服务(策略入口)
* 主动请求(下单/退款/转账)永远走 SDK 自签名直连微信
* 回调处理根据 signMode 决定:自己验签解密 或 读取集成中心已解密的明文
*/
const { signMode, verifyMode, payConfig } = require('../config/config');
const SdkStrategy = require('./strategies/sdkStrategy');
const OrderService = require('./orderService');
class PayService {
constructor() {
this.orderService = new OrderService();
// 永远使用 SDK 策略(自己签名直连微信)
this.strategy = new SdkStrategy(payConfig);
console.log(`[PayService] 签名模式: SDK | 回调处理: ${signMode} | 验签方式: ${verifyMode}`);
}
// ========== 统一下单(支持多平台)==========
/**
* 统一下单
* @param {Object} params - 下单参数
* @param {string} payType - 支付类型: jsapi | h5 | native
* @returns {Object} 微信支付返回结果
*/
async unifiedOrder(params, payType = 'jsapi') {
try {
// 注入通用参数
// 根据 useServiceAccount 参数选择 appId(支持服务号/小程序双账号)
if (params.useServiceAccount) {
if (!payConfig.serviceAppId) {
throw new Error('useServiceAccount=true 但未配置 service_app_id 环境变量,请在 CloudBase 控制台为 pay-common 云函数添加该环境变量');
}
params.appid = payConfig.serviceAppId;
} else {
params.appid = params.appid || payConfig.appId;
}
// 用完删除,不传给微信支付 API
delete params.useServiceAccount;
params.mchid = params.mchid || payConfig.mchId;
// 根据支付类型设置回调 URL
params.notify_url = params.notify_url || payConfig.jsapiNotifyUrl;
// 根据支付类型调不同接口
let result;
switch (payType) {
case 'h5':
result = await this.strategy.h5(params);
break;
case 'native':
result = await this.strategy.native(params);
break;
case 'jsapi':
default:
result = await this.strategy.jsapi(params);
break;
}
if (result.status === 200 && result.data) {
await this.orderService.handlerUnified(params);
return result;
} else {
console.error(`[PayService] ${payType} 下单失败: status=${result?.status}, code=${result?.data?.code}, message=${result?.data?.message}`);
return result;
}
} catch (err) {
console.error('[PayService] unifiedOrder error:', err);
throw err;
}
}
// ========== 回调处理 ==========
/**
* 支付回调处理
* SDK 模式:验签 → 解密 → 处理业务
* 网关模式:集成中心已解密,明文在 body.ParsedContent 中
* @param {Object} callbackParams - 回调参数
* @returns {Object} 解密后的支付结果
*/
async handlePayCallback(callbackParams) {
let payResult;
if (signMode === 'gateway' && callbackParams.decryptedData) {
// 网关模式:集成中心已解密,直接使用明文
payResult = callbackParams.decryptedData;
} else {
// SDK 模式(或网关模式但无解密数据时回退):自己验签 + 解密
const verified = await this.strategy.verifySign(callbackParams);
if (!verified) {
console.error('[PayService] 支付回调验签失败');
return null;
}
const { ciphertext, associated_data, nonce } = callbackParams.body.resource;
payResult = await this.strategy.decryptResource(ciphertext, associated_data, nonce);
}
console.info('[PayService] 支付回调结果: out_trade_no=%s, trade_state=%s', payResult?.out_trade_no, payResult?.trade_state);
// 异步处理业务(不阻塞回调应答)
this.orderService.handlerUnifiedTrigger(payResult).catch(err => {
console.error('[PayService] 异步处理支付回调业务失败:', err);
});
return payResult;
}
/**
* 退款回调处理
*/
async handleRefundCallback(callbackParams) {
let refundResult;
if (signMode === 'gateway' && callbackParams.decryptedData) {
// 网关模式:集成中心已解密,直接使用明文
refundResult = callbackParams.decryptedData;
} else {
// SDK 模式(或网关模式但无解密数据时回退):自己验签 + 解密
const verified = await this.strategy.verifySign(callbackParams);
if (!verified) {
console.error('[PayService] 退款回调验签失败');
return null;
}
const { ciphertext, associated_data, nonce } = callbackParams.body.resource;
refundResult = await this.strategy.decryptResource(ciphertext, associated_data, nonce);
}
console.info('[PayService] 退款回调结果: out_refund_no=%s, refund_status=%s', refundResult?.out_refund_no, refundResult?.refund_status);
this.orderService.handlerRefundTrigger(refundResult).catch(err => {
console.error('[PayService] 异步处理退款回调业务失败:', err);
});
return refundResult;
}
// ========== 查询 ==========
async queryOrderByOutTradeNo(params) {
return this.strategy.query(params);
}
async queryOrderByTransactionId(params) {
return this.strategy.query(params);
}
// ========== 关闭订单 ==========
async closeOrder(params) {
return this.strategy.close(params);
}
// ========== 退款 ==========
async refund(params) {
try {
params.notify_url = params.notify_url || payConfig.refundNotifyUrl;
const result = await this.strategy.refund(params);
if (result.status === 200 && result.data) {
await this.orderService.handlerRefund(params);
return result;
} else {
console.error('[PayService] 退款失败: status=%s, code=%s, message=%s', result?.status, result?.data?.code, result?.data?.message);
return result;
}
} catch (err) {
console.error('[PayService] refund error:', err);
throw err;
}
}
async queryRefund(params) {
return this.strategy.queryRefund(params);
}
// ========== 商家转账 ==========
/**
* 发起商家转账(升级版 - 单笔模式)
* 文档:https://pay.weixin.qq.com/doc/v3/merchant/4012716434
*
* ⚠️ 重要:
* 1. 受理成功 ≠ 转账成功,必须通过查单或回调确认最终状态
* 2. 系统错误/资金不足/频率超限时,必须用相同参数重试,勿更换单号
* 3. user_name 字段规则:< 0.3元不填,≥ 2000元必填
*/
async transfer(params) {
try {
// 注入通用参数
// 根据 useServiceAccount 参数选择 appId(支持服务号/小程序双账号)
if (params.useServiceAccount) {
if (!payConfig.serviceAppId) {
throw new Error('useServiceAccount=true 但未配置 service_app_id 环境变量,请在 CloudBase 控制台为 pay-common 云函数添加该环境变量');
}
params.appid = payConfig.serviceAppId;
} else {
params.appid = params.appid || payConfig.appId;
}
// 用完删除,不传给微信支付 API
delete params.useServiceAccount;
// 转账回调 URL
params.notify_url = params.notify_url || payConfig.transferNotifyUrl || '';
const result = await this.strategy.transfer(params);
if (result.status === 200 && result.data?.out_bill_no) {
console.info('[PayService] 转账受理成功, transfer_bill_no:', result.data.transfer_bill_no, 'out_bill_no:', result.data.out_bill_no);
// 业务钩子:记录转账单
await this.orderService.handlerTransfer(params, result.data);
} else {
console.error('[PayService] 转账失败: status=%s, code=%s, message=%s', result?.status, result?.data?.code, result?.data?.message);
}
return result;
} catch (err) {
console.error('[PayService] transfer error:', err);
throw err;
}
}
/**
* 商户单号查询转账单(升级版)
*/
async queryTransferBill(params) {
return this.strategy.queryTransferBill(params);
}
/**
* 微信单号查询转账单(升级版)
*/
async queryTransferBillByNo(params) {
return this.strategy.queryTransferBillByNo(params);
}
/**
* 商家转账回调处理
*/
async handleTransferCallback(callbackParams) {
let transferResult;
if (signMode === 'gateway' && callbackParams.decryptedData) {
// 网关模式:集成中心已解密,直接使用明文
transferResult = callbackParams.decryptedData;
} else {
// SDK 模式(或网关模式但无解密数据时回退):自己验签 + 解密
const verified = await this.strategy.verifySign(callbackParams);
if (!verified) {
console.error('[PayService] 转账回调验签失败');
return null;
}
const { ciphertext, associated_data, nonce } = callbackParams.body.resource;
transferResult = await this.strategy.decryptResource(ciphertext, associated_data, nonce);
}
console.info('[PayService] 转账回调结果: out_bill_no=%s, state=%s', transferResult?.out_bill_no, transferResult?.state);
this.orderService.handlerTransferTrigger(transferResult).catch(err => {
console.error('[PayService] 异步处理转账回调业务失败:', err);
});
return transferResult;
}
}
module.exports = PayService;
/**
* SDK 签名策略
* 使用 wechatpay-node-v3 SDK 自行签名验签
*/
const WxPay = require('wechatpay-node-v3');
const crypto = require('crypto');
const https = require('https');
const { verifyMode: defaultVerifyMode } = require('../../config/config');
class SdkStrategy {
constructor(payConfig) {
this.payConfig = payConfig;
this.verifyMode = defaultVerifyMode;
if (this.verifyMode === 'certificate') {
// 证书模式:SDK 构造函数无条件要求 publicKey(wechatpay-node-v3#2.2.x 已知限制)
// 传空对象绕过构造函数校验,实际验签由 verifySign() 走 Pay.certificates 自动管理
this.wxPay = new WxPay({
appid: payConfig.appId,
mchid: payConfig.mchId,
serial_no: payConfig.mchSerialNo,
key: payConfig.mchAPIv3Key,
privateKey: payConfig.mchPrivateKey,
publicKey: '__CERT_MODE_PLACEHOLDER__', // wechatpay-node-v3#2.2.x 要求 publicKey 必填;证书模式下实际验签使用 getCertificates() 获取的平台证书
});
console.log('[SdkStrategy] 验证模式: CERTIFICATE(SDK 内置证书管理)');
} else {
// 公钥模式:使用固定公钥验签(原有逻辑)
this.wxPay = new WxPay({
appid: payConfig.appId,
mchid: payConfig.mchId,
serial_no: payConfig.mchSerialNo,
key: payConfig.mchAPIv3Key,
privateKey: payConfig.mchPrivateKey,
publicKey: payConfig.mchWechatpayPublicKey,
});
console.log('[SdkStrategy] 验证模式: PUBLICKEY(固定公钥)');
}
}
// ========== 下单 ==========
/**
* JSAPI/小程序下单
* SDK 的 transactions_jsapi 只返回 prepay_id,
* 需要额外生成调起支付所需的签名参数(timeStamp、nonceStr、paySign)
*/
async jsapi(params) {
const result = await this.wxPay.transactions_jsapi(params);
// 下单成功,生成调起支付参数
if (result.status === 200 && result.data?.prepay_id) {
const prepay_id = result.data.prepay_id;
const timeStamp = Math.floor(Date.now() / 1000).toString();
const nonceStr = crypto.randomBytes(16).toString('hex');
const packageStr = `prepay_id=${prepay_id}`;
// 使用请求中的 appid(支持多 appId 场景),回退到配置值
const appId = params.appid || this.payConfig.appId;
// 拼接签名串:appId\ntimeStamp\nnonceStr\npackage\n
const signStr = `${appId}\n${timeStamp}\n${nonceStr}\n${packageStr}\n`;
const paySign = crypto
.createSign('RSA-SHA256')
.update(signStr)
.sign(this.payConfig.mchPrivateKey, 'base64');
// 返回前端调起支付所需的完整参数
result.data = {
appId,
prepay_id,
timeStamp,
nonceStr,
package: packageStr,
signType: 'RSA',
paySign,
};
}
return result;
}
async h5(params) {
return this.wxPay.transactions_h5(params);
}
async native(params) {
return this.wxPay.transactions_native(params);
}
// ========== 查询 ==========
async query(params) {
return this.wxPay.query(params);
}
// ========== 关闭 ==========
async close(params) {
if (!params?.out_trade_no) {
throw new Error('closeOrder 缺少 out_trade_no 参数');
}
// ⚠️ wechatpay-node-v3 的 close 方法签名: close(out_trade_no: string)
// 必须传字符串,不能传对象,否则 URL 会拼成 [object Object]
return this.wxPay.close(params.out_trade_no);
}
// ========== 退款 ==========
async refund(params) {
return this.wxPay.refunds(params);
}
async queryRefund(params) {
if (!params?.out_refund_no) {
throw new Error('queryRefund 缺少 out_refund_no 参数');
}
// ⚠️ wechatpay-node-v3 的 find_refunds 方法签名: find_refunds(out_refund_no: string)
// 必须传字符串,不能传对象,否则 URL 会拼成 [object Object]
return this.wxPay.find_refunds(params.out_refund_no);
}
// ========== 商家转账(升级版) ==========
/**
* 发起商家转账(POST /v3/fund-app/mch-transfer/transfer-bills)
*
* ⚠️ 本模板仅支持【免密小额转账(0.3 - 2000 元,不含 user_name)】场景。
*
* 📌 限制说明:
* - 官方要求:转账金额 ≥ 2000 元时,必须填写 user_name(收款人姓名)
* - user_name 是【敏感字段】,必须使用微信支付公钥进行 RSA/OAEP 加密
* - 本模板【未实现】加密逻辑,因此【禁止传入 user_name】
*
* 🔧 如需支持 ≥ 2000 元转账(含 user_name),请自行改造:
* 1. 引入 crypto.publicEncrypt 用微信支付公钥加密 user_name
* 加密参数:RSA_PKCS1_OAEP_PADDING + SHA-1
* 2. 对 user_name 先加密再发送
* 3. Header 已正确设置 Wechatpay-Serial(公钥 ID),无需改动
*
* 📚 参考文档:
* - 接口定义: https://pay.weixin.qq.com/doc/v3/merchant/4012716434
* - 敏感信息加密: https://pay.weixin.qq.com/doc/v3/merchant/4013053257
*
* wechatpay-node-v3 SDK 不内置此接口,这里走自签名 HTTP 请求
*/
async transfer(params) {
// 🚫 运行时保护:拦截包含 user_name 的请求,避免明文上送触发官方报错
if (params.user_name) {
throw new Error(
'[pay-common] 本模板未实现 user_name 加密逻辑,禁止传入。' +
'如需 ≥ 2000 元转账场景,请参考 sdkStrategy.js 注释自行实现加密。' +
'文档:https://pay.weixin.qq.com/doc/v3/merchant/4013053257'
);
}
const url = '/v3/fund-app/mch-transfer/transfer-bills';
const bodyStr = JSON.stringify(params);
const result = await this._signAndRequest('POST', url, bodyStr);
return result;
}
/**
* 商户单号查询转账单(GET /v3/fund-app/mch-transfer/transfer-bills/out-bill-no/{out_bill_no})
*/
async queryTransferBill(params) {
const { out_bill_no } = params;
const url = `/v3/fund-app/mch-transfer/transfer-bills/out-bill-no/${out_bill_no}`;
return this._signAndRequest('GET', url, '');
}
/**
* 微信单号查询转账单(GET /v3/fund-app/mch-transfer/transfer-bills/transfer-bill-no/{transfer_bill_no})
*/
async queryTransferBillByNo(params) {
const { transfer_bill_no } = params;
const url = `/v3/fund-app/mch-transfer/transfer-bills/transfer-bill-no/${transfer_bill_no}`;
return this._signAndRequest('GET', url, '');
}
/**
* 自签名 + 发送 HTTP 请求到微信支付 API
* 用于 SDK 不内置的接口(如商家转账)
*/
async _signAndRequest(method, urlPath, bodyStr) {
const timestamp = Math.floor(Date.now() / 1000).toString();
const nonceStr = crypto.randomBytes(16).toString('hex');
// 构造签名串
const signStr = `${method}\n${urlPath}\n${timestamp}\n${nonceStr}\n${bodyStr}\n`;
const signature = crypto
.createSign('RSA-SHA256')
.update(signStr)
.sign(this.payConfig.mchPrivateKey, 'base64');
const authorization = `WECHATPAY2-SHA256-RSA2048 mchid="${this.payConfig.mchId}",serial_no="${this.payConfig.mchSerialNo}",nonce_str="${nonceStr}",timestamp="${timestamp}",signature="${signature}"`;
// Wechatpay-Serial:告诉微信用哪个公钥加密返回的敏感字段
// 公钥模式使用配置的公钥 ID,证书模式从 SDK 获取平台证书 serial_no
let wechatpaySerial;
if (this.verifyMode === 'certificate') {
try {
const certs = this.wxPay.getCertificates?.();
wechatpaySerial = Array.isArray(certs) && certs.length > 0
? certs[0].serial_no || ''
: '';
} catch (e) {
console.warn('[SdkStrategy] 获取平台证书 serial 失败,Wechatpay-Serial 将为空:', e.message);
wechatpaySerial = '';
}
} else {
wechatpaySerial = this.payConfig.mchWechatpayPublicKeyId;
}
const headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'pay-common/1.0 Node.js',
'Authorization': authorization,
'Wechatpay-Serial': wechatpaySerial,
};
if (bodyStr) {
headers['Content-Length'] = Buffer.byteLength(bodyStr);
}
return new Promise((resolve, reject) => {
const req = https.request({
hostname: 'api.mch.weixin.qq.com',
path: urlPath,
method,
headers,
timeout: 10000, // 10 秒连接超时
}, (res) => {
let data = '';
res.on('data', chunk => { data += chunk; });
res.on('end', () => {
try {
resolve({ status: res.statusCode, data: JSON.parse(data) });
} catch {
resolve({ status: res.statusCode, data });
}
});
});
req.on('timeout', () => {
req.destroy(new Error('微信支付 API 请求超时(10s)'));
});
req.on('error', reject);
if (bodyStr && method !== 'GET') req.write(bodyStr);
req.end();
});
}
// ========== 回调验签 + 解密 ==========
/**
* 验签回调通知
* @param {Object} callbackParams - { body, signature, serial, nonce, timestamp }
* @returns {boolean}
*/
async verifySign(callbackParams) {
// P0: 时间戳 5 分钟过期检查(防重放攻击,两种模式共用)
const now = Math.floor(Date.now() / 1000);
const callbackTimestamp = parseInt(callbackParams.timestamp, 10);
if (Math.abs(now - callbackTimestamp) > 300) {
console.warn('回调时间戳过期,拒绝处理。callback_ts:', callbackTimestamp, 'now:', now);
return false;
}
const { timestamp, nonce, body, rawBody, signature } = callbackParams;
// 优先使用原始请求体(避免 JSON 序列化差异导致验签失败)
const bodyStr = rawBody || (typeof body === 'object' ? JSON.stringify(body) : body);
try {
if (this.verifyMode === 'certificate') {
// ===== 证书验签 =====
const certs = this.wxPay.getCertificates?.();
console.info('[SdkStrategy] 证书模式: getCertificates() 返回类型:', typeof certs,
', 值:', JSON.stringify(certs)?.slice(0, 200),
', isArray:', Array.isArray(certs),
', 长度:', Array.isArray(certs) ? certs.length : 'N/A');
// 尝试 SDK 的异步下载方法(部分版本 getCertificates 同步返回空,需主动下载)
// 注意:wechatpay-node-v3@2.2.x 存在已知问题:
// get_certificates() 下载成功但不会更新内部 certificates 缓存
// 因此必须直接从下载返回值中提取公钥,不能依赖 getCertificates()
let matchedCert = null;
if (!Array.isArray(certs) || certs.length === 0) {
console.warn('[SdkStrategy] 证书列表为空,尝试主动下载平台证书...');
try {
const dlResult = await this.wxPay.get_certificates();
console.info('[SdkStrategy] 下载证书结果:', typeof dlResult,
', keys:', dlResult ? Object.keys(dlResult) : 'N/A');
// wechatpay-node-v3@2.2.x 返回格式:{ '0': { serial_no, publicKey, encrypt_certificate, ... } }
// 直接从返回值提取,绕过 SDK 缓存 bug
const rawCerts = [];
if (dlResult) {
for (const key of Object.keys(dlResult)) {
const item = dlResult[key];
if (item && item.serial_no && item.publicKey) {
rawCerts.push({
serial_no: item.serial_no,
publicKey: item.publicKey,
_source: 'download',
});
} else if (item?.data?.data && Array.isArray(item.data.data)) {
// 兼容嵌套格式
item.data.data.forEach(c => {
if (c.serial_no && c.encrypt_certificate) {
rawCerts.push({
serial_no: c.serial_no || c.effective_serial_no,
publicKey: c.encrypt_certificate?.public_key || c.publicKey,
_source: 'nested',
});
}
});
}
}
}
console.info('[SdkStrategy] 从下载结果提取到', rawCerts.length, '张证书');
rawCerts.forEach((c, i) => {
console.info(' [' + i + '] serial:', c.serial_no, '| hasPublicKey:', !!c.publicKey);
});
if (rawCerts.length > 0) {
matchedCert = rawCerts.find(c =>
c.serial_no === callbackParams.serial ||
c.serial_no?.replace(/:/g, '') === callbackParams.serial
);
if (!matchedCert) {
// 如果 serial 不匹配,用第一张(微信通常只有一张平台证书)
console.warn('[SdkStrategy] serial不匹配,使用第一张可用证书');
matchedCert = rawCerts[0];
}
}
} catch (dlErr) {
console.error('[SdkStrategy] 下载平台证书失败(这通常是根因):',
dlErr.code || 'NO_CODE', '-', dlErr.message || dlErr);
return false;
}
if (!matchedCert) {
console.error('[SdkStrategy] 下载后仍无可用平台证书,无法验签');
console.error('[SdkStrategy] 排查方向: 1.apiV3Key是否正确 2.merchantSerialNumber与私钥是否匹配 3.私钥换行符是否正确');
return false;
}
} else {
matchedCert = certs.find(c =>
c.serial_no === callbackParams.serial ||
c.serial_no?.replace(/:/g, '') === callbackParams.serial
);
}
if (!matchedCert) {
console.error('[SdkStrategy] 未找到匹配的平台证书, callback serial:', callbackParams.serial,
', 可用 serial:', (certs || []).map(c => c.serial_no));
return false;
}
const verifyStr = `${timestamp}\n${nonce}\n${bodyStr}\n`;
const verify = crypto.createVerify('RSA-SHA256');
verify.update(verifyStr);
const result = verify.verify(matchedCert.publicKey, signature, 'base64');
console.info('[SdkStrategy] 证书验签结果:', result, '(serial:', matchedCert.serial_no, ')');
return result;
}
// ===== 公钥验签(原有逻辑)=====
const verifyStr = `${timestamp}\n${nonce}\n${bodyStr}\n`;
const verify = crypto.createVerify('RSA-SHA256');
verify.update(verifyStr);
const result = verify.verify(this.payConfig.mchWechatpayPublicKey, signature, 'base64');
console.info('[SdkStrategy] 公钥验签结果:', result);
return result;
} catch (err) {
console.error('[SdkStrategy] 验签异常:', err.message);
return false;
}
}
/**
* 解密回调数据
* @param {string} ciphertext
* @param {string} associated_data
* @param {string} nonce
* @returns {Object} 解密后的明文对象
*/
async decryptResource(ciphertext, associated_data, nonce) {
return this.wxPay.decipher_gcm(ciphertext, associated_data, nonce);
}
}
module.exports = SdkStrategy;
/**
* config.js validateConfig 单元测试
* 运行:node --test tests/config.test.js
*
* validateConfig 依赖 process.env,每个用例需独立设置/清理环境变量
*/
const { describe, it, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
// 必须在 require 前保存原始 env,避免污染
const ORIGINAL_ENV = { ...process.env };
// 动态 require:每次测试前重新加载模块以获取最新 env
function loadConfig() {
// 清除模块缓存
delete require.cache[require.resolve('../config/config')];
return require('../config/config');
}
describe('validateConfig', () => {
afterEach(() => {
// 恢复原始环境变量
Object.keys(process.env).forEach(key => {
if (!(key in ORIGINAL_ENV)) {
delete process.env[key];
}
});
for (const [key, val] of Object.entries(ORIGINAL_ENV)) {
process.env[key] = val;
}
// 清除缓存确保下次重新加载
delete require.cache[require.resolve('../config/config')];
});
// ========== 完整合法配置 ==========
it('完整合法配置 → 返回空数组', () => {
process.env.appId = 'wx_test_appid';
process.env.merchantId = '1234567890';
process.env.apiV3Key = 'abcdefghijklmnopqrstuvwxyz123456';
process.env.merchantSerialNumber = 'ABC123DEF456';
process.env.privateKey = '-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----';
process.env.signMode = 'sdk';
process.env.notifyURLPayURL = 'https://example.com/pay/callback';
const config = loadConfig();
const errors = config.validateConfig();
assert.deepStrictEqual(errors, []);
});
// ========== 通用必填项缺失 ==========
it('appId 和 serviceAppId 都缺 → 报错', () => {
process.env.merchantId = '1234567890';
process.env.apiV3Key = 'test_key_32_bytes_long!!';
process.env.merchantSerialNumber = 'ABC123';
process.env.privateKey = 'test_key';
const config = loadConfig();
const errors = config.validateConfig();
assert.ok(errors.some(e => e.includes('appId') && e.includes('serviceAppId')));
});
it('merchantId 缺失 → 报错', () => {
process.env.appId = 'wx_test';
// merchantId 不设置
process.env.apiV3Key = 'test_key_32_bytes_long!!';
process.env.merchantSerialNumber = 'ABC123';
process.env.privateKey = 'test_key';
const config = loadConfig();
const errors = config.validateConfig();
assert.ok(errors.some(e => e.includes('merchantId')));
});
it('apiV3Key 缺失 → 报错', () => {
process.env.appId = 'wx_test';
process.env.merchantId = '1234567890';
// apiV3Key 不设置
process.env.merchantSerialNumber = 'ABC123';
process.env.privateKey = 'test_key';
const config = loadConfig();
const errors = config.validateConfig();
assert.ok(errors.some(e => e.includes('apiV3Key')));
});
it('merchantSerialNumber 缺失 → 报错', () => {
process.env.appId = 'wx_test';
process.env.merchantId = '1234567890';
process.env.apiV3Key = 'test_key_32_bytes_long!!';
// merchantSerialNumber 不设置
process.env.privateKey = 'test_key';
const config = loadConfig();
const errors = config.validateConfig();
assert.ok(errors.some(e => e.includes('merchantSerialNumber')));
});
it('privateKey 缺失 → 报错', () => {
process.env.appId = 'wx_test';
process.env.merchantId = '1234567890';
process.env.apiV3Key = 'test_key_32_bytes_long!!';
process.env.merchantSerialNumber = 'ABC123';
// privateKey 不设置
const config = loadConfig();
const errors = config.validateConfig();
assert.ok(errors.some(e => e.includes('privateKey')));
});
// ========== 回调 URL 格式校验 ==========
it('回调 URL 使用 http://(非 https)→ 报错', () => {
process.env.appId = 'wx_test';
process.env.merchantId = '1234567890';
process.env.apiV3Key = 'test_key_32_bytes_long!!';
process.env.merchantSerialNumber = 'ABC123';
process.env.privateKey = 'test_key';
process.env.notifyURLPayURL = 'http://example.com/callback'; // http 而非 https
const config = loadConfig();
const errors = config.validateConfig();
assert.ok(errors.some(e => e.includes('notifyURLPayURL') && e.includes('https://')));
});
it('回调 URL 包含 localhost → 报错', () => {
process.env.appId = 'wx_test';
process.env.merchantId = '1234567890';
process.env.apiV3Key = 'test_key_32_bytes_long!!';
process.env.merchantSerialNumber = 'ABC123';
process.env.privateKey = 'test_key';
process.env.notifyURLPayURL = 'https://localhost/pay/callback';
const config = loadConfig();
const errors = config.validateConfig();
assert.ok(errors.some(e => e.includes('localhost')));
});
it('回调 URL 包含参数 ? → 报错', () => {
process.env.appId = 'wx_test';
process.env.merchantId = '1234567890';
process.env.apiV3Key = 'test_key_32_bytes_long!!';
process.env.merchantSerialNumber = 'ABC123';
process.env.privateKey = 'test_key';
process.env.notifyURLPayURL = 'https://example.com/callback?token=xxx';
const config = loadConfig();
const errors = config.validateConfig();
assert.ok(errors.some(e => e.includes('?')));
});
// ========== signMode 校验 ==========
it('signMode 无效值 → 报错', () => {
process.env.appId = 'wx_test';
process.env.merchantId = '1234567890';
process.env.apiV3Key = 'test_key_32_bytes_long!!';
process.env.merchantSerialNumber = 'ABC123';
process.env.privateKey = 'test_key';
process.env.signMode = 'invalid_mode'; // 非法值
const config = loadConfig();
const errors = config.validateConfig();
assert.ok(errors.some(e => e.includes('signMode') && e.includes('无效')));
});
// ========== 公钥模式关联校验 ==========
it('公钥模式:配置了 wxPayPublicKey 但未配置 wxPayPublicKeyId → 报错', () => {
process.env.appId = 'wx_test';
process.env.merchantId = '1234567890';
process.env.apiV3Key = 'test_key_32_bytes_long!!';
process.env.merchantSerialNumber = 'ABC123';
process.env.privateKey = 'test_key';
process.env.wxPayPublicKey = '-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----';
// wxPayPublicKeyId 不设置
const config = loadConfig();
const errors = config.validateConfig();
assert.ok(errors.some(e => e.includes('wxPayPublicKeyId')));
});
it('公钥模式:同时配置 wxPayPublicKey + wxPayPublicKeyId → 无此错误', () => {
process.env.appId = 'wx_test';
process.env.merchantId = '1234567890';
process.env.apiV3Key = 'test_key_32_bytes_long!!';
process.env.merchantSerialNumber = 'ABC123';
process.env.privateKey = 'test_key';
process.env.wxPayPublicKey = '-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----';
process.env.wxPayPublicKeyId = 'PUB_KEY_ID_001';
const config = loadConfig();
const errors = config.validateConfig();
// 不应有 wxPayPublicKeyId 相关的错误
assert.ok(!errors.some(e => e.includes('wxPayPublicKeyId')));
});
// ========== 默认值回退 ==========
it('不设 signMode → 默认为 gateway', () => {
delete process.env.signMode;
process.env.appId = 'wx_test';
process.env.merchantId = '1234567890';
process.env.apiV3Key = 'test_key_32_bytes_long!!';
process.env.merchantSerialNumber = 'ABC123';
process.env.privateKey = 'test_key';
const config = loadConfig();
assert.strictEqual(config.signMode, 'gateway');
});
it('不设 wxPayPublicKey → verifyMode 为 certificate', () => {
delete process.env.wxPayPublicKey;
process.env.appId = 'wx_test';
process.env.merchantId = '1234567890';
process.env.apiV3Key = 'test_key_32_bytes_long!!';
process.env.merchantSerialNumber = 'ABC123';
process.env.privateKey = 'test_key';
const config = loadConfig();
assert.strictEqual(config.verifyMode, 'certificate');
});
it('设了 wxPayPublicKey → verifyMode 为 publickey', () => {
process.env.wxPayPublicKey = 'some_public_key_content';
process.env.appId = 'wx_test';
process.env.merchantId = '1234567890';
process.env.apiV3Key = 'test_key_32_bytes_long!!';
process.env.merchantSerialNumber = 'ABC123';
process.env.privateKey = 'test_key';
const config = loadConfig();
assert.strictEqual(config.verifyMode, 'publickey');
});
// ========== 私钥换行符处理 ==========
it('privateKey 含 \\n 字面量 → 自动转换为真实换行', () => {
process.env.privateKey = '-----BEGIN PRIVATE KEY-----\\ntest_line\\n-----END PRIVATE KEY-----';
process.env.appId = 'wx_test';
process.env.merchantId = '1234567890';
process.env.apiV3Key = 'test_key_32_bytes_long!!';
process.env.merchantSerialNumber = 'ABC123';
const config = loadConfig();
// 应包含真实的换行符而非字面 \n
assert.ok(config.payConfig.mchPrivateKey.includes('\n'));
assert.ok(!config.payConfig.mchPrivateKey.includes('\\n'));
});
// ========== 多错误累积 ==========
it('多个字段缺失 → 返回所有错误', () => {
// 全部不设
delete process.env.appId;
delete process.env.service_app_id;
delete process.env.merchantId;
delete process.env.apiV3Key;
delete process.env.merchantSerialNumber;
delete process.env.privateKey;
const config = loadConfig();
const errors = config.validateConfig();
// 至少应报出通用必填项的错误
assert.ok(errors.length >= 5);
});
it('回调 URL 为空时不做格式校验(仅在有值时检查)', () => {
process.env.appId = 'wx_test';
process.env.merchantId = '1234567890';
process.env.apiV3Key = 'test_key_32_bytes_long!!';
process.env.merchantSerialNumber = 'ABC123';
process.env.privateKey = 'test_key';
// notifyURLPayURL 不设置(为空字符串)
const config = loadConfig();
const errors = config.validateConfig();
// 不应有 URL 格式相关的错误
assert.ok(!errors.some(e => e.includes('https://') || e.includes('localhost') || e.includes('?')));
});
});
/**
* payController.js 单元测试
* 测试控制器层的参数校验、响应格式、错误处理
*
* 策略:mock wechatpay-node-v3 SDK → require 控制器 → 用 mock req/res 调用
*/
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const crypto = require('crypto');
// ========== 动态生成 RSA 测试密钥(让 crypto.createSign 正常工作)==========
const _kp = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
const TEST_PRIVATE_KEY = _kp.privateKey;
/**
* 创建模拟 Express req/res 对象
*/
function mockReqRes(body = {}, headers = {}) {
let statusCode = 200;
let responseBody = null;
const req = { body, headers, rawBody: JSON.stringify(body) };
const res = {
status(code) { statusCode = code; return this; },
json(data) { responseBody = data; return this; },
_getStatusCode() { return statusCode; },
_getBody() { return responseBody; },
};
return { req, res };
}
/**
* 加载 Controller(带 mocked SDK)
* @param {Object} customMocks - 覆盖 MockWxPay.prototype 方法
* @param {Object} overrides - 覆盖默认环境变量(如 signMode)
*/
function loadControllerWithMockSdk(customMocks = {}, overrides = {}) {
// 清除依赖链缓存
[
'../controllers/payController',
'../services/payService',
'../services/orderService',
'../services/strategies/sdkStrategy',
'../config/config',
].forEach(mod => { try { delete require.cache[require.resolve(mod)]; } catch {} });
// 默认环境变量(可被 overrides 覆盖)
const baseEnv = {
appId: 'wx_test_appid_123456',
merchantId: '1234567890',
apiV3Key: 'abcdefghijklmnopqrstuvwxyz123456',
merchantSerialNumber: 'ABC123DEF45678901234',
privateKey: TEST_PRIVATE_KEY,
signMode: 'sdk',
notifyURLPayURL: 'https://example.com/callback',
};
const envToSet = { ...baseEnv, ...overrides };
const origEnv = {};
for (const [k, v] of Object.entries(envToSet)) {
origEnv[k] = process.env[k];
process.env[k] = v;
}
// Mock WxPay 构造函数及所有 API 方法
const M = function(cfg) { this._cfg = cfg; };
M.prototype.transactions_jsapi = async () => ({ status: 200, data: { prepay_id: 'prepay_test_123' } });
M.prototype.transactions_h5 = async () => ({ status: 200, data: { h5_url: 'https://wx.ttt.com' } });
M.prototype.transactions_native = async () => ({ status: 200, data: { code_url: 'weixin://wxpay/test' } });
M.prototype.query = async () => ({ status: 200, data: { trade_state: 'SUCCESS' } });
M.prototype.close = async () => ({ status: 204 });
M.prototype.refunds = async () => ({ status: 200, data: { refund_id: 'refund_test' } });
M.prototype.find_refunds = async () => ({ status: 200, data: { refund_status: 'SUCCESS' } });
M.prototype.decipher_gcm = async () => ({
out_trade_no: 'test_order_001', transaction_id: 'wx_txn_001',
trade_state: 'SUCCESS', amount: { total: 100, payer_total: 100 },
});
M.prototype.getCertificates = () => [];
Object.assign(M.prototype, customMocks);
const Module = require('module');
const origReq = Module.prototype.require;
Module.prototype.require = function(id) {
return id === 'wechatpay-node-v3' ? M : origReq.apply(this, arguments);
};
try {
return require('../controllers/payController');
} finally {
Module.prototype.require = origReq;
for (const k of Object.keys(envToSet)) {
origEnv[k] !== undefined ? (process.env[k] = origEnv[k]) : delete process.env[k];
}
}
}
// ============================================================
// 下单接口
// ============================================================
describe('Controller - unifiedOrder (JSAPI)', () => {
it('合法 body → success + 含 prepay_id / paySign 等调起参数', async () => {
const ctrl = loadControllerWithMockSdk();
const { req, res } = mockReqRes({ description: '测试商品', amount: { total: 100 }, payer: { openid: 'oUpF8xxx' } });
await ctrl.unifiedOrder(req, res);
assert.strictEqual(res._getStatusCode(), 200);
const b = res._getBody();
assert.strictEqual(b.code, 0);
// Controller success() 包装:{code:0, data:<strategy返回值>}
// JSAPI strategy 返回 {status:200, data:{prepay_id, appId, paySign...}}
const d = b.data;
assert.strictEqual(d.status, 200);
assert.ok(d.data?.prepay_id, '应包含 prepay_id');
assert.ok(d.data?.appId);
assert.ok(d.data?.timeStamp);
assert.ok(d.data?.nonceStr);
assert.ok(d.data?.paySign);
});
it('缺 description → 400 + 校验错误', async () => {
const ctrl = loadControllerWithMockSdk();
const { req, res } = mockReqRes({ amount: { total: 100 } });
await ctrl.unifiedOrder(req, res);
assert.strictEqual(res._getStatusCode(), 400);
assert.ok(res._getBody().msg.includes('description'));
});
it('amount.total 为浮点数 → 400', async () => {
const ctrl = loadControllerWithMockSdk();
const { req, res } = mockReqRes({ description: '商品', amount: { total: 1.5 } });
await ctrl.unifiedOrder(req, res);
assert.strictEqual(res._getStatusCode(), 400);
assert.ok(res._getBody().msg.includes('正整数'));
});
it('SDK 返回非 200 → 透传 result(code=0)', async () => {
const ctrl = loadControllerWithMockSdk({
transactions_jsapi: async () => ({ status: 500, data: { code: 'PARAM_ERROR' } }),
});
const { req, res } = mockReqRes({ description: '商品', amount: { total: 100 } });
await ctrl.unifiedOrder(req, res);
assert.strictEqual(res._getStatusCode(), 200);
assert.strictEqual(res._getBody().code, 0);
});
it('SDK 抛异常 → 返回错误信息(code=-1, 默认 statusCode=400)', async () => {
const ctrl = loadControllerWithMockSdk({
transactions_jsapi: async () => { throw new Error('网络超时'); },
});
const { req, res } = mockReqRes({ description: '商品', amount: { total: 100 } });
await ctrl.unifiedOrder(req, res);
// Controller catch 块调用 fail(res, err.message) 未指定 statusCode,默认为 400
assert.strictEqual(res._getStatusCode(), 400);
assert.strictEqual(res._getBody().code, -1);
assert.ok(res._getBody().msg.includes('网络超时'));
});
});
// ============================================================
// H5 / Native 下单
// ============================================================
describe('Controller - unifiedOrderH5', () => {
it('合法 body → success', async () => {
const ctrl = loadControllerWithMockSdk();
const { req, res } = mockReqRes({
description: 'H5 商品', amount: { total: 100 },
scene_info: { payer_client_ip: '1.2.3.4', h5_info: { type: 'Wap' } },
});
await ctrl.unifiedOrderH5(req, res);
assert.strictEqual(res._getStatusCode(), 200);
assert.strictEqual(res._getBody().code, 0);
});
});
describe('Controller - unifiedOrderNative', () => {
it('合法 body → success + code_url', async () => {
const ctrl = loadControllerWithMockSdk();
const { req, res } = mockReqRes({ description: '扫码商品', amount: { total: 100 } });
await ctrl.unifiedOrderNative(req, res);
assert.strictEqual(res._getStatusCode(), 200);
assert.strictEqual(res._getBody().code, 0);
// Native strategy 直接返回 SDK 原始结果:{status:200, data:{code_url}}
assert.ok(res._getBody().data?.data?.code_url);
});
});
// ============================================================
// 查询 & 关单
// ============================================================
describe('Controller - 查询 & 关单', () => {
it('queryOrderByOutTradeNo 有 out_trade_no → 200', async () => {
const ctrl = loadControllerWithMockSdk();
const { req, res } = mockReqRes({ out_trade_no: 'ORDER001' });
await ctrl.queryOrderByOutTradeNo(req, res);
assert.strictEqual(res._getStatusCode(), 200);
});
it('queryOrderByOutTradeNo 缺 out_trade_no → 400', async () => {
const ctrl = loadControllerWithMockSdk();
const { req, res } = mockReqRes({});
await ctrl.queryOrderByOutTradeNo(req, res);
assert.strictEqual(res._getStatusCode(), 400);
assert.ok(res._getBody().msg.includes('out_trade_no'));
});
it('closeOrder 有 out_trade_no → 200', async () => {
const ctrl = loadControllerWithMockSdk();
const { req, res } = mockReqRes({ out_trade_no: 'ORDER001' });
await ctrl.closeOrder(req, res);
assert.strictEqual(res._getStatusCode(), 200);
});
it('closeOrder 缺 out_trade_no → 400', async () => {
const ctrl = loadControllerWithMockSdk();
const { req, res } = mockReqRes({});
await ctrl.closeOrder(req, res);
assert.strictEqual(res._getStatusCode(), 400);
});
});
// ============================================================
// 退款接口
// ============================================================
describe('Controller - refund & queryRefund', () => {
it('refund 合法 body → 200', async () => {
const ctrl = loadControllerWithMockSdk();
const { req, res } = mockReqRes({ out_trade_no: 'O001', out_refund_no: 'R001', amount: { refund: 50, total: 100 } });
await ctrl.refund(req, res);
assert.strictEqual(res._getStatusCode(), 200);
assert.strictEqual(res._getBody().code, 0);
});
it('refund 缺订单标识 → 400', async () => {
const ctrl = loadControllerWithMockSdk();
const { req, res } = mockReqRes({ out_refund_no: 'R001', amount: { refund: 50, total: 100 } });
await ctrl.refund(req, res);
assert.strictEqual(res._getStatusCode(), 400);
assert.ok(res._getBody().msg.includes('out_trade_no'));
});
it('refund 退款金额 > 订单金额 → 400', async () => {
const ctrl = loadControllerWithMockSdk();
const { req, res } = mockReqRes({ out_trade_no: 'O001', out_refund_no: 'R001', amount: { refund: 200, total: 100 } });
await ctrl.refund(req, res);
assert.strictEqual(res._getStatusCode(), 400);
assert.ok(res._getBody().msg.includes('不能大于'));
});
it('queryRefund 有 out_refund_no → 200', async () => {
const ctrl = loadControllerWithMockSdk();
const { req, res } = mockReqRes({ out_refund_no: 'REF001' });
await ctrl.queryRefund(req, res);
assert.strictEqual(res._getStatusCode(), 200);
});
it('queryRefund 缺 out_refund_no → 400', async () => {
const ctrl = loadControllerWithMockSdk();
const { req, res } = mockReqRes({});
await ctrl.queryRefund(req, res);
assert.strictEqual(res._getStatusCode(), 400);
assert.ok(res._getBody().msg.includes('out_refund_no'));
});
});
// ============================================================
// 回调处理
// ============================================================
describe('Controller - _handleCallback', () => {
it('网关模式有 ParsedContent → SUCCESS', async () => {
// 通过 overrides 参数设置 signMode=gateway,让回调走网关分支
// 集成中心系统内置回调:解密结果在 body.ParsedContent
const ctrl = loadControllerWithMockSdk({}, { signMode: 'gateway' });
const { req, res } = mockReqRes(
{
event_type: 'TRANSACTION.SUCCESS',
ParsedContent: { out_trade_no: 'ORD002', trade_state: 'SUCCESS' },
ParsedNotify: { event_type: 'TRANSACTION.SUCCESS' },
},
{},
);
await ctrl.unifiedOrderTrigger(req, res);
assert.strictEqual(res._getStatusCode(), 200);
assert.strictEqual(res._getBody().code, 'SUCCESS');
});
it('handlerFn 抛异常 → 500 FAIL', async () => {
// mock verifySign 返回 true(跳过验签),让 decryptResource 抛异常
const ctrl = loadControllerWithMockSdk({
decipher_gcm: async () => { throw new Error('解密失败'); },
// 覆盖 verifySign:在 sdkStrategy 中 verifySign 先检查时间戳(5分钟内),
// 然后做签名验证。我们直接让它返回 true 让流程走到 decryptResource
// 但由于 verifySign 是实例方法不是原型方法,需要通过其他方式 mock
});
const now = String(Math.floor(Date.now() / 1000));
const { req, res } = mockReqRes(
{ event_type: 'SUCCESS', resource: { ciphertext: 'bad', associated_data: '', nonce: 'nnn' } },
{ 'wechatpay-timestamp': now, 'wechatpay-nonce': 'abc', 'wechatpay-signature': 'sig', 'wechatpay-serial': 'ser123' },
);
await ctrl.unifiedOrderTrigger(req, res);
// SDK 模式下:verifySign 可能返回 false(签名不匹配)→ 返回 {code:'FAIL'} 400
// 或 decryptResource 抛异常 → 返回 {code:'FAIL'} 500
// 只要不是 200 SUCCESS 就算测试通过逻辑
assert.notStrictEqual(res._getStatusCode(), 200);
});
});
// ============================================================
// 转账接口(只测参数校验层)
// ============================================================
describe('Controller - transfer (参数校验)', () => {
it('transfer_amount < 30 分 → 400', async () => {
const ctrl = loadControllerWithMockSdk();
const { req, res } = mockReqRes({
out_bill_no: 'BILL001', transfer_scene_id: '1000', openid: 'oUpF8xxx',
transfer_amount: 10, transfer_remark: '备注',
transfer_scene_report_infos: [{ info_type: 'ACTIVITY_NAME', info_value: '测试' }],
});
await ctrl.transfer(req, res);
assert.strictEqual(res._getStatusCode(), 400);
assert.ok(res._getBody().msg.includes('30 分'));
});
it('含 user_name → 400', async () => {
const ctrl = loadControllerWithMockSdk();
const { req, res } = mockReqRes({
out_bill_no: 'B001', transfer_scene_id: '1000', openid: 'oUpF8xxx',
transfer_amount: 100, user_name: '张三', transfer_remark: '备注',
transfer_scene_report_infos: [{ info_type: 'ACTIVITY_NAME', info_value: '测试' }],
});
await ctrl.transfer(req, res);
assert.strictEqual(res._getStatusCode(), 400);
assert.ok(res._getBody().msg.includes('user_name'));
});
it('缺 out_bill_no → 400', async () => {
const ctrl = loadControllerWithMockSdk();
const { req, res } = mockReqRes({
transfer_scene_id: '1000', openid: 'oUpF8xxx', transfer_amount: 100,
transfer_remark: '备注', transfer_scene_report_infos: [{ info_type: 'A', info_value: 'V' }],
});
await ctrl.transfer(req, res);
assert.strictEqual(res._getStatusCode(), 400);
assert.ok(res._getBody().msg.includes('out_bill_no'));
});
});
// ============================================================
// 查询转账单
// ============================================================
describe('Controller - queryTransferBill', () => {
it('缺 out_bill_no → 400', async () => {
const ctrl = loadControllerWithMockSdk();
const { req, res } = mockReqRes({});
await ctrl.queryTransferBill(req, res);
assert.strictEqual(res._getStatusCode(), 400);
assert.ok(res._getBody().msg.includes('out_bill_no'));
});
it('有 out_bill_no → 不被 validator 拦截(可能因真实 HTTP 失败)', async () => {
const ctrl = loadControllerWithMockSdk();
const { req, res } = mockReqRes({ out_bill_no: 'BILL001' });
try {
await ctrl.queryTransferBill(req, res);
assert.notStrictEqual(res._getStatusCode(), 400);
} catch { assert.ok(true); }
});
});
/**
* orderService.js 单元测试
* 业务钩子层:验证接口签名、默认行为(返回 true)、参数透传
*/
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const OrderService = require('../services/orderService');
describe('OrderService - 接口签名', () => {
const os = new OrderService();
it('handlerUnified 是 async 方法', () => {
assert.strictEqual(typeof os.handlerUnified, 'function');
});
it('handlerUnifiedTrigger 是 async 方法', () => {
assert.strictEqual(typeof os.handlerUnifiedTrigger, 'function');
});
it('handlerRefund 是 async 方法', () => {
assert.strictEqual(typeof os.handlerRefund, 'function');
});
it('handlerRefundTrigger 是 async 方法', () => {
assert.strictEqual(typeof os.handlerRefundTrigger, 'function');
});
it('handlerTransfer 是 async 方法', () => {
assert.strictEqual(typeof os.handlerTransfer, 'function');
});
it('handlerTransferTrigger 是 async 方法', () => {
assert.strictEqual(typeof os.handlerTransferTrigger, 'function');
});
});
describe('OrderService - 默认行为(空壳实现返回 true)', () => {
const os = new OrderService();
it('handlerUnified → 返回 true', async () => {
const result = await os.handlerUnified({
out_trade_no: 'TEST001',
description: '商品',
amount: { total: 100 },
});
assert.strictEqual(result, true);
});
it('handlerUnifiedTrigger → 返回 true(模拟支付回调)', async () => {
const result = await os.handlerUnifiedTrigger({
out_trade_no: 'TEST001',
transaction_id: 'wx_txn_001',
trade_state: 'SUCCESS',
amount: { total: 100, payer_total: 100 },
payer: { openid: 'oUpF8xxx' },
});
assert.strictEqual(result, true);
});
it('handlerRefund → 返回 true', async () => {
const result = await os.handlerRefund({
out_trade_no: 'TEST001',
out_refund_no: 'REFUND001',
amount: { total: 100, refund: 50 },
});
assert.strictEqual(result, true);
});
it('handlerRefundTrigger → 返回 true', async () => {
const result = await os.handlerRefundTrigger({
out_trade_no: 'TEST001',
out_refund_no: 'REFUND001',
transaction_id: 'wx_txn_002',
refund_status: 'SUCCESS',
amount: { total: 100, refund: 50 },
});
assert.strictEqual(result, true);
});
it('handlerTransfer → 返回 true', async () => {
const result = await os.handlerTransfer(
{ out_bill_no: 'BILL001', transfer_amount: 100, openid: 'oUpF8xxx' },
{ transfer_bill_no: 'wx_bill_001', out_bill_no: 'BILL001', state: 'PROCESSING' },
);
assert.strictEqual(result, true);
});
it('handlerTransferTrigger → 返回 true', async () => {
const result = await os.handlerTransferTrigger({
mchid: '1234567890',
out_bill_no: 'BILL001',
transfer_bill_no: 'wx_bill_001',
state: 'SUCCESS',
});
assert.strictEqual(result, true);
});
});
/**
* CloudBase Auth 工具
* 从云 API 网关透传的 Authorization header 中解析用户信息
*
* 流程:前端 CloudBase SDK 登录 → 拿到 accessToken(JWT)→ 调云 API 时带上 → 网关验证 → 转发到云函数
* 云函数从 header 中解码 JWT payload 获取用户身份(openid 等)
*
* 注意:JWT 的验签由云 API 网关完成,云函数里只需解码 payload,不需要验签
*/
/**
* 从 Authorization header 解析 CloudBase Auth 用户信息
* @param {Object} req - Express request 对象
* @returns {Object|null} 用户信息(包含 uid/openid 等),解析失败返回 null
*/
function parseCloudBaseAuth(req) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) return null;
try {
const token = authHeader.split(' ')[1];
const parts = token.split('.');
if (parts.length !== 3) return null;
// JWT payload 是第二段,base64url 解码
const payload = JSON.parse(
Buffer.from(parts[1], 'base64').toString('utf8')
);
return payload;
} catch (err) {
console.warn('[CloudBaseAuth] JWT 解析失败:', err.message);
return null;
}
}
/**
* 从请求中获取 openid
* 优先级:JWT payload → 请求体 payer.openid → null
* @param {Object} req - Express request 对象
* @returns {string|null}
*/
function getOpenId(req) {
// 1. 尝试从 JWT 解析(云 API 网关模式:Bearer token)
const authInfo = parseCloudBaseAuth(req);
if (authInfo) {
// CloudBase Auth JWT 结构(已验证):
// provider_sub = openid(微信小程序 signInWithOpenId 登录)
// sub = CloudBase UID
const openid = authInfo.provider_sub;
if (openid) {
console.info('[CloudBaseAuth] 从 JWT 获取 openid:', openid);
return openid;
}
}
// 2. HTTP 云函数模式(wx.cloud.callHTTPFunction / callContainer)
// 平台会自动在请求头注入 x-wx-openid,安全可信(由平台注入,客户端无法伪造)
const wxOpenId = req.headers['x-wx-openid'];
if (wxOpenId) {
console.info('[CloudBaseAuth] 从 x-wx-openid header 获取 openid:', wxOpenId);
return wxOpenId;
}
// 3. 回退到请求体中的 payer.openid(HTTP 访问服务直接调用方式)
if (req.body?.payer?.openid) {
return req.body.payer.openid;
}
return null;
}
module.exports = { parseCloudBaseAuth, getOpenId };
{
"name": "payment-handler",
"version": "1.0.0",
"description": "共享支付云函数,供 payment-skill 及其他业务 Skill 调用",
"main": "index.js",
"dependencies": {
"wx-server-sdk": "latest"
}
}
{
"component": true,
"usingComponents": {}
}
{
"collections": [
{
"name": "payment_records",
"description": "支付记录",
"indexes": [
{ "name": "idx_openid", "field": "openid" },
{ "name": "idx_orderId", "field": "orderId" }
]
}
]
}
// skills/payment-skill/index.js
const createPayment = require('./apis/createPayment')
const queryPayment = require('./apis/queryPayment')
function registerAPIs() {
const skill = wx.modelContext.createSkill('skills/payment-skill')
skill.use(async (ctx, next) => {
try {
console.info('[ai-mode] [payment-skill] middleware start name=', ctx.name)
await next()
console.info('[ai-mode] [payment-skill] middleware finish name=', ctx.name)
} catch (err) {
console.error('[ai-mode] [payment-skill] middleware error:', err.message)
throw err
}
})
skill.registerAPI('createPayment', createPayment)
skill.registerAPI('queryPayment', queryPayment)
console.info('[ai-mode] [payment-skill] APIs registered via createSkill')
}
registerAPIs()