NodeJS实现微信公众号内网页支付&沙箱环境测试验收

本文主要关注在微信公众号内网页支付的NodeJS实现,总结了编码过程中遇到的一些问题。因为在弄微信公众号网页支付的时候,遇到比较多的坑,而且微信给出的文档也比较乱。自己在百度的时候也看到很多人遇到相同的问题,但是都没有得到满意的答案。所以就自己记录一下,也算是自己的一个积累。

前期准备

  • 前期配置工作

    • 进入微信商户平台,配置公众号支付:支付授权目录
      • 请确保实际支付时的请求目录与后台配置的目录一致(现在已经支持配置根目录,配置后有一定的生效时间,一般5分钟内生效),否则将无法成功唤起微信支付。
      • 在微信商户平台(pay.weixin.qq.com)设置您的JSAPI支付目录,设置路径:商户平台-->产品中心-->开发配置,如图7.7所示。JSAPI支付在请求支付的时候会校验请求来源是否有在商户平台做了配置,所以必须确保支付目录已经正确的被配置,否则将验证失败,请求支付不成功。
        修改微信商户平台中关于JSAPI支付目录的配置
    • 进入微信公众平台,设置IP白名单(通过开发者ID及密码调用获取access_token接口时,需要设置访问来源IP为白名单。)位置:微信公众平台-->开发-->基本配置-->IP白名单
      设置微信公众平台中IP白名单的配置
    • 设置微信公众号JS接口安全域名和网页授权域名。位置:微信公众平台-->设置-->公众号设置-->功能设置


      设置微信公众平台中公众号JS接口安全域名和网页授权域名

      设置微信公众平台中公众号JS接口安全域名和网页授权域名
      • 注意:JS接口安全域名存在修改次数限制,目前为每个月三次修改机会,操作需要谨慎
      • 设置该属性过程中,需要将验证文件MP_verify_4TheHtbC9LHo2QGp.txt放置在项目主目录下,确保可以正常访问
  • 编码中需要参与支付的关键参数

    • app_id:开发者ID是公众号开发识别码,配合开发者密码可调用公众号的接口能力。
    • app_secret:开发者密码是校验公众号开发者身份的密码,具有极高的安全性。切记勿把密码直接交给第三方开发者或直接存储在代码中。如需第三方代开发公众号,请使用授权方式接入。
    • mch_id:商户ID
    • mch_key:商户支付密匙

开发过程

微信授权登录并获取用户基本信息
微信授权使用的是OAuth2.0授权的方式。主要有以下简略步骤:

  1. 用户同意授权,获取code
  2. 通过code换取网页授权access_token
  3. 刷新access_token(如果需要)
  4. 拉取用户信息(需scope为 snsapi_userinfo)

详细的步骤如下:

  1. 用户关注微信公众账号。
  2. 微信公众账号提供用户请求授权页面URL。
  3. 用户点击授权页面URL,将向服务器发起请求
  4. 服务器询问用户是否同意授权给微信公众账号(scope为snsapi_base时无此步骤)
  5. 用户同意(scope为snsapi_base时无此步骤)
  6. 服务器将CODE通过回调传给微信公众账号
  7. 微信公众账号获得CODE
  8. 微信公众账号通过CODE向服务器请求Access Token
  9. 服务器返回Access Token和OpenID给微信公众账号
  10. 微信公众账号通过Access Token向服务器请求用户信息(scope为snsapi_base时无此步骤)
  11. 服务器将用户信息回送给微信公众账号(scope为snsapi_base时无此步骤)

请求授权页面的构造方式:
https://open.weixin.qq.com/connect/oauth2/authorize?appid=APPID&redirect_uri=REDIRECT_URI&response_type=code&scope=SCOPE&state=STATE#wechat_redirect

参数 必须 说明
appid 公众号的唯一标识(这个就是我们前面申请的)
redirect_uri 授权后重定向的回调链接地址(我们前面申请的)
response_type 返回类型,请填写code
scope 应用授权作用域,snsapi_base (不弹出授权页面,直接跳转,只能获取用户openid),snsapi_userinfo (弹出授权页面,可通过openid拿到昵称、性别、所在地。并且,即使在未关注的情况下,只要用户授权,也能获取其信息)
state 重定向后会带上state参数,开发者可以填写a-zA-Z0-9的参数值,最多128字节,该值会被微信原样返回,我们可以将其进行比对,防止别人的攻击。
#wechat_redirect 直接在微信打开链接,可以不填此参数。做页面302重定向时候,必须带此参数

根据我们之前的配置和相关参数定义授权页面uri,这个地址可以在微信开发者工具中打开,但是如果要验证支付,需要将该地址发到微信中,使用微信浏览器打开(开发者工具不能进行支付测试)

相关代码编码工作

  • 安装需要的npm包:npm i express axios xml2js body-parser crypto --save
    • express:Web服务端框架
    • axios:用于发起请求
    • xml2js:将xml解析为js对象
    • body-parser:用于解析post请求
    • crypto:NodeJS加密库
