uni-app uniCloud node.js支付宝网页支付开发心得
支付宝网页支付的流程:前端请求支付宝支付表单参数->后端生成支付表单参数给前端->前端根据支付参数构建form表单对支付宝发起POST请求->支付宝支付成功POST异步通知开发者的服务器。
支付宝用到的是RSA加密,搞懂RSA加密的原理有助于理解支付宝支付流程。推荐李永乐老师讲非对称加密的视频。
代码很简单,直接看代码就理解了,注意点写在注释里。
支付宝官方文档:
https://opendocs.alipay.com/apis/api_1/alipay.trade.page.pay
https://opendocs.alipay.com/apis/api_1/alipay.trade.wap.pay
请求支付参数的服务端代码:
'use strict'
const NodeRSA = require('node-rsa') // 需要执行npm install node-rsa才能调用
const querystring = require('querystring') // node自带,无需安装,直接调用
exports.main = async (event, context) => {
const appId = '支付宝的appId'
let merchantPrivateKey = '支付宝商家公钥'
let ua = ''
try {
ua = event.headers['user-agent']
}catch(e){}
let productCode = 'FAST_INSTANT_TRADE_PAY'
let method = 'alipay.trade.page.pay'
if (ua.indexOf('Mobile') > -1) {
productCode = 'QUICK_WAP_WAY'
method = 'alipay.trade.wap.pay'
}
let bodyObj = querystring.parse(event.body) // url请求参数字符串转object。uni-app云函数实例化后,POST的请求参数在body里
if (Object.keys(bodyObj).length) {
const passbackParams = JSON.stringify({mobile: '18888888888', sku: 'year'}) // 开发者想要传递的参数,字符串,支付宝异步通知会带上这个
let bizContent = JSON.stringify({
subject: '支付宝测试-'+bodyObj.price+'元',
out_trade_no: (new Date()).getTime(),
total_amount: bodyObj.price,
product_code: productCode,
quit_url: 'https://yourdomain.com/static/alipay-return.html', // 手机网页支付放弃支付时返回的网址
passback_params: passbackParams
})
let queryObject = ksort({
app_id: appId,
biz_content: bizContent,
charset: 'UTF-8',
method: method,
notify_url: '支付宝异步通知地址',
return_url: 'https://yourdomain.com/static/alipay-return.html',
sign_type: 'RSA2',
timestamp: time2date((new Date()).getTime()), // Y-m-d H:i:s格式的字符串
version: '1.0'
})
const query = querystring.unescape(querystring.stringify(queryObject)) // 要再套一层querystring.unescape,否则query被转义,会导致签名的字符串跟支付宝不一致
const key = new NodeRSA(merchantPrivateKey, 'pkcs8-private')
const sign = key.sign(Buffer.from(query)).toString('base64')
queryObject.sign = sign
return {code:0, alipayParams: queryObject}
} else {
return {code: 1, msg: 'event.body is empty'}
}
/****************************************************************************************************/
// 毫秒时间戳转Y-m-d H:i:s
function time2date(time) {
const date = new Date(time)
const year = date.getFullYear()
const month = (date.getMonth()+1).toString().padStart(2, '0')
const day = date.getDate().toString().padStart(2, '0')
const hour = date.getHours().toString().padStart(2, '0')
const minute = date.getMinutes().toString().padStart(2, '0')
const second = date.getSeconds().toString().padStart(2, '0')
return year+'-'+month+'-'+day+' '+hour+':'+minute+':'+second
}
// 对object的key进行排序
function ksort(params) {
let keys = Object.keys(params).sort();
let newParams = {};
keys.forEach((key) => {
newParams[key] = params[key];
});
return newParams;
}
}
前端支付代码,为了方便,我用了vue+vant:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=0, viewport-fit=cover">
<title>支付宝支付演示</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/lib/index.css"/>
</head>
<body>
<div id='app' style="display: none; padding: 100px 15px 15px 15px;">
<div style="max-width: 512px; margin: 0 auto;">
<p><img height="44px" src="https://vkceyugu.cdn.bspapp.com/VKCEYUGU-imgbed/dca221bb-2a37-4527-bea7-6fcb92945c18.png"></p>
<p v-for="price in prices"><van-button @click="alipay(price)" type="info" block>{{price}}元</van-button></p>
</div>
<div id="form-pay"></div>
<van-overlay :show="showLoading">
<div style="display: flex; align-items: center; justify-content: center; height: 100%">
<van-loading size="24px" vertical>加载中...</van-loading>
</div>
</van-overlay>
</div>
<script src="https://cdn.bootcdn.net/ajax/libs/zepto/1.2.0/zepto.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/vue/2.6.12/vue.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/vant.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/axios/0.21.0/axios.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/qs/6.9.4/qs.min.js"></script>
<script>
const vm = new Vue({
el: '#app',
data() {
return {
prices: [0.01, 1, 10, 100],
showLoading: false
}
},
methods: {
alipay(price) {
if (navigator.userAgent.indexOf('MicroMessenger')>-1) {
vant.Dialog({ message: '点击微信右上角···,选择“用浏览器打开”' })
} else {
this.showLoading = true
axios({
url: 'https://b1ebbd3c-ca49-405b-957b-effe60782276.bspapp.com/http/alipay-params-demo', // uni-app云函数URL实例化的api
data: Qs.stringify({price: price}),
method: 'POST'
}).then(res=>{
this.showLoading = false
if (0===res.data.code) {
const obj = res.data.alipayParams
const keys = Object.keys(obj)
let formHtml = ''
formHtml += '<meta charset="utf-8">'
formHtml += '<form id="alipaysubmit" method="POST" name="alipaysubmit" action="https://openapi.alipay.com/gateway.do?charset=UTF-8">'
for (i=0; i<keys.length; i++) {
formHtml += '<input type="hidden" name="'+keys[i]+'" value=\''+obj[keys[i]]+'\'>'
}
// formHtml += '<input type="submit">' // 手动提交表单
formHtml += '</form>'
$('#form-pay').html(formHtml)
document.forms["alipaysubmit"].submit() // 自动提交表单
} else {
console.error(res.data.msg)
}
}).catch(err=>{
this.showLoading = false
console.error(err)
})
}
}
}
})
$(document).ready(function(){
$('#app').show()
})
</script>
</body>
</html>
支付成功后,支付宝会异步通知,开发者接收异步通知,验签,代码如下:
'use strict'
const querystring = require('querystring')
const NodeRSA = require('node-rsa')
exports.main = async (event, context) => {
const alipayPublicKey = '支付宝公钥'
let bodyObj = ksort(querystring.parse(event.body))
if ('TRADE_SUCCESS'===bodyObj.trade_status) { // 支付失败也有可能会收到异步通知,所以这里要判断TRADE_SUCCESS
const outTradeNo = bodyObj.out_trade_no
if (outTradeNo是否已经存在于数据库) { // 异步通知可能会收到多次,判断out_trade_no是否存过数据库来判断重复通知
const sign = bodyObj.sign
delete bodyObj.sign
delete bodyObj.sign_type
const body = querystring.unescape(querystring.stringify(bodyObj, '&', '='))
const key = new NodeRSA(alipayPublicKey, 'pkcs8-public')
if (key.verify(Buffer.from(body), sign, 'utf8', 'base64')) { // 验签通过,继续执行业务代码
// 用户处理订单的代码
} else {
console.error('验签失败')
}
} else {
console.error('订单号已存在')
}
} else {
console.error('trade_status', bodyObj.trade_status)
}
return 'success' // 一定要返回'success'给支付宝,否则会重复多次通知
}
// 对object的key进行排序
function ksort(params) {
let keys = Object.keys(params).sort();
let newParams = {};
keys.forEach((key) => {
newParams[key] = params[key];
});
return newParams;
}
2021.08.08补充:
后来开发中发现,前端js自带的fetch请求挺方便的,可以用fetch代替axios
2021.08.11补充:
支付完成跳转回如下页面,方便App端自动跳转回App首页
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>支付宝</title>
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=0">
</head>
<body>
<div id="app" style="text-align: center; padding-top: 50vh;">
<button onclick="goHome()">返回首页</button>
</div>
<script src="https://js.cdn.aliyun.dcloud.net.cn/dev/uni-app/uni.webview.1.5.2.js"></script>
<script>
document.addEventListener('UniAppJSBridgeReady', ()=>{
goHome()
})
function goHome() {
const ua = navigator.userAgent
if (ua.indexOf('uni-app')>-1 || ua.indexOf('Html5Plus')>-1) {
uni.reLaunch({ url: '/pages/home/home' }) // uni.reLaunch在非webview的网页里不执行
} else {
location.replace('/')
}
}
</script>
</body>
</html>