微信公眾號開發 [05] 微信支付功能開發(網頁JSAPI調用)



1、微信支付的流程

如下三張手機截圖,我們在微信網頁端看到的支付,表面上看到的是 “點擊支付按鈕 - 彈出支付框 - 支付成功后出現提示頁面”,實際上的核心處理過程是:
  • 點擊支付按鈕時,執行一個Ajax到后台
  • 后台通過前台的部分信息(如商品名額,金額等),將其組裝成符合微信要求格式的xml,然后調用微信的“統一下單接口”
  • 調用成功后微信會返回一個組裝好的xml,我們提取之中的消息(預支付id也在其中)以JSON形式返回給前台
  • 前台將該JSON傳參給微信內置JS的方法中,調其微信支付
  • 支付成功后,微信會將本次支付相關信息返回給我們的服務器

這些在《 微信支付官方文檔 - 場景介紹》和《 微信支付官方文檔 - 業務流程》都進行了更詳細的說明。




2、微信支付功能開發詳解 

2.1 設置支付目錄和授權域名

登陸公眾號,進行支付相關的目錄和域名設置,詳情參考《 微信支付官方文檔 - 開發步驟》,我這里簡單貼幾張官方的圖就行了,這步比較簡單,就不過多說明了,只提其中一點:對於微信支付授權的目錄,發起微信支付的頁面必須精確地位於授權目錄下,假如支付頁面為 http://www.a.com/wx/pay/a.html,那么授權目錄必須為 http://www.a.com/wx/pay/,其他的如 http://www.a.com/wx/ , https://www.a.com/wx/pay/(http和https是不一樣的),http://a.com/wx/pay/(千萬別忘了www)都是不行的。填寫了這些非法目錄無法調起支付。

支付目錄配置

微信網頁授權域名


2.2 組裝xml,調用統一下單接口,獲取prepay_id

2.2.1 組裝xml

點擊支付按鈕后,寫一個Ajax將前台部分信息發送給后台,然后組裝xml,調用統一下單接口。該接口在《 微信支付官方文檔 - 統一下單》進行了很詳細的解釋,我在這里進行部分說明:
參數 說明     備注
appId     開發者應用ID,在 “開發 - 基本配置” 查看
mch_id 微信支付的商戶號,在 “微信支付 - 商戶信息” 查看
device_info     終端設備號(門店號或收銀設備ID) PC網頁或公眾號內支付,則傳 “WEB”
body 商品或支付的簡單描述
trade_type 可取值JSAPI,NATIVE,APP等,我們這里使用的是JSAPI JSAPI 公眾號支付;NATIVE 原生掃碼支付;APP app支付
nonce_str 隨機字符串 參考算法:《微信支付官方文檔 - 安全規范
notify_url 通知地址,微信支付成功后,微信服務器會發送信息到該url
out_trade_no 商戶系統內部訂單號,由商戶自定義,訂單號要保持唯一性
total_fee 訂單總金額,單位:分
openid 用戶標識,用戶在該公眾號下的唯一身份標識
sign 簽名 參考算法:《微信支付官方文檔 - 安全規范
key API密鑰,在 “微信商戶平台 - 賬戶中心 - API安全 - API密鑰”

其他的都比較簡單,重要的在於這兩個涉及算法的參數,nonce_str 和 sign,這里說明一下:

  • nonce_str 隨機字符串,用於保證簽名不可預測
    • 算法:
    • 官方建議調用隨機數函數生成,然后轉為字符串

  • sign 簽名
    • 算法:
    • 所有發送或接收的數據按參數名ASCII碼從小到大排序,使用鍵值對形式拼接為字符串(如 key1=value1&key2=value2…)
    • ASCII碼的字典排序,可以利用TreeMap幫我們自動實現
    • 將拼接好的字符串最后,再拼接上API密鑰,即key,得到新的字符串
    • 將新的字符串進行MD5加密,並將加密后字符串全部轉換為大寫

