Node.js接入支付宝(蚂蚁金服)支付

最近项目(Android和Ios)中需要接入付费功能(支付宝和微信),下面就先来介绍下接入支付宝的流程。文章主要分为三大块:

第一块是如何在蚂蚁金服的开放平台创建一个应用并且配置开发选项。

第二块是node端接入支付功能生成前端支付需要的参数(私钥签名)。

第三块是node端对支付结果异步通知的验签(公钥验签)。


蚂蚁金服开放平台创建一个应用

一、登陆:进入开放平台登录账号后,进入开发者中心-网页&移动应用栏目,点击创建应用中的支付接入

二、创建应用:使用场景选择“自用型应用”,并且给你的应用取一个响亮的名字(应用名称和应用图标会在授权、分享的场景中露出)


三、创建完成:这时候在我的应用里面可以看到我们刚刚创建的应用了,这时候点击“查看”按钮开始配置应用



四、添加功能:进入之后需要添加我们需要的功能选项(手机网站支付、app支付、授权等),很多功能是需要签约的,按照签约的提示填写即可。添加完毕后就可以开始开发配置了

五、开发配置:开发配置分为3步,第1步设置应用公钥,第2步设置应用公关,第3步设置授权回调地址,接下来详细介绍


第1步:生成应用公钥我们需要先下载一个软件( https://docs.open.alipay.com/291/106097),通过这个软件我们可以生成公钥。因为我们用的开发语言是nodejs, 所以在生成公钥的时候注意选择的类型(密钥格式选择PKCS1(非JAVA适用))。生成完之后,将生成的商户应用公钥填入开放平台中即可。 设置完应用公钥之后不要着急关闭我们生成签名的软件,我们需要将公钥和私钥(签名使用)保存到文件中,之后的代码中需要调用。


填写完成后,我们应该可以看到下面的界面。我们可以查看、修改之前的应用公钥, 并且此时注意,在“查看应用公钥”的旁边出现了另外一个按钮“查看支付宝公钥”,这个非常重要,很多新手把支付宝公钥和应用公钥搞混淆了,正常情况下我们代码中只需要用到两种密钥, 一个是应用私钥(用于生成app或网页端支付需要的签名参数)还有个是支付宝公钥(用于对支付宝异步通知结果进行验签的) ,到了之后的代码解析模块会详细讲解。


第2步:设置应用网关,这个地址也是很重要的,我们之后的支付结果支付宝都会通过异步的post请求这个到该地址上(用户付钱有没有成功就是依据他啦)。具体的请求参数参考:https://docs.open.alipay.com/204/105301/



第3步:设置授权回调地址,第三方授权或用户信息授权后回调地址。授权链接中配置的redirect_uri的值必须与此值保持一致。当填入该地址时,系统会自动进行安全检测。授权回调地址很多同学可能用不上,详细的使用请参考: https://docs.open.alipay.com/316/106274



6.提交审核:填写完上述信息就可以提交审核了,经过我们几次开发,发现支付宝审核非常快,白天几十分钟就会审核完毕了,在这个过程中我们也不要等着了,可以开始coding咯。




Node.js实现支付参数的生成

下面就以app支付为例子进行分析:

app端发起一个支付请求,需要一个参数(orderInfo),这个参数是从后台生成,如果我们后台(node)能够生成一个正确的参数,app端就可以成功的唤起支付宝,并且完成支付。



后台具体需要拼接哪些参数,请参考https://docs.open.alipay.com/204/105465/,文档中标记必填的参数我们也必须要填写。



看过请求参数的文档之后我们就可以正式开始组成app端需要的参数了,我们按照文档中的步骤进行构建参数,总共分为三步:

第一步:把所有必填的参数以及我们自己业务需要的参数组成key-value对象。

第二步:在第一步中有一个参数是最复杂,也是支付宝用来校验请求的合法性。就是sign(签名)这个参数,我们无法直接填写,需要通过应用的私钥去签名得到,我们第二步就是为了生成这个参数。

第三步:对我们参数中所有的value进行编码(encodeURIComponent),并且将参数转换成字符串返回给客户端即可;



第一步:生成基础参数

let params = new Map();
params.set('app_id', this.accountSettings.APP_ID);
params.set('method', 'alipay.trade.app.pay');
params.set('charset', 'utf-8');
params.set('sign_type', 'RSA2');
params.set('timestamp', moment().format('YYYY-MM-DD HH:mm:ss'));
params.set('version', '1.0');
params.set('notify_url', this.accountSettings.APP_GATEWAY_URL);
params.set('biz_content', this._buildBizContent('商品名称xxxx', '商户订单号xxxxx', '商品金额8.88'));

_buildBizContent()这个方法是用来生成参数biz_content的,这个参数用来传递一些附加参数,具体参数请参考文档中的业务参数

/**
 * 生成业务请求参数的集合
 * @param subject       商品的标题/交易标题/订单标题/订单关键字等。
 * @param outTradeNo    商户网站唯一订单号
 * @param totalAmount   订单总金额,单位为元,精确到小数点后两位,取值范围[0.01,100000000]
 * @returns {string}    json字符串
 * @private
 */
_buildBizContent(subject, outTradeNo, totalAmount) {
    let bizContent = {
        subject: subject,
        out_trade_no: outTradeNo,
        total_amount: totalAmount,
        product_code: 'QUICK_MSECURITY_PAY',
    };

    return JSON.stringify(bizContent);
}


第二步:生成签名

通过第一步,我们已经生成了基础参数存放在了params对象中,但是params中还缺少非常核心的一个参数就是“sign”下面我们就来说说如何生成sign,这次先看代码吧!(生成签名的官方文档在此)

/**
 * 根据参数构建签名
 * @param paramsMap    Map对象
 * @returns {number|PromiseLike<ArrayBuffer>}
 * @private
 */
_buildSign(paramsMap) {
    //1.获取所有请求参数,不包括字节类型参数,如文件、字节流,剔除sign字段,剔除值为空的参数
    let paramsList = [...paramsMap].filter(([k1, v1]) => k1 !== 'sign' && v1);
    //2.按照字符的键值ASCII码递增排序
    paramsList.sort();
    //3.组合成“参数=参数值”的格式,并且把这些参数用&字符连接起来
    let paramsString = paramsList.map(([k, v]) => `${k}=${v}`).join('&');

    let privateKey = fs.readFileSync(this.accountSettings.APP_PRIVATE_KEY_PATH, 'utf8');
    let signType = paramsMap.get('sign_type');
    return this._signWithPrivateKey(signType, paramsString, privateKey);
}

/**
 * 通过私钥给字符串签名
 * @param signType      返回参数的签名类型:RSA2或RSA
 * @param content       需要加密的字符串
 * @param privateKey    私钥
 * @returns {number | PromiseLike<ArrayBuffer>}
 * @private
 */
_signWithPrivateKey(signType, content, privateKey) {
    let sign;
    if (signType.toUpperCase() === 'RSA2') {
        sign = crypto.createSign("RSA-SHA256");
    } else if (signType.toUpperCase() === 'RSA') {
        sign = crypto.createSign("RSA-SHA1");
    } else {
        throw new Error('请传入正确的签名方式,signType:' + signType);
    }
    sign.update(content);
    return sign.sign(privateKey, 'base64');
}

当我们调用_buildSign()方法的时候,需要传入一个参数,就是我们第一步构建出来的params,函数返回的就是我们需要的sign参数,下面来看看它具体做了什么。

1.筛选字段:获取所有请求参数,不包括字节类型参数,如文件、字节流,剔除sign字段,剔除值为空的参数。

//1.获取所有请求参数,不包括字节类型参数,如文件、字节流,剔除sign字段,剔除值为空的参数
let paramsList = [...paramsMap].filter(([k1, v1]) => k1 !== 'sign' && v1);

2.根据key的ascii排序:按照第一个字符的键值ASCII码递增排序(字母升序排序),如果遇到相同字符则按照第二个字符的键值ASCII码递增排序,以此类推。

//2.按照字符的键值ASCII码递增排序
paramsList.sort();

3.拼接字符串:将排序后的参数与其对应值,组合成“参数=参数值”的格式,并且把这些参数用&字符连接起来,此时生成的字符串为待签名字符串。

//3.组合成“参数=参数值”的格式,并且把这些参数用&字符连接起来
let paramsString = paramsList.map(([k, v]) => `${k}=${v}`).join('&');

4.需要把上一步获取到的待签名字符串进行签名,签名分为两种,根据传递给支付宝的参数sign_type来判断(商户生成签名字符串所使用的签名算法类型,目前支持RSA2和RSA,推荐使用RSA2),此时还需要把我们的应用私钥给取出来,用来签名。应用的私钥就是我们在一开始配置应用的时候,在生成应用公钥的时候与之对应的私钥。


需要注意的是我们将私钥存储在文件中的时候,需要在第一行和最后一行分别加上一行,否则会报错



5.接下来调用_signWithPrivateKey方法即可获取到我们的sign参数的内容了


第三步:对所有的参数的value进行编码,并获得最终字符串

params.set('sign', this._buildSign(params));
return [...params].map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&');

将我们上一步获取到的签名也设置到sign中,然后将所有的value进行encode,最终用“=“和“&“拼接成字符串返回给前端,到这里我们就完成了所有的步骤:)



