UNPKG

yu-wxapi

Version:

对微信支付及小程序相关后端API的封装

388 lines (371 loc) 16.4 kB
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()) }