按照以上的這些說明,進行xml的拼裝,貼上我自己的測試代碼(注:為方便測試,部分數據我直接寫入方法了,如body、openId等):
String appId = WeChatAPI.getAppID(); 
String body = "JSAPI支付測試";
String merchantId = WeChatAPI.getMerchantID();
String tradeNo = String.valueOf(new Date().getTime());
String nonceStr1 = SignUtil.createNonceStr();
String notifyUrl = "http://k169710n05.51mypc.cn/pay/do/afterPaySuccess.q";
String openId = "okAkc0muYuSJUtvMf25UQHnqYvM4";
String totalFee = "1";

TreeMap<String, String> map = new TreeMap<String, String>();
map.put("appid", appId);
map.put("mch_id", merchantId);
map.put("device_info", "WEB");
map.put("body", body);
map.put("trade_type", "JSAPI");
map.put("nonce_str", nonceStr1);
map.put("notify_url", notifyUrl);
map.put("out_trade_no", tradeNo);
map.put("total_fee", totalFee);
map.put("openid", openId);
String sign = SignUtil.createSign(map);

String xml = "<xml>" +
                "<appid>" + appId + "</appid>" +
                "<body>" + body +"</body>" +
                "<device_info>WEB</device_info>" +
                "<mch_id>" + merchantId + "</mch_id>" +
                "<nonce_str>" + nonceStr1 + "</nonce_str>" +
                "<notify_url>" + notifyUrl +"</notify_url>" +
                "<openid>" + openId + "</openid>" +
                "<out_trade_no>" + tradeNo + "</out_trade_no>" +
                "<total_fee>" + totalFee + "</total_fee>" +
                "<trade_type>JSAPI</trade_type>" +
                "<sign>" + sign + "</sign>" +
             "</xml>";

注意
  • body參數如果直接填寫中文,在調用接口時會出現“簽名錯誤”,要以ISO8859-1編碼
  • 所以 String body = new String("body內容字符串".getBytes("ISO8859-1"));
  • 但即便如此,在支付完成后微信推送的“微信支付憑證”中,商品詳情中的中文也依然顯示的亂碼

  • body參數內容如果包含中文,那么在調用接口時會出現“簽名錯誤”
  • 在網上找了很多方法,有了如上刪除線部分的方法,但是仍然是有問題的,因為支付成功后的憑證里中文是亂碼
  • 后來終於在網上各種倒騰,找到了原因,確實是編碼問題,但問題不在body是否使用ISO8859-1,而在MD5的加密算法中是否使用UTF-8
  • 所以 md.update(sourceStr.getBytes("UTF-8"));

兩個算法的代碼如下:
/**
 * 生成隨機數
 * <p>算法參考:https://pay.weixin.qq.com/wiki/doc/api/jsapi.php?chapter=4_3</p>
 * @return 隨機數字符串
 */
public static String createNonceStr() {
    SecureRandom random = new SecureRandom();
    int randomNum = random.nextInt();
    return Integer.toString(randomNum);
}


/**
 * 生成簽名,用於在微信支付前,獲取預支付時候需要使用的參數sign
 * <p>算法參考:https://pay.weixin.qq.com/wiki/doc/api/jsapi.php?chapter=4_3</p>
 * @param params 需要發送的所有數據設置為的Map
 * @return 簽名sign
 */
public static String createSign(TreeMap<String, String> params) {
    String signValue = "";
    String stringSignTemp = "";
    String stringA = "";

    //獲得stringA
    Set<String> keys = params.keySet();
    for (String key : keys) {
        stringA += (key + "=" + params.get(key) + "&");
    }
    stringA = stringA.substring(0, stringA.length() - 1);
    //獲得stringSignTemp
    stringSignTemp = stringA + "&key=" + WeChatAPI.getMerchantKey();
    //獲得signValue
    signValue = encryptByMD5(stringSignTemp).toUpperCase();
    log.debug("預支付簽名:" + signValue);
    return signValue;
}


/**
 * MD5加密
 *
 * @param sourceStr
 * @return
 */
public static String encryptByMD5(String sourceStr) {
    String result = "";
    try {
        MessageDigest md = MessageDigest.getInstance("MD5");
        md.update(sourceStr.getBytes("UTF-8"));
        byte b[] = md.digest();
        int i;
        StringBuffer buf = new StringBuffer("");
        for (int offset = 0; offset < b.length; offset++) {
            i = b[offset];
            if (i < 0)
                i += 256;
            if (i < 16)
                buf.append("0");
            buf.append(Integer.toHexString(i));
        }
        result = buf.toString();
    } catch (NoSuchAlgorithmException e) {
        System.out.println(e);
    }
    return result;
}