Node.js实现服务器对支付结果异步通知的验签

对于App支付产生的交易,支付宝会根据原始支付API中传入的异步通知地址notify_url,通过POST请求的形式将支付结果作为参数通知到商户系统。异步通知的详细参数列表请参考:https://docs.open.alipay.com/204/105301/

接受异步通知这一步非常的重要,用户是否真正的支付成功绝大部分是依赖于这个请求,我们不可能根据客户端返回的支付结果来判断,也不可能每一笔账都去蚂蚁金服的后台去对账。所以一定要处理好支付宝给我们发的请求,一定要对收到参数进行验证签名,保证这个请求确实是支付宝给我们发送的,而不是某人捏造的请求,处理不好会造成很大的损失。

小提示:但我们收到请求并且处理完成后必须打印输出“success”(不包含引号)。如果商户反馈给支付宝的字符不是success这7个字符,支付宝服务器会不断重发通知,直到超过24小时22分钟。一般情况下,25小时以内完成8次通知(通知的间隔频率一般是:4m,10m,10m,1h,2h,6h,15h)。

下面是接受请求的路由的处理代码:

handler.aliGateway = function (req, res, next) {
    let notifyTime = req.body.notify_time;//通知时间:通知的发送时间。格式为yyyy-MM-dd HH:mm:ss
    let notifyType = req.body.notify_type;//通知类型:通知的类型
    let notifyId = req.body.notify_id;//通知校验ID:通知校验ID
    let appId = req.body.app_id;//支付宝分配给开发者的应用Id:支付宝分配给开发者的应用Id
    let charset = req.body.charset;//编码格式:编码格式,如utf-8、gbk、gb2312等
    let version = req.body.version;//接口版本:调用的接口版本,固定为:1.0
    let signType = req.body.sign_type;//签名类型:商户生成签名字符串所使用的签名算法类型,目前支持RSA2和RSA,推荐使用RSA2
    let sign = req.body.sign;//签名:请参考<a href="#yanqian" class="bi-link">异步返回结果的验签</a>
    let tradeNo = req.body.trade_no;//支付宝交易号:支付宝交易凭证号
    let outTradeNo = req.body.out_trade_no;//商户订单号:原支付请求的商户订单号
    let outBizNo = req.body.out_biz_no;//商户业务号:商户业务ID,主要是退款通知中返回退款申请的流水号
    let buyerId = req.body.buyer_id;//买家支付宝用户号:买家支付宝账号对应的支付宝唯一用户号。以2088开头的纯16位数字
    let buyerLogonId = req.body.buyer_logon_id;//买家支付宝账号:买家支付宝账号
    let sellerId = req.body.seller_id;//卖家支付宝用户号:卖家支付宝用户号
    let sellerEmail = req.body.seller_email;//卖家支付宝账号:卖家支付宝账号
    let tradeStatus = req.body.trade_status;//交易状态:交易目前所处的状态,见<a href="#jiaoyi" class="bi-link">交易状态说明</a>
    let totalAmount = req.body.total_amount;//订单金额:本次交易支付的订单金额,单位为人民币(元)
    let receiptAmount = req.body.receipt_amount;//实收金额:商家在交易中实际收到的款项,单位为元
    let invoiceAmount = req.body.invoice_amount;//开票金额:用户在交易中支付的可开发票的金额
    let buyerPayAmount = req.body.buyer_pay_amount;//付款金额:用户在交易中支付的金额
    let pointAmount = req.body.point_amount;//集分宝金额:使用集分宝支付的金额
    let refundFee = req.body.refund_fee;//总退款金额:退款通知中,返回总退款金额,单位为元,支持两位小数
    let subject = req.body.subject;//订单标题:商品的标题/交易标题/订单标题/订单关键字等,是请求时对应的参数,原样通知回来
    let body = req.body.body;//商品描述:该订单的备注、描述、明细等。对应请求时的body参数,原样通知回来
    let gmtCreate = req.body.gmt_create;//交易创建时间:该笔交易创建的时间。格式为yyyy-MM-dd HH:mm:ss
    let gmtPayment = req.body.gmt_payment;//交易付款时间:该笔交易的买家付款时间。格式为yyyy-MM-dd HH:mm:ss
    let gmtRefund = req.body.gmt_refund;//交易退款时间:该笔交易的退款时间。格式为yyyy-MM-dd HH:mm:ss.S
    let gmtClose = req.body.gmt_close;//交易结束时间:该笔交易结束时间。格式为yyyy-MM-dd HH:mm:ss
    let fundBillList = req.body.fund_bill_list;//支付金额信息:支付成功的各个渠道金额信息,详见<a href="#zijin" class="bi-link">资金明细信息说明</a>
    let passbackParams = req.body.passback_params;//回传参数:公共回传参数,如果请求时传递了该参数,则返回给商户时会在异步通知时将该参数原样返回。本参数必须进行UrlEncode之后才可以发送给支付宝
    let voucherDetailList = req.body.voucher_detail_list;//优惠券信息:本交易支付时所使用的所有优惠券信息,详见<a href="#youhui" class="bi-link">优惠券信息说明</a>

    let payHelper = new AliPayHelper(DefineProto.AliAccountType.AAT_REMIND);
    let isSuccess = payHelper.verifySign(req.body);
    if (isSuccess) {
        if (tradeStatus === 'TRADE_FINISHED') {//交易状态TRADE_FINISHED的通知触发条件是商户签约的产品不支持退款功能的前提下,买家付款成功;或者,商户签约的产品支持退款功能的前提下,交易已经成功并且已经超过可退款期限。

        } else if (tradeStatus === 'TRADE_SUCCESS') {//状态TRADE_SUCCESS的通知触发条件是商户签约的产品支持退款功能的前提下,买家付款成功

        } else if (tradeStatus === 'WAIT_BUYER_PAY') {

        } else if (tradeStatus === 'TRADE_CLOSED') {

        }
        res.send('success');
    } else {
        res.send('fail');
    }
};

