微信小程序支付、云支付

1,294 阅读4分钟

前言

微信支付的方式有以下几种:付款码支付Native支付APP支付H5支付JSAPI支付小程序支付。其中 JSAPI支付 是指用户在微信APP内(微信浏览器)打开H5网页,发起的支付;小程序 支付也属于 JSAPI支付

微信小程序支付大致流程:

  1. 用户在前端发起支付请求(将业务参数传给服务端 商品信息...

  2. 先创建一些微信发起下单请求所需的参数 (时间戳随机字符串商户订单号签名...)

  3. 服务端组织 xml格式 的请求参数,向微信发起 统一下单请求 得到 prepay_id

  4. 返回小程序端支付 api 所需参数(timeStampnonceStrsignTypepackagepaySign

  5. 获取支付所需参数之后,小程序端调用 wx.requestPayment() api,唤起支付弹框

  6. 服务端处理 notify_url 回调支付结果通知

微信支付v2

定义 config 文件

首先在配置文件中定义相关配置

// config/index.js

module.exports = {
  // baseUrl: 'http://127.0.0.1:3000',
  baseUrl: 'http://192.168.5.96:3000', // 本机路由ip域名 + 端口
  // 微信小程序
  mp: {
   appId: 'XXXXXX', // appid
   appSecret: 'XXXXXX' // app secret
  },
  // 商户号信息
  mch: {
    mchId: 'XXXXXX', // 商户 id
    mchKey: 'XXXXXX' // api key
  },
}

创建时间戳(秒)

// utils/mpPayUtil.js

const request = require('request');
const { mp: mpConfig, mch: mchConfig } = require('../config');
const crypto = require('crypto');
const xml2js = require('xml2js'); // xml 解析模块

/**
 * 创建时间戳 (秒)
 * @return {String} '1628515132'
 */
const _creatTimeStamp = () => {
  return parseInt(+new Date() / 1000) + '';
};

创建随机字符串

// utils/mpPayUtil.js

/**
 * 生成随机字符串
 * @param {Number} strLen 字符串长度
 * @return {String} 'hKcmnxAEVFsJVLwQgx1s'
 */ 
const _createNonceStr = (strLen = 20) => {
  const str = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
  let nonceStr = '';
  for (let i = 0; i < strLen; i++) {
    nonceStr += str[Math.floor(Math.random() * str.length)];
  }

  return nonceStr;
};

创建订单号

// utils/mpPayUtil.js

/**
 * 创建订单号
 * @return {String} '1628515375405237420'
 */
const _createTradeNo = () => {
  let tradeNo = '';
  const timeStampStr = (+new Date() + ''); // 时间戳字符串
  let randomNumstr = '';

  const numStr = '0123456789';
  for (let i = 0; i < 6; i++) {
    randomNumstr += numStr[Math.floor(Math.random() * numStr.length)];
  }
  
  tradeNo = timeStampStr + randomNumstr;
  return tradeNo;
};

生成签名

签名 sign: 是指所有要传的非空参数,按照参数名 ASCII码 从小到大排序(字典序),使用URL键值对的格式(即key1=value1&key2=value2…)拼接成字符串 stringA。在 stringA 最后拼接上 key 得到 stringSignTemp 字符串,并 对stringSignTemp 进行 MD5 运算,再把加密后的字符串都 转换为大写

其中参数: 可以按照自己的需要进行选择性传参,但是接口文档里要求必填的,那就必须要传,否则接口会调用失败。

// utils/mpPayUtil.js

/**
 * 生成签名 签名算法用的是 md5
 * @param {Object} paramObj 需要签名的参数对象
 */ 
const _createSign = signParamObj => {
  // 对对象中的 key 值进行排序 (对所有待签名参数按照字段名 key 的 ASCII 码从小到大排序(字典序))
  const signParamKeys = Object.keys(signParamObj).sort();
  const stringA = signParamKeys.map(signParamKey => `${ signParamKey }=${ signParamObj[signParamKey] }`).join('&'); // a=1&b=2&c=3
  const stringSignTemp = stringA + `&key=${ mchConfig.mchKey }`; // 注:key 为商户平台设置的密钥 key
  // 签名
  const _sign = crypto.createHash('md5').update(stringSignTemp).digest('hex').toUpperCase();

  return _sign;
};

将 obj 参数转为下单所需 xml 格式

// utils/mpPayUtil.js

/**
 * 将 obj 转为微信提交 xml 格式,包含签名
 * @param {Object} paramObj 转换对象
 * @return {String<XML>}
 */
const _createXMLData = paramObj => {
  let formData = '';
  formData += '<xml>';

  Object.keys(paramObj).sort().map(itmKey => {
    formData += `<${ itmKey }>${ paramObj[itmKey] }</${ itmKey }>`;
  });

  formData += `</xml>`;
  return formData;
};

创建微信预支付订单 id

下单接口必填参数:appidmch_idnonce_strsign_typebodyout_trade_nototal_feespbill_create_ipnotify_urltrade_typesign,其中 sign 为前面所传参数加密后的字符;将对象参数转换成 xml格式 后发起预支付下单请求。微信返回 xml格式 的结果中 return_coderesult_code 都为 SUCCESS 才有返回的预支付id (prepay_id)

// utils/mpPayUtil.js

/**
 * 创建微信预支付订单 id
 * https://pay.weixin.qq.com/wiki/doc/api/wxa/wxa_api.php?chapter=9_1
 * @param {String<XML>} xmlFormData xml
 * @return {Object} { prepay_id } 预支付 id
 */
const _v2createPrePayOrder = async xmlFormData => {
  // 请求微信服务器统一下单接口
  const requrl = 'https://api.mch.weixin.qq.com/pay/unifiedorder';

  return new Promise((resolve, reject) => {
    request({ url: requrl, method: 'POST', body: xmlFormData }, (error, response, body) => {
      // console.log(body); 微信返回 xml 格式数据
      if (error || response.statusCode !== 200) return reject({ errmsg: '请求微信服务器失败' });

      // 解析 xml 格式数据
      xml2js.parseString(body, (err, result) => {
        if (err) return reject({ errmsg: 'xml解析出错' }); // 解析出错
        const resData = result.xml;
        // console.log('xml解析结果:', resData);
        // 微信返回结果中 return_code 和 result_code 都为 SUCCESS 的时候才有返回的预支付id (prepay_id)
        if (resData.return_code[0] === 'SUCCESS' && resData.result_code[0] === 'SUCCESS' && resData.prepay_id) {
          resolve({ prepay_id: resData.prepay_id[0] });
        } else {
          reject(resData);
        }
      });
    });
  });
};

获取支付参数(入口)

导出支付获参函数,返回小程序端 wx.requestPayment() 所需参数对象。

// utils/mpPayUtil.js

/**
 * 获取支付参数
 * @param {Object} param 对象
 * @return {Object} 前端支付所需参数
 */
exports.v2getPayParam = async param => {
  const { openid, attach, body, total_fee, notify_url, spbill_create_ip } = param;
  const appid = mpConfig.appId; // 微信小程序 appid
  const mch_id = mchConfig.mchId; // 商户 id
  const timeStamp = _creatTimeStamp(); // 时间戳字符串 (秒)
  const nonce_str = _createNonceStr(); // 随机字符串
  const out_trade_no = _createTradeNo(); // 订单号
  const sign_type = 'MD5'; // 签名类型
  const trade_type = 'JSAPI'; // 交易类型 (小程序支付方式)
  // 签名
  const sign = _createSign({ appid, mch_id, nonce_str, out_trade_no, sign_type, trade_type, openid, attach, body, total_fee, notify_url, spbill_create_ip });
  // xml格式数据
  const xmlFormData = _createXMLData({ appid, mch_id, nonce_str, out_trade_no, sign_type, trade_type, openid, attach, body, total_fee, notify_url, spbill_create_ip, sign });

  try {
    // 创建微信预支付 id
    const { prepay_id } = await _v2createPrePayOrder(xmlFormData);
    if (!prepay_id) return '';
    
    const payParamObj = {
      appId: appid, // 必须添加上 appid, 否则报错:支付验证签名失败
      timeStamp,
      nonceStr: nonce_str,
      signType: 'MD5',
      package: `prepay_id=${ prepay_id }`
    };
    // 支付签名
    const paySign = _createSign(payParamObj);

    return {
      timeStamp: payParamObj.timeStamp,
      nonceStr: payParamObj.nonceStr,
      signType: payParamObj.signType,
      package: `prepay_id=${ prepay_id }`,
      paySign
    };
  } catch (error) {
    console.log('创建支付订单出错', error);
    return '';
  }
};

路由:请求支付参数

支付下单路由,其中 total_fee 订单总金额, 单位为【分】,参数值不能带小数。也就是说 1 = 1分10 = 1角100 = 1元1000 = 10元 ...

// route/mpRoute.js

const express = require('express');
const router = express.Router();

const { mp: mpConfig, baseUrl } = require('../config');
const commonUtil = require('../utils');
const mpPayUtil = require('../utils/mpPayUtil');

/**
 * 微信支付下单 获取小程序端支付所需参数
 */
router.get('/v2Pay', async (req, res) => {
  const openid = req.query.userOpenid; // 用户的 openid
  const total_fee = Number(req.query.money) * 100; // 支付金额 单位为分
  const attach = '支付附加数据'; // 附加数据
  const body = '小程序支付';  // 主体内容
  const notify_url = `${ baseUrl }/api/mp/payCallback`; // 异步接收微信支付结果通知的回调地址,通知 url必须为外网可访问的url,不能携带参数。公网域名必须为 https
  const spbill_create_ip = '192.168.5.96'; // 终端ip (可填本地路由 ip)
  
  const param = { openid, attach, body, total_fee, notify_url, spbill_create_ip };
  const payParam = await mpPayUtil.v2getPayParam(param);
  if (!payParam) return res.send(commonUtil.resFail('创建支付订单出错'));

  res.send(commonUtil.resSuccess(payParam));
});

路由:支付回调结果通知

下单接口会带上 notify_url 参数,接收微信支付结果通知地址;此地址一定要是可外网访问的接口地址,必须为https,而且该请求是 POST请求。 由微信服务器调用该接口,不管支付成功与否,此接口都会调用,并返回相应数据,所以可以在此接口中编写相关业务逻辑、如支付成功后写入数据库等操作,并向微信服务器 返回应答,告知通知已经接收到。

因为回调返回的参数是也是 XML格式,所以使用常规方法接收是接收不到的这时候需要安装一个解析中间件 express-xml-bodyparser,就可以使用 req.body.xml 获得解析后的参数了。

// app.js

const xmlparser = require('express-xml-bodyparser'); // 解析微信支付回调通知的 XML
app.use(xmlparser());

// route/mpRoute.js

/**
 * 支付结果通知 (需保证小程序上线后才能回调) 需要为 POST
 * 返回结果格式为 XML
 * 
 * 此接口中编写相关业务逻辑、如支付成功后写入数据库等操作
 * https://pay.weixin.qq.com/wiki/doc/api/wxa/wxa_api.php?chapter=9_7&index=8
 */
router.post('/payCallback', async (req, res) => {
  console.log(req.body.xml);
  // json 转 xml
  const _json2Xml = json => {
    let _xml = '';
    Object.keys(json).map((key) => {
        _xml += `<${ key }>${ json[key ]}</${ key }>`
    });
    return `<xml>${ _xml }</xml>`;
  }
  const sendData = { return_code: 'SUCCESS', return_msg: 'OK' };
  res.end(_json2Xml(sendData));
});

其他:工具函数封装

// utils/index.js

/**
 * 封装成功响应
 */
exports.resSuccess = (data = null) => {
  return { code: 0, data, message: '成功' };
};

/**
 * 封装失败响应
 */
exports.resFail = (message = '服务器错误', code = 10001) => {
  return { message, code, data: null };
};

......

小程序端

小程序端首先向服务端发起支付参数请求,再传给 wx.requestPayment,唤起支付弹框。

/**
 * 微信支付 v2
 */
async _v2Pay(param) {
  const { userOpenid, money } = param;
   try {
    // 发起 http 请求获取支付参数
    const { data: payParam } = await app.$fetchReq(app.$api.v2Pay, { userOpenid, money });
    // 调起支付 api
    wx.requestPayment({
      timeStamp: payParam.timeStamp,
      nonceStr: payParam.nonceStr,
      package: payParam.package,
      paySign: payParam.paySign,
      signType: payParam.signType,
      success: res => {
        console.log(res);
        if (res.errMsg == 'requestPayment:ok') wx.showToast({ title: '支付成功', icon: 'success' });
      },
      fail: error => {
        console.log(error);
        if (error.errMsg == 'requestPayment:fail cancel') wx.showToast({ title: '支付取消', icon: 'none' });
      }
    });
  } catch (error) {
    console.log(error);
   }
}

微信云支付

相比较于自搭服务端来说,使用云开发来实现相应的支付功能,无需关心证书、签名、...,使用简单,代码较少,直接调用 CloudPay.unifiedOrder() 函数下单即可,返回 JSON格式 的结果中 payment 字段就是小程序端调用 wx.requestPayment() 所需的信息。

云函数

const cloud = require('wx-server-sdk');
cloud.init();

const envId = 'XXXXXX';
const subMchId = 'XXXXXX';
const functionName = 'pay-callback';

/**
 * 统一下单
 */
exports.main = async (event, context) => {
  const wxContext = cloud.getWXContext();
  const openid = wxContext.OPENID;
  const { money } = event;

  const attach = '支付附加数据';
  const body = '小程序支付';
  const nonceStr = _createNonceStr();
  const outTradeNo = _createOutTradeNo();
  const totalFee = Number(money) * 100;

  const res = await cloud.cloudPay.unifiedOrder({
    openid, // 用户 openid
    subMchId, // 子商户号
    totalFee, // 支付金额 单位为分
    envId, // 结果通知回调云函数环境
    functionName, // 结果通知回调云函数名
    outTradeNo, // 创建订单号
    attach, // 附加数据
    body, // 主体内容
    nonceStr, // 随机字符串
    tradeType: 'JSAPI', // 交易类型
    spbillCreateIp : '127.0.0.1', // 终端IP
  });

  const { returnCode, payment } = res;
  if (returnCode !== 'SUCCESS') return { message: '请求支付订单失败' };
  
  return { payment };
};

/**
 * 创建随机字符串
 */
 const _createNonceStr = (strLen = 20) => {
  const str = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
  let nonceStr = '';
  for (let i = 0; i < strLen; i++) {
    nonceStr += str[Math.floor(Math.random() * str.length)];
  }

  return nonceStr;
};

/**
 * 
 * 创建订单号
 */
const _createOutTradeNo = () => {
  const date = new Date(); // 当前时间
  // 年
  const Year = `${ date.getFullYear() }`;
  // 月
  const Month = `${ date.getMonth() + 1 < 10 ? `0${ date.getMonth() + 1 }` : date.getMonth() + 1 }`;
  // 日
  const Day = `${ date.getDate() < 10 ? `0${ date.getDate() }` : date.getDate() }`;
  // 时
  const hour = `${ date.getHours() < 10 ? `0${date.getHours()}` : date.getHours() }`;
  // 分
  const min = `${ date.getMinutes() < 10 ? `0${date.getMinutes()}` : date.getMinutes() }`;
  // 秒
  const sec = `${ date.getSeconds() < 10 ? `0${ date.getSeconds() }` : date.getSeconds() }`;
  // 时间
  const formateDate = `${ Year }${ Month }${ Day }${ hour }${ min }${ sec }`;
  // console.log('时间:', formateDate);

  return `${ Math.round(Math.random() * 1000) }${ formateDate + Math.round(Math.random() * 89 + 100).toString()}`;
};

小程序端

/**
 * 云支付
 */
async _cloudPay({ money }) {
  try {
    wx.showLoading({ title: '加载中...', mask: true });
    const result = (await wx.cloud.callFunction({
        name: 'pay-req',
        data: { money }
      })).result;
      const { payment } = result;
      // 调起支付 api
      wx.requestPayment({
        ...payment, // 根据获取到的参数调用支付 API 发起支付
        success: res => {
          console.log(res);
          if (res.errMsg == 'requestPayment:ok') wx.showToast({ title: '支付成功', icon: 'success' });
        },
        fail: error => {
          console.log(error);
          if (error.errMsg == 'requestPayment:fail cancel') wx.showToast({ title: '支付取消', icon: 'none' });
        }
      });
    } catch (error) {
      wx.hideLoading();
      console.log(error);
    }
  }

最后

代码地址:pay-server

文中如有错误或者小伙伴有自己的看法,欢迎在评论区留言 😀