1. 程式人生 > >微信公眾號支付開發手記(node)

微信公眾號支付開發手記(node)

微信支付

前言

總結一下最近業務開發中對微信公眾號支付的開發過程,微信支付的開發前提是已經具備可上線微信公眾號開發的基礎上進行的,如果你的開發階段目前停留在起步,建議參考這篇文章開始

好了,來聊一聊微信支付。不論是今天的分享,還是網上其他的分享,開頭總是在吐槽微信的文件。我也不例外,剛開始總是覺得文件寫的不夠具體,寫的模稜兩可。後來發現一個是自己太浮躁,不能沉下心去分析文件的細節,另一方面是習慣性先去網上找相關的教程,然後發現教程傳遞過來的第一感受就是——微信開發是個大坑。

網上的經驗分享還是很有幫助的,但是首先要清楚地明白微信支付的整個流程以及自己目前的進展,這樣才能有目的的去找到自己需要的東西。

微信支付開發和微信其他功能開發有一個共同點,就是需要耐心。而這似乎也是微信團隊的初衷,通過零散的文件,不清晰的說明來過濾掉一批缺乏耐心的程式設計師(幫微信團隊圓個場)。所以開始接觸微信開發之前,會有人告訴你這裡會有很多坑,會怎麼樣怎麼樣,其實完全不用擔心,因為這裡的坑並不是在考驗技術。

關於微信支付的除錯過程,由於微信支付對安全要求較高,不能做內網穿透本機測試,所以需要先將專案部署到線上,開發除錯不是很方便。可能會有一些本地支付沙箱之類的工具,我沒有去研究,希望有做過此類工作的小夥伴留個言提醒一下。而且微信支付是不能夠通過微信開發者工具測試的,只能在真機上跑。如果在開發者工具上遇到報錯,不妨在手機上跑一下。

微信支付文件分析

不管什麼開發,都要從官方文件開始。關於公眾號支付的部分,需要關注文件的兩個部分,一個是“公眾號支付”一個是“API列表”,其他部分不是重點。

公眾號支付

  • 場景介紹
  • 案例介紹
  • 開發步驟
  • 業務流程
  • 獲取微信版本號
  • 微信內H5調起支付
  • 收穫地址共享
  • 支援常見問題

這一級的目錄,重點是業務流程微信內H5調起支付,因為這兩塊內容搞明白了,整個支付流程就清晰了。

首先需要了解一下公眾號支付的具體場景,也就是需要閱讀第一部分“場景介紹”,瞭解一下這個場景是否符合具體業務。

如果這就是你要的,那就來到重點了,也就是“

業務流程”。

看到業務流程的流程圖,估計有計算機專業背景的朋友會很熟悉,也很容易理解。不理解也沒關係,我這裡準備了一副含有分析過程的流程圖,結合實際業務,來幫助理解。

微信支付流程圖分析

先來看黃色的甬道,這兩塊也就是實際的前臺頁面和後臺服務,我們的閱讀順序是自上而下,流程圖中的文字是微信官方提供的,右邊的說明文字是我根據業務寫的,看哪部分都可以。我把整個流程用顏色分為了三大塊幫助理解。

從紅色部分開始,紅色部分主要工作是後臺生成預付款單,然後通過回撥資訊將內容傳送到前臺。

接下來是藍色部分,這一部分包含兩塊,一個是付款前,一個是付款後。

首先前臺拿到紅色部分由後臺發來的資訊,然後再微信內H5頁面調起支付,此時頁面的付款都是由微信來控制。

付款結束後,會發送兩個回撥,一個傳送給後臺服務,也就是圖片藍色部分中的綠色區塊,告訴後臺具體哪個訂單現在的完成狀態。另一個傳送給客戶前端,通知前端交易狀態。

從這部分內容瞭解到,需要先了解統一下單API,之後是微信H5內調起支付,然後處理支付結果通知

開始開發

準備

微信服務配置