可以看到上面验签的核心代码就是payHelper.verifySign(req.body),我们来具体看看支付是要求我们如何验签的,参考文档:https://docs.open.alipay.com/204/105301/


很多操作都和签名的时候类似,唯一需要注意的是:验签的时候用的是支付宝的公钥而不是应用的公钥


需要注意的是我们将公钥存储在文件中的时候,需要在第一行和最后一行分别加上一行,否则会报错


贴上具体的验签代码:

/**
 * 验证支付宝异步通知的合法性
 * @param params  支付宝异步通知结果的参数
 * @returns {*}
 */
verifySign(params) {
    try {
        let sign = params['sign'];//签名
        let signType = params['sign_type'];//签名类型
        let paramsMap = new Map();
        for (let key in params) {
            paramsMap.set(key, params[key]);
        }
        let paramsList = [...paramsMap].filter(([k1, v1]) => k1 !== 'sign' && k1 !== 'sign_type' && v1);
        //2.按照字符的键值ASCII码递增排序
        paramsList.sort();
        //3.组合成“参数=参数值”的格式,并且把这些参数用&字符连接起来
        let paramsString = paramsList.map(([k, v]) => `${k}=${decodeURIComponent(v)}`).join('&');
        let publicKey = fs.readFileSync(this.accountSettings.ALI_PUBLIC_KEY_PATH, 'utf8');
        return this._verifyWithPublicKey(signType, sign, paramsString, publicKey);
    } catch (e) {
        console.error(e);
        return false;
    }
}

