一、前言
本博客实现的功能需求很单一,就是实现微信商户号中的企业付款到零钱的功能。
简单的来说,就是提现功能,最为普遍的使用场景大概是小程序/APP中举办一些活动,然后给予用户现金的奖励,由用户从小程序/APP提现到微信钱包中。
但是本文比较特殊,传统实现此功能避免不了使用服务器,但是微信小程序已经推出了云开发这一能力,那么能不能在云开发的云函数中实现提现这一功能呢?即免服务器实现。
答案是可以的。(不然我还写啥?)
二、实现的可行性分析
目前云开发基本成为未来小程序开发模式的一大趋势,对于全栈开发者/中小型项目开发来说,无疑是提供了巨大的便捷性。
但是请求外网API,例如文章中要讲的提现这种需要调用外网API的功能来说,云函数环境是否能够胜任呢?
需要满足什么样的条件的外网接口能够使用云函数环境代替传统服务器进行开发呢?
或许看到这里,
大家会觉得这一"实现的可行性分析”是啰嗦之语,但是因为我希望能把这种思路提供给大家。
让大家能够在项目开发前进行需求分析时对采用云开发是否能满足类似的需求有一个判断。进而减少开发的错误。
其实:
对于大部分无需证书(*.pem),普通的数据获取接口,都是可以通过云函数进行数据请求的。例如:需要爬取某网站的数据/请求、接收第三方API的数据 。
当接口请求的文档里出现:
需要配置请求IP白名单、需要携带证书、需要提供服务器回调地址
这些关键字眼的时候就要特别注意了。
因为可能云函数环境没办法实现。
1、配置请求IP地址白名单
目前云函数是可以实现固定一个IP请求接口,在云函数设置-高级里面可以设置以及查看请求的ip地址。所以这个是可以实现的。
2、需要携带证书访问(*.pem文件)
一般需要证书访问的接口,文档里面都会提到提供存放证书的绝对路径。
例如支付宝的付款到用户余额。
云函数其实也是可以拿到这个绝对的路径的。绝对路径为:/var/user/证书名称.pem。
也可以通过 __dirname() 方法获取到云函数运行时的绝对路径。具体用法百度即可,注意是两个_。
所以这个基本也可以满足。
3、需要提供服务器回调地址
有些接口这个参数是非必填的,但是如果文档里提示是必填的,那就需要考虑一下了。云函数目前没办法提供这个地址,所以要想其他办法实现。这个满足不了。
2024-09-09更新说明:之前由于个人对云开发的了解不足,这一点有个错误,今天恰好有个朋友问了关于企业付款到零钱相关的,就看了一下文章,想着分享给他作为参考,恰巧看到了这个错误,遂更新一下。
回调地址也是可以实现的:只需要开启云函数的公网访问即可。进入云开发控制台后,如下图:
三、实际分析
根据上面的可行性分析,我们来实际分析一下微信的企业付款到零钱的接口,是否能够满足在云函数实现的条件。打开企业付款到微信的开发文档
调用要求里面说需要携带证书进行访问。
关于API证书
其实这里不熟悉的开发者(例如我)一开始看可能会有些迷惑,以为是需要携带3个证书才可以成功访问接口。
就会很疑惑,因为.p12证书云函数没办法安装,仔细看了几次才发现,其实是两种选择。
3个证书中:
要么请求环境安装了.p12证书(仅限windows系统)
要么是请求的时候携带两个.pem证书。
这样看来,云函数其实是可以实现的这一接口调用的。
可行性分析完毕,接下来就开始实现代码了。
四、实现过程
实现前提
开通产品
要实现这个功能,前提当然是企业的商户号成功开通了企业付款到零钱这一功能。
申请开通的门槛还是稍微有点高的。
如果满足了其他条件,唯独不满足连续三十日交易流水这个条件的话,可以试一下每天随机支付金额,刷一下流水,有几率可以开通成功。
如果是比较老的商户号,有一定的支付记录了,大概率是可以开通的。
如果是新的商户号的话,自测。
检查证书以及API密钥
请求过程中需要携带API证书,以及需要API密钥生成签名组成请求参数。所以二者缺一不可。
具体的可以直接参照文档去操作。这个没有坑。生成的证书以及设置的API密钥记得保存好。
代码实现
完成了上面的前提之后,就可以进行代码的编写了。
1、生成签名
可以不看文档,直接看代码。
请求参数
具体你的业务逻辑需要什么参数,直接去文档里看就行,然后放到这个参数对象里就行。
需要注意的是,提现的金额的单位是分,也就是说,假设提现1块钱,需要传入的参数数值则是 100。
openid、随机字符串、随机订单号这些变量,自行填入即可,测试时可以写死。
上面是需要提交的请求数据对象,需要将其进行排序、加密,得出签名。
计算签名模块
const crypto = require('crypto') // 引入MD5加密模块
/**
* 根据传入的参数对象以及密钥,经过处理以及加密, 生成并返回sing参数
* @param {object} obj 参数组成的对象
* @param {String} mchKey 商户平台设置设置的密钥
* @returns
*/
const getSign = (obj, mchKey) => {
// 对传入的对象进行排序
let arr = new Array();
let num = 0;
for (let i in obj) {
arr[num] = i;
num++;
}
let sortArr = arr.sort();
let sortObj = {};
for (let i in sortArr) {
sortObj[sortArr[i]] = obj[sortArr[i]];
}
// 对传入的对象进行拼接
let sortStr = ''
sortArr.forEach(key => {
sortStr += key + "=" + sortObj[key] + "&"
});
// 减去最后一个参数的&连接符
// sortStr = sortStr.substring(0, sortStr.length - 1)
// 拼接密钥
sortStr += "key=" + mchKey
// 使用MD5进行加密, 并将加密结果的英文字母全部转换成大写
const sign = crypto.createHash('md5').update(sortStr, 'utf8').digest('hex').toUpperCase();
return sign;
}
module.exports = {
getSign: getSign
}
调用签名模块,组成最终的提交数据对象(json格式)
const key = '32位支付密钥' // 支付密钥
const signStr = getSign.getSign(paramObj, key) // 获取签名
paramObj.sign = signStr // 将签名放入请求参数对象
发起请求函数
这里需要引入两个模块。
const request = require('request') // 网络请求模块
const fs = require('fs') // 文件读取模块
证书放置位置
请求代码逻辑
const requestFun = (paramObj) => {
// 将json请求数据转换成xml
let xml = '<xml>\n'
Object.keys(paramObj).forEach(function (key) {
xml += '<' + key + '>' + paramObj[key] + '</' + key + '>\n'
});
xml += '</xml>'
// 读取证书
const cert = fs.readFileSync('./apiclient_cert.pem', 'ascii')
const prikey = fs.readFileSync('./apiclient_key.pem', 'ascii')
// 请求数据,放入API证书
const opt = {
url: 'https://api.mch.weixin.qq.com/mmpaymkttransfers/promotion/transfers',
body: xml,
key: prikey,
cert: cert
}
// 发起请求
return new Promise((resolve, reject) => {
request.post(opt, (err, res) => {
resolve(res)
})
})
}
请求结果
如果没有出现什么问题,此时应该会返回接口的请求结果。可能会出现以下几种情况:
- 签名错误
使用在线密钥检查工具检测以下是否密钥生成有误。
直接把代码中生成的xml打印出来,整个粘贴进去,输入密钥,然后校验就行。看看签名是否出现问题。按文档中操作即可。
-
API证书错误
检查一下API密钥以及API证书是否有误,如果确认无误还是不行,尝试一下更换新的证书。 -
余额不足
扣款有两种情况:
1、如果没有开通运营账户,则在基本账户余额扣除。
2、如果开通了运营账户,则在运营账户扣除。
余额不足,直接充值进对应得账户即可。 -
低于多少元下限或高于多少元上限制
查看企业付款到零钱的设置,提现最低门槛可手动设置至0.3元。
在设置的提现金额范围内发起提现即可。
解析返回结果xml为json格式
该接口返回的结果的格式是XML格式,对于交易结果的判断比较麻烦,因此为了便利,将结果转成JSON的格式。
xml解析模块
直接将返回的xml数据整个传入即可。
const xml2js = require('xml2js') //引入xml解析模块
/**
* 解析付款到零钱返回的xml为JSON
* @param xmlText 返回的xml
*/
const parseXMLToJSON = (xmlText) =>{
let xmlJsonText = ''
xml2js.parseString(xmlText, (err, result)=>{
xmlJsonText = result
})
xmlJsonText = JSON.stringify(xmlJsonText)
const xmlJson = JSON.parse(xmlJsonText)
let resultObj = xmlJson.xml
let resultJson = {}
Object.keys(resultObj).forEach((key)=>{
resultJson[key] = resultObj[key][0]
})
return resultJson
}
module.exports = {
parseXMLToJSON: parseXMLToJSON
}
调用
打印输出结果示例(云函数日志)
到账提示
目录结构
五、结语
主要的请求逻辑基本就在上面了。至于请求后的业务逻辑处理,例如数据入库等操作,基本就是可以自行实现了。这个没有什么可说的。
希望以上的文章可以帮助到大家,有不当的地方或者我没写明白的地方,
欢迎大家能够在评论区提出来。
六、完整的index.js代码
// 云函数入口文件
const cloud = require('wx-server-sdk')
const request = require('request') // 网络请求模块
const fs = require('fs') // 文件读取模块
const getSign = require('./utils/getSing.js') // 计算签名模块
const xmlToJson = require('./utils/xmlToJson.js') // xml转json模块
cloud.init({
evn: '云环境id'
})
const db = cloud.database()
const _ = db.command
// 云函数入口函数
exports.main = async (event) => {
const partner_trade_no = getOrderId('商户号') // 随机订单号
const nonce_str = getNonceStr() // 随机字符串
let paramObj = {
mchid: "商户号",
mch_appid: 'mch_appid',
device_info: 1000,
nonce_str: nonce_str,
partner_trade_no: partner_trade_no,
openid: event.openid,
check_name: 'FORCE_CHECK', // 是否校验实名,可选,详看文档
re_user_name: event.name, // 详看文档
amount: event.amount * 100, // 注意金额的单位,我传入的是以元为单位的,所以需要乘以100
desc: event.desc
}
const key = '32位支付密钥' // 支付密钥
const signStr = getSign.getSign(paramObj, key) // 获取签名
paramObj.sign = signStr
// 发起提现请求
const xmlText = await requestFun(paramObj)
// 将返回的xml解析成JSON格式
const xmlJson = xmlToJson.parseXMLToJSON(xmlText.body)
console.log(xmlJson)
// 判断提现状态
// 通讯成功
if (xmlJson.return_code == 'SUCCESS') {
// 交易成功
if (xmlJson.result_code == 'SUCCESS') {
// 提现成功后的业务逻辑
}
// 交易失败
else if (xmlJson.result_code == 'FAIL') {
// 提现失败后的业务逻辑
}
}
return xmlJson
}
/**
* 请求函数
* @param paramObj 请求时提交的参数对象,JSON格式
*/
const requestFun = (paramObj) => {
// 将json请求数据转换成xml
let xml = '<xml>\n'
Object.keys(paramObj).forEach(function (key) {
xml += '<' + key + '>' + paramObj[key] + '</' + key + '>\n'
});
xml += '</xml>'
// 读取证书
const cert = fs.readFileSync('./apiclient_cert.pem', 'ascii')
const prikey = fs.readFileSync('./apiclient_key.pem', 'ascii')
// 请求体
const opt = {
url: 'https://api.mch.weixin.qq.com/mmpaymkttransfers/promotion/transfers',
body: xml,
key: prikey,
cert: cert
}
// 发起请求
return new Promise((resolve, reject) => {
request.post(opt, (err, res) => {
resolve(res)
})
})
}
/**
* 获取32位随机支付订单号
*/
const getOrderId = (mchid) => {
let randomStr = ""
const timeStamp = new Date().getTime()
for (let i = 0; i < (32 - 13 - mchid.length); i++) {
let randomNum = Math.floor(Math.random() * 10) // 获取 0-9随机整数
randomStr += randomNum
}
// 拼接 商户号 + 随机数字 + 时间戳 返回
return mchid + randomStr + timeStamp
}
/**
* 获取32位随机字符串
*/
const getNonceStr = () => {
let str = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
let randomStr = '';
for (let i = 32; i > 0; --i) {
randomStr += str[Math.floor(Math.random() * str.length)];
}
return randomStr
}