微信掃碼支付之Java遊記
《滿江紅·雨後晴初》
年代: 宋 作者: 京鏜
雨後晴初,覺春在、榿村柳陌。修禊事、郊坰尋勝,特邀君出。繚繞群山疑虎踞,瀰漫一水容鯨吸。怪西湖、底事卻移來,龜城北。酬令節,逢佳日。風遞暖,煙凝碧。趁蘭舟遊玩,盡杯中物。十里輪蹄塵不斷,幾多粉黛花無色。笑杜陵、昔賦麗人行,空遺蹟。
喜歡雨後晴初的感覺,看網上說支付寶介面文件亂如初,微信支付介面亂如麻。
今天先研究微信支付(掃碼支付),快刀斬亂麻。
首先,先來看看微信掃碼支付API文件
業務流程說明:
(1)商戶後臺系統根據微信支付規定格式生成二維碼(規則見下文),展示給使用者掃碼。
(2)使用者開啟微信“掃一掃”掃描二維碼,微信客戶端將掃碼內容傳送到微信支付系統。
(3)微信支付系統收到客戶端請求,發起對商戶後臺系統支付回撥URL的呼叫。呼叫請求將帶productid和使用者的openid等引數,並要求商戶系統返回交資料包,詳細請見"本節3.1回撥資料輸入引數"
(4)商戶後臺系統收到微信支付系統的回撥請求,根據productid生成商戶系統的訂單。
(5)商戶系統呼叫微信支付【統一下單API】請求下單,獲取交易會話標識(prepay_id)
(6)微信支付系統根據商戶系統的請求生成預支付交易,並返回交易會話標識(prepay_id)。
(7)商戶後臺系統得到交易會話標識prepay_id(2小時內有效)。
(8)商戶後臺系統將prepay_id返回給微信支付系統。返回資料見"本節3.2回撥資料輸出引數"
(9)微信支付系統根據交易會話標識,發起使用者端授權支付流程。
(10)使用者在微信客戶端輸入密碼,確認支付後,微信客戶端提交支付授權。
(11)微信支付系統驗證後扣款,完成支付交易。
(12)微信支付系統完成支付交易後給微信客戶端返回交易結果,並將交易結果通過簡訊、微信訊息提示使用者。微信客戶端展示支付交易結果頁面。
(13)微信支付系統通過傳送非同步訊息通知商戶後臺系統支付結果。商戶後臺系統需回覆接收情況,通知微信後臺系統不再發送該單的支付通知。
(14)未收到支付通知的情況,商戶後臺系統呼叫【查詢訂單API】。
(15)商戶確認訂單已支付後給使用者發貨。
其實,我總結一下,上邊說對應的編碼流程:步驟一:你需要打包微信所需的引數
1.1 輸入引數
微信所需要的輸入引數說明
名稱 | 變數名 | 型別 | 必填 | 示例值 | 描述 |
---|---|---|---|---|---|
公眾賬號ID | appid | String(32) | 是 | wx8888888888888888 | 微信分配的公眾賬號ID |
使用者標識 | openid | String(128) | 是 | o8GeHuLAsgefS_80exEr1cTqekUs | 使用者在商戶appid下的唯一標識 |
商戶號 | mch_id | String(32) | 是 | 1900000109 | 微信支付分配的商戶號 |
是否關注公眾賬號 | is_subscribe | String(1) | 是 | Y | 使用者是否關注公眾賬號,僅在公眾賬號型別支付有效,取值範圍:Y或N;Y-關注;N-未關注 |
隨機字串 | nonce_str | String(32) | 是 | 5K8264ILTKCH16CQ2502SI8ZNMTM67VS | 隨機字串,不長於32位。推薦隨機數生成演算法 |
商品ID | product_id | String(32) | 是 | 88888 | 商戶定義的商品id 或者訂單號 |
簽名 | sign | String(32) | 是 | C380BEC2BFD727A4B6845133519F3AD6 |
步驟二:打包引數的時候扔給微信,返回微信一個code碼,根據code碼,你可以選擇google二維碼生成規則,或者其他二維碼生成器來生成
二維碼中的內容為連結,形式為:
weixin://wxpay/bizpayurl?sign=XXXXX&appid=XXXXX&mch_id=XXXXX&product_id=XXXXXX&time_stamp=XXXXXX&nonce_str=XXXXX
其中XXXXX為商戶需要填寫的內容,商戶將該連結生成二維碼,如需要打印發布二維碼,需要採用此格式。商戶可呼叫第三方庫生成二維碼圖片。引數說明如下:
步驟三:使用者通過掃碼支付之後,會呼叫你配置檔案的回撥介面,主要是通過報文的形式來進行傳輸的
1.2 輸出引數
微信輸出引數說明
名稱 | 變數名 | 型別 | 必填 | 示例值 | 描述 |
---|---|---|---|---|---|
返回狀態碼 | return_code | String(16) | 是 | SUCCESS | SUCCESS/FAIL,此欄位是通訊標識,非交易標識,交易是否成功需要檢視result_code來判斷 |
返回資訊 | return_msg | String(128) | 否 | 簽名失敗 | 返回資訊,如非空,為錯誤原因;簽名失敗;具體某個引數格式校驗錯誤. |
公眾賬號ID | appid | String(32) | 是 | wx8888888888888888 | 微信分配的公眾賬號ID |
商戶號 | mch_id | String(32) | 是 | 1900000109 | 微信支付分配的商戶號 |
隨機字串 | nonce_str | String(32) | 是 | 5K8264ILTKCH16CQ2502SI8ZNMTM67VS | 微信返回的隨機字串 |
預支付ID | prepay_id | String(64) | 是 | wx201410272009395522657a690389285100 | 呼叫統一下單介面生成的預支付ID |
業務結果 | result_code | String(16) | 是 | SUCCESS | SUCCESS/FAIL |
錯誤描述 | err_code_des | String(128) | 否 | 當result_code為FAIL時,商戶展示給使用者的錯誤提 | |
簽名 | sign | String(32) | 是 | C380BEC2BFD727A4B6845133519F3AD6 |
上邊是一個流程介紹:
下面開始實戰,業務清楚了,程式碼就好實現了,也許會出現錯誤(坑),但是,身為程式設計師,天職就是以解決bug為快樂。
專案框架:springboot+spring security+jpa+jwt認證
application.yaml檔案
wx: pay: appId:******** appSecret:********* mchId:******** apiKey:*********ufdoderUrl: https://api.mch.weixin.qq.com/pay/unifiedorder notifyUrl: //回撥地址 createIp: signType: MD5
package com.***.service; import com.***.domain.*; import com.***.domain.repository.*; import com.***.domain.request.RequestNotify; import com.***.domain.request.RequestOrder; import com.***.domain.sys.Result; import com.***.enums.ResultEnum; import com.***.enums.StateEnum; import com.***.excpetion.ClassOnlineException; import com.***.utils.CommonUtil; import com.***.utils.HttpUtil; import com.***.utils.ResultUtil; import com.***.utils.XMLUtil; import com.***.utils.wxpay.WXPayUtil; import org.jdom.JDOMException; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.mobile.device.Device; import org.springframework.stereotype.Service; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.transaction.Transactional; import java.io.*; import java.text.SimpleDateFormat; import java.util.*; @Service public class WxPayServiceImpl implements WxPayService { @Value("${wx.pay.appId}") private String appId; @Value("${wx.pay.mchId}") private String mchId; @Value("${wx.pay.apiKey}") private String apiKey; @Value("${wx.pay.notifyUrl}") private String notifyUrl; @Value("${wx.pay.createIp}") private String createIp; @Value("${wx.pay.ufdoderUrl}") private String ufdoderUrl; private String charset = "utf-8"; final String TRADETYPE = "NATIVE"; final Long MAXCLASSES = 30L; @Autowired private OrdersRepository ordersRepository; @Autowired private UserRepository userRepository; @Autowired private CoursesRepository coursesRepository; @Autowired private ClassesRepository classesRepository; @Autowired private CourseRecordRepository courseRecordRepository; private static org.slf4j.Logger logger = LoggerFactory.getLogger(WxPayServiceImpl.class)
@Override public String wxPay(RequestOrder requestOrder, HttpServletRequest httpServletRequest, Device device) throws Exception { Orders orders = ordersRepository.findByOrderNumber(requestOrder.getOrderNumber()); if(orders == null) throw new ClassOnlineException(ResultEnum.NOT_FIND); //交易號(訂單號) String outTradeNo = orders.getOrderNumber(); String totalAmount = CommonUtil.subZeroAndDot(Float.toString(orders.getPrice())); //注意這個地方,微信是一分為單位,不支援小數點 String subject = orders.getCourse().getName(); String body = orders.getUser().getUsername() + "購入" + orders.getClassName(); SortedMap<Object,Object> packageParams = new TreeMap<Object,Object>(); packageParams.put("appid", appId); packageParams.put("mch_id", mchId); packageParams.put("nonce_str", CommonUtil.uniqueUUID()); packageParams.put("body", body); packageParams.put("out_trade_no", outTradeNo); packageParams.put("total_fee", totalAmount); packageParams.put("spbill_create_ip", CommonUtil.getIpAddr(httpServletRequest)); packageParams.put("notify_url", notifyUrl); packageParams.put("trade_type", TRADETYPE); //建立一個簽名 String sign = WXPayUtil.createSign("UTF-8", packageParams,apiKey); packageParams.put("sign", sign); //獲取微信所需的引數包,上面所需的一個不少,少了會出現引數錯誤 String requestXML = XMLUtil.getRequestXml(packageParams); System.out.println(requestXML); //通過微信統一呼叫微信介面https://api.mch.weixin.qq.com/pay/unifiedorder來獲取請求 String resXml = HttpUtil.postData(ufdoderUrl, requestXML); Map map = null; try { map = XMLUtil.doXMLParse(resXml); String urlCode = (String) map.get("code_url"); String qrUrlCode = CommonUtil.QRfromGoogle(urlCode); return qrUrlCode; } catch (JDOMException e) { throw new ClassOnlineException(-1,e.getMessage()); } catch (IOException e) { throw new ClassOnlineException(-1,e.getMessage()); } }
//微信回撥程式碼 @Transactional public Result<?> wxPayNotify(HttpServletRequest request, HttpServletResponse response) throws Exception{ //讀取引數 InputStream inputStream ; StringBuffer sb = new StringBuffer(); inputStream = request.getInputStream(); String s ; BufferedReader in = new BufferedReader(new InputStreamReader(inputStream, "UTF-8")); while ((s = in.readLine()) != null){ sb.append(s); } in.close(); inputStream.close(); //解析xml成map Map<String, String> m = new HashMap<String, String>(); m = XMLUtil.doXMLParse(sb.toString()); //過濾空 設定 TreeMap SortedMap<Object,Object> packageParams = new TreeMap<Object,Object>(); Iterator it = m.keySet().iterator(); while (it.hasNext()) { String parameter = (String) it.next(); String parameterValue = m.get(parameter); String v = ""; if(null != parameterValue) { v = parameterValue.trim(); } packageParams.put(parameter, v); } // 賬號資訊 String key = apiKey; // key logger.info(String.valueOf(packageParams)); //判斷簽名是否正確 if(WXPayUtil.isTenpaySign("UTF-8", packageParams,key)) { //------------------------------ /** * 處理業務 * 一整套流程用事務來控制 */ String resXml = ""; String resultCode = (String) packageParams.get("result_code"); if("SUCCESS".equals(resultCode)){ // 這裡是支付成功執行自己的業務邏輯 String mch_id = (String)packageParams.get("mch_id"); String openid = (String)packageParams.get("openid"); String is_subscribe = (String)packageParams.get("is_subscribe"); String out_trade_no = (String)packageParams.get("out_trade_no"); String total_fee = (String)packageParams.get("total_fee"); RequestNotify requestNotify = new RequestNotify(); requestNotify.setOrderNumber(out_trade_no); requestNotify.setPaymentType(StateEnum.ONLINE_WX.getCode()); //呼叫支付成功後的業務程式碼 ******************省略,每個公司不一樣 logger.info("支付成功"); //通知微信.非同步確認成功.必寫.不然會一直通知後臺.八次之後就認為交易失敗了. resXml = returnXML(resultCode); } else { logger.info("支付失敗,錯誤資訊:" + packageParams.get("err_code")); resXml = returnXML("FAIL"); throw new ClassOnlineException(ResultEnum.ORDER_PAY_FAIL); } //------------------------------ //處理業務完畢 //------------------------------ BufferedOutputStream out = new BufferedOutputStream( response.getOutputStream()); out.write(resXml.getBytes()); out.flush(); out.close(); } else{ logger.info("通知簽名驗證失敗"); throw new ClassOnlineException(ResultEnum.SIGN_FAIL); } return ResultUtil.success(ResultEnum.SUCCESS); } private String returnXML(String return_code) { return "<xml><return_code><![CDATA[" + return_code + "]]></return_code><return_msg><![CDATA[OK]]></return_msg></xml>"; } }
下面是工具類:
package com.***.utils; import javax.servlet.http.HttpServletRequest; import java.io.UnsupportedEncodingException; import java.net.URLEncoder; import java.text.SimpleDateFormat; import java.util.Calendar; import java.util.Date; import java.util.UUID; public class CommonUtil { //google 二維碼生成器 public static String QRfromGoogle(String chl) throws Exception { int widhtHeight = 300; String EC_level = "L"; int margin = 0; chl = UrlEncode(chl); String QRfromGoogle = "http://chart.apis.google.com/chart?chs=" + widhtHeight + "x" + widhtHeight + "&cht=qr&chld=" + EC_level + "|" + margin + "&chl=" + chl; return QRfromGoogle; } // 特殊字元處理 public static String UrlEncode(String src) throws UnsupportedEncodingException { return URLEncoder.encode(src, "UTF-8").replace("+", "%20"); } //生成唯一碼 public static String uniqueUUID(){ return UUID.randomUUID().toString().replaceAll("-", "").substring(0, 32); } //獲取真實IP地址 public static String getIpAddr(HttpServletRequest request) { String ip = request.getHeader("x-forwarded-for"); if(ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) { ip = request.getHeader("Proxy-Client-IP"); } if(ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) { ip = request.getHeader("WL-Proxy-Client-IP"); } if(ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) { ip = request.getRemoteAddr(); } return ip; } /** * 去掉float小數點後邊的數字 * @param s * @return */ public static String subZeroAndDot(String s){ s=s.substring(0, s.indexOf('.')); return s; } public static String getOrderIdByUUId() { int machineId = 1;//最大支援1-9個叢集機器部署 int hashCodeV = UUID.randomUUID().toString().hashCode(); if(hashCodeV < 0) {//有可能是負數 hashCodeV = - hashCodeV; } // 0 代表前面補充0 // 4 代表長度為4 // d 代表引數為正數型 return machineId + String.format("%015d", hashCodeV); } //獲取日期點凌晨和二十四時 public static Date getDate(Date date, int flag) { Calendar cal = Calendar.getInstance(); cal.setTime(date); int hour = cal.get(Calendar.HOUR_OF_DAY); int minute = cal.get(Calendar.MINUTE); int second = cal.get(Calendar.SECOND); //時分秒(毫秒數) long millisecond = hour*60*60*1000 + minute*60*1000 + second*1000; //凌晨00:00:00 cal.setTimeInMillis(cal.getTimeInMillis()-millisecond); if (flag == 0) { return cal.getTime(); } else if (flag == 1) { //凌晨23:59:59 cal.setTimeInMillis(cal.getTimeInMillis()+23*60*60*1000 + 59*60*1000 + 59*1000); } return cal.getTime(); } }
接下來就是:微信的建立簽名,解析簽名處理的工具類都是以xml的形式進行傳遞的(你可以用它原生的V3,也可以自己寫)
package com.***.utils.wxpay; import com.***.utils.MD5Util; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.w3c.dom.Node; import org.w3c.dom.NodeList; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.transform.OutputKeys; import javax.xml.transform.Transformer; import javax.xml.transform.TransformerFactory; import javax.xml.transform.dom.DOMSource; import javax.xml.transform.stream.StreamResult; import java.io.ByteArrayInputStream; import java.io.InputStream; import java.io.StringWriter; import java.security.MessageDigest; import java.util.*; public class WXPayUtil { /** * XML格式字串轉換為Map * * @param strXML XML字串 * @return XML資料轉換後的Map * @throws Exception */ public static Map<String, String> xmlToMap(String strXML) throws Exception { try { Map<String, String> data = new HashMap<String, String>(); DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance(); DocumentBuilder documentBuilder = documentBuilderFactory.newDocumentBuilder(); InputStream stream = new ByteArrayInputStream(strXML.getBytes("UTF-8")); org.w3c.dom.Document doc = documentBuilder.parse(stream); doc.getDocumentElement().normalize(); NodeList nodeList = doc.getDocumentElement().getChildNodes(); for (int idx = 0; idx < nodeList.getLength(); ++idx) { Node node = nodeList.item(idx); if (node.getNodeType() == Node.ELEMENT_NODE) { org.w3c.dom.Element element = (org.w3c.dom.Element) node; data.put(element.getNodeName(), element.getTextContent()); } } try { stream.close(); } catch (Exception ex) { // do nothing } return data; } catch (Exception ex) { WXPayUtil.getLogger().warn("Invalid XML, can not convert to map. Error message: {}. XML content: {}", ex.getMessage(), strXML); throw ex; } } /** * 將Map轉換為XML格式的字串 * * @param data Map型別資料 * @return XML格式的字串 * @throws Exception */ public static String mapToXml(Map<String, String> data) throws Exception { DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance(); DocumentBuilder documentBuilder= documentBuilderFactory.newDocumentBuilder(); org.w3c.dom.Document document = documentBuilder.newDocument(); org.w3c.dom.Element root = document.createElement("xml"); document.appendChild(root); for (String key: data.keySet()) { String value = data.get(key); if (value == null) { value = ""; } value = value.trim(); org.w3c.dom.Element filed = document.createElement(key); filed.appendChild(document.createTextNode(value)); root.appendChild(filed); } TransformerFactory tf = TransformerFactory.newInstance(); Transformer transformer = tf.newTransformer(); DOMSource source = new DOMSource(document); transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8"); transformer.setOutputProperty(OutputKeys.INDENT, "yes"); StringWriter writer = new StringWriter(); StreamResult result = new StreamResult(writer); transformer.transform(source, result); String output = writer.getBuffer().toString(); //.replaceAll("\n|\r", ""); try { writer.close(); } catch (Exception ex) { } return output; } /** * 生成帶有 sign 的 XML 格式字串 * * @param data Map型別資料 * @param key API金鑰 * @return 含有sign欄位的XML */ public static String generateSignedXml(final Map<String, String> data, String key) throws Exception { return generateSignedXml(data, key, WXPayConstants.SignType.MD5); } /** * 生成帶有 sign 的 XML 格式字串 * * @param data Map型別資料 * @param key API金鑰 * @param signType 簽名型別 * @return 含有sign欄位的XML */ public static String generateSignedXml(final Map<String, String> data, String key, WXPayConstants.SignType signType) throws Exception { String sign = generateSignature(data, key, signType); data.put(WXPayConstants.FIELD_SIGN, sign); return mapToXml(data); } /** * 判斷簽名是否正確 * * @param xmlStr XML格式資料 * @param key API金鑰 * @return 簽名是否正確 * @throws Exception */ public static boolean isSignatureValid(String xmlStr, String key) throws Exception { Map<String, String> data = xmlToMap(xmlStr); if (!data.containsKey(WXPayConstants.FIELD_SIGN) ) { return false; } String sign = data.get(WXPayConstants.FIELD_SIGN); return generateSignature(data, key).equals(sign); } /** * 判斷簽名是否正確,必須包含sign欄位,否則返回false。使用MD5簽名。 * * @param data Map型別資料 * @param key API金鑰 * @return 簽名是否正確 * @throws Exception */ public static boolean isSignatureValid(Map<String, String> data, String key) throws Exception { return isSignatureValid(data, key, WXPayConstants.SignType.MD5); } /** * 判斷簽名是否正確,必須包含sign欄位,否則返回false。 * * @param data Map型別資料 * @param key API金鑰 * @param signType 簽名方式 * @return 簽名是否正確 * @throws Exception */ public static boolean isSignatureValid(Map<String, String> data, String key, WXPayConstants.SignType signType) throws Exception { if (!data.containsKey(WXPayConstants.FIELD_SIGN) ) { return false; } String sign = data.get(WXPayConstants.FIELD_SIGN); return generateSignature(data, key, signType).equals(sign); } /** * 生成簽名 * * @param data 待簽名資料 * @param key API金鑰 * @return 簽名 */ public static String generateSignature(final Map<String, String> data, String key) throws Exception { return generateSignature(data, key, WXPayConstants.SignType.MD5); } /** * 生成簽名. 注意,若含有sign_type欄位,必須和signType引數保持一致。 * * @param