/**
 * 验证签名
 * @param signType      返回参数的签名类型:RSA2或RSA
 * @param sign          返回参数的签名
 * @param content       参数组成的待验签串
 * @param publicKey     支付宝公钥
 * @returns {*}         是否验证成功
 * @private
 */
_verifyWithPublicKey(signType, sign, content, publicKey) {
    try {
        let verify;
        if (signType.toUpperCase() === 'RSA2') {
            verify = crypto.createVerify('RSA-SHA256');
        } else if (signType.toUpperCase() === 'RSA') {
            verify = crypto.createVerify('RSA-SHA1');
        } else {
            throw new Error('未知signType:' + signType);
        }
        verify.update(content);
        return verify.verify(publicKey, sign, 'base64')
    } catch (err) {
        console.error(err);
        return false;
    }
}



到这里我们的三大块已经介绍完成啦,贴上完整的代码:

const path = require('path');
const fs = require('fs');
const moment = require('moment');
const crypto = require('crypto');

let ALI_PAY_SETTINGS = {
    APP_ID: '2016091100487933',
    APP_GATEWAY_URL: 'xxxxxxx',//用于接收支付宝异步通知
    AUTH_REDIRECT_URL: 'xxxxxxx',//第三方授权或用户信息授权后回调地址。授权链接中配置的redirect_uri的值必须与此值保持一致。
    APP_PRIVATE_KEY_PATH: path.join(__dirname, 'pem', 'remind', 'sandbox', 'app-private.pem'),//应用私钥
    APP_PUBLIC_KEY_PATH: path.join(__dirname, 'pem', 'remind', 'sandbox', 'app-public.pem'),//应用公钥
    ALI_PUBLIC_KEY_PATH: path.join(__dirname, 'pem', 'remind', 'sandbox', 'ali-public.pem'),//阿里公钥
    AES_PATH: path.join(__dirname, 'pem', 'remind', 'sandbox', 'aes.txt'),//aes加密(暂未使用)
};