相关代码
  • app.js(NodeJS)
let express = require('express');
let app = express();
let path = require('path');
let axios = require('axios');
const util = require('./util');
const config = require('./config');
const xml2js = require('xml2js');
const bodyParser = require('body-parser');

app.set('views', path.join(__dirname, 'views'));
app.set("view engine", "ejs")

app.get('/api', (req, res) => {
  // FIXME: 微信Auth2.0授权成功,通过回传的code,发起请求,拿到openid,并返回给客户端
  let code = req.query.code;
    let result = new Promise((resolve, reject) => {
        axios({
            url: 'https://api.weixin.qq.com/sns/oauth2/access_token',
            method: 'GET',
            params: {
                appid: config.app_id,
                secret: config.app_secret,
                code,
                grant_type: 'authorization_code'
            }
        }).then(result => {
            resolve(result.data)
        })
    })
    result.then((result) => {
    return res.render('index', {title: '微信公众号支付测试', desc: '点击下方按钮支付0.01元', openid: result.openid});
    })
});

//发起请求,获取微信支付沙箱环境的沙箱签名 sandbox_signkey
app.post('/api/pay', bodyParser.json({extended: false}), (req, res) => {
  // 根据微信支付沙箱文档,通过mch_id和nonce_str,拼接 构建获取沙箱key的签名
  let nonce_str = util.randomStr();
  let signoption = {
    mch_id: config.mch_id, //商户号
    nonce_str, //随机字符串
  }
  let sign = util.createSign(signoption, config.mch_key)

  let formData = '<xml><mch_id>' + signoption.mch_id + '</mch_id><nonce_str>' + signoption.nonce_str + '</nonce_str><sign>' + sign + '</sign></xml>';
  
  let promise = new Promise((resolve, reject) => {
    axios({
      url: 'https://api.mch.weixin.qq.com/sandboxnew/pay/getsignkey',
      method: 'post',
      responseType: 'text',
      data: formData
    }).then(result => {
      if (result.status === 200) {
        try {
          xml2js.parseString(result.data, function (error, result) {
            return resolve(result) 
          })
        } catch (e) {
          reject(e);
        }
      } 
    })
  }).then(result => {
    // 获得沙盒密匙,通过哈河环境,模拟生成订单
    let sandbox_signkey = result.xml.sandbox_signkey[0]
    let orderno = new Date().getTime() + '';
    // 根据统一订单生成文档,创建签名需要的参数
    let signoption = {
      appid: config.app_id, //小程序appid
      body: 'suanming_test', //商品描述
      mch_id: config.mch_id, //商户号
      nonce_str, //随机字符串
      notify_url: 'http://www.virgos.top/api/server/', //回调地址
      openid: req.body.openid, // 交易类型是JSAPI的话,此参数必传   可从过code获取openid
      out_trade_no: orderno,
      spbill_create_ip: '10.18.49.131', //因为微信支付需要有回调url,所以没法确定你的公网ip就没法发送订单支付通知给你,所以提供一个解析的正常ip就好
      total_fee: 101, //商品价格
      trade_type: 'JSAPI', //交易类型,JSAPI为小程序交易类型,
    }

    let sign = util.createSign(signoption, sandbox_signkey);
    let formDataForPay = '<xml><appid>' + signoption.appid + '</appid><body>' + signoption.body + '</body><mch_id>' + signoption.mch_id + '</mch_id><nonce_str>' + signoption.nonce_str + '</nonce_str><notify_url>' + signoption.notify_url + '</notify_url><openid>' + signoption.openid + '</openid><out_trade_no>' + signoption.out_trade_no + '</out_trade_no><spbill_create_ip>' + signoption.spbill_create_ip + '</spbill_create_ip><total_fee>' + signoption.total_fee + '</total_fee><trade_type>' + signoption.trade_type + '</trade_type><sign>' + sign + '</sign></xml>';

    return new Promise((resolve, reject) => {
      axios({
        url: 'https://api.mch.weixin.qq.com/sandboxnew/pay/unifiedorder',
        method: "POST",
        responseType: 'text',
        data: formDataForPay
      }).then(result => {
        if (result.status === 200) {
          try {
            xml2js.parseString(result.data, function (error, result) {
              resolve(result)
            }) 
          } catch (e) {
            reject(e)
          } 
        }
      })
    })
  }).then(result => {
    let timeStamp = parseInt(new Date().getTime() / 1000) + ''
    let reData = result.xml
    let responseData = {
      appId: config.app_id,
      timeStamp,
      nonceStr: reData.nonce_str[0],
      package: `prepay_id=${reData.prepay_id[0]}`,
      paySign: util.createSign({
        appId: config.app_id,
        timeStamp,
        nonceStr: reData.nonce_str[0],
        package: `prepay_id=${reData.prepay_id[0]}`,
        signType: 'MD5'
      })
    }
    res.json({ error_code: 0, result: responseData })
    console.log(`返回给前端的二次签名数据`, responseData)
  })
})


