1. 程式人生 > >Node.js接入支付寶(螞蟻金服)支付

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端就可以成功的喚起支付寶,並且完成支付。



看過請求引數的文件之後我們就可以正式開始組成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;
如果有寫的不對的地方麻煩在評論中指出,如果有疑問也歡迎提問哦~