class AliPayHelper {

    /**
     * 构造方法
     * @param accountType   用于以后区分多支付账号
     */
    constructor(accountType) {
        this.accountType = accountType;
        this.accountSettings = ALI_PAY_SETTINGS;
    }

    /**
     * 构建app支付需要的参数
     * @param subject       商品名称
     * @param outTradeNo    自己公司的订单号
     * @param totalAmount   金额
     * @returns {string}
     */
    buildParams(subject, outTradeNo, totalAmount) {
        let params = new Map();
        params.set('app_id', this.accountSettings.APP_ID);
        params.set('method', 'alipay.trade.app.pay');
        params.set('charset', 'utf-8');
        params.set('sign_type', 'RSA2');
        params.set('timestamp', moment().format('YYYY-MM-DD HH:mm:ss'));
        params.set('version', '1.0');
        params.set('notify_url', this.accountSettings.APP_GATEWAY_URL);
        params.set('biz_content', this._buildBizContent(subject, outTradeNo, totalAmount));
        params.set('sign', this._buildSign(params));

        return [...params].map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&');
    }

    /**
     * 根据参数构建签名
     * @param paramsMap    Map对象
     * @returns {number|PromiseLike<ArrayBuffer>}
     * @private
     */
    _buildSign(paramsMap) {
        //1.获取所有请求参数,不包括字节类型参数,如文件、字节流,剔除sign字段,剔除值为空的参数
        let paramsList = [...paramsMap].filter(([k1, v1]) => k1 !== 'sign' && v1);
        //2.按照字符的键值ASCII码递增排序
        paramsList.sort();
        //3.组合成“参数=参数值”的格式,并且把这些参数用&字符连接起来
        let paramsString = paramsList.map(([k, v]) => `${k}=${v}`).join('&');

        let privateKey = fs.readFileSync(this.accountSettings.APP_PRIVATE_KEY_PATH, 'utf8');
        let signType = paramsMap.get('sign_type');
        return this._signWithPrivateKey(signType, paramsString, privateKey);
    }