2018.05.22 補充:
以前寫的時候沒注意過緣由,今天在思考公司內部api調用時簡單的安全方式,突然想到了微信的這個。參數字典序后進行md5加密,就是為了防止請求的參數被過程中篡改。你想說,參數我知道,字典序md5加密的簽名算法我知道,我改了參數我按同樣的方式也修改簽名,不是一樣的嗎?那你可能遺漏了一個東西,就是商戶的密鑰key,這個東西也是參與進了md5加密算法的,也就是說,第三者修改了參數,但是因為沒有密鑰,它無法修改簽名為正確的簽名,篡改后的請求到達服務器就會被發現有問題。

所以有時候密鑰不是說一定是用來解密的,單純用來加密就可以了,這跟加密的算法也是有關系的,md5本身就是不可逆的算法,我只需要比對簽名是否一致即可。

2.2.2 調用統一下單接口,獲取prepay_id

有了組裝好的xml,現在我們直接使用POST方式的請求發送給微信提供的接口就可以了,如果一切順利,我們會收到微信返回的xml字符串,格式示例如下:
<xml>
   <return_code><![CDATA[SUCCESS]]></return_code>
   <return_msg><![CDATA[OK]]></return_msg>
   <appid><![CDATA[wx2421b1c4370ec43b]]></appid>
   <mch_id><![CDATA[10000100]]></mch_id>
   <nonce_str><![CDATA[IITRi8Iabbblz1Jc]]></nonce_str>
   <openid><![CDATA[oUpF8uMuAJO_M2pxb1Q9zNjWeS6o]]></openid>
   <sign><![CDATA[7921E432F65EB8ED0CE9755F0E86D72F]]></sign>
   <result_code><![CDATA[SUCCESS]]></result_code>
   <prepay_id><![CDATA[wx201411101639507cbf6ffd8b07629950874]]></prepay_id>
   <trade_type><![CDATA[JSAPI]]></trade_type>
</xml>

其中我們最需要的就是 prepay_id,這個值在后續需要用到。這段過程比較簡單,其中提取prepay_id我是用的正則,我直接貼代碼好了:

String url = WeChatAPI.getUrl_prePay();
String result = NetUtil.sendRequest(url, "POST", xml);

String reg = "<prepay_id><!\\[CDATA\\[(\\w+)\\]\\]></prepay_id>";
Pattern pattern = Pattern.compile(reg);
Matcher matcher = pattern.matcher(result);
String prepayId = "";
while (matcher.find()) {
    prepayId = matcher.group(1);
    log.debug("預支付ID:" + prepayId);
}


2.3 回傳參數,調起微信支付JS

2.3.1 回傳參數

這時候,已經有了預支付ID,但是后台的處理還沒有結束,我們還沒有把該有的信息返回給前台。那么前台需要哪些東西呢?《 微信支付官方文檔 - 微信內H5調起支付》有詳細的解釋,這里再貼一下:
參數     說明     備注
appId 開發者應用ID,在 “開發 - 基本配置” 查看
timeStamp     時間戳,標准北京時間,秒級(10位數字)
nonceStr     隨機字符串 參考算法:《微信支付官方文檔 - 安全規范
package     訂單詳情擴展字符串,其實就是預支付ID 示例: prepay_id=***
signType     簽名方式,暫支持MD5
paySign     簽名 參考算法:《微信支付官方文檔 - 安全規范

有了之前的經驗,想必到這里對這些的獲取已經沒有什么問題了,但是仍然有幾個 注意的地方:
  • package的值是 “prepay_id=***”,而不是 "***" 的方式(***表示之前獲取的prepay_id)
  • timeStamp注意使用標准北京時間,可以使用Calendar設置Locale為CHINA,因為是秒級所以記得除以1000
  • paySign簽名要重新生成,算法還是之前的,但是參數需要除自己以外的 appId、timeStamp、nonceStr、package、signType
  • 之前xml中參數appid是小寫,這里的appId是大寫的I