開始開發前,需要對現有專案設定支付目錄和設定授權域名,具體可以參考這裡另外需要注意的是,也是微信文件裡沒有提到的地方。需要在微信支付平臺設定API金鑰。需要提醒的是,微信支付平臺的配置需要超級管理員賬號登入才可以進行配置操作。

微信支付API金鑰

收集資訊

上一步配置完成後,需要收集一些資訊為後續開發做準備。

  • token 微信公眾號後臺取得
  • appid 微信公眾號後臺取得
  • appsecret 微信公眾號後臺取得
  • encodingAESKey 微信公眾號後臺取得
  • mch_id 商戶號(微信支付平臺取得)
  • notifyUrl 微信支付回撥地址(服務端後臺介面:POST)
  • partnerKey 微信支付API金鑰(微信支付平臺取得)

準備好以上資訊後,就可以開始著手寫程式碼了。

開工

首先,需要準備一個前臺介面,模擬使用者訪問商品頁面點選購買。

image

後端部分

後臺這邊,node開發微信支付有很多現成的封裝庫可以使用,這裡使用wechat-pay

首先在專案開始處初始化wechat-pay

下單
const Payment = require('wechat-pay').Payment;
const initConfig = {
  partnerKey: config.wechat.partnerKey,
  appId: config.wechat.appid,
  mchId: config.wechat.mch_id,
  notifyUrl: config.wechat.notifyUrl,
};
const payment = new Payment(initConfig);
複製程式碼

然後編寫前臺介面使用者點選購買後的介面業務程式碼:

async genAdvanceOrder(ctx) {
    try {
      // 1. 通過前臺發來的商品ID查詢商品
      const { user } = ctx.state;
      const { product_id, comment, school_id } = ctx.request.body;
      const product = await prodDao.findOneProduct(product_id);

      if (product.length <= 0) {
        return ctx.body = new Error(C.ERROR_CODE.QUERY_EMPTY, '沒有找到商品');
      }
      
      
      // 2. 通過查詢結果填寫預付款單
      // 獲取client ip地址
      const clientIp = getClientIp(ctx.req);
      const order = {
        body: product[0].course_title,        // 商品描述
        attach: product[0].comment,           // 商品附加資料
        out_trade_no: UUID.v1().replace(/\-/g, ''),   //  商戶系統內部訂單號,自己生成32位隨機串 unique
        total_fee: product[0].price,          // 費用(單位:分) 
        spbill_create_ip: clientIp,           // 客戶端IP
        openid: user.openid,                  // 使用者openid
        trade_type: 'JSAPI'
      };

      // 3. 根據預付款單回撥結果往資料庫插入資料(判斷錯誤碼,修改訂單狀態)
      // 向微信請求生成預付款單
      let payargs = await payment.getBrandWCPayRequestParams(order);
      await orderDao.add({
        user_id: user.id,
        school_id: school_id, // TODO: 增加並分配學校ID,業務邏輯需要變動
        scene: product[0].scene,
        product_id: product_id,
        price: product[0].price,
        product_price: product[0].price,
        status: C.PAY_STATUS.NO_PAY,
        wx_open_id: user.openid,
        wx_out_trade_no: order.out_trade_no,
        wx_prepay_id: payargs.package.split('=')[1], // 取prepay_id
        comment: comment
      });

      // 4. 傳送預付款單內容
      ctx.body = new Success(payargs);

    } catch (e) {
      ctx.body = new Error('', '未知錯誤', e)
    }
  }
  
function getClientIp(req) {
    const ip = req.headers['x-forwarded-for'] ||
    req.connection.remoteAddress ||
    req.socket.remoteAddress ||
    req.connection.socket.remoteAddress;
    return ip.replace(/:|\wf/g, '');
}
複製程式碼

這裡需要注意的是填寫預付款單這裡的操作。

out_trade_no是要自己生成32位隨機字串,相當於是儲存在自己資料庫中的訂單唯一值,以後在微信回執付款資訊時也會用到這個欄位。

total_fee的單位是分,在開發過程中,也應當使用分作為資料庫價錢的單位,這樣可以有效避免浮點數精讀損失問題。

