支付宝网页支付的流程:前端请求支付宝支付表单参数->后端生成支付表单参数给前端->前端根据支付参数构建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>

添加新评论