好了,因為前台接受到參數以后會以JSON的形式發送給微信服務器,所以我們這里后台,直接就把這些參數封裝到一個JSONObject中就行了,然后轉成JSON的形式發給前台。下面貼一下我的測試代碼,簽名算法和之前一樣,我這里就不重復貼出來了:
Date beijingDate = Calendar.getInstance(Locale.CHINA).getTime();
String nonceStr2 = SignUtil.createNonceStr();
JSONObject json = new JSONObject();
json.put("appId", appId);
json.put("timeStamp", beijingDate.getTime() / 1000);
json.put("nonceStr", nonceStr2);
json.put("package", "prepay_id=" + prepayId);
json.put("signType", "MD5");

TreeMap<String, String> map2 = new TreeMap<String, String>();
map2.put("appId", appId);
map2.put("timeStamp", String.valueOf(beijingDate.getTime() / 1000));
map2.put("nonceStr", nonceStr2);
map2.put("package", "prepay_id=" + prepayId);
map2.put("signType", "MD5");
String paySign = SignUtil.createSign(map2);

json.put("paySign", paySign);
String re = json.toJSONString();

AjaxSupport.sendSuccessText(null, re);

2.3.2 使用微信內置的JS調起微信支付

前台的調用就很簡單了,看下官方給的示例代碼:
function onBridgeReady(){
   WeixinJSBridge.invoke(
       'getBrandWCPayRequest', {
           "appId":"wx2421bk1c4370c43b",     //公眾號名稱,由商戶傳入     
           "timeStamp":"1395712654",         //時間戳,自1970年以來的秒數     
           "nonceStr":"e61463f8efa94090b1f366cccfbbb444", //隨機串     
           "package":"prepay_id=u802345jfgjsdfgsdg888",     
           "signType":"MD5",         //微信簽名方式:     
           "paySign":"70EA570631E4B79628FBCS90534C63FF7FADD89" //微信簽名 
       },
       function(res){     
           if(res.err_msg == "get_brand_wcpay_request:ok" ) {}     
           // 使用以上方式判斷前端返回,微信團隊鄭重提示:res.err_msg將在用戶支付成功后返回ok,但並不保證它絕對可靠。 
       }
   ); 
}
if (typeof WeixinJSBridge == "undefined"){
   if( document.addEventListener ){
       document.addEventListener('WeixinJSBridgeReady', onBridgeReady, false);
   }else if (document.attachEvent){
       document.attachEvent('WeixinJSBridgeReady', onBridgeReady); 
       document.attachEvent('onWeixinJSBridgeReady', onBridgeReady);
   }
}else{
   onBridgeReady();
}

使用時直接替換掉invoke方法中的參數即可,實際上如果后台直接是傳遞的JSON字符串到前台,可以直接解析為JS對象作為參數,下面貼我自己的代碼:
$().invoke("/pay/do/pay.q", null, function (re) {
    var result = JSON.parse(re);
    function onBridgeReady(){
       WeixinJSBridge.invoke(
           'getBrandWCPayRequest', result, function(res){
               alert(JSON.stringify(res));
               if(res.err_msg == "get_brand_wcpay_request:ok" ) {
                   //doit 這里處理支付成功后的邏輯,通常為頁面跳轉
               }
           }
       );
    }

    if (typeof WeixinJSBridge == "undefined"){
       if( document.addEventListener ){
           document.addEventListener('WeixinJSBridgeReady', onBridgeReady, false);
       }else if (document.attachEvent){
           document.attachEvent('WeixinJSBridgeReady', onBridgeReady);
           document.attachEvent('onWeixinJSBridgeReady', onBridgeReady);
       }
    }else{
       onBridgeReady();
    }

});

這里還有個 ,是iOS和Android系統不同導致的,如上代碼:
  • 如果你在 var result = JSON.parse(re); 之前再添加一個用於debug的輸出語句  alert(re);
  • 你可以看到傳過來的各項參數,其中timeStamp的值是沒有雙引號的,這會導致在iOS中支付出現錯誤,提示缺少timeStamp參數

   
 
所以為了兼容,必須要將這個轉換成字符串,帶上雙引號:

$().invoke("/pay/do/pay.q", null, function (re) {
    var result = JSON.parse(re);
    result['timeStamp'] = result['timeStamp'] + "";
    function onBridgeReady(){
       WeixinJSBridge.invoke(
           'getBrandWCPayRequest', result, function(res){
               alert(JSON.stringify(res));
               if(res.err_msg == "get_brand_wcpay_request:ok" ) {
                   //doit 這里處理支付成功后的邏輯,通常為頁面跳轉
               }
           }
       );
    }
    if (typeof WeixinJSBridge == "undefined"){
       if( document.addEventListener ){
           document.addEventListener('WeixinJSBridgeReady', onBridgeReady, false);
       }else if (document.attachEvent){
           document.attachEvent('WeixinJSBridgeReady', onBridgeReady);
           document.attachEvent('onWeixinJSBridgeReady', onBridgeReady);
       }
    }else{
       onBridgeReady();
    }
});


另外,在這個頁面調試有個小技巧,將微信回調的JS對象序列化為JSON字符串,進行彈窗顯示:alert(JSON.stringify(res));
 

2.4 校驗信息的正確性

實際上在完成上面的步驟以后,已經可以進行微信支付了。這最后一步主要是為了確認支付信息的正確性,以及傳遞給我們本次支付的一些信息,以便業務處理。

支付成功后,微信會將本次支付的相關信息,以流的方式發送給我們指定的url地址,而我們指定的url地址,就是第一次組裝xml時 <notify_url> 中填寫的地址,下面我們可以先回顧一下:
...
String notifyUrl = "http://k169710n05.51mypc.cn/pay/do/afterPaySuccess.q";
...
String xml = "<xml>" +
                "<appid>" + appId + "</appid>" +
                "<body>" + body +"</body>" +
                "<device_info>WEB</device_info>" +
                "<mch_id>" + merchantId + "</mch_id>" +
                "<nonce_str>" + nonceStr1 + "</nonce_str>" +
                "<notify_url>" + notifyUrl +"</notify_url>" +
                "<openid>" + openId + "</openid>" +
                "<out_trade_no>" + tradeNo + "</out_trade_no>" +
                "<total_fee>" + totalFee + "</total_fee>" +
                "<trade_type>JSAPI</trade_type>" +
                "<sign>" + sign + "</sign>" +
             "</xml>";

而我們要做的,就是接受到這些信息后,進行處理,並對微信服務器做出應答。如果微信收到商戶的應答不是成功或超時,微信認為通知失敗,微信會通過一定的策略定期重新發起通知,盡可能提高通知的成功率,但微信不保證通知最終能成功。詳情請參考《 微信支付官方文檔 - 支付結果通知

需要做三件事
  • 解析微信發來的信息,通過重新簽名的方式驗證信息的正確性,確認信息是否是微信所發
  • return_code和result_code都是SUCCESS的話,處理商戶自己的業務邏輯
  • 應答微信,告訴它說我們收到信息了,不用再發了(如果不進行應答,則微信服務器會通過一定的策略定期重新發起通知)

過程也很簡單,將微信發來的流信息解析出來之后,再次調用之前的簽名算法,用計算出來的算法,和微信發來的xml中的簽名sign進行對比,如果相同,則說明是微信返回的通知,響應給微信即可。

注意:驗證調用返回或微信主動通知簽名時,傳送的sign參數不參與簽名,而是將生成的簽名與該sign值作校驗。也就是說,微信發來的xml中包含元素sign,該元素內容不參與簽名算法之中,而是和最后算法的結果進行比較的,所以傳參進行算法的時候不用加入sign值。