spbill_create_ip是使用者端下單裝置的ip,是必填項,微信那邊處於安全要求每份訂單都必須要填寫。這個也很好獲取(上面程式碼中貼出來了),只不過要做一些處理,因為直接通過koa ctx.ip獲取的地址可能會被Nginx或者其他伺服器配置服務轉發成127.0.0.1。這就不是我們需要的真實的客戶端ip。最後要處理字串字首,一般直接拿到ip的格式是:fff:54.00.00.1

支付通知

這裡就是之前的流程圖中,藍色區塊中的綠色部分。可以對比流程圖理解支付流程。

微信開發中,大量來自微信傳送的通知都是xml格式,所以為了方便使用,需要先增加以下中介軟體來幫助開發。

const bodyParser = require('koa-bodyparser');
const xmlParser = require('koa-xml-body');

app.use(xmlParser({
  key: 'body'
}));
// app.use(bodyParser());
app.use(bodyParser({
  enableTypes: ['json', 'form', 'text'],
  extendTypes: {
    text: ['text/xml', 'application/xml']
  }
}));
複製程式碼

之後是通知部分的程式碼:

async wxPayNotify(ctx) {
    // TODO: 安全驗證 簽名驗證 並校驗返回的訂單金額是否與商戶側的訂單金額一致
    /*
      // 微信傳送通知的內容
      {
        appid: [ '**********' ],
        attach: [ '附加內容' ],
        bank_type: [ 'CFT' ],
        cash_fee: [ '1' ],
        fee_type: [ 'CNY' ],
        is_subscribe: [ 'Y' ],
        mch_id: [ '1498496372' ],
        nonce_str: [ '4KewHbQvsQPaGsaeoICLbKD1ySFDlPdL' ],
        openid: [ 'oZZUx0X2LSM1j652P6r2R*******' ],
        out_trade_no: [ '8ff9fdd0e33411e8a07c833c43c4e4e7' ],
        result_code: [ 'SUCCESS' ],
        return_code: [ 'SUCCESS' ],
        sign: [ '6A14538FE1651CECDFCDFE375383B9AA' ],
        time_end: [ '20181108165920' ],
        total_fee: [ '1' ],
        trade_type: [ 'JSAPI' ],
        transaction_id: [ '4200000235201811***********' ]
      }
    */
    try {

      // 1. 通過回撥資訊查詢訂單
      const content = ctx.request.body['xml'];
      const order = await orderDao.selectOne({ wx_out_trade_no: content['out_trade_no'][0] });

      if (!order) {
      //  TODO: 處理查詢不到訂單的通知
      }

      // 2. 安全驗證,對比簽名和訂單金額
      if (checkWeChatPaySign(content) && order.price === parseInt(content['total_fee'][0])) {
        await orderDao.update({
          wx_notify_backup: JSON.stringify(content),
          status: C.PAY_STATUS.PAID,
          wx_transaction_id: content['transaction_id'][0]
        }, { wx_out_trade_no: content['out_trade_no'][0] })
      } else {
      //  TODO: 處理驗證不通過的通知
      }

      // 3. 回撥通知微信
      ctx.body = '<xml>' +
        '<return_code><![CDATA[SUCCESS]]></return_code>' +
        '<return_msg><![CDATA[OK]]></return_msg>' +
        '</xml>'
    } catch (e) {
    //  TODO: 收款回調出錯通知
    }
  }
  
function checkWeChatPaySign(obj) {
    let template = `appid=${obj.appid[0]}&attach=${obj.attach[0]}
    &bank_type=${obj.bank_type[0]}&cash_fee=${obj.cash_fee[0]}&fee_type=${obj.fee_type[0]}
    &is_subscribe=${obj.is_subscribe[0]}&mch_id=${obj.mch_id[0]}
    &nonce_str=${obj.nonce_str[0]}&openid=${obj.openid[0]}
    &out_trade_no=${obj.out_trade_no[0]}&result_code=${obj.result_code[0]}
    &return_code=${obj.return_code[0]}&time_end=${obj.time_end[0]}&total_fee=${obj.total_fee[0]}
    &trade_type=${obj.trade_type[0]}&transaction_id=${obj.transaction_id[0]}`;
    template += '&key=' + config.wechat.partnerKey;
    
    // notice 模板字串會帶有換行和空格
    return md5(template.replace(/[\r\n][ | ]*/g,"")).toUpperCase() && obj.sign[0];
}
複製程式碼