    /**
     * 通过私钥给字符串签名
     * @param signType      返回参数的签名类型:RSA2或RSA
     * @param content       需要加密的字符串
     * @param privateKey    私钥
     * @returns {number | PromiseLike<ArrayBuffer>}
     * @private
     */
    _signWithPrivateKey(signType, content, privateKey) {
        let sign;
        if (signType.toUpperCase() === 'RSA2') {
            sign = crypto.createSign("RSA-SHA256");
        } else if (signType.toUpperCase() === 'RSA') {
            sign = crypto.createSign("RSA-SHA1");
        } else {
            throw new Error('请传入正确的签名方式,signType:' + signType);
        }
        sign.update(content);
        return sign.sign(privateKey, 'base64');
    }

    /**
     * 生成业务请求参数的集合
     * @param subject       商品的标题/交易标题/订单标题/订单关键字等。
     * @param outTradeNo    商户网站唯一订单号
     * @param totalAmount   订单总金额,单位为元,精确到小数点后两位,取值范围[0.01,100000000]
     * @returns {string}    json字符串
     * @private
     */
    _buildBizContent(subject, outTradeNo, totalAmount) {
        let bizContent = {
            subject: subject,
            out_trade_no: outTradeNo,
            total_amount: totalAmount,
            product_code: 'QUICK_MSECURITY_PAY',
        };

        return JSON.stringify(bizContent);
    }

    /**
     * 验证支付宝异步通知的合法性
     * @param params  支付宝异步通知结果的参数
     * @returns {*}
     */
    verifySign(params) {
        try {
            let sign = params['sign'];//签名
            let signType = params['sign_type'];//签名类型
            let paramsMap = new Map();
            for (let key in params) {
                paramsMap.set(key, params[key]);
            }
            let paramsList = [...paramsMap].filter(([k1, v1]) => k1 !== 'sign' && k1 !== 'sign_type' && v1);
            //2.按照字符的键值ASCII码递增排序
            paramsList.sort();
            //3.组合成“参数=参数值”的格式,并且把这些参数用&字符连接起来
            let paramsString = paramsList.map(([k, v]) => `${k}=${decodeURIComponent(v)}`).join('&');
            let publicKey = fs.readFileSync(this.accountSettings.ALI_PUBLIC_KEY_PATH, 'utf8');
            return this._verifyWithPublicKey(signType, sign, paramsString, publicKey);
        } catch (e) {
            console.error(e);
            return false;
        }
    }

    /**
     * 验证签名
     * @param signType      返回参数的签名类型:RSA2或RSA
     * @param sign          返回参数的签名
     * @param content       参数组成的待验签串
     * @param publicKey     支付宝公钥
     * @returns {*}         是否验证成功
     * @private
     */
    _verifyWithPublicKey(signType, sign, content, publicKey) {
        try {
            let verify;
            if (signType.toUpperCase() === 'RSA2') {
                verify = crypto.createVerify('RSA-SHA256');
            } else if (signType.toUpperCase() === 'RSA') {
                verify = crypto.createVerify('RSA-SHA1');
            } else {
                throw new Error('未知signType:' + signType);
            }
            verify.update(content);
            return verify.verify(publicKey, sign, 'base64')
        } catch (err) {
            console.error(err);
            return false;
        }
    }

}

module.exports = AliPayHelper;
如果有写的不对的地方麻烦在评论中指出,如果有疑问也欢迎提问哦~



阅读更多

更多精彩内容