好了,現在我們先看下微信發回來的流信息是什么,實際上文檔里有說明,就是個xml,我們看下官方的示例:
<xml>
  <appid><![CDATA[wx2421b1c4370ec43b]]></appid>
  <attach><![CDATA[支付測試]]></attach>
  <bank_type><![CDATA[CFT]]></bank_type>
  <fee_type><![CDATA[CNY]]></fee_type>
  <is_subscribe><![CDATA[Y]]></is_subscribe>
  <mch_id><![CDATA[10000100]]></mch_id>
  <nonce_str><![CDATA[5d2b6c2a8db53831f7eda20af46e531c]]></nonce_str>
  <openid><![CDATA[oUpF8uMEb4qRXf22hE3X68TekukE]]></openid>
  <out_trade_no><![CDATA[1409811653]]></out_trade_no>
  <result_code><![CDATA[SUCCESS]]></result_code>
  <return_code><![CDATA[SUCCESS]]></return_code>
  <sign><![CDATA[B552ED6B279343CB493C5DD0D78AB241]]></sign>
  <sub_mch_id><![CDATA[10000100]]></sub_mch_id>
  <time_end><![CDATA[20140903131540]]></time_end>
  <total_fee>1</total_fee>
  <trade_type><![CDATA[JSAPI]]></trade_type>
  <transaction_id><![CDATA[1004400740201409030005092168]]></transaction_id>
</xml>

其中除了sign的值,其他的值需要做成集合進行簽名算法,然后結果和sign值對比,相同的話,給微信一個應答,應答的格式官方也給出了示例,如下:
<xml>
  <return_code><![CDATA[SUCCESS]]></return_code>
  <return_msg><![CDATA[OK]]></return_msg>
</xml>

總之,這一部分還是很簡單的,就直接上我的代碼了:
/**
 * 支付成功后的處理
 * <p>微信支付成功后,對微信返回的信息進行校驗</p>
 * @return
 */
public String afterPaySuccess() {
    HttpServletRequest request = ServletActionContext.getRequest();
    HttpServletResponse response = ServletActionContext.getResponse();

    TreeMap<String, String> map = new TreeMap<String, String>();
    try {
        //解析xml,存入map
        InputStream inputStream = request.getInputStream();
        SAXReader saxReader = new SAXReader();
        Document document = saxReader.read(inputStream);
        Element rootElement = document.getRootElement();
        List<Element> elements = rootElement.elements();

        String reg = "<!\\[CDATA\\[(.+)\\]\\]>";
        Pattern pattern = Pattern.compile(reg);
        for (Element element : elements) {
            String key = element.getName();
            String value = element.getText();
            Matcher matcher = pattern.matcher(value);
            while (matcher.find()) {
                value = matcher.group(1);
            }
            map.put(key, value);
        }

        //如果微信結果通知為失敗
        if ("FAIL".equals(map.get("return_code"))) {
            log.debug(map.get("return_msg"));
            return NONE;
        }

        //doit 處理商戶業務邏輯

        //簽名對比,應答微信服務器
        String signFromWechat = map.get("sign");
        map.remove("sign");
        String sign = SignUtil.createSign(map);
        if (sign.equals(signFromWechat)) {
            String responseXml = "<xml>" +
                                    "<return_code><![CDATA[SUCCESS]]></return_code>" +
                                    "<return_msg><![CDATA[OK]]></return_msg>" +
                                 "</xml>";
            response.getWriter().write(responseXml);
        }

    } catch (IOException e) {
        e.printStackTrace();
    } catch (DocumentException e) {
        e.printStackTrace();
    }

    return NONE;
}

另外,如果在執行支付流程中,有部分數據希望能放在支付完成后再處理,可以在組裝xml的時候放置在attach標簽中;然后在支付完成后微信發送來的xml中,會將原數據在此返回。需要注意的是,該attach有字符串的長度限制(詳見文檔),所以試圖直接在支付處理時直接把某個類的JSON格式放進來留做事后處理,是會出錯的(我就是這樣踩了坑),所以用來傳遞一些核心數據就行了。

再另外,對於最后這部分,看看微信推薦我們的做法是: 當收到通知進行處理時,首先檢查對應業務數據的狀態,判斷該通知是否已經處理過,如果沒有處理過再進行處理,如果處理過直接返回結果成功。在對業務數據進行狀態檢查和處理之前,要采用數據鎖進行並發控制,以避免函數重入造成的數據混亂。另,商戶系統對於支付結果通知的內容一定要做簽名驗證,並校驗返回的訂單金額是否與商戶側的訂單金額一致,防止數據泄漏導致出現“假通知”,造成資金損失。


3、參考鏈接




附件列表

     


    免責聲明!

    本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



     
    粵ICP備18138465號   © 2018-2025 CODEPRJ.COM