這裡比較不好理解的是驗證簽名,而微信文件也沒有給出樣例程式碼,所以比較混亂。而且拼簽名拼串驗證又容易出錯,多一個空格少一個字元都不一樣。這裡就得結合官方給出的簽名演算法效驗工具耐著性子除錯了。

前端部分

然後就回到我們的前端部分。

由於微信H5支付是基於騰訊瀏覽器的,所以只有在手機微信中或者開發者工具中開啟的網址,才能呼叫到WeixinJSBridge

我這邊前端是拿Angular寫的,不過程式碼不復雜,著重理解業務流程。

import { Component, OnInit } from '@angular/core';
import {UserService} from '../../../services/user.service';
import {OrderService} from '../../../services/order.service';
import {ActivatedRoute} from '@angular/router';

@Component({
  selector: 'app-wx-pay-test',
  templateUrl: './wx-pay-test.component.html',
  styleUrls: ['./wx-pay-test.component.scss']
})
export class WxPayTestComponent implements OnInit {
  wxBridge;
  logs = [];
  productId;

  constructor(
    private orderService: OrderService, // API
    private activateRouter: ActivatedRoute
  ) { }

  // 頁面初始化就會執行的鉤子函式, React 應該使用ComponentDidMount
  ngOnInit() {
    // 這裡是為了獲取WeixinJSBridge
    if (typeof window['WeixinJSBridge'] === 'undefined') {
      if (document.addEventListener ) {
        document.addEventListener('WeixinJSBridgeReady', this.onBridgeReady, false);
      } else if (document['attachEvent']) {
        document['attachEvent']('WeixinJSBridgeReady', this.onBridgeReady);
        document['attachEvent']('onWeixinJSBridgeReady', this.onBridgeReady);
      }
    } else {
      this.onBridgeReady();
    }
    // 獲取url中的引數
    this.activateRouter.params.subscribe( params => {
      if (params.id) {
        this.productId = params.id;
      }
    });
  }

  onBridgeReady = () => {
    this.wxBridge = window['WeixinJSBridge'];
  }
  // 點選購買觸發該函式
  genPreOrder(ev) {
    const that = this;
    // 1、向後臺傳送請求 對應後臺 genAdvanceOrder
    this.orderService.genAdvanceOrder({
      product_id: this.productId
    }).then(data => {
      // 2、拿到服務端回執資料,呼叫invoke,請求微信支付
      that.wxBridge.invoke('getBrandWCPayRequest', data, function(res) {
        // 3、處理微信支付回執結果
        if (res.err_msg === 'get_brand_wcpay_request:ok') {
          // TODO: 顯示支付成功頁面
          alert('支付成功');
          // 這裡可以跳轉到訂單完成頁面向用戶展示
        } else {
          // TODO: 顯示支付失敗頁面
          alert('支付失敗,請重試');
        }
      });
    }).catch(err => {
      console.log(err);
    });
  }
}
複製程式碼

前端的程式碼還是相對容易理解的,前提是理解文章開頭部分的流程圖。知道每一步是處理什麼問題,需要幹什麼。

在呼叫微信支付後,返回的結果程式碼中我只處理了支付成功的部分,但是回撥還會有支付失敗、超時等等,就不一一列舉。

結語

文章到這裡,大概也就講清楚了微信公眾號支付的整個環節。但是在流程圖中最後一部分灰色區塊的業務沒有講,因為覺得前兩部分是最主要的,後面可以自行理解流程圖,根據具體業務開發。

折騰微信支付這塊內容大概也有幾天了,總結一下整個開發流程,分享一下,希望能夠幫助大家理解整個支付業務。

要說難嗎其實也不難,主要就是考察耐心吧。