app.all('/api/server', bodyParser.xml({
  limit: '2MB', // Reject payload bigger than 1 MB 
  xmlParseOptions: { 
    normalize: true, // Trim whitespace inside text nodes normalizeTags: true, // Transform tags to lowercase 
    explicitArray: false // Only put nodes in array if >1
  }
}), (req, res)=> {
  var jsonData = req.body.xml;
  console.log(jsonData)
  console.log(req.body)
})


app.listen(3000, () => {
    console.log('app is running on 3000')
});
  • util.js
const crypto = require('crypto');
// 生出随机数的算法
function randomStr() {
  var str = "";
  var arr = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z'];
  for (var i = 1; i <= 32; i++) {
      var random = Math.floor(Math.random() * arr.length);
      str += arr[random];
  }
  return str;
}

//签名算法(把所有的非空的参数,按字典顺序组合起来+key,然后md5加密,再把加密结果都转成大写的即可)
function createSign(obj, key) {
  let keys = Object.keys(obj).sort()
  let keysAndValuesList = keys.map(item => {
    return `${item}=${obj[item]}`
  })
  let stringA = keysAndValuesList.join('&')
  return _generateMD5Sign(stringA, key)
}

// 私有方法,将整合的字符串,搭配固定的key,进行加密
// 主要是为了区别在沙箱环境下,需要使用sandbox_signkey来进行签名,拉起统一支付接口,而正是环境下使用的是mch_key,进行加密
function _generateMD5Sign (string, key) {
  let stringSignTemp = string + '&key=' + key;
  let hash = crypto.createHash('md5');
  stringSignTemp = hash.update(stringSignTemp);
  let signValue = hash.digest('hex');
  return signValue.toUpperCase();
}

module.exports = {
  randomStr,
  createSign
}
  • ejs模板:views/index.ejs
<!DOCTYPE html>
<html>
<head>
    <title><%= title %></title>
    <link rel='stylesheet' href='/stylesheets/style.css'/>
    <meta name="viewport" content="width=device-width,initial-scale=1.0,maximum=1.0,minimum=1.0,user-scalable=0" />
</head>
<body>
<h1><%= title %></h1>
<p><%= desc %></p>
<button id="payBtn">点我支付</button>
</body>
<script src="https://cdn.bootcss.com/jquery/2.2.1/jquery.js"></script>
<script src="https://cdn.bootcss.com/axios/0.19.0/axios.js"></script>

<script>
let openid = <%- JSON.stringify(openid) %>

$(document).ready(() => {
    $('#payBtn').click(() => {
        if (openid) {
            axios({
                method: 'post',
                url: '/api/pay',
                data: {
                    openid
                }
            }).then(res => {
                if (typeof WeixinJSBridge == "undefined"){
                    if( document.addEventListener ){
                        document.addEventListener('WeixinJSBridgeReady', onBridgeReady, false);
                    }else if (document.attachEvent){
                        document.attachEvent('WeixinJSBridgeReady', onBridgeReady); 
                        document.attachEvent('onWeixinJSBridgeReady', onBridgeReady);
                    }
                }else{
                    onBridgeReady(res.data.result);
                }
            })
        }      
    })
})
    
function onBridgeReady(params){
   console.log('参数输出', params)
   WeixinJSBridge.invoke(
      'getBrandWCPayRequest', {
         "appId": params.appId,     //公众号名称,由商户传入     
         "timeStamp": params.timeStamp,         //时间戳,自1970年以来的秒数     
         "nonceStr":params.nonceStr, //随机串     
         "package": params.package,     
         "signType": params.signType,         //微信签名方式:     
         "paySign":  params.paySign//微信签名 
      },
      function(res){
      if(res.err_msg == "get_brand_wcpay_request:ok" ){
        alert('支付成功')
      } 
   }); 
}
</script>
</html>

编码过程中的注意事项

  • 在公众号的页面中拉起微信支付时使用的签名,并不是统一下单时的签名,而是根据统一下单过程中,返回的相关数据nonce_str,prepay_id来进行二次签名;
  • 二次签名使用appIdtimeStampnonceStrpackagesignTypeMD5)和key五个参数,其中在沙箱环境中,使用获取沙箱签名中返回的sandbox_signkey;而正式环境中,则使用商户支付密匙mch_key,进行MD5加密。(注意大小写,和签名规则的顺序问题)
  • 使用二次签名中使用的五个参数和二次签名数据作为paySign字段,传递前端。
  • 二次签名中使用的时间戳格式为1970年到当前时间的秒数,10位字符串。
  • 前端通过得到的六个参数,通过微信浏览器的内置对象WeixinJSBridge拉起支付
  • 在沙箱环境中,会报缺少total_fee字段的错误,但其并不影响验证支付流程,通过沙箱环境下的统一订单查询接口,可以查看到当前订单号的订单,已经成功完成了支付。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,723评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,080评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,604评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,440评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,431评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,499评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,893评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,541评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,751评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,547评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,619评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,320评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,890评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,896评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,137评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,796评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,335评论 2 342