本文主要关注在微信公众号内网页支付的NodeJS实现,总结了编码过程中遇到的一些问题。因为在弄微信公众号网页支付的时候,遇到比较多的坑,而且微信给出的文档也比较乱。自己在百度的时候也看到很多人遇到相同的问题,但是都没有得到满意的答案。所以就自己记录一下,也算是自己的一个积累。
前期准备
-
前期配置工作
- 进入微信商户平台,配置
公众号支付:支付授权目录
- 请确保实际支付时的请求目录与后台配置的目录一致(现在已经支持配置根目录,配置后有一定的生效时间,一般5分钟内生效),否则将无法成功唤起微信支付。
-
在微信商户平台(pay.weixin.qq.com)设置您的JSAPI支付目录,设置路径:商户平台-->产品中心-->开发配置,如图7.7所示。JSAPI支付在请求支付的时候会校验请求来源是否有在商户平台做了配置,所以必须确保支付目录已经正确的被配置,否则将验证失败,请求支付不成功。
- 进入微信公众平台,设置IP白名单(通过开发者ID及密码调用获取access_token接口时,需要设置访问来源IP为白名单。)位置:微信公众平台-->开发-->基本配置-->IP白名单
-
设置微信公众号JS接口安全域名和网页授权域名。位置:微信公众平台-->设置-->公众号设置-->功能设置
- 注意:JS接口安全域名存在修改次数限制,目前为每个月三次修改机会,操作需要谨慎
- 设置该属性过程中,需要将验证文件
MP_verify_4TheHtbC9LHo2QGp.txt
放置在项目主目录下,确保可以正常访问
- 进入微信商户平台,配置
-
编码中需要参与支付的关键参数
- app_id:开发者ID是公众号开发识别码,配合开发者密码可调用公众号的接口能力。
- app_secret:开发者密码是校验公众号开发者身份的密码,具有极高的安全性。切记勿把密码直接交给第三方开发者或直接存储在代码中。如需第三方代开发公众号,请使用授权方式接入。
- mch_id:商户ID
- mch_key:商户支付密匙
开发过程
微信授权登录并获取用户基本信息
微信授权使用的是OAuth2.0授权的方式。主要有以下简略步骤:
- 用户同意授权,获取code
- 通过code换取网页授权access_token
- 刷新access_token(如果需要)
- 拉取用户信息(需scope为 snsapi_userinfo)
详细的步骤如下:
- 用户关注微信公众账号。
- 微信公众账号提供用户请求授权页面URL。
- 用户点击授权页面URL,将向服务器发起请求
- 服务器询问用户是否同意授权给微信公众账号(scope为snsapi_base时无此步骤)
- 用户同意(scope为snsapi_base时无此步骤)
- 服务器将CODE通过回调传给微信公众账号
- 微信公众账号获得CODE
- 微信公众账号通过CODE向服务器请求Access Token
- 服务器返回Access Token和OpenID给微信公众账号
- 微信公众账号通过Access Token向服务器请求用户信息(scope为snsapi_base时无此步骤)
- 服务器将用户信息回送给微信公众账号(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
来进行二次签名; - 二次签名使用
appId
,timeStamp
,nonceStr
,package
,signType
(MD5
)和key
五个参数,其中在沙箱环境中,使用获取沙箱签名中返回的sandbox_signkey
;而正式环境中,则使用商户支付密匙mch_key
,进行MD5
加密。(注意大小写,和签名规则的顺序问题) - 使用二次签名中使用的五个参数和二次签名数据作为
paySign
字段,传递前端。 - 二次签名中使用的时间戳格式为1970年到当前时间的秒数,10位字符串。
- 前端通过得到的六个参数,通过微信浏览器的内置对象
WeixinJSBridge
拉起支付 - 在沙箱环境中,会报
缺少total_fee字段
的错误,但其并不影响验证支付流程,通过沙箱环境下的统一订单查询接口,可以查看到当前订单号的订单,已经成功完成了支付。