1. 程式人生 > >服務端微信小程式支付/退款詳解

服務端微信小程式支付/退款詳解

賬號支援:小程式appid,小程式secret,商戶號mchid,商戶secret

服務端和微信支付系統主要互動:

1、小程式內呼叫登入介面,獲取到使用者的openid,api參見公共api【小程式登入API】

前端呼叫介面wx.login() 獲取臨時登入憑證(code)

通過code值獲取openid地址:

請求引數:

引數	必填	說明
appid	是	小程式唯一標識
secret	是	小程式的 app secret
js_code	是	登入時獲取的 code
grant_type	是	填寫為 authorization_code
傳送一個get請求即可引數也比較容易理解,這樣我們就完成了第一步獲取到了使用者的openid

2、商戶server呼叫支付統一下單,api參見公共api【統一下單API】

統一下單介面地址:


/**
 * 商戶發起生成預付單請求(統一下單介面)
 *
 * @param body      商品資訊
 * @param orderId   商戶自己的訂單號
 * @param totalFee  支付價格(單位分)
 * @param openid    微信使用者的openid(必須關注此公眾號的openid)
 * @param notifyUrl 微信回撥地址
 */
public Map<String, Object> unifiedOrder(String body, Long orderId, Long totalFee, String openid, String notifyUrl) {
    //微信賬號校驗
    checkConfig(weixinProperties);
    SortedMap<String, Object> params = new TreeMap<>();
    //小程式appid
    params.put("appid", "**************");
    //商戶號
    params.put("mch_id", "**************");
    params.put("spbill_create_ip", getUserContext().getIp());
    params.put("trade_type", "JSAPI";
    params.put("nonce_str", StringUtil.getRandomStringByLength(24));
    params.put("notify_url", notifyUrl);
    params.put("body", body);
    params.put("out_trade_no", fictitiousOrderService.insertFictitiousOrder(orderId, body, totalFee).toString());
    params.put("total_fee", totalFee);
    params.put("openid", openid);
    //簽名演算法
    String sign = getSign(params);
    params.put("sign", sign);
    String xml = XmlHelper.mapToXml(params);
    try {
        //傳送xmlPost請求
        String xmlStr = HttpUtil.xmlPost("https://api.mch.weixin.qq.com/pay/unifiedorder", xml);
        //加入微信支付日誌
        payWechatLogService.insertPayWechatLog(Constants.PAY_UNIFIED_ORDER_RESULT_LOG, xmlStr);
        Map map = XmlHelper.xmlToMap(xmlStr);
        if (map != null && Constants.REQUEST_SUCCESS.equals(map.get("result_code")) && map.get("prepay_id") != null) {
            //返回二次簽名前端呼叫微信資訊
            Map<String, Object> result = secondarySign(map);
            return result;
        } else {
            //異常通知
            mqSendService.sendRobotMsg(
                    String.format("微信訊息-傳入引數[%s];微信輸出[%s]", JSON.toJSONString(params), JSON.toJSONString(map)),
                    "統一下單");
            throw serviceExceptionService.createServiceException(ExceptionConstants.UNIFIED_ORDER_INTERFACE_ERROR);
        }
    } catch (Exception e) {
        mqSendService.sendRobotMsg(String.format("微信訊息-[%s]", e.getMessage()), "統一下單");
        //統一下單介面異常
        throw serviceExceptionService.createServiceException(ExceptionConstants.UNIFIED_ORDER_INTERFACE_ERROR);
    }
}

方法注意點:

引數out_trade_no商戶訂單號即自己資料庫儲存的訂單號這裡有個坑,統一下單介面不予許同一訂單發起兩次下單請求,但是我們業務常常有這個需要。解決的辦法是下單時用商戶訂單號生成一個虛擬訂單號,用虛擬支付單號去請求微信,微信回撥時用返回的虛擬支付單號解析出商戶的訂單號,修改訂單的支付狀態。

微信下單和退款的介面請求方式都是將引數轉為xml格式傳送跑

/**
 * 獲取簽名 md5加密(微信支付必須用MD5加密) 獲取支付簽名
 */
public String getSign(SortedMap<String, Object> params) {
    StringBuffer sb = new StringBuffer();
    //所有參與傳參的引數按照accsii排序(升序)
    Set es = params.entrySet();
    Iterator it = es.iterator();
    while (it.hasNext()) {
        Map.Entry entry = (Map.Entry) it.next();
        String k = (String) entry.getKey();
        Object v = entry.getValue();
        if (null != v && !"".equals(v) && !"sign".equals(k) && !"key".equals(k)) {
            sb.append(k + "=" + v + "&");
        }
    }
    //key後面接商戶號的祕鑰secret
    sb.append("key=" + "*************");
    String sign = EncryptUtil.MD5Encode(sb.toString(), "UTF-8").toUpperCase();
    return sign;
}

簽名規則:

所有的引數按照accsii排序(升序), 所有引數按照key1=value1&key2=value2&…拼接成字串 最後在key後面拼上祕鑰(注:該祕鑰是商戶祕鑰不是小程式祕鑰) 通過MD5加密字串後將所有的字元改為大寫 微信線上簽名校驗工具地址:https://pay.weixin.qq.com/wiki/doc/api/jsapi.php?chapter=20_1

注:微信下單和退款的介面請求方式都是將引數轉為xml格式傳送post請求,提供xmlPost方法程式碼

/**
 * 微信提交xml引數
 */
public static String xmlPost(String url1, String xml) {
    try {
        // 建立url資源
        URL url = new URL(url1);
        // 建立http連線
        HttpURLConnection conn = (HttpURLConnection) url.openConnection();
        // 設定允許輸出
        conn.setDoOutput(true);

        conn.setDoInput(true);

        // 設定不用快取
        conn.setUseCaches(false);
        // 設定傳遞方式
        conn.setRequestMethod("POST");
        // 設定維持長連線
        conn.setRequestProperty("Connection", "Keep-Alive");
        // 設定檔案字符集:
        conn.setRequestProperty("Charset", "UTF-8");
        //轉換為位元組陣列
        byte[] data = xml.getBytes();
        // 設定檔案長度
        conn.setRequestProperty("Content-Length", String.valueOf(data.length));
        // 設定檔案型別:
        conn.setRequestProperty("contentType", "text/xml");
        // 開始連線請求
        conn.connect();
        OutputStream out = conn.getOutputStream();
        // 寫入請求的字串
        out.write(data);
        out.flush();
        out.close();

        System.out.println(conn.getResponseCode());

        // 請求返回的狀態
        if (conn.getResponseCode() == 200) {
            System.out.println("連線成功");
            // 請求返回的資料
            InputStream in = conn.getInputStream();
            String a = null;
            try {
                byte[] data1 = new byte[in.available()];
                in.read(data1);
                // 轉成字串
                a = new String(data1);
                System.out.println(a);
            } catch (Exception e1) {
                // TODO Auto-generated catch block
                e1.printStackTrace();
            }
            return a;
        } else {
            System.out.println("no++");
        }

    } catch (Exception e) {

    }
    return null;
}

3、商戶server呼叫再次簽名,api參見公共api【再次簽名】

通過統一下單介面我們可以拿到prepay_id,將prepay_id組裝成package進行簽名。

簽名引數: 欄位名 變數名 必填 型別 示例值 描述 小程式ID appId 是 String wxd678efh567hg6787 微信分配的小程式ID 時間戳 timeStamp 是 String 1490840662 時間戳從1970年1月1日00:00:00至今的秒數,即當前的時間 隨機串 nonceStr 是 String 5K8264ILTKCH16CQ2502SI8ZNMTM67VS 隨機字串,不長於32位。推薦隨機數生成演算法 資料包 package 是 String prepay_id=wx2017033010242291fcfe0db70013231072 統一下單介面返回的 prepay_id 引數值,提交格式如:prepay_id=wx2017033010242291fcfe0db70013231072 簽名方式 signType 是 String MD5 簽名型別,預設為MD5,支援HMAC-SHA256和MD5。注意此處需與統一下單的簽名型別一致

/**
 * 二次簽名
 */
private Map<String, Object> secondarySign(Map map) {
    SortedMap<String, Object> secondarySignParam = new TreeMap<>();
    secondarySignParam.put("appId", weixinProperties.getMiniapp().getUser().getAppId());
    secondarySignParam.put("timeStamp", new Date().getTime() + "");
    secondarySignParam.put("nonceStr", StringUtil.getRandomStringByLength(24));
    secondarySignParam.put("package", "prepay_id=" + map.get("prepay_id").toString());
    secondarySignParam.put("signType", "MD5");
    String paySign = getSign(secondarySignParam);
    secondarySignParam.put("paySign", paySign);
    //簽完名去掉appId防止暴露賬號
    secondarySignParam.remove("appId");
    return secondarySignParam;
}

前端調起微信支付引數:

引數 型別 必填 說明 timeStamp String 是 時間戳從1970年1月1日00:00:00至今的秒數,即當前的時間 nonceStr String 是 隨機字串,長度為32個字元以下。 package String 是 統一下單介面返回的 prepay_id 引數值,提交格式如:prepay_id=* signType String 是 簽名型別,預設為MD5,支援HMAC-SHA256和MD5。注意此處需與統一下單的簽名型別一致 paySign String 是 簽名,具體簽名方案參見微信公眾號支付幫助文件; 組裝前端所需要的引數通過統一下單介面直接返回給前端,前端呼叫wx.requestPayment(OBJECT)發起微信支付。

4、商戶server接收支付通知,api參見公共api【支付結果通知API】

介面地址為【統一下單API】中提交的引數notify_url設定,如果連結無法訪問,商戶將無法接收到微信通知。

/**
 * 微信支付回撥
 */
@RequestMapping(value = "/notify", produces = "application/xml; charset=UTF-8")
@ResponseBody
public void notify(HttpServletRequest request, HttpServletResponse response) throws Exception {
    BufferedReader reader = request.getReader();
    StringBuffer inputString = new StringBuffer();
    String line = "";
    while ((line = reader.readLine()) != null) {
        inputString.append(line);
    }
    payWechatLogService.insertPayWechatLog(Constants.PAY_SUCCESS_RESULT_LOG, inputString.toString());
    Map<String, String> map = XmlHelper.xmlToMap(inputString.toString());
    String return_code = map.get("return_code");
    //客戶訂單id(虛擬支付單號)
    String out_trade_no = map.get("out_trade_no");
    //微信支付訂單號(流水號)
    String transaction_id = map.get("transaction_id");
    //todo 修改訂單對應的狀態
    //商戶處理後同步返回給微信引數
    response.getWriter().print("<xml><return_code><![CDATA[" + return_code + "]]></return_code><return_msg><![CDATA[OK]]></return_msg></xml>");
}

回撥成功給微信傳送通知,不然微信會繼續回撥該介面

賬號支援:小程式appid,商戶號mchid,商戶secret,退款證書,退款證書密碼

系統中有了支付肯定會設及到退款

實現程式碼:


/**
 * 申請退款
 *
 * @param orderId       商戶訂單號
 * @param refundId      商戶退款單號
 * @param totalFee      訂單金額
 * @param refundFee     退款金額
 * @param refundAccount 退款資金來源(預設傳 "REFUND_SOURCE_UNSETTLED_FUNDS")
 */
public Map<String, String> refund(Long orderId, String refundId, Long totalFee,
                                  Long refundFee, String refundAccount) {
    checkConfig(weixinProperties);
    SortedMap<String, Object> params = new TreeMap<>();
    params.put("appid", "************");
    params.put("mch_id", "************");
    params.put("nonce_str", StringUtil.getRandomStringByLength(24));
    //商戶訂單號和微信訂單號二選一
    params.put("out_trade_no", fictitiousOrderService.findFictitiousIdByOrder(orderId));
    params.put("out_refund_no", refundId);
    params.put("total_fee", totalFee);
    params.put("refund_fee", refundFee);
    params.put("refund_account", refundAccount);
    //簽名演算法
    String sign = getSign(params);
    params.put("sign", sign);
    try {
        String xml = XmlHelper.mapToXml(params);
        String xmlStr = doRefund("https://api.mch.weixin.qq.com/secapi/pay/refund", xml);
        //加入微信支付日誌
        payWechatLogService.insertPayWechatLog(Constants.PAY_REFUND_RESULT_LOG, xmlStr);
        Map map = XmlHelper.xmlToMap(xmlStr);
        if (map == null || !Constants.REQUEST_SUCCESS.equals(map.get("result_code"))) {
            //訊息通知
            mqSendService.sendRobotMsg(
                    String.format("微信訊息-傳入引數[%s];微信輸出[%s]", JSON.toJSONString(params), JSON.toJSONString(map)),
                    "申請退款");
        }
        //未結算金額不足  使用餘額退款
        if (map != null && Constants.REQUEST_FAIL.equals(map.get("result_code")) &&
                Constants.REQUEST_SUCCESS.equals(map.get("return_code")) && Constants.REFUND_NOT_ENOUGH_MONEY.equals(map.get("err_code")) &&
                Constants.REFUND_SOURCE_UNSETTLED_FUNDS.equals(refundAccount)) {
            refund(orderId, refundId, totalFee, refundFee, Constants.REFUND_SOURCE_RECHARGE_FUNDS);
        }
        return map;
    } catch (Exception e) {
        //微信退款介面異常
        mqSendService.sendRobotMsg(String.format("微信訊息-傳入引數[%s];異常資訊-[%s]", JSON.toJSONString(params), e.getMessage()), "申請退款");
        throw serviceExceptionService.createServiceException(ExceptionConstants.PAY_REFUND_INTERFACE_RRROR);
    }
}

方法注意點:

其中getSign(params)獲取簽名方法和上面支付簽名方法一致可共用;out_trade_no填寫微信支付時對應的虛擬支付單號;這裡我根據refund_account退款資金來源作了一個邏輯處理,退款資金優先退商戶未結算資金,如果未結算資金不足退商戶餘額的資金。

退款請求及證書的使用:

/**
 * 申請退款
 */
public String doRefund(String url, String data) throws Exception {
    KeyStore keyStore = KeyStore.getInstance("PKCS12");
    FileInputStream is = new FileInputStream(new File("****證書檔案存放的路勁*******"));
    try {
        keyStore.load(is, "********證書密碼*******".toCharArray());
    } finally {
        is.close();
    }
    // Trust own CA and all self-signed certs
    SSLContext sslcontext = SSLContexts.custom().loadKeyMaterial(
            keyStore,
            "********證書密碼*******".toCharArray())
            .build();
    // Allow TLSv1 protocol only
    SSLConnectionSocketFactory sslsf = new SSLConnectionSocketFactory(
            sslcontext,
            new String[]{"TLSv1"},
            null,
            SSLConnectionSocketFactory.BROWSER_COMPATIBLE_HOSTNAME_VERIFIER
    );
    CloseableHttpClient httpclient = HttpClients.custom().setSSLSocketFactory(sslsf).build();
    try {
        HttpPost httpost = new HttpPost(url); // 設定響應頭資訊
        httpost.addHeader("Connection", "keep-alive");
        httpost.addHeader("Accept", "*/*");
        httpost.addHeader("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8");
        httpost.addHeader("Host", "api.mch.weixin.qq.com");
        httpost.addHeader("X-Requested-With", "XMLHttpRequest");
        httpost.addHeader("Cache-Control", "max-age=0");
        httpost.addHeader("User-Agent", "Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 6.0) ");
        httpost.setEntity(new StringEntity(data, "UTF-8"));
        CloseableHttpResponse response = httpclient.execute(httpost);
        try {
            HttpEntity entity = response.getEntity();

            String jsonStr = EntityUtils.toString(response.getEntity(), "UTF-8");
            EntityUtils.consume(entity);
            return jsonStr;
        } finally {
            response.close();
        }
    } finally {
        httpclient.close();
    }
}

證書密碼如果沒設定預設為商戶號mchid