yu-wxapi
Version:
对微信支付及小程序相关后端API的封装
388 lines (371 loc) • 16.4 kB
JavaScript
const { promisify } = require("util")
const request = promisify(require("request"))
const crypto = require("crypto")
const fs = require('fs')
let wxInfo = {
appid: '',
secret: '',
mchid: '',
privateKey: '',
serial_no: '',
apiV3: ''
}
/**
* 初始化微信小程序接口(根据需要使用到的接口选择性初始化即可)
* @param {string} appid - 小程序唯一凭证,即 AppID
* @param {string} secret - 小程序唯一凭证密钥,即 AppSecret,获取方式同 appid
* @param {string} mchid - 商户id
* @param {string} privateKeyPath - 商户私钥路径,填入apiclient_key.pem的路径即可
* @param {string} serial_no - 证书序列号
* @param {string} apiV3 - 商户APIv3密钥,通过商户平台获取
* @returns
*/
exports.init = function (appid, secret, mchid, privateKeyPath, serial_no, apiV3) {
wxInfo = {
appid: appid, //小程序唯一凭证,即 AppID
secret: secret, //小程序唯一凭证密钥,即 AppSecret,获取方式同 appid
mchid: mchid, //商户id
privateKey: fs.readFileSync(privateKeyPath), //商户私钥,通过apiclient_key.pem获取
serial_no: serial_no, //证书序列号
apiV3: apiV3
}
}
async function getAccessToken() {
const url = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${wxInfo.appid}&secret=${wxInfo.secret}`
const ret = await request({ url: url, method: 'GET' })
const body = JSON.parse(ret.body)
return body.access_token
}
/**
* 小程序登录
* @param {string} code - 前端调用wx.login()返回的code
* @returns {Promise<{openid: string,session_key: string,unionid: string|null}>} 返回登录信息
*/
exports.loginForApplet = async function (code) {
const url = `https://api.weixin.qq.com/sns/jscode2session?appid=${wxInfo.appid}&secret=${wxInfo.secret}&js_code=${code}&grant_type=authorization_code`
const ret = await request({ url: url, method: 'GET' })
const body = JSON.parse(ret.body)
if (body.errcode && body.errcode !== 0) {
throw body.errmsg
} else {
return {
openid: body.openid,
session_key: body.session_key,
unionid: body.unionid ? body.unionid : null
}
}
}
/**
* 获取用户电话号码
* @param {string} code - 前端getPhoneNumber控件返回的code
* @returns {Promise<string>} - 返回获取到的电话号码
*/
exports.getUserPhoneNumber = async function (code) {
const access_token = await getAccessToken()
const url = `https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=${access_token}`
const ret = await request({ url: url, method: 'POST', body: JSON.stringify({ code: code }) })
const body = JSON.parse(ret.body)
if (body.errcode !== 0) {
throw body.errmsg
} else {
return body.phone_info.phoneNumber
}
}
/**
* 微信JSAPI下单(小程序或微信内置浏览器)
* @param {string} description - 商品描述
* @param {int} amountTotal - 下单金额(单位:分)
* @param {string} openid - 下单用户的openid
* @param {string} notify_url - 回调地址,需要传入https地址
* @param {boolean} support_fapiao - 是否出现开票入口,需先在微信支付商户平台配置后生效
* @returns 返回下单结果(商户下单号,支付截止时间,payData)
*/
exports.orderPayByJSAPI = async function (description, amountTotal, openid, notify_url = '', support_fapiao = false) {
const url = 'https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi'
const ret = await orderPay(url, description, notify_url, amountTotal, openid, null, support_fapiao)
let payData = {
timeStamp: Math.floor(Date.now() / 1000).toString(), //时间戳(秒)
nonceStr: crypto.randomUUID().replaceAll('-', ''), //随机字符串,32位以下
package: 'prepay_id=' + ret.body.prepay_id,
signType: 'RSA',
paySign: ''
}
payData.paySign = toSign(wxInfo.appid, payData.timeStamp, payData.nonceStr, payData.package)
return {
out_trade_no: ret.out_trade_no,
endTime: ret.endTime,
payData: payData
}
}
/**
* 微信H5下单(微信之外浏览器)
* @param {string} description - 商品描述
* @param {int} amountTotal - 下单金额(单位:分)
* @param {string} payer_client_ip - 下单客户端IP
* @param {string} notify_url - 回调地址,需要传入https地址
* @param {boolean} support_fapiao - 是否出现开票入口,需先在微信支付商户平台配置后生效
* @returns 返回下单结果(商户下单号,支付截止时间,h5_url)
*/
exports.orderPayByH5 = async function (description, amountTotal, payer_client_ip, notify_url = '', support_fapiao = false) {
const url = 'https://api.mch.weixin.qq.com/v3/pay/transactions/h5'
const ret = await orderPay(url, description, notify_url, amountTotal, null, payer_client_ip, support_fapiao)
return {
out_trade_no: ret.out_trade_no,
endTime: ret.endTime,
h5_url: ret.body.h5_url
}
}
/**
* 微信Native下单(APP)
* @param {string} description - 商品描述
* @param {int} amountTotal - 下单金额(单位:分)
* @param {string} notify_url - 回调地址,需要传入https地址
* @param {boolean} support_fapiao - 是否出现开票入口,需先在微信支付商户平台配置后生效
* @returns 返回下单结果(商户下单号,支付截止时间,code_url)
*/
exports.orderPayByNative = async function (description, amountTotal, notify_url = '', support_fapiao = false) {
const url = 'https://api.mch.weixin.qq.com/v3/pay/transactions/native'
let ret = await orderPay(url, description, notify_url, amountTotal, null, null, support_fapiao)
return {
out_trade_no: ret.out_trade_no,
endTime: ret.endTime,
code_url: ret.body.code_url
}
}
/**
* 查询订单支付结果
* @param {string} out_trade_no - 商户下单号
* @returns 返回是否支付成功的结果
*/
exports.orderQuery = async function (out_trade_no) {
const url = 'https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/{}?mchid={}'.format(out_trade_no, wxInfo.mchid)
const ret = await request(getPayOptions('GET', url))
const body = JSON.parse(ret.body)
if (ret.statusCode !== 200)
throw body.message
if (body.trade_state === 'SUCCESS') {
return {
trade_state: body.trade_state,
payer_total: body.amount.payer_total, //支付金额
success_time: body.success_time.replace('T', ' ').replace('+08:00', '') //支付成功时间
}
} else {
let ret = {
trade_state: body.trade_state,
message: ''
}
switch (body.trade_state) {
case 'REFUND': { ret.message = '转入退款'; break; }
case 'NOTPAY': { ret.message = '未支付'; break; }
case 'CLOSED': { ret.message = '已关闭'; break; }
}
return ret
}
}
/**
* 微信退款申请
* @param {string} out_trade_no - 商户下单号
* @param {string} reason - 退款原因
* @param {int} refund - 退款金额(单位:分)
* @param {int} total - 原订单总金额(单位:分)
* @param {string} notify_url - 退款结果回调(https://)
* @returns {Promise<string>} 返回商户退款单号,退款失败返回null
*/
exports.refund = async function (out_trade_no, reason, refund, total, notify_url = null) {
const url = 'https://api.mch.weixin.qq.com/v3/refund/domestic/refunds'
let data = {
out_trade_no: out_trade_no,
out_refund_no: crypto.randomUUID().replaceAll('-', ''), //商户退款单号,同一退款单号多次请求只退一笔
reason: reason, //退款原因
amount: {
refund: refund, //退款金额
total: total, //原订单金额
currency: 'CNY'
}
}
notify_url && (data.notify_url = notify_url)
const ret = await request(getPayOptions('POST', url, data))
const body = JSON.parse(ret.body)
console.log(body)
if (body.code) return null
return data.out_refund_no
}
/**
* 退款信息查询
* @param {string} out_refund_no - 商户退款单号
* @returns 返回退款查询结果(商户退款金额,退款成功时间)
*/
exports.refundQuery = async function (out_refund_no) {
const url = 'https://api.mch.weixin.qq.com/v3/refund/domestic/refunds/{}'.format(out_refund_no)
const ret = await request(getPayOptions('GET', url))
const body = JSON.parse(ret.body)
if (ret.statusCode !== 200)
throw body.message
if (body.status === 'SUCCESS') { //成功时返回
return {
status: 'SUCCESS',
amountRefund: body.amount.refund, //退款金额
success_time: body.success_time.replace('T', ' ').replace('+08:00', '') //退款成功时间
}
} else { //未成功时返回
let ret = {
status: body.status,
message: ''
}
switch (body.status) {
case 'CLOSED': { ret.message = '退款关闭'; break; }
case 'PROCESSING': { ret.message = '退款处理中'; break; }
case 'ABNORMAL': { ret.message = '退款异常'; break; }
}
return ret
}
}
/**
* 退款通知解密
* @param {*} refundData - 退款通知的数据
*/
exports.refundNotify = function (refundData) {
if (refundData.event_type !== 'REFUND.SUCCESS') return null
let result = decodeResource(refundData.resource)
return {
out_trade_no: result.out_trade_no,
out_refund_no: result.out_refund_no,
success_time: new Date(result.success_time).format('datetime'),
refundAmountFee: result.amount.refund
}
}
/**
* 支付通知解密
* @param {*} payData - 支付通知的数据
*/
exports.payNotify = function (payData) {
if (payData.event_type !== 'TRANSACTION.SUCCESS') return null
let result = decodeResource(payData.resource)
return {
out_trade_no: result.out_trade_no,
success_time: new Date(result.success_time).format('datetime'),
paymentAmountFee: result.amount.payer_total
}
}
/**
* 微信下单
* @param {string} url - 微信不同下单类型的接口url
* @param {string} description - 商品描述
* @param {string} notify_url - 回调地址,需要传入https地址
* @param {int} amountTotal - 下单金额(单位:分)
* @param {string} openid - 下单用户的openid
* @param {string} payer_client_ip - 下单客户端IP
* @param {boolean} support_fapiao - 是否出现开票入口,需先在微信支付商户平台配置后生效
* @returns 返回下单结果(商户下单号,支付截止时间,微信根据不同的下单类型返回的结果)
*/
async function orderPay(url, description, notify_url, amountTotal, openid = null, payer_client_ip = null, support_fapiao = false) {
if (typeof (description) !== 'string' || description.length === 0)
throw '参数description必须是有效的字符串'
if (typeof (amountTotal) !== 'number')
throw '参数amountTotal不是数字'
if (amountTotal <= 0)
throw '参数amountTotal必须大于0'
if (!notify_url.startsWith('https') && !notify_url.startsWith('http'))
throw '参数notify_url不是正确的url地址'
if (openid && payer_client_ip)
throw '参数openid或payer_client_ip只能传入其中一个'
const out_trade_no = crypto.randomUUID().replaceAll('-', '')
const endTime = (new Date()).addTime(0, 30)
let data = {
appid: wxInfo.appid,
mchid: wxInfo.mchid,
description: description, //商品描述
out_trade_no: out_trade_no, //商户订单号,32位随机字符串(字符串)
time_expire: endTime.format('datetimeTZone'), //订单失效时间,30分钟
notify_url: notify_url, //支付成功后的回调地址,公网域名必须为https
support_fapiao: support_fapiao, //传入true时,支付成功消息和支付详情页将出现开票入口。需要在微信支付商户平台或微信公众平台开通电子发票功能,传此字段才可生效。
amount: {
total: amountTotal //金额:分
}
}
if (openid) {
data.payer = {
openid: openid //用户openid
}
} else if (payer_client_ip) {
data.scene_info = {
payer_client_ip: payer_client_ip, //调用者的IP
h5_info: {
type: 'Wap' //iOS, Android, Wap
}
}
}
const ret = await request(getPayOptions('POST', url, data))
const body = JSON.parse(ret.body)
if (ret.statusCode !== 200)
throw body.message
return {
out_trade_no: out_trade_no,
endTime: endTime.format('datetime'),
body: body
}
}
/**
* 获取商户支付相关的GET或POST的options对象
* @param {'GET'|'POST'} method - 请求方法
* @param {string} url - 含https://或http://
* @param {JSON|''} data - 'GET'时传入空字符串或不传,'POST'时传入JSON对象
* @returns 返回已签名的options对象
*/
function getPayOptions(method = 'POST', url, data = '') {
let body = ''
if (method === 'POST')
body = JSON.stringify(data)
const nonce_str = crypto.randomUUID().replaceAll('-', '').toUpperCase()
const timestamp = Math.floor(Date.now() / 1000).toString()
const signature = toSign(method, url.substring(url.indexOf('/', 8)), timestamp, nonce_str, body) //签名
const authorization = `mchid="${wxInfo.mchid}",nonce_str="${nonce_str}",signature="${signature}",timestamp="${timestamp}",serial_no="${wxInfo.serial_no}"`
const options = {
url: url,
method: method,
headers: {
'Authorization': `WECHATPAY2-SHA256-RSA2048 ${authorization}`,
'Accept': 'application/json',
'Content-Type': 'application/json',
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36',
},
body: body
}
return options
}
/**
* 微信签名
* @param {...string} data - 需被签名的数据数组
* @returns 返回签名结果
*/
function toSign(...data) {
const signStr = data.join('\n') + '\n'
const signature = crypto.createSign('SHA256')
signature.update(signStr)
const signatureValue = signature.sign(wxInfo.privateKey.toString())
const signatureBase64 = signatureValue.toString('base64')
return signatureBase64
}
/**
* 解密微信支付/退款通知密文
* @param {string} resource - 微信支付/退款通知里面的resource密文
*/
function decodeResource(resource) {
const AUTH_KEY_LENGTH = 16;
const { ciphertext, associated_data, nonce } = resource; // ciphertext = 密文,associated_data = 填充内容, nonce = 位移
const key_bytes = Buffer.from(wxInfo.apiV3, 'utf8'); // 密钥
const nonce_bytes = Buffer.from(nonce, 'utf8'); // 位移
const associated_data_bytes = Buffer.from(associated_data, 'utf8'); // 填充内容
const ciphertext_bytes = Buffer.from(ciphertext, 'base64'); // 密文Buffer
const cipherdata_length = ciphertext_bytes.length - AUTH_KEY_LENGTH; // 计算减去16位长度
const cipherdata_bytes = ciphertext_bytes.slice(0, cipherdata_length); // upodata
const auth_tag_bytes = ciphertext_bytes.slice(cipherdata_length, ciphertext_bytes.length); // tag
const decipher = crypto.createDecipheriv('aes-256-gcm', key_bytes, nonce_bytes);
decipher.setAuthTag(auth_tag_bytes);
decipher.setAAD(Buffer.from(associated_data_bytes));
const output = Buffer.concat([
decipher.update(cipherdata_bytes),
decipher.final(),
]);
return JSON.parse(